@pylonsync/sync 0.22.25 → 0.22.27
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/dist/index.d.ts +12 -36
- package/dist/local-store.d.ts +18 -3
- package/dist/mutation-queue.d.ts +6 -2
- package/dist/persistence.d.ts +3 -4
- package/dist/server-subscriptions.d.ts +2 -0
- package/dist/subscription-coordinator.d.ts +1 -7
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +50 -51
- package/src/client-performance.test.ts +366 -0
- package/src/crdt-follower-catchup.test.ts +56 -25
- package/src/index.ts +146 -132
- package/src/local-store.ts +112 -47
- package/src/mutation-owner-token.test.ts +6 -1
- package/src/mutation-owner.test.ts +6 -1
- package/src/mutation-queue.ts +58 -35
- package/src/persistence.ts +16 -4
- package/src/push-scheduling.test.ts +171 -0
- package/src/reconcile.test.ts +19 -0
- package/src/round6-codex.test.ts +1 -4
- package/src/scenarios.test.ts +6 -8
- package/src/server-subscriptions.ts +6 -0
- package/src/subscription-coordinator.test.ts +5 -2
- package/src/subscription-coordinator.ts +12 -14
package/src/local-store.ts
CHANGED
|
@@ -35,7 +35,36 @@ export class LocalStore {
|
|
|
35
35
|
* `optimistic_delete_releases_id_when_server_confirms`.
|
|
36
36
|
*/
|
|
37
37
|
private optimisticTombstones: Map<string, Set<string>> = new Map();
|
|
38
|
-
private listeners
|
|
38
|
+
private listeners = new Map<(entity?: string) => void, string | undefined>();
|
|
39
|
+
private versions = new Map<string, number>();
|
|
40
|
+
private rowVersions = new Map<string, Map<string, number>>();
|
|
41
|
+
private revision = 0;
|
|
42
|
+
private resetVersion = 0;
|
|
43
|
+
|
|
44
|
+
/** Changes only when this entity changes or the replica is cleared. */
|
|
45
|
+
version(entity: string): number {
|
|
46
|
+
return this.versions.get(entity) ?? this.resetVersion;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Changes only when this row changes or the replica is cleared. */
|
|
50
|
+
rowVersion(entity: string, id: string): number {
|
|
51
|
+
return this.rowVersions.get(entity)?.get(id) ?? this.resetVersion;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private touch(entity: string, id: string): void {
|
|
55
|
+
const revision = ++this.revision;
|
|
56
|
+
this.versions.set(entity, revision);
|
|
57
|
+
if (this.tables.get(entity)?.has(id)) {
|
|
58
|
+
let versions = this.rowVersions.get(entity);
|
|
59
|
+
if (!versions) {
|
|
60
|
+
versions = new Map();
|
|
61
|
+
this.rowVersions.set(entity, versions);
|
|
62
|
+
}
|
|
63
|
+
versions.set(id, revision);
|
|
64
|
+
} else {
|
|
65
|
+
this.rowVersions.get(entity)?.delete(id);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
39
68
|
|
|
40
69
|
/** Get all rows for an entity. */
|
|
41
70
|
list(entity: string): Row[] {
|
|
@@ -44,6 +73,11 @@ export class LocalStore {
|
|
|
44
73
|
return Array.from(table.values());
|
|
45
74
|
}
|
|
46
75
|
|
|
76
|
+
/** Iterate current rows without copying the table. Consume synchronously. */
|
|
77
|
+
rows(entity: string): IterableIterator<Row> {
|
|
78
|
+
return this.tables.get(entity)?.values() ?? [][Symbol.iterator]();
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
/** Get a row by ID. */
|
|
48
82
|
get(entity: string, id: string): Row | null {
|
|
49
83
|
return this.tables.get(entity)?.get(id) ?? null;
|
|
@@ -84,6 +118,7 @@ export class LocalStore {
|
|
|
84
118
|
const table = this.tables.get(entity);
|
|
85
119
|
if (!table || !table.has(id)) return false;
|
|
86
120
|
table.delete(id);
|
|
121
|
+
this.touch(entity, id);
|
|
87
122
|
this.recordTombstone(entity, id, tombstoneSeq);
|
|
88
123
|
return true;
|
|
89
124
|
}
|
|
@@ -107,6 +142,7 @@ export class LocalStore {
|
|
|
107
142
|
*/
|
|
108
143
|
revokeRow(entity: string, id: string, tombstoneSeq: number): boolean {
|
|
109
144
|
const removed = this.tables.get(entity)?.delete(id) ?? false;
|
|
145
|
+
if (removed) this.touch(entity, id);
|
|
110
146
|
this.recordTombstone(entity, id, tombstoneSeq);
|
|
111
147
|
return removed;
|
|
112
148
|
}
|
|
@@ -180,6 +216,7 @@ export class LocalStore {
|
|
|
180
216
|
this.recordTombstone(change.entity, change.row_id, change.seq);
|
|
181
217
|
break;
|
|
182
218
|
}
|
|
219
|
+
this.touch(change.entity, change.row_id);
|
|
183
220
|
}
|
|
184
221
|
|
|
185
222
|
/** Apply multiple changes synchronously. Persistence runs fire-
|
|
@@ -190,7 +227,7 @@ export class LocalStore {
|
|
|
190
227
|
for (const change of changes) {
|
|
191
228
|
this.applyChange(change);
|
|
192
229
|
}
|
|
193
|
-
this.
|
|
230
|
+
this.notifyEntities(new Set(changes.map((change) => change.entity)));
|
|
194
231
|
|
|
195
232
|
if (this._persistFn) {
|
|
196
233
|
for (const change of changes) {
|
|
@@ -210,7 +247,7 @@ export class LocalStore {
|
|
|
210
247
|
for (const change of changes) {
|
|
211
248
|
this.applyChange(change);
|
|
212
249
|
}
|
|
213
|
-
this.
|
|
250
|
+
this.notifyEntities(new Set(changes.map((change) => change.entity)));
|
|
214
251
|
return changes.map((c) => this.hydrateFromMemory(c));
|
|
215
252
|
}
|
|
216
253
|
|
|
@@ -233,7 +270,7 @@ export class LocalStore {
|
|
|
233
270
|
for (const change of changes) {
|
|
234
271
|
this.applyChange(change);
|
|
235
272
|
}
|
|
236
|
-
this.
|
|
273
|
+
this.notifyEntities(new Set(changes.map((change) => change.entity)));
|
|
237
274
|
let allDurable = true;
|
|
238
275
|
if (this._persistFn) {
|
|
239
276
|
// Sequential await — concurrent IDB writes can resolve out of
|
|
@@ -272,15 +309,25 @@ export class LocalStore {
|
|
|
272
309
|
* (treated as durable / fire-and-forget). */
|
|
273
310
|
_persistFn: ((change: ChangeEvent) => void | Promise<boolean>) | null = null;
|
|
274
311
|
|
|
275
|
-
/** Subscribe to
|
|
276
|
-
subscribe(listener: () => void): () => void {
|
|
277
|
-
this.listeners.
|
|
278
|
-
return () =>
|
|
312
|
+
/** Subscribe globally or to one entity. Global status/reset signals reach both. */
|
|
313
|
+
subscribe(listener: (entity?: string) => void, entity?: string): () => void {
|
|
314
|
+
this.listeners.set(listener, entity);
|
|
315
|
+
return () => {
|
|
316
|
+
this.listeners.delete(listener);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** No entity means a global status or reset signal. */
|
|
321
|
+
notify(entity?: string): void {
|
|
322
|
+
this.notifyEntities(entity === undefined ? undefined : new Set([entity]));
|
|
279
323
|
}
|
|
280
324
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
325
|
+
private notifyEntities(entities?: ReadonlySet<string>): void {
|
|
326
|
+
if (entities?.size === 0) return;
|
|
327
|
+
const changedEntity =
|
|
328
|
+
entities?.size === 1 ? entities.values().next().value : undefined;
|
|
329
|
+
for (const [listener, entity] of this.listeners) {
|
|
330
|
+
if (!entities || entity === undefined || entities.has(entity)) listener(changedEntity);
|
|
284
331
|
}
|
|
285
332
|
}
|
|
286
333
|
|
|
@@ -291,7 +338,8 @@ export class LocalStore {
|
|
|
291
338
|
this.tables.set(entity, new Map());
|
|
292
339
|
}
|
|
293
340
|
this.tables.get(entity)!.set(tempId, { id: tempId, ...data });
|
|
294
|
-
this.
|
|
341
|
+
this.touch(entity, tempId);
|
|
342
|
+
this.notify(entity);
|
|
295
343
|
return tempId;
|
|
296
344
|
}
|
|
297
345
|
|
|
@@ -312,7 +360,8 @@ export class LocalStore {
|
|
|
312
360
|
this.tables.set(entity, new Map());
|
|
313
361
|
}
|
|
314
362
|
this.tables.get(entity)!.set(id, { ...data, id });
|
|
315
|
-
this.
|
|
363
|
+
this.touch(entity, id);
|
|
364
|
+
this.notify(entity);
|
|
316
365
|
}
|
|
317
366
|
|
|
318
367
|
/**
|
|
@@ -327,7 +376,10 @@ export class LocalStore {
|
|
|
327
376
|
*/
|
|
328
377
|
rollbackOptimisticInsert(entity: string, id: string): void {
|
|
329
378
|
const removed = this.tables.get(entity)?.delete(id);
|
|
330
|
-
if (removed)
|
|
379
|
+
if (removed) {
|
|
380
|
+
this.touch(entity, id);
|
|
381
|
+
this.notify(entity);
|
|
382
|
+
}
|
|
331
383
|
}
|
|
332
384
|
|
|
333
385
|
/** Apply an optimistic update. */
|
|
@@ -337,7 +389,8 @@ export class LocalStore {
|
|
|
337
389
|
const existing = table.get(id);
|
|
338
390
|
if (existing) {
|
|
339
391
|
table.set(id, { ...existing, ...data });
|
|
340
|
-
this.
|
|
392
|
+
this.touch(entity, id);
|
|
393
|
+
this.notify(entity);
|
|
341
394
|
}
|
|
342
395
|
}
|
|
343
396
|
|
|
@@ -369,7 +422,8 @@ export class LocalStore {
|
|
|
369
422
|
// Server authoritatively removed this row mid-flight — its
|
|
370
423
|
// deletion outranks our local rollback.
|
|
371
424
|
this.tables.get(entity)?.delete(id);
|
|
372
|
-
this.
|
|
425
|
+
this.touch(entity, id);
|
|
426
|
+
this.notify(entity);
|
|
373
427
|
return;
|
|
374
428
|
}
|
|
375
429
|
if (prev) {
|
|
@@ -378,7 +432,8 @@ export class LocalStore {
|
|
|
378
432
|
} else {
|
|
379
433
|
this.tables.get(entity)?.delete(id);
|
|
380
434
|
}
|
|
381
|
-
this.
|
|
435
|
+
this.touch(entity, id);
|
|
436
|
+
this.notify(entity);
|
|
382
437
|
}
|
|
383
438
|
|
|
384
439
|
/** Apply an optimistic delete. Block any incoming insert/update
|
|
@@ -389,7 +444,8 @@ export class LocalStore {
|
|
|
389
444
|
this.optimisticTombstones.set(entity, new Set());
|
|
390
445
|
}
|
|
391
446
|
this.optimisticTombstones.get(entity)!.add(id);
|
|
392
|
-
this.
|
|
447
|
+
this.touch(entity, id);
|
|
448
|
+
this.notify(entity);
|
|
393
449
|
}
|
|
394
450
|
|
|
395
451
|
/**
|
|
@@ -409,12 +465,12 @@ export class LocalStore {
|
|
|
409
465
|
* the engine's cursor stays pinned to the change-log position the
|
|
410
466
|
* reconcile started from.
|
|
411
467
|
*/
|
|
412
|
-
|
|
468
|
+
applyReconcileInMemory(
|
|
413
469
|
entity: string,
|
|
414
470
|
upserts: Row[],
|
|
415
471
|
removalIds: string[],
|
|
416
472
|
tombstoneSeq: number,
|
|
417
|
-
):
|
|
473
|
+
): ChangeEvent[] {
|
|
418
474
|
if (!this.tables.has(entity)) this.tables.set(entity, new Map());
|
|
419
475
|
const table = this.tables.get(entity)!;
|
|
420
476
|
const applied: Array<{ id: string; row: Row }> = [];
|
|
@@ -427,6 +483,7 @@ export class LocalStore {
|
|
|
427
483
|
if (this.isTombstoned(entity, id, tombstoneSeq + 1)) continue;
|
|
428
484
|
const merged = { ...row, id };
|
|
429
485
|
table.set(id, merged);
|
|
486
|
+
this.touch(entity, id);
|
|
430
487
|
applied.push({ id, row: merged });
|
|
431
488
|
}
|
|
432
489
|
const removed: string[] = [];
|
|
@@ -435,43 +492,51 @@ export class LocalStore {
|
|
|
435
492
|
removed.push(id);
|
|
436
493
|
}
|
|
437
494
|
}
|
|
438
|
-
if (applied.length > 0 || removed.length > 0) this.notify();
|
|
439
|
-
|
|
440
|
-
|
|
495
|
+
if (applied.length > 0 || removed.length > 0) this.notify(entity);
|
|
496
|
+
return [
|
|
497
|
+
...applied.map(({ id, row }): ChangeEvent => ({
|
|
498
|
+
seq: tombstoneSeq + 1,
|
|
499
|
+
entity,
|
|
500
|
+
row_id: id,
|
|
501
|
+
kind: "insert",
|
|
502
|
+
data: row,
|
|
503
|
+
timestamp: "",
|
|
504
|
+
})),
|
|
505
|
+
...removed.map((id): ChangeEvent => ({
|
|
506
|
+
seq: tombstoneSeq,
|
|
507
|
+
entity,
|
|
508
|
+
row_id: id,
|
|
509
|
+
kind: "delete",
|
|
510
|
+
timestamp: "",
|
|
511
|
+
})),
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Apply and persist with the single-row fallback for standalone stores. */
|
|
516
|
+
async applyReconcileBatch(
|
|
517
|
+
entity: string,
|
|
518
|
+
upserts: Row[],
|
|
519
|
+
removalIds: string[],
|
|
520
|
+
tombstoneSeq: number,
|
|
521
|
+
): Promise<void> {
|
|
522
|
+
const changes = this.applyReconcileInMemory(
|
|
523
|
+
entity, upserts, removalIds, tombstoneSeq,
|
|
524
|
+
);
|
|
441
525
|
if (this._persistFn) {
|
|
442
|
-
for (const
|
|
443
|
-
const ev: ChangeEvent = {
|
|
444
|
-
seq: tombstoneSeq + 1,
|
|
445
|
-
entity,
|
|
446
|
-
row_id: id,
|
|
447
|
-
kind: "insert",
|
|
448
|
-
data: row,
|
|
449
|
-
timestamp: "",
|
|
450
|
-
};
|
|
451
|
-
const result = this._persistFn(ev);
|
|
452
|
-
if (result instanceof Promise) await result;
|
|
453
|
-
}
|
|
454
|
-
for (const id of removed) {
|
|
455
|
-
const ev: ChangeEvent = {
|
|
456
|
-
seq: tombstoneSeq,
|
|
457
|
-
entity,
|
|
458
|
-
row_id: id,
|
|
459
|
-
kind: "delete",
|
|
460
|
-
data: undefined as unknown as Row,
|
|
461
|
-
timestamp: "",
|
|
462
|
-
};
|
|
463
|
-
const result = this._persistFn(ev);
|
|
464
|
-
if (result instanceof Promise) await result;
|
|
465
|
-
}
|
|
526
|
+
for (const change of changes) await this._persistFn(change);
|
|
466
527
|
}
|
|
467
528
|
}
|
|
468
529
|
|
|
530
|
+
|
|
469
531
|
/**
|
|
470
532
|
* Drop every table + tombstone in-place, then notify. Used by the
|
|
471
533
|
* sync engine's `resetReplica()` on identity flip (token or tenant
|
|
472
534
|
* changed — the old replica reflects a different visible set).
|
|
473
535
|
*/
|
|
474
536
|
clearAll(): void {
|
|
537
|
+
this.resetVersion = ++this.revision;
|
|
538
|
+
this.versions.clear();
|
|
539
|
+
this.rowVersions.clear();
|
|
475
540
|
this.tables.clear();
|
|
476
541
|
this.tombstones.clear();
|
|
477
542
|
this.optimisticTombstones.clear();
|
|
@@ -92,9 +92,13 @@ describe("mutation owner and the bearer token", () => {
|
|
|
92
92
|
}, 10_000);
|
|
93
93
|
|
|
94
94
|
test("a session that expired across a reload keeps the user's queued writes", async () => {
|
|
95
|
+
let pushCalls = 0;
|
|
95
96
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
96
97
|
const url = String(input);
|
|
97
|
-
if (url.includes("/api/sync/push"))
|
|
98
|
+
if (url.includes("/api/sync/push")) {
|
|
99
|
+
pushCalls += 1;
|
|
100
|
+
throw new Error("must not push");
|
|
101
|
+
}
|
|
98
102
|
const body = url.includes("/api/auth/me")
|
|
99
103
|
? { user_id: null }
|
|
100
104
|
: { changes: [], cursor: { last_seq: 0 }, has_more: false };
|
|
@@ -124,6 +128,7 @@ describe("mutation owner and the bearer token", () => {
|
|
|
124
128
|
});
|
|
125
129
|
await engine.start();
|
|
126
130
|
await engine.push();
|
|
131
|
+
expect(pushCalls).toBe(0);
|
|
127
132
|
expect(engine.pendingCount()).toBe(1);
|
|
128
133
|
engine.stop();
|
|
129
134
|
reopened.connection?.close();
|
|
@@ -112,9 +112,13 @@ describe("mutation owner", () => {
|
|
|
112
112
|
}, 10_000);
|
|
113
113
|
|
|
114
114
|
test("after a reload while signed out, the previous user's queued writes are not pushed", async () => {
|
|
115
|
+
let pushCalls = 0;
|
|
115
116
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
116
117
|
const url = String(input);
|
|
117
|
-
if (url.includes("/api/sync/push"))
|
|
118
|
+
if (url.includes("/api/sync/push")) {
|
|
119
|
+
pushCalls += 1;
|
|
120
|
+
throw new Error("must not push");
|
|
121
|
+
}
|
|
118
122
|
const body = url.includes("/api/sync/pull")
|
|
119
123
|
? { changes: [], cursor: { last_seq: 0 }, has_more: false }
|
|
120
124
|
: url.includes("/api/auth/me")
|
|
@@ -150,6 +154,7 @@ describe("mutation owner", () => {
|
|
|
150
154
|
});
|
|
151
155
|
await engine.start();
|
|
152
156
|
await engine.push();
|
|
157
|
+
expect(pushCalls).toBe(0);
|
|
153
158
|
expect(engine.pendingCount()).toBe(1);
|
|
154
159
|
expect(engine.mutations.pending()[0]!.owner).toBe("u1");
|
|
155
160
|
engine.stop();
|
package/src/mutation-queue.ts
CHANGED
|
@@ -73,12 +73,14 @@ export interface PendingMutation {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Optional persistence backend. The default IndexedDB persistence
|
|
76
|
-
* layer
|
|
77
|
-
*
|
|
76
|
+
* layer supports incremental writes with `saveChanges`. Custom backends
|
|
77
|
+
* can keep the full-snapshot `saveAll` contract.
|
|
78
78
|
*/
|
|
79
79
|
export interface MutationQueuePersistence {
|
|
80
80
|
saveAll(mutations: PendingMutation[]): Promise<void>;
|
|
81
81
|
loadAll(): Promise<PendingMutation[]>;
|
|
82
|
+
/** Optional ordered, atomic delta write. Unchanged entries stay on disk. */
|
|
83
|
+
saveChanges?(upserts: PendingMutation[], removedIds: string[]): Promise<void>;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
interface OutcomeWaiter {
|
|
@@ -87,7 +89,7 @@ interface OutcomeWaiter {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
export class MutationQueue {
|
|
90
|
-
private queue
|
|
92
|
+
private queue = new Map<string, PendingMutation>();
|
|
91
93
|
private persistence?: MutationQueuePersistence;
|
|
92
94
|
/** Callers waiting for a mutation's outcome, by op id. Memory-only:
|
|
93
95
|
* a promise cannot outlive the page that created it. */
|
|
@@ -116,11 +118,10 @@ export class MutationQueue {
|
|
|
116
118
|
// hydrate was awaiting `loadAll()` will already have flushed a
|
|
117
119
|
// snapshot that didn't include the loaded rows — re-flush
|
|
118
120
|
// after merge so disk matches memory again.
|
|
119
|
-
const existingIds = new Set(this.queue.map((m) => m.id));
|
|
120
121
|
let mergedAny = false;
|
|
121
122
|
for (const m of loaded) {
|
|
122
|
-
if (!
|
|
123
|
-
this.queue.
|
|
123
|
+
if (!this.queue.has(m.id)) {
|
|
124
|
+
this.queue.set(m.id, m);
|
|
124
125
|
mergedAny = true;
|
|
125
126
|
}
|
|
126
127
|
}
|
|
@@ -150,25 +151,29 @@ export class MutationQueue {
|
|
|
150
151
|
typeof change.op_id === "string" && change.op_id.length > 0
|
|
151
152
|
? change.op_id
|
|
152
153
|
: `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
153
|
-
if (this.queue.
|
|
154
|
+
if (this.queue.has(id)) return id;
|
|
154
155
|
const changeWithOp: ClientChange = { ...change, op_id: id };
|
|
155
156
|
const entry: PendingMutation = { id, change: changeWithOp, status: "pending", prevRow };
|
|
156
157
|
if (owner !== undefined) entry.owner = owner;
|
|
157
158
|
if (ownerPending) entry.ownerPending = true;
|
|
158
|
-
this.queue.
|
|
159
|
-
this.flush();
|
|
159
|
+
this.queue.set(id, entry);
|
|
160
|
+
this.flush([entry]);
|
|
160
161
|
return id;
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
pending(): PendingMutation[] {
|
|
164
|
-
|
|
165
|
+
const pending: PendingMutation[] = [];
|
|
166
|
+
for (const mutation of this.queue.values()) {
|
|
167
|
+
if (mutation.status === "pending") pending.push(mutation);
|
|
168
|
+
}
|
|
169
|
+
return pending;
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
/** Look up a queued mutation by op id (any status). Used by the
|
|
168
173
|
* follower's mutations-failed handler to reach the captured
|
|
169
174
|
* `prevRow` for rollback. */
|
|
170
175
|
get(id: string): PendingMutation | undefined {
|
|
171
|
-
return this.queue.
|
|
176
|
+
return this.queue.get(id);
|
|
172
177
|
}
|
|
173
178
|
|
|
174
179
|
/**
|
|
@@ -186,7 +191,7 @@ export class MutationQueue {
|
|
|
186
191
|
*/
|
|
187
192
|
pendingRowKeys(): Set<string> {
|
|
188
193
|
const out = new Set<string>();
|
|
189
|
-
for (const m of this.queue) {
|
|
194
|
+
for (const m of this.queue.values()) {
|
|
190
195
|
if (m.status === "pending" || m.status === "failed") {
|
|
191
196
|
out.add(`${m.change.entity}/${m.change.row_id}`);
|
|
192
197
|
}
|
|
@@ -219,20 +224,31 @@ export class MutationQueue {
|
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
markApplied(id: string): void {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
this.markAppliedMany([id]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Persist one response batch once, then settle each caller. */
|
|
231
|
+
markAppliedMany(ids: string[]): void {
|
|
232
|
+
const changed: PendingMutation[] = [];
|
|
233
|
+
for (const id of new Set(ids)) {
|
|
234
|
+
const m = this.queue.get(id);
|
|
235
|
+
if (m) {
|
|
236
|
+
m.status = "applied";
|
|
237
|
+
changed.push(m);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (changed.length > 0) this.flush(changed);
|
|
241
|
+
for (const id of ids) this.resolveWaiters(id);
|
|
226
242
|
}
|
|
227
243
|
|
|
228
244
|
markFailed(id: string, error: string, errorCode?: string): void {
|
|
229
|
-
const m = this.queue.
|
|
245
|
+
const m = this.queue.get(id);
|
|
230
246
|
if (m) {
|
|
231
247
|
m.status = "failed";
|
|
232
248
|
m.error = error;
|
|
233
249
|
if (errorCode) m.errorCode = errorCode;
|
|
234
250
|
}
|
|
235
|
-
this.flush();
|
|
251
|
+
if (m) this.flush([m]);
|
|
236
252
|
const waiters = this.waiters.get(id);
|
|
237
253
|
if (!waiters) return;
|
|
238
254
|
this.waiters.delete(id);
|
|
@@ -260,24 +276,28 @@ export class MutationQueue {
|
|
|
260
276
|
* surface them to the user and so retries are possible.
|
|
261
277
|
*/
|
|
262
278
|
clear(): void {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
279
|
+
const removedIds: string[] = [];
|
|
280
|
+
for (const [id, m] of this.queue) {
|
|
281
|
+
if (m.status === "applied") {
|
|
282
|
+
this.queue.delete(id);
|
|
283
|
+
removedIds.push(id);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (removedIds.length > 0) this.flush([], removedIds);
|
|
267
287
|
}
|
|
268
288
|
|
|
269
289
|
/** Stamp every write whose owner was pending with the user the first
|
|
270
290
|
* resolved session names. */
|
|
271
291
|
stampPendingOwner(owner: string | null): void {
|
|
272
|
-
|
|
273
|
-
for (const m of this.queue) {
|
|
292
|
+
const changed: PendingMutation[] = [];
|
|
293
|
+
for (const m of this.queue.values()) {
|
|
274
294
|
if (m.ownerPending) {
|
|
275
295
|
m.owner = owner;
|
|
276
296
|
delete m.ownerPending;
|
|
277
|
-
changed
|
|
297
|
+
changed.push(m);
|
|
278
298
|
}
|
|
279
299
|
}
|
|
280
|
-
if (changed) this.flush();
|
|
300
|
+
if (changed.length > 0) this.flush(changed);
|
|
281
301
|
}
|
|
282
302
|
|
|
283
303
|
/** Drop one mutation that must never be pushed (it belongs to another
|
|
@@ -285,8 +305,8 @@ export class MutationQueue {
|
|
|
285
305
|
* touch the local store. */
|
|
286
306
|
discard(id: string): void {
|
|
287
307
|
const m = this.get(id);
|
|
288
|
-
this.queue
|
|
289
|
-
this.flush();
|
|
308
|
+
this.queue.delete(id);
|
|
309
|
+
this.flush([], [id]);
|
|
290
310
|
const waiters = this.waiters.get(id);
|
|
291
311
|
if (!waiters) return;
|
|
292
312
|
this.waiters.delete(id);
|
|
@@ -306,8 +326,8 @@ export class MutationQueue {
|
|
|
306
326
|
/** Remove a specific mutation by id. Used by the UI after user
|
|
307
327
|
* ack of failures. */
|
|
308
328
|
remove(id: string): void {
|
|
309
|
-
this.queue
|
|
310
|
-
this.flush();
|
|
329
|
+
this.queue.delete(id);
|
|
330
|
+
this.flush([], [id]);
|
|
311
331
|
}
|
|
312
332
|
|
|
313
333
|
/**
|
|
@@ -323,11 +343,11 @@ export class MutationQueue {
|
|
|
323
343
|
*/
|
|
324
344
|
clearAll(): void {
|
|
325
345
|
const dropped = this.queue;
|
|
326
|
-
this.queue =
|
|
346
|
+
this.queue = new Map();
|
|
327
347
|
this.flush();
|
|
328
348
|
// Nobody will push these writes any more. Reject their waiters so a
|
|
329
349
|
// caller awaiting the outcome does not hang.
|
|
330
|
-
for (const m of dropped) {
|
|
350
|
+
for (const m of dropped.values()) {
|
|
331
351
|
const waiters = this.waiters.get(m.id);
|
|
332
352
|
if (!waiters) continue;
|
|
333
353
|
this.waiters.delete(m.id);
|
|
@@ -346,10 +366,13 @@ export class MutationQueue {
|
|
|
346
366
|
}
|
|
347
367
|
|
|
348
368
|
/** Fire-and-forget persistence write. */
|
|
349
|
-
private flush(): void {
|
|
369
|
+
private flush(upserts?: PendingMutation[], removedIds: string[] = []): void {
|
|
350
370
|
if (!this.persistence) return;
|
|
351
|
-
|
|
352
|
-
this.persistence.
|
|
371
|
+
// Copy mutable status/owner fields before handing data to an async backend.
|
|
372
|
+
const write = upserts && this.persistence.saveChanges
|
|
373
|
+
? this.persistence.saveChanges(upserts.map((m) => ({ ...m })), removedIds)
|
|
374
|
+
: this.persistence.saveAll(Array.from(this.queue.values(), (m) => ({ ...m })));
|
|
375
|
+
write.catch((err) => {
|
|
353
376
|
console.warn("[sync] mutation-queue persist failed:", err);
|
|
354
377
|
});
|
|
355
378
|
}
|
package/src/persistence.ts
CHANGED
|
@@ -417,10 +417,8 @@ export async function persistChange(
|
|
|
417
417
|
* `MutationQueue` into the same database as the entity mirror so everything
|
|
418
418
|
* the app needs to resume a session lives in one place.
|
|
419
419
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* which is tiny in practice. If that ever becomes a bottleneck, switch to
|
|
423
|
-
* per-id `put`/`delete` — the schema (`keyPath: "id"`) already supports it.
|
|
420
|
+
* Incremental writes update only changed IDs. Full snapshots support initial
|
|
421
|
+
* hydration and identity reset, and remain available to custom backends.
|
|
424
422
|
*/
|
|
425
423
|
export class IndexedDBMutationPersistence implements MutationQueuePersistence {
|
|
426
424
|
private db: IDBDatabase | null = null;
|
|
@@ -449,6 +447,20 @@ export class IndexedDBMutationPersistence implements MutationQueuePersistence {
|
|
|
449
447
|
});
|
|
450
448
|
}
|
|
451
449
|
|
|
450
|
+
async saveChanges(upserts: PendingMutation[], removedIds: string[]): Promise<void> {
|
|
451
|
+
const db = this.handle();
|
|
452
|
+
if (!db) return;
|
|
453
|
+
const tx = db.transaction(MUTATIONS_STORE, "readwrite");
|
|
454
|
+
const store = tx.objectStore(MUTATIONS_STORE);
|
|
455
|
+
for (const mutation of upserts) store.put(mutation);
|
|
456
|
+
for (const id of removedIds) store.delete(id);
|
|
457
|
+
return new Promise((resolve, reject) => {
|
|
458
|
+
tx.oncomplete = () => resolve();
|
|
459
|
+
tx.onerror = () => reject(tx.error ?? new Error("mutation queue update failed"));
|
|
460
|
+
tx.onabort = () => reject(tx.error ?? new Error("mutation queue update aborted"));
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
452
464
|
async loadAll(): Promise<PendingMutation[]> {
|
|
453
465
|
const db = this.handle();
|
|
454
466
|
if (!db) return [];
|