@pylonsync/sync 0.3.213 → 0.3.216

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.216",
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
+ });