@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 +4 -1
- package/src/idb-warm-load.test.ts +426 -0
- package/src/index.ts +412 -12
- package/src/multi-tab-orchestrator.ts +80 -0
- package/src/persistence.ts +55 -0
- package/src/room-push.test.ts +197 -0
- package/src/room-subscriptions.test.ts +189 -0
- package/src/room-subscriptions.ts +301 -0
- package/src/test-harness/env.ts +6 -0
- package/src/test-harness/transport.ts +23 -0
package/package.json
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
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
|
+
});
|