@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 +4 -1
- package/src/idb-warm-load.test.ts +426 -0
- package/src/index.ts +73 -8
- package/src/persistence.ts +55 -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.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
|
|
454
|
+
const { IndexedDBPersistence } = await import("./persistence");
|
|
437
455
|
this.persistence = new IndexedDBPersistence(this.config.appName);
|
|
438
456
|
await this.persistence.open();
|
|
439
457
|
|
|
440
|
-
//
|
|
441
|
-
|
|
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
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
},
|
package/src/persistence.ts
CHANGED
|
@@ -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;
|