Mert Özen Her Satırda Daha İleri
Frontend Browser API'leri ve PWA — #03

IndexedDB'yi Angular'da Kullanmak: Schema Versiyonlama, Transaction'lar ve Offline Outbox

Mert Özen 15 Kas 2026 15 dk 7 görüntülenme
IndexedDB'yi Angular'da Kullanmak: Schema Versiyonlama, Transaction'lar ve Offline Outbox

IndexedDB'de schema yalnız sürüm artırınca onupgradeneeded içinde değişir ve transaction, IndexedDB dışı bir await'te kendiliğinden commit edilir; bu yüzden ağ işi önce, yazmalar sonra yapılır. Sürüm başına migration'ları, TransactionInactiveError'u, başka sekmenin upgrade'i bloklamasını Chromium ve WebKit'te gösteriyor, Angular'da idb ile okuma listesini ve offline yorumları idempotency key ile çift kayıtsız gönderen bir outbox'ı kuruyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), idb 8.0.4, Node.js 24 ve Playwright 1.65 ile denenmiştir; tarayıcı çıktıları Playwright'ın Chromium 158 ve WebKit 27.2 build'lerinden alınmıştır. Destek bilgileri MDN'in (Mozilla'nın web platformu dokümantasyonu) browser-compat-data 8.1.9 verisinden okunmuştur.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) okuma listesi artık IndexedDB'de. Aşağıdaki fonksiyon bir transaction açıyor, Dune'u yazıyor, sonra Emma'nın güncel fiyatını API'den alıp onu da aynı transaction'a yazmaya çalışıyor:

const dune = { isbn: "9780441172719", title: "Dune", addedAt: "2026-11-14T09:00:00Z" };
const emma = { isbn: "9780141439587", title: "Emma", addedAt: "2026-11-14T09:05:00Z" };

// Bekleyen isteği kalmayan transaction, kontrol event loop'a döner dönmez commit edilir
export async function autoCommitDemo() {
  const db = await openBookstoreDb(2);
  const tx = db.transaction("readingList", "readwrite");
  const store = tx.objectStore("readingList");
  store.put(dune);
  tx.oncomplete = () => console.log("transaction complete");

  const price = await (await fetch("/api/books/9780141439587")).json(); // IndexedDB isteği değil
  try {
    store.put({ ...emma, price: price.price });
  } catch (err) {
    console.log(`${err.name}: ${err.message}`);
  }
  db.close();
}

Konsolda ne görürsün? İki kitap da yazılır mı, transaction hata verip ikisini de geri mi alır, yoksa yalnız biri mi kalır? "transaction complete" satırı hangi sırada gelir?

Bir önceki yazıda okuma listesinin localStorage'dan çıkıp IndexedDB'ye gitmesi gerektiğine karar vermiştik. Bu yazıda IndexedDB'nin kavramlarını, onupgradeneeded ile schema versiyonlamayı, transaction'ların ne zaman kendiliğinden kapandığını ve başka bir sekmenin upgrade'i nasıl blokladığını önce framework'süz kodla göreceğiz. Sonra aynı şeyi Angular'da, idb kütüphanesi ve signal'larla yazacak, offline'da eklenen yorumları bağlantı gelince çift kayıt oluşturmadan gönderen bir outbox kuracağız.

Database, Object Store, Index ve Key Path

IndexedDB'de bir origin'in birden çok database'i olabilir; her database'in bir sürüm numarası ve içinde object store'ları var. Object store, SQL'deki tabloya en yakın kavram ama şeması yok: içine JavaScript object'leri, Blob'lar, tarihler yazarsın (structured clone ile kopyalanıyor). Her kaydın bir key'i var; key ya kaydın bir property'sinden (key path) ya da otomatik artan bir sayıdan geliyor. Index, aynı store'daki kayıtları başka bir property'ye göre sıralı tutan ve o property'yle sorgulamayı sağlayan yapı. Bookstore'un database'inde iki store var: key path'i isbn olan readingList (eklenme zamanına göre byAddedAt index'iyle) ve offline yorumların beklediği outbox.

Schema'yı değiştirmenin tek yolu database'i daha yüksek bir sürümle açmak. Bu durumda tarayıcı upgradeneeded event'ini tetikliyor ve store ile index oluşturma yalnız bu event'in içinde, bir versionchange transaction'ında mümkün:

// Her schema sürümü için bir fonksiyon; kullanıcı herhangi bir eski sürümden en yeniye atlayabilir
const migrations = {
  1: (db) => {
    db.createObjectStore("readingList", { keyPath: "isbn" });
  },
  2: (db, tx) => {
    tx.objectStore("readingList").createIndex("byAddedAt", "addedAt");
    db.createObjectStore("outbox", { keyPath: "idempotencyKey" }).createIndex("byCreatedAt", "createdAt");
  },
};

export function openBookstoreDb(version, { onVersionChange } = {}) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("bookstore", version);
    request.onupgradeneeded = (event) => {
      console.log(`upgrade ${event.oldVersion} -> ${event.newVersion}`);
      for (let v = event.oldVersion + 1; v <= event.newVersion; v++) {
        migrations[v](request.result, request.transaction); // versionchange transaction'ının içinde çalışır
      }
    };
    request.onblocked = (event) => console.log(`blocked: another tab still has version ${event.oldVersion} open`);
    request.onsuccess = () => {
      const db = request.result;
      db.onversionchange = (event) => {
        console.log(`versionchange: ${event.oldVersion} -> ${event.newVersion} requested elsewhere`);
        if (onVersionChange) onVersionChange(db);
      };
      resolve(db);
    };
    request.onerror = () => reject(request.error);
  });
}

Schema Versiyonlama: onupgradeneeded

Sürüm başına bir migration fonksiyonu yazmak, kullanıcının hangi sürümden geldiğini düşünmeyi kolaylaştırıyor. Bir kullanıcı uygulamayı ilk kez açıyor (0 → 2), bir başkası yıllardır sürüm 1'de (1 → 2); döngü ikisini de aynı kodla taşıyor. Önce sürüm 1'i, sonra sürüm 2'yi, sonra database'i silip doğrudan sürüm 2'yi açtım:

export async function upgradeDemo(version) {
  const db = await openBookstoreDb(version);
  const stores = [...db.objectStoreNames].map((name) => {
    const indexes = [...db.transaction(name).objectStore(name).indexNames];
    return indexes.length ? `${name} [${indexes.join(", ")}]` : name;
  });
  console.log(`version ${db.version}: ${stores.join("; ")}`);
  db.close();
}
upgrade 0 -> 1
version 1: readingList
upgrade 1 -> 2
version 2: outbox [byCreatedAt]; readingList [byAddedAt]
version 2: outbox [byCreatedAt]; readingList [byAddedAt]
upgrade 0 -> 2
version 2: outbox [byCreatedAt]; readingList [byAddedAt]

İkinci kez sürüm 2 açılınca upgrade çalışmadı; sürüm zaten 2. Bu kurgunun iki kuralı var. Birincisi, yayınlanmış bir migration'ı asla değiştirme: sürüm 2'nin index'ini düzeltmek istiyorsan bunu sürüm 3 olarak ekle, çünkü sürüm 2'ye geçmiş kullanıcılarda o kod bir daha çalışmayacak. İkincisi, migration'ı oldVersion ile sınırla: sürüm 1'in bütün kayıtlarını dönüştüren pahalı bir adım, sıfırdan gelen bir kullanıcıda boş bir store üzerinde boşuna çalışır ama en azından zarar vermez. Bu yazının ilk denemesinde döngü yerine yalnız oldVersion < 2 koşulunu kullanmıştım; sürüm 1'i açarken bile sürüm 2'nin adımları çalıştı ve ikinci açılışta "An index with the specified name already exists" hatasıyla upgrade abort oldu. Hedef sürümü de kontrol etmek gerekiyor.

Transaction'lar Ne Zaman Kapanır?

Önce Tahmin Et sorusunun cevabı:

Chromium:
  transaction complete
  TransactionInactiveError: Failed to execute 'put' on 'IDBObjectStore': The transaction has finished.
WebKit:
  transaction complete
  TransactionInactiveError: Failed to store record in an IDBObjectStore: The transaction is inactive or finished.

Dune yazıldı, Emma yazılmadı ve hata transaction'ın değil ikinci put'un içinden geldi. IndexedDB transaction'ları otomatik commit ediliyor: transaction'da bekleyen bir istek kalmadığında ve kontrol event loop'a döndüğünde transaction tamamlanıyor. await fetch(...) tam olarak bunu yapıyor: fonksiyon duruyor, event loop başka işlere geçiyor, transaction'ın bekleyen isteği kalmadığı için commit ediliyor ve fetch dönünce elindeki store artık ölü bir transaction'a ait. Aynı şey setTimeout, bir HTTP isteği ya da IndexedDB dışındaki herhangi bir Promise'i beklerken de oluyor. Bir IndexedDB isteğinin sonucunu beklemek ise sorun değil, çünkü o istek transaction'ı açık tutuyor.

Düzeltmesi sırayı değiştirmek: ağ işi önce, sonra bütün yazmalar tek ve kısa bir transaction'da:

// Düzeltme: önce ağ işi, sonra bütün yazmalar tek ve kısa bir transaction'da
export async function autoCommitFixed() {
  const book = await (await fetch("/api/books/9780141439587")).json();
  const db = await openBookstoreDb(2);
  const tx = db.transaction("readingList", "readwrite");
  tx.objectStore("readingList").put(dune);
  tx.objectStore("readingList").put({ ...emma, price: book.price });
  await new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
  const count = await new Promise((resolve) => {
    const request = db.transaction("readingList").objectStore("readingList").count();
    request.onsuccess = () => resolve(request.result);
  });
  console.log(`saved, reading list has ${count} books`);
  db.close();
}
Chromium: saved, reading list has 2 books
WebKit:   saved, reading list has 2 books

Transaction'ın amacı bir grup değişikliği atomik yapmak. "Önce okuyup kontrol et, ağa sor, sonra yaz" ihtiyacın varsa, ağ cevabından sonra yeni bir transaction aç ve okuduğun değeri orada tekrar kontrol et; arada başka bir sekme veriyi değiştirmiş olabilir.

Başka Bir Sekme Upgrade'i Bloklayınca

Kullanıcıda Bookstore iki sekmede açık. Birinde uygulamanın eski sürümü database'e sürüm 1 ile bağlı; yeni deploy'dan sonra açılan ikinci sekme sürüm 2'yi istiyor. Upgrade, eski bağlantılar kapanmadan başlayamaz. Playwright'ta aynı origin'de iki sayfa açıp iki durumu denedim: A sekmesinin versionchange handler'ı bağlantıyı kapatmıyor, ve kapatıyor:

// A sekmesi eski bağlantıyı açık tutuyor; yol verip vermeyeceğine handler karar verir
export async function holdConnection(version, closeOnVersionChange) {
  window.heldDb = await openBookstoreDb(version, {
    onVersionChange: closeOnVersionChange ? (db) => db.close() : undefined,
  });
  return `holding version ${window.heldDb.version}`;
}
--- tab A closes on versionchange: false
tab A: upgrade 0 -> 1
tab A: holding version 1
tab A: versionchange: 1 -> 2 requested elsewhere
tab B: blocked: another tab still has version 1 open
tab A: closing its connection after 1 s
tab B: upgrade 1 -> 2
tab B: version 2: outbox [byCreatedAt]; readingList [byAddedAt]
--- tab A closes on versionchange: true
tab A: upgrade 0 -> 1
tab A: holding version 1
tab A: versionchange: 1 -> 2 requested elsewhere
tab B: upgrade 1 -> 2
tab B: version 2: outbox [byCreatedAt]; readingList [byAddedAt]

İlk durumda B'nin isteği blocked event'iyle bekledi ve A bağlantısını kapatana kadar (burada bir saniye, gerçekte kullanıcı A sekmesini kapatana kadar) ilerlemedi. İkinci durumda A, versionchange event'inde kendi bağlantısını kapattı ve B'nin upgrade'i hemen çalıştı. Kural basit: her bağlantı versionchange'te kendini kapatmalı ve kullanıcıya sayfayı yenilemesini söylemeli. Kapatmazsa, yeni sürüm sekmesindeki uygulama açılışta asılı kalır ve kullanıcı ne olduğunu anlamaz. WebKit'te de aynı sıra ve aynı mesajlar geldi.

idb ile Okunur Kod

Ham IndexedDB API'si event ve callback tabanlı; her isteği bir Promise'e çevirmek için yukarıdaki gibi küçük sarmalayıcılar yazmak gerekiyor. Jake Archibald'ın idb kütüphanesi (ISC lisanslı, 8.0.4) bunu ince bir katmanla yapıyor: API'nin kendisine çok yakın kalıyor ama istekleri Promise olarak döndürüyor ve TypeScript ile schema'yı tiplemene izin veriyor. Angular tarafında database'i bir service açıyor:

import { Service, signal } from '@angular/core';
import { DBSchema, IDBPDatabase, openDB } from 'idb';

export interface ReadingListItem {
  isbn: string;
  title: string;
  addedAt: string;
}

export interface PendingReview {
  idempotencyKey: string;
  isbn: string;
  rating: number;
  comment: string;
  createdAt: string;
}

interface BookstoreSchema extends DBSchema {
  readingList: { key: string; value: ReadingListItem; indexes: { byAddedAt: string } };
  outbox: { key: string; value: PendingReview; indexes: { byCreatedAt: string } };
}

@Service()
export class BookstoreDatabase {
  // başka bir sekme daha yeni bir schema açtığında ve bu sekme bağlantısını bıraktığında true
  readonly outdated = signal(false);

  private readonly db = openDB<BookstoreSchema>('bookstore', 2, {
    upgrade(db, oldVersion, _newVersion, tx) {
      if (oldVersion < 1) {
        db.createObjectStore('readingList', { keyPath: 'isbn' });
      }
      if (oldVersion < 2) {
        tx.objectStore('readingList').createIndex('byAddedAt', 'addedAt');
        const outbox = db.createObjectStore('outbox', { keyPath: 'idempotencyKey' });
        outbox.createIndex('byCreatedAt', 'createdAt');
      }
    },
    blocking: (_current, _blocked, event) => {
      (event.target as IDBDatabase).close(); // diğer sekme upgrade edebilsin
      this.outdated.set(true);
    },
  });

  connection(): Promise<IDBPDatabase<BookstoreSchema>> {
    return this.db;
  }
}

DBSchema sayesinde db.put('readingList', ...) yanlış tipte bir object'i derleme sırasında reddediyor ve getAllFromIndex'in index adı da kontrol ediliyor. blocking, ham API'deki versionchange'in karşılığı: bağlantıyı kapatıyor ve outdated signal'ını true yapıyor; uygulamanın kabuğu bu signal'a bakıp "Bookstore başka bir sekmede güncellendi, sayfayı yenile" banner'ını gösteriyor. Uygulama açıkken başka bir sekmede sürüm 3'ü açtığımda:

tab B opened version 3
tab A banner: Bookstore was updated in another tab. Reload this page to continue.

Angular'da Okuma Listesi: Service ve resource()

Okuma listesi bir resource(); IndexedDB'den okuyan loader bir Promise döndürüyor, ekleme ve silme işlemleri yazdıktan sonra reload() çağırıyor:

import { inject, resource, Service } from '@angular/core';
import { Book } from '../catalog/book';
import { BookstoreDatabase } from '../core/bookstore-database';

@Service()
export class ReadingListStore {
  private readonly database = inject(BookstoreDatabase);

  readonly items = resource({
    loader: async () =>
      (await this.database.connection()).getAllFromIndex('readingList', 'byAddedAt'),
  });

  async add(book: Book) {
    const db = await this.database.connection();
    await db.put('readingList', {
      isbn: book.isbn,
      title: book.title,
      addedAt: new Date().toISOString(),
    });
    this.items.reload();
  }

  async remove(isbn: string) {
    const db = await this.database.connection();
    await db.delete('readingList', isbn);
    this.items.reload();
  }
}

Her işlem kendi kısa transaction'ını açıyor; idb'nin db.put ve db.delete kısayolları bunu tek satırda yapıyor. Birden çok store'a atomik yazman gerekiyorsa db.transaction(['readingList', 'outbox'], 'readwrite') ile açıp tx.done'ı bekle; aynı otomatik commit kuralı burada da geçerli. Kitap sayfasında "Add to reading list"e basıp sayfayı yenileyince liste yerinde kalıyor (aşağıdaki çıktının ilk satırı).

Offline Outbox ve Idempotency Key

Kullanıcı trende, bağlantı yok, Dune için bir yorum yazıp gönderiyor. Bunu hata mesajıyla reddetmek yerine yorumu bir outbox store'una yazıp bağlantı gelince göndermek istiyoruz. İki zorluk var. Birincisi sıra: yorumlar yazıldıkları sırayla gitmeli. İkincisi çift kayıt: istek sunucuya ulaşıp kaydedildiği hâlde cevap yolda kaybolursa, istemci aynı yorumu tekrar gönderir. İkincisinin çözümü, Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısında anlatılan idempotency key: her yorum outbox'a yazılırken bir crypto.randomUUID() alıyor ve her denemede aynı key'le gidiyor. Sahte API aynı key'i ikinci kez görünce kaydı tekrar oluşturmak yerine ilk cevabı Idempotency-Replayed: true header'ıyla tekrar dönüyor.

@Service()
export class ReviewOutbox {
  private readonly database = inject(BookstoreDatabase);
  private readonly http = inject(HttpClient);
  private flushing = false;
  readonly pending = signal(0);

  constructor() {
    window.addEventListener('online', () => void this.flush());
    void this.flush(); // önceki ziyaretten kalan bir şey olabilir
  }

  async enqueue(isbn: string, rating: number, comment: string) {
    const db = await this.database.connection();
    await db.put('outbox', {
      idempotencyKey: crypto.randomUUID(), // bu yorumun her denemesinde aynı key
      isbn,
      rating,
      comment,
      createdAt: new Date().toISOString(),
    });
    await this.flush();
  }

  async flush() {
    if (this.flushing) return;
    this.flushing = true;
    try {
      const db = await this.database.connection();
      // önce oku, sonra gönder: ağ beklenirken transaction açık kalmamalı
      const items = await db.getAllFromIndex('outbox', 'byCreatedAt');
      this.pending.set(items.length);
      if (!navigator.onLine) return;
      for (const item of items) {
        if (!(await this.send(item))) break; // bunu ve sonrakileri sırasıyla beklet
        await db.delete('outbox', item.idempotencyKey);
        this.pending.update((n) => n - 1);
      }
    } finally {
      this.flushing = false;
    }
  }

  // kayıt outbox'tan çıkabilecekse true
  private async send(item: PendingReview): Promise<boolean> {
    const key = item.idempotencyKey;
    try {
      const response = await firstValueFrom(
        this.http.post(
          `/api/books/${item.isbn}/reviews`,
          { rating: item.rating, comment: item.comment },
          { headers: { 'Idempotency-Key': key }, observe: 'response' },
        ),
      );
      const replayed = response.headers.has('Idempotency-Replayed') ? ' (replayed)' : '';
      console.log(`outbox: ${key.slice(0, 8)} -> ${response.status}${replayed}`);
      return true;
    } catch (err) {
      const status = err instanceof HttpErrorResponse ? err.status : 0;
      console.log(
        `outbox: ${key.slice(0, 8)} -> ${status === 0 ? 'network error, will retry' : status}`,
      );
      // 4xx sonradan da başarılı olmaz; timeout ve rate limiting hariç
      return status >= 400 && status < 500 && status !== 408 && status !== 429;
    }
  }
}

flush önce outbox'ın tamamını okuyor, sonra her kaydı gönderiyor ve başarılı olanı ayrı bir işlemle siliyor; Önce Tahmin Et bölümündeki hatayı burada yapmamak için ağ beklenirken açık bir transaction yok. Başarısız bir gönderimde döngü duruyor, böylece sonraki yorumlar öncekinden önce gitmiyor. Tetikleyiciler üç tane: online event'i, service'in oluşturulması (önceki ziyaretten kalanlar için) ve her yeni yorum.

Senaryoyu Playwright'la oynattım: okuma listesine Dune'u ekle, sayfayı yenile, bağlantıyı kes, iki yorum gönder, bağlantıyı geri getir. İlk POST'un cevabını Playwright'ın page.route'u ile yolda düşürdüm: istek sunucuya ulaştı ve kaydedildi, ama sayfa bir ağ hatası gördü. Sonra sayfayı yeniledim:

reading list after reload: Dune
offline
2 review(s) waiting for a connection
outbox in IndexedDB: 2
online
(response of the first POST dropped on the way back)
outbox: efa85646 -> network error, will retry
2 review(s) waiting for a connection
outbox: efa85646 -> 201 (replayed)
outbox: 180b6bf0 -> 201
pending after reload: none
[api] #48 POST /api/books/9780441172719/reviews
[api] #48 201 review 505 for 9780441172719 (key efa85646)
[api] #50 POST /api/books/9780441172719/reviews
[api] #50 201 replayed for key efa85646
[api] #51 POST /api/books/9780441172719/reviews
[api] #51 201 review 506 for 9780441172719 (key 180b6bf0)

Offline'da iki yorum IndexedDB'de bekledi. Bağlantı gelince ilk deneme sunucuda 505 numaralı kaydı oluşturdu ama cevabı kayboldu; outbox kaydı silinmedi ve sıra korunsun diye ikinci yorum da gönderilmedi. Yenilemeden sonra aynı key tekrar gitti, sunucu yeni bir kayıt açmak yerine 505'in cevabını tekrar verdi, ardından ikinci yorum 506 olarak kaydedildi. Sunucuda iki yorum var, üç değil. Aynı senaryo WebKit'te de aynı sonucu verdi.

Üretimde Dikkat

online event'i yalnız "bir ağ arayüzü var" diyor; captive portal'a (otel ya da havalimanı Wi-Fi giriş sayfası) bağlı bir cihaz online görünür ama API'ye ulaşamaz. Bu yüzden outbox başarısızlıkta kaydı silmiyor ve bir sonraki tetiklemeyi bekliyor. Uygulama uzun süre açık kalıyorsa online'a ek olarak belirli aralıklarla (ya da sayfa tekrar görünür olduğunda) flush çağır. flushing bayrağı aynı sekmedeki iki flush'ı engelliyor, iki sekmenin aynı anda göndermesini değil; idempotency key bu durumda da çift kaydı önlüyor, sekmeler arası tek gönderici ise Web Locks ile mümkün ve bu serinin sekmeler yazısının konusu.

Sunucu tarafında idempotency key'leri sonsuza kadar saklama; ama outbox'ta bir kaydın bekleyebileceği en uzun süreden daha kısa da tutma. Bir kullanıcı haftalarca offline kalıp döndüğünde key'i unutmuş bir sunucu yorumu ikinci kez kaydeder. Bir önceki yazıdaki Safari kuralını da hatırla: yedi gün etkileşim olmazsa script ile yazılan storage, outbox dahil, silinebilir.

Schema değişikliklerini deploy sürecinin parçası yap. Yeni bir store ya da index gerektiren bir sürüm, eski sürümün açık sekmeleri yüzünden bloklanabilir; blocking handler'ı ve kullanıcıya gösterilen banner olmadan bu, "uygulama açılmıyor" şikâyeti olarak döner. Büyük veri taşıyan migration'lar (sürüm 1'deki 20.000 kaydı yeni biçime çevirmek gibi) upgrade transaction'ında yapılıyor ve o sürede kullanıcı bekliyor; mümkünse veriyi tembel biçimde, okunurken dönüştür.

Sık Düşülen Tuzaklar

  • Transaction açıkken IndexedDB dışı bir şeyi await etmek. Transaction commit edilir ve sonraki istek TransactionInactiveError fırlatır; ağ işini önce yap, yazmaları sonra tek transaction'da topla.
  • Schema'yı sürüm numarasını artırmadan değiştirmek. Store ya da index yalnız upgradeneeded içinde oluşturulabilir; sürüm aynı kalırsa yeni kod mevcut kullanıcıda hiç çalışmaz.
  • Yayınlanmış bir migration'ı düzenlemek. O sürüme geçmiş kullanıcılar düzeltmeyi hiç almaz; değişikliği yeni bir sürüm olarak ekle.
  • versionchange'i dinlememek. Yeni sürümü açan sekme blocked'da kalır ve kullanıcı neden açılmadığını göremez.
  • Büyük bir veri setini tek transaction'da, tek tek await ederek yazmak. Transaction boyunca store kilitli kalır ve diğer okuma/yazmalar bekler; isteği await etmeden art arda gönderip yalnız tx.done'ı bekle ya da veriyi parçalara bölüp birkaç transaction'da yaz.
  • Outbox'ta başarısız bir kaydı atlayıp sonrakine geçmek. Sıra bozulur; aynı kitap için "düzenle" işlemi "ekle"den önce sunucuya gidebilir.

Küçük Bir Deneme

★☆☆ ReadingListStore'a has(isbn) diye bir computed ekle ve kitap sayfasındaki butonu kitap listedeyse "Remove from reading list" olarak göster.

★★☆ Database'e sürüm 3'ü ekle: readingList kayıtlarına finished: false alanını yazan bir migration. Playwright'ta önce sürüm 2 ile birkaç kayıt oluştur, sonra sürüm 3'le aç ve eski kayıtların dönüştüğünü, sıfırdan açılan bir database'de ise migration'ın boş store üzerinde hatasız çalıştığını doğrula.

★★★ Outbox'a deneme sayısı ve son deneme zamanı ekle; 5xx cevaplarında bir sonraki denemeyi üstel artan bir beklemeyle (1, 2, 4, 8 saniye) yap ve beş denemeden sonra kaydı kullanıcıya "gönderilemedi, tekrar dene ya da sil" diye göster. Sahte API'ye geçici 503 dönen bir mod ekleyip davranışı test et.

Outbox'ı bu yazıda sayfanın kendisi gönderdi; sayfa kapalıyken hiçbir şey olmuyor. Bir sonraki yazıda bunun altyapısına, Service Worker'a geçiyoruz: kayıt ve scope, install → waiting → activate yaşam döngüsü, yeni sürümün neden beklediği ve fetch event'inde cache-first, network-first ve stale-while-revalidate stratejilerini elle yazmak.