@pylonsync/sync 0.3.213 → 0.3.215

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,11 +3,14 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.213",
6
+ "version": "0.3.215",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
10
10
  "scripts": {
11
11
  "check": "tsc -p tsconfig.json --noEmit"
12
+ },
13
+ "devDependencies": {
14
+ "fake-indexeddb": "^6.2.5"
12
15
  }
13
16
  }
@@ -0,0 +1,426 @@
1
+ // IDB warm-load hydration tests.
2
+ //
3
+ // Returning visits to a Pylon app must render the last-known data
4
+ // IMMEDIATELY — before the network pull resolves — by draining the
5
+ // IndexedDB replica into the in-memory store during start(). These
6
+ // tests cover:
7
+ //
8
+ // 1. New user (no IDB cache): start() works, hooks return [].
9
+ // 2. Returning user with cache: list(E) returns rows BEFORE pull.
10
+ // 3. Mid-pull cached visibility: list(E) sees cache while pull races.
11
+ // 4. Stale-while-revalidate: offline-deleted rows get cleaned up.
12
+ // 5. Sign-out clears IDB: next user can't see prior user's data.
13
+ // 6. Single-tx load consistency: one tx returns entities + cursor.
14
+ //
15
+ // Bun has no native IndexedDB; we install `fake-indexeddb` globals
16
+ // here. The reset (`indexedDB.deleteDatabase`) between tests gives
17
+ // each scenario a clean DB without sharing state across suites.
18
+
19
+ import "fake-indexeddb/auto";
20
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
21
+
22
+ import { IndexedDBPersistence, SyncEngine, type Row } from "./index";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Test fetch stub — drives /api/sync/pull + /api/auth/me + entity reconcile.
26
+ // Per-test mutable so individual scenarios can stage server state.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ type Snapshot = Record<string, Row[]>;
30
+ interface ServerState {
31
+ /** Current canonical rows per entity. */
32
+ rows: Snapshot;
33
+ /** Current authoritative sync cursor. */
34
+ serverSeq: number;
35
+ /** Optional delay before /api/sync/pull responds — used to assert
36
+ * that cached data is visible WHILE the pull is in flight. */
37
+ pullDelayMs: number;
38
+ /** Per-test session shape returned by /api/auth/me. */
39
+ session: { user: { id: string } | null; tenant: { id: string } | null };
40
+ }
41
+
42
+ let server: ServerState;
43
+
44
+ function resetServer(): void {
45
+ server = {
46
+ rows: {},
47
+ serverSeq: 1,
48
+ pullDelayMs: 0,
49
+ session: { user: null, tenant: null },
50
+ };
51
+ }
52
+
53
+ function installFetch(): () => void {
54
+ const original = globalThis.fetch;
55
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
56
+ const url = typeof input === "string" ? input : input.toString();
57
+ let status = 200;
58
+ let body: unknown = {};
59
+
60
+ if (url.includes("/api/auth/me")) {
61
+ body = server.session;
62
+ } else if (url.includes("/api/sync/pull")) {
63
+ if (server.pullDelayMs > 0) {
64
+ await new Promise((r) => setTimeout(r, server.pullDelayMs));
65
+ }
66
+ // For a cursor-based pull we return the current set of rows as
67
+ // `insert` events at serverSeq. This is enough to drive the
68
+ // SyncEngine's apply path; reconcile catches deletes elsewhere.
69
+ const changes: Array<Record<string, unknown>> = [];
70
+ for (const [entity, rows] of Object.entries(server.rows)) {
71
+ for (const row of rows) {
72
+ changes.push({
73
+ seq: server.serverSeq,
74
+ entity,
75
+ row_id: row.id as string,
76
+ kind: "insert",
77
+ data: row,
78
+ timestamp: "",
79
+ });
80
+ }
81
+ }
82
+ body = {
83
+ changes,
84
+ cursor: { last_seq: server.serverSeq },
85
+ has_more: false,
86
+ };
87
+ } else if (url.includes("/api/entities/")) {
88
+ // Reconcile per-entity endpoint. URL shape:
89
+ // /api/entities/<EntityName>/cursor?...
90
+ const match = url.match(/\/api\/entities\/([^/?]+)/);
91
+ const entity = match?.[1] ?? "";
92
+ const rows = server.rows[entity] ?? [];
93
+ body = { data: rows, next_cursor: null, has_more: false };
94
+ } else {
95
+ status = 404;
96
+ }
97
+
98
+ return {
99
+ ok: status >= 200 && status < 300,
100
+ status,
101
+ json: async () => body,
102
+ text: async () => JSON.stringify(body),
103
+ headers: new Headers(),
104
+ } as unknown as Response;
105
+ }) as typeof fetch;
106
+ return () => {
107
+ globalThis.fetch = original;
108
+ };
109
+ }
110
+
111
+ async function deleteAllDatabases(): Promise<void> {
112
+ // Drop every Pylon-named DB so a previous test can't bleed into
113
+ // the next one. Each test installs its own engine with a unique
114
+ // appName, but defensive cleanup keeps the suite hermetic.
115
+ const dbs = await (
116
+ indexedDB as unknown as { databases?: () => Promise<{ name?: string }[]> }
117
+ ).databases?.();
118
+ if (!dbs) return;
119
+ await Promise.all(
120
+ dbs
121
+ .filter((d) => d.name?.startsWith("pylon_sync_"))
122
+ .map(
123
+ (d) =>
124
+ new Promise<void>((resolve) => {
125
+ const req = indexedDB.deleteDatabase(d.name!);
126
+ req.onsuccess = () => resolve();
127
+ req.onerror = () => resolve();
128
+ req.onblocked = () => resolve();
129
+ }),
130
+ ),
131
+ );
132
+ }
133
+
134
+ let restoreFetch: (() => void) | null = null;
135
+
136
+ beforeEach(async () => {
137
+ resetServer();
138
+ restoreFetch = installFetch();
139
+ await deleteAllDatabases();
140
+ });
141
+
142
+ afterEach(async () => {
143
+ restoreFetch?.();
144
+ restoreFetch = null;
145
+ await deleteAllDatabases();
146
+ });
147
+
148
+ function makeEngine(appName: string): SyncEngine {
149
+ return new SyncEngine({
150
+ baseUrl: "http://test.invalid",
151
+ persist: true,
152
+ appName,
153
+ transport: "poll",
154
+ multiTab: false,
155
+ reconcileMinIntervalMs: 0,
156
+ pollInterval: 60_000,
157
+ });
158
+ }
159
+
160
+ /** Seed IDB directly under the engine's appName key — simulates "a
161
+ * prior session of this app left data on disk before the user
162
+ * closed the tab". */
163
+ async function seedIDB(
164
+ appName: string,
165
+ entities: Snapshot,
166
+ cursor: { last_seq: number } | null,
167
+ ): Promise<void> {
168
+ const p = new IndexedDBPersistence(appName);
169
+ await p.open();
170
+ for (const [entity, rows] of Object.entries(entities)) {
171
+ for (const row of rows) {
172
+ await p.saveRow(entity, row.id as string, row);
173
+ }
174
+ }
175
+ if (cursor) await p.saveCursor(cursor);
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Tests
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe("IDB warm-load hydration", () => {
183
+ test("first-time user: empty IDB → store starts empty, then populates from pull", async () => {
184
+ server.rows = { Channel: [{ id: "c1", name: "general" }] };
185
+ const engine = makeEngine("warmload-fresh");
186
+ expect(engine.store.list("Channel")).toEqual([]);
187
+ await engine.start();
188
+ // After start the pull has landed seeds — same as today.
189
+ expect(engine.store.list("Channel")).toHaveLength(1);
190
+ engine.stop();
191
+ });
192
+
193
+ test("returning user: cached rows visible immediately after start() (before any pull)", async () => {
194
+ // Seed disk with 3 channels from "yesterday".
195
+ await seedIDB(
196
+ "warmload-return",
197
+ {
198
+ Channel: [
199
+ { id: "c1", name: "general" },
200
+ { id: "c2", name: "random" },
201
+ { id: "c3", name: "off-topic" },
202
+ ],
203
+ },
204
+ { last_seq: 42 },
205
+ );
206
+ // Server has the same 3 channels (no offline mutations).
207
+ server.rows = {
208
+ Channel: [
209
+ { id: "c1", name: "general" },
210
+ { id: "c2", name: "random" },
211
+ { id: "c3", name: "off-topic" },
212
+ ],
213
+ };
214
+ server.serverSeq = 42;
215
+ // Make the pull slow so we can assert cached rows are visible
216
+ // BEFORE the network round-trip lands.
217
+ server.pullDelayMs = 100;
218
+
219
+ const engine = makeEngine("warmload-return");
220
+ const startP = engine.start();
221
+
222
+ // Race: poll the store until rows show up OR the start promise
223
+ // resolves. The warm-load apply has to land FIRST.
224
+ let cachedSeen = -1;
225
+ const deadline = Date.now() + 80;
226
+ while (Date.now() < deadline) {
227
+ const rows = engine.store.list("Channel");
228
+ if (rows.length > 0) {
229
+ cachedSeen = rows.length;
230
+ break;
231
+ }
232
+ await new Promise((r) => setTimeout(r, 5));
233
+ }
234
+ expect(cachedSeen).toBe(3);
235
+
236
+ await startP;
237
+ expect(engine.store.list("Channel")).toHaveLength(3);
238
+ engine.stop();
239
+ });
240
+
241
+ test("mid-pull, hooks read cached rows (stale-while-revalidate)", async () => {
242
+ await seedIDB(
243
+ "warmload-mid",
244
+ { Todo: [{ id: "t1", text: "old" }] },
245
+ { last_seq: 10 },
246
+ );
247
+ server.rows = { Todo: [{ id: "t1", text: "new" }] };
248
+ server.serverSeq = 11;
249
+ server.pullDelayMs = 80;
250
+
251
+ const engine = makeEngine("warmload-mid");
252
+ const startP = engine.start();
253
+ // After IDB hydration but before pull settles: should see the old
254
+ // value. Poll for it.
255
+ let preReconcileText: string | undefined;
256
+ const deadline = Date.now() + 60;
257
+ while (Date.now() < deadline) {
258
+ const t = engine.store.get("Todo", "t1") as { text?: string } | null;
259
+ if (t) {
260
+ preReconcileText = t.text;
261
+ break;
262
+ }
263
+ await new Promise((r) => setTimeout(r, 5));
264
+ }
265
+ expect(preReconcileText).toBe("old");
266
+ await startP;
267
+ // After pull lands: fresh value.
268
+ const after = engine.store.get("Todo", "t1") as { text?: string } | null;
269
+ expect(after?.text).toBe("new");
270
+ engine.stop();
271
+ });
272
+
273
+ test("stale-while-revalidate: rows deleted on server while offline get cleaned up", async () => {
274
+ // Cache has 3 channels from last session. Server has only 2 — c3
275
+ // was deleted while this client was offline, the delete event has
276
+ // fallen off the change log. Reconcile must remove c3.
277
+ await seedIDB(
278
+ "warmload-delete",
279
+ {
280
+ Channel: [
281
+ { id: "c1", name: "general" },
282
+ { id: "c2", name: "random" },
283
+ { id: "c3", name: "deleted-while-offline" },
284
+ ],
285
+ },
286
+ { last_seq: 5 },
287
+ );
288
+ server.rows = {
289
+ Channel: [
290
+ { id: "c1", name: "general" },
291
+ { id: "c2", name: "random" },
292
+ ],
293
+ };
294
+ server.serverSeq = 5; // no new events past our cursor
295
+
296
+ const engine = makeEngine("warmload-delete");
297
+ await engine.start();
298
+ // Trigger reconcile manually — in production this fires from the
299
+ // WS onConnected callback. We're on the poll transport here so we
300
+ // call it directly to assert the behavior under test.
301
+ await engine.reconcile(["Channel"]);
302
+ const ids = engine.store
303
+ .list("Channel")
304
+ .map((r) => (r as { id: string }).id)
305
+ .sort();
306
+ expect(ids).toEqual(["c1", "c2"]);
307
+ engine.stop();
308
+ });
309
+
310
+ test("sign-out clears IDB: a different user does not see prior user's data", async () => {
311
+ const appName = "warmload-signout";
312
+
313
+ // User A signs in, cache gets populated.
314
+ await seedIDB(
315
+ appName,
316
+ { Channel: [{ id: "c-a", name: "user-a-only" }] },
317
+ { last_seq: 7 },
318
+ );
319
+
320
+ // Simulate sign-out via resetReplica (the path applySessionTransition
321
+ // takes on tenant flip / token flip).
322
+ {
323
+ const engine = makeEngine(appName);
324
+ await engine.start();
325
+ expect(engine.store.list("Channel")).toHaveLength(1);
326
+ await engine.resetReplica();
327
+ expect(engine.store.list("Channel")).toEqual([]);
328
+ engine.stop();
329
+ }
330
+
331
+ // User B signs in (server returns different data).
332
+ server.rows = { Channel: [{ id: "c-b", name: "user-b-only" }] };
333
+ server.serverSeq = 1;
334
+
335
+ const engineB = makeEngine(appName);
336
+ await engineB.start();
337
+ const ids = engineB.store
338
+ .list("Channel")
339
+ .map((r) => (r as { id: string }).id);
340
+ // No leakage of c-a — clear() wiped IDB on resetReplica.
341
+ expect(ids).not.toContain("c-a");
342
+ expect(ids).toContain("c-b");
343
+ engineB.stop();
344
+ });
345
+
346
+ test("loadSnapshot returns entities + cursor in one transaction", async () => {
347
+ const appName = "warmload-atomic";
348
+ await seedIDB(
349
+ appName,
350
+ {
351
+ Channel: [{ id: "c1" }],
352
+ Todo: [{ id: "t1" }, { id: "t2" }],
353
+ },
354
+ { last_seq: 99 },
355
+ );
356
+ const p = new IndexedDBPersistence(appName);
357
+ await p.open();
358
+ const result = await p.loadSnapshot();
359
+ expect(result.hadCache).toBe(true);
360
+ expect(result.cursor?.last_seq).toBe(99);
361
+ expect(Object.keys(result.entities).sort()).toEqual(["Channel", "Todo"]);
362
+ expect(result.entities.Channel).toHaveLength(1);
363
+ expect(result.entities.Todo).toHaveLength(2);
364
+
365
+ // Empty DB: hadCache = false.
366
+ await deleteAllDatabases();
367
+ const p2 = new IndexedDBPersistence(appName);
368
+ await p2.open();
369
+ const empty = await p2.loadSnapshot();
370
+ expect(empty.hadCache).toBe(false);
371
+ expect(empty.cursor).toBeNull();
372
+ expect(empty.entities).toEqual({});
373
+ });
374
+
375
+ test("lastPullStartedFromZero only short-circuits the post-pull reconcile when IDB cache was empty", async () => {
376
+ // Returning user with cached cursor=0 (rare — manual partial wipe).
377
+ // The IDB rows exist; the cursor was lost. Pull from 0 returns
378
+ // current server rows but no tombstones for offline deletes. The
379
+ // fast-path MUST NOT skip the reconcile pass.
380
+ await seedIDB(
381
+ "warmload-fastpath",
382
+ { Channel: [{ id: "c-ghost", name: "deleted-on-server" }] },
383
+ null, // no cursor — pull will start from 0
384
+ );
385
+ // Server has different rows (c-ghost no longer exists).
386
+ server.rows = { Channel: [{ id: "c-live", name: "current" }] };
387
+ server.serverSeq = 100;
388
+
389
+ const engine = makeEngine("warmload-fastpath");
390
+ await engine.start();
391
+ // After start() the engine has hydrated c-ghost from IDB and the
392
+ // pull has applied c-live as a fresh insert (both rows in memory).
393
+ // Run the reconcile that onConnected would normally fire — c-ghost
394
+ // must get cleaned up.
395
+ await engine.reconcile(["Channel"]);
396
+ const ids = engine.store
397
+ .list("Channel")
398
+ .map((r) => (r as { id: string }).id)
399
+ .sort();
400
+ expect(ids).toEqual(["c-live"]);
401
+ engine.stop();
402
+ });
403
+
404
+ test("cold IDB load > 100ms warns via console", async () => {
405
+ // No data — opening the DB is fast on fake-indexeddb. We can't
406
+ // realistically force a >100ms read here without monkey-patching
407
+ // the transaction queue. Instead, assert the warning logic is
408
+ // wired by intercepting console.warn during a normal start() and
409
+ // verifying the absence of the warning when the load is fast.
410
+ let warned = false;
411
+ const origWarn = console.warn;
412
+ console.warn = (...args: unknown[]) => {
413
+ const msg = args.map((a) => String(a)).join(" ");
414
+ if (msg.includes("cold IDB load took")) warned = true;
415
+ };
416
+ try {
417
+ const engine = makeEngine("warmload-perf");
418
+ await engine.start();
419
+ engine.stop();
420
+ } finally {
421
+ console.warn = origWarn;
422
+ }
423
+ // Fast path — no warning expected on a trivial load.
424
+ expect(warned).toBe(false);
425
+ });
426
+ });
package/src/index.ts CHANGED
@@ -202,6 +202,24 @@ export class SyncEngine {
202
202
  return this._hydrated;
203
203
  }
204
204
 
205
+ /**
206
+ * True when the engine drained at least one row OR a saved cursor
207
+ * out of IndexedDB during `start()`. Distinguishes a returning user
208
+ * (cached replica may contain rows the server has since deleted) from
209
+ * a true first-time user (cache empty, pull-from-0 IS canonical
210
+ * truth).
211
+ *
212
+ * Used by the WS `onConnected` fast-path: `lastPullStartedFromZero`
213
+ * only fires the reconcile-skip when this flag is ALSO false. A
214
+ * returning user whose IDB cursor somehow rolled back to 0 (rare:
215
+ * partial wipe, corrupt write) must still get the reconcile pass —
216
+ * otherwise rows deleted on the server while the tab was closed
217
+ * survive forever.
218
+ *
219
+ * Read-only after start() observes the IDB load.
220
+ */
221
+ private _hadCachedReplica = false;
222
+
205
223
  readonly store: LocalStore;
206
224
  readonly mutations: MutationQueue;
207
225
 
@@ -433,12 +451,38 @@ export class SyncEngine {
433
451
  const shouldPersist = this.config.persist !== false && typeof indexedDB !== "undefined";
434
452
  if (shouldPersist) {
435
453
  try {
436
- const { IndexedDBPersistence, persistChange } = await import("./persistence");
454
+ const { IndexedDBPersistence } = await import("./persistence");
437
455
  this.persistence = new IndexedDBPersistence(this.config.appName);
438
456
  await this.persistence.open();
439
457
 
440
- // Load cached data into the store.
441
- const cached = await this.persistence.loadAllEntities();
458
+ // Warm-load entities + cursor in ONE readonly transaction so
459
+ // the hydrated rows and the cursor we'll advance from are a
460
+ // consistent snapshot. Separate reads could (in a multi-tab
461
+ // race) interleave a mid-load save and read (rows@C, cursor@C+1)
462
+ // — the pull would then skip seqs we never applied. The
463
+ // post-load timing log surfaces cold-IDB pages so a regression
464
+ // (50MB cache, slow disk) is observable.
465
+ const idbLoadStart =
466
+ typeof performance !== "undefined" ? performance.now() : Date.now();
467
+ const { entities: cached, cursor: cachedCursor, hadCache } =
468
+ await this.persistence.loadSnapshot();
469
+ const idbLoadMs =
470
+ (typeof performance !== "undefined" ? performance.now() : Date.now()) -
471
+ idbLoadStart;
472
+ if (idbLoadMs > 100) {
473
+ console.warn(
474
+ `[persistence] cold IDB load took ${idbLoadMs.toFixed(0)}ms (${
475
+ Object.keys(cached).length
476
+ } entities)`,
477
+ );
478
+ }
479
+ // Record whether IDB had a prior session's state. The cold-load
480
+ // fast-path in onConnected (skip post-pull reconcile when the
481
+ // pull was a full snapshot from cursor=0) is only safe when
482
+ // there was no cached replica to begin with — a returning user
483
+ // whose pull-from-cursor misses an offline server-side delete
484
+ // depends on that reconcile pass to catch the ghost row.
485
+ this._hadCachedReplica = hadCache;
442
486
  let hydrated = false;
443
487
  for (const [entity, rows] of Object.entries(cached)) {
444
488
  for (const row of rows) {
@@ -459,10 +503,13 @@ export class SyncEngine {
459
503
  else this.store.notify(); // notify even on empty cache so useQuery
460
504
  // sees `isHydrated()` flip and can drop its initial loading state.
461
505
 
462
- // Load cursor.
463
- const savedCursor = await this.persistence.loadCursor();
464
- if (savedCursor) {
465
- this.cursor = savedCursor;
506
+ // Apply the cached cursor BEFORE pull so the first pull is a
507
+ // delta against where we left off, not a full re-snapshot.
508
+ // Already part of the single loadAll() tx above — assigning
509
+ // here can't race a concurrent save because pull/push haven't
510
+ // started yet (initMultiTab is still ahead).
511
+ if (cachedCursor) {
512
+ this.cursor = cachedCursor;
466
513
  }
467
514
 
468
515
  // Auto-save changes to IndexedDB. Returns a Promise so the async
@@ -951,6 +998,14 @@ export class SyncEngine {
951
998
  private async resetReplicaInner(): Promise<void> {
952
999
  this.cursor = { last_seq: 0 };
953
1000
  this.store.clearAll();
1001
+ // The cache is now empty. The next pull will start from 0 and
1002
+ // return a full snapshot — that's a true cold start, so the
1003
+ // onConnected fast-path may skip the post-pull reconcile. Without
1004
+ // this flip, a sign-out → sign-in inside the same tab would
1005
+ // forever re-run reconcile after every pull because
1006
+ // `_hadCachedReplica` was set to true at start() time and never
1007
+ // cleared.
1008
+ this._hadCachedReplica = false;
954
1009
  if (this.persistence) {
955
1010
  try {
956
1011
  await this.persistence.clear();
@@ -2147,10 +2202,20 @@ export class SyncEngine {
2147
2202
  // reconnect-after-disconnect paths invoke reconcile() directly
2148
2203
  // (not gated by this flag) so the safety net still triggers.
2149
2204
  void this.pull().then(() => {
2150
- if (this.lastPullStartedFromZero) {
2205
+ // Cold-load fast-path: skip reconcile only when this WAS a
2206
+ // true cold start (no IDB cache → the pull-from-0 returned
2207
+ // every visible row, reconcile would refetch the same set).
2208
+ // A returning user whose pull happened to start from 0
2209
+ // (cursor rolled back, partial cache wipe) MUST still run
2210
+ // reconcile to catch rows deleted on the server while the
2211
+ // tab was closed — the snapshot path only returns currently-
2212
+ // visible rows, never tombstones, so ghost rows on the
2213
+ // cached side persist without the reconcile pass.
2214
+ if (this.lastPullStartedFromZero && !this._hadCachedReplica) {
2151
2215
  this.lastPullStartedFromZero = false;
2152
2216
  return;
2153
2217
  }
2218
+ this.lastPullStartedFromZero = false;
2154
2219
  return this.reconcile();
2155
2220
  });
2156
2221
  },
@@ -162,6 +162,61 @@ export class IndexedDBPersistence {
162
162
  });
163
163
  }
164
164
 
165
+ /**
166
+ * Atomic warm-load: returns entities + cursor in a single IDB
167
+ * read transaction. Used by `SyncEngine.start()` to hydrate the
168
+ * in-memory replica BEFORE the network pull resolves so React
169
+ * hooks see real data on first render (no empty-then-populated
170
+ * flash on returning visits).
171
+ *
172
+ * `hadCache` is true when at least one row OR a saved cursor
173
+ * was found. The engine uses it to distinguish "true cold start
174
+ * — pull-from-0 IS a full snapshot, skip the post-snapshot
175
+ * reconcile" from "returning user with cached state — pull-from-
176
+ * cursor may miss server-side deletes that happened offline, the
177
+ * onConnected reconcile MUST run". Without that distinction, a
178
+ * returning user whose cursor somehow rolled back to 0 (rare:
179
+ * IDB partial corruption, cleared-by-mistake) would end up with
180
+ * ghost rows that survive forever.
181
+ *
182
+ * Single readonly tx is intentional — two separate reads could
183
+ * race a mid-load saveCursor/saveRow from another tab's apply
184
+ * pipeline and read an inconsistent (cursor C', rows for cursor C)
185
+ * pair. One tx guarantees a consistent snapshot.
186
+ */
187
+ async loadSnapshot(): Promise<{
188
+ entities: Record<string, Row[]>;
189
+ cursor: SyncCursor | null;
190
+ hadCache: boolean;
191
+ }> {
192
+ if (!this.db) return { entities: {}, cursor: null, hadCache: false };
193
+ const tx = this.db.transaction([STORE_NAME, CURSOR_STORE], "readonly");
194
+ const entitiesReq = tx.objectStore(STORE_NAME).getAll();
195
+ const cursorReq = tx.objectStore(CURSOR_STORE).get("cursor");
196
+ return new Promise((resolve) => {
197
+ tx.oncomplete = () => {
198
+ const entities: Record<string, Row[]> = {};
199
+ for (const item of (entitiesReq.result ?? []) as {
200
+ entity: string;
201
+ id: string;
202
+ data: Row;
203
+ }[]) {
204
+ if (!entities[item.entity]) entities[item.entity] = [];
205
+ entities[item.entity].push({ id: item.id, ...item.data });
206
+ }
207
+ const cursorRec = cursorReq.result as { last_seq?: number } | undefined;
208
+ const cursor: SyncCursor | null = cursorRec
209
+ ? { last_seq: cursorRec.last_seq ?? 0 }
210
+ : null;
211
+ const hadCache =
212
+ Object.keys(entities).length > 0 || cursor !== null;
213
+ resolve({ entities, cursor, hadCache });
214
+ };
215
+ tx.onerror = () => resolve({ entities: {}, cursor: null, hadCache: false });
216
+ tx.onabort = () => resolve({ entities: {}, cursor: null, hadCache: false });
217
+ });
218
+ }
219
+
165
220
  /** Save the sync cursor. */
166
221
  async saveCursor(cursor: SyncCursor): Promise<void> {
167
222
  if (!this.db) return;