@pylonsync/sync 0.3.179 → 0.3.180
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 +1 -1
- package/src/index.ts +106 -25
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -114,6 +114,19 @@ export class LocalStore {
|
|
|
114
114
|
* dominate anything a concurrent pull could replay.
|
|
115
115
|
*/
|
|
116
116
|
private tombstones: Map<string, Map<string, number>> = new Map();
|
|
117
|
+
/**
|
|
118
|
+
* Pending optimistic deletes — `(entity, row_id)` pairs the local
|
|
119
|
+
* client has dropped but the server hasn't yet confirmed. Stored
|
|
120
|
+
* separately from `tombstones` because the optimistic "block any
|
|
121
|
+
* incoming insert/update for this row" guard runs at infinite
|
|
122
|
+
* seq, but the real server delete seq (typically 4–6 digits)
|
|
123
|
+
* would never max-merge past `Number.MAX_SAFE_INTEGER`. The old
|
|
124
|
+
* design left MAX_SAFE_INTEGER permanently in `tombstones` for
|
|
125
|
+
* any optimistically-deleted id, so a future server-issued insert
|
|
126
|
+
* with seq=N could never pass the `seq < tombstoneSeq` check —
|
|
127
|
+
* the row id was blocked for the lifetime of the replica.
|
|
128
|
+
*/
|
|
129
|
+
private optimisticTombstones: Map<string, Set<string>> = new Map();
|
|
117
130
|
private listeners: Set<() => void> = new Set();
|
|
118
131
|
|
|
119
132
|
/** Get all rows for an entity. */
|
|
@@ -163,6 +176,9 @@ export class LocalStore {
|
|
|
163
176
|
|
|
164
177
|
/** Check if `(entity, id)` has a tombstone. */
|
|
165
178
|
private isTombstoned(entity: string, id: string, at_seq?: number): boolean {
|
|
179
|
+
// Pending optimistic delete — block everything until the server's
|
|
180
|
+
// real delete arrives and supersedes us.
|
|
181
|
+
if (this.optimisticTombstones.get(entity)?.has(id)) return true;
|
|
166
182
|
const tombSeq = this.tombstones.get(entity)?.get(id);
|
|
167
183
|
if (tombSeq === undefined) return false;
|
|
168
184
|
// If the caller didn't tell us when their change happened, treat as
|
|
@@ -172,6 +188,11 @@ export class LocalStore {
|
|
|
172
188
|
}
|
|
173
189
|
|
|
174
190
|
private recordTombstone(entity: string, id: string, seq: number): void {
|
|
191
|
+
// A real (server-issued) tombstone supersedes any pending optimistic
|
|
192
|
+
// entry for this id. Without this drop, the optimistic
|
|
193
|
+
// MAX_SAFE_INTEGER entry would persist forever and block future
|
|
194
|
+
// re-creations of the same id (the case codex flagged P1).
|
|
195
|
+
this.optimisticTombstones.get(entity)?.delete(id);
|
|
175
196
|
if (!this.tombstones.has(entity)) {
|
|
176
197
|
this.tombstones.set(entity, new Map());
|
|
177
198
|
}
|
|
@@ -264,8 +285,20 @@ export class LocalStore {
|
|
|
264
285
|
}
|
|
265
286
|
this.notify();
|
|
266
287
|
if (this._persistFn) {
|
|
267
|
-
|
|
268
|
-
|
|
288
|
+
// Persist sequentially in arrival order — `Promise.all` would
|
|
289
|
+
// fire every IndexedDB write concurrently and the IDB scheduler
|
|
290
|
+
// can resolve them out of order. An `update → delete` pair on
|
|
291
|
+
// the same row would race the delete behind the update on disk,
|
|
292
|
+
// leaving a stale row in the persisted replica while the cursor
|
|
293
|
+
// advanced past the delete. Sequencing here matches the in-memory
|
|
294
|
+
// apply order, which itself is sequenced by the engine's
|
|
295
|
+
// `applyQueue`.
|
|
296
|
+
for (const change of changes) {
|
|
297
|
+
const result = this._persistFn(this.hydrateFromMemory(change));
|
|
298
|
+
if (result instanceof Promise) {
|
|
299
|
+
await result;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
269
302
|
}
|
|
270
303
|
}
|
|
271
304
|
|
|
@@ -364,11 +397,17 @@ export class LocalStore {
|
|
|
364
397
|
/** Apply an optimistic delete. */
|
|
365
398
|
optimisticDelete(entity: string, id: string): void {
|
|
366
399
|
this.tables.get(entity)?.delete(id);
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
400
|
+
// Optimistic delete: block any incoming insert/update for this id
|
|
401
|
+
// until the server's authoritative delete arrives. Tracked in
|
|
402
|
+
// `optimisticTombstones` rather than `tombstones` so the real
|
|
403
|
+
// server seq can supersede it cleanly — the previous design wrote
|
|
404
|
+
// MAX_SAFE_INTEGER into `tombstones` and `recordTombstone`'s
|
|
405
|
+
// max-merge would never replace it with the smaller real seq,
|
|
406
|
+
// leaving the id permanently quarantined.
|
|
407
|
+
if (!this.optimisticTombstones.has(entity)) {
|
|
408
|
+
this.optimisticTombstones.set(entity, new Set());
|
|
409
|
+
}
|
|
410
|
+
this.optimisticTombstones.get(entity)!.add(id);
|
|
372
411
|
this.notify();
|
|
373
412
|
}
|
|
374
413
|
|
|
@@ -381,6 +420,7 @@ export class LocalStore {
|
|
|
381
420
|
clearAll(): void {
|
|
382
421
|
this.tables.clear();
|
|
383
422
|
this.tombstones.clear();
|
|
423
|
+
this.optimisticTombstones.clear();
|
|
384
424
|
this.notify();
|
|
385
425
|
}
|
|
386
426
|
}
|
|
@@ -991,16 +1031,40 @@ export class SyncEngine {
|
|
|
991
1031
|
*/
|
|
992
1032
|
private enqueueApply(
|
|
993
1033
|
changes: ChangeEvent[],
|
|
994
|
-
|
|
1034
|
+
options:
|
|
1035
|
+
| SyncCursor
|
|
1036
|
+
| { targetCursor?: SyncCursor; skipSeqGuard?: boolean; advanceCursor?: boolean } = {},
|
|
995
1037
|
): Promise<void> {
|
|
1038
|
+
// Back-compat: callers that pass a SyncCursor positional arg get
|
|
1039
|
+
// the same semantics as before. New callers can pass an options
|
|
1040
|
+
// object — `skipSeqGuard` lets reconcile bypass the seq filter
|
|
1041
|
+
// (its synthetic events fabricate seqs that don't fit the natural
|
|
1042
|
+
// monotonic order), and `advanceCursor: false` keeps the cursor
|
|
1043
|
+
// pinned where it was so reconcile doesn't fake-advance past the
|
|
1044
|
+
// server's real position.
|
|
1045
|
+
const opts: { targetCursor?: SyncCursor; skipSeqGuard?: boolean; advanceCursor?: boolean } =
|
|
1046
|
+
options && typeof options === "object" && "last_seq" in options
|
|
1047
|
+
? { targetCursor: options as SyncCursor }
|
|
1048
|
+
: (options as {
|
|
1049
|
+
targetCursor?: SyncCursor;
|
|
1050
|
+
skipSeqGuard?: boolean;
|
|
1051
|
+
advanceCursor?: boolean;
|
|
1052
|
+
});
|
|
1053
|
+
const skipSeqGuard = opts.skipSeqGuard ?? false;
|
|
1054
|
+
const advanceCursor = opts.advanceCursor ?? true;
|
|
1055
|
+
const targetCursor = opts.targetCursor;
|
|
1056
|
+
|
|
996
1057
|
const prev = this.applyQueue;
|
|
997
1058
|
const next = prev.then(async () => {
|
|
998
|
-
const filtered =
|
|
999
|
-
|
|
1000
|
-
|
|
1059
|
+
const filtered = skipSeqGuard
|
|
1060
|
+
? changes
|
|
1061
|
+
: changes.filter(
|
|
1062
|
+
(c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
|
|
1063
|
+
);
|
|
1001
1064
|
if (filtered.length > 0) {
|
|
1002
1065
|
await this.store.applyChangesAsync(filtered);
|
|
1003
1066
|
}
|
|
1067
|
+
if (!advanceCursor) return;
|
|
1004
1068
|
// Pick the cursor target. Explicit `targetCursor` (from pull) wins
|
|
1005
1069
|
// — pull's response carries the server's authoritative current_seq
|
|
1006
1070
|
// even when no changes landed in this window. Otherwise derive
|
|
@@ -1734,30 +1798,47 @@ export class SyncEngine {
|
|
|
1734
1798
|
}
|
|
1735
1799
|
}
|
|
1736
1800
|
if (changes.length > 0) {
|
|
1737
|
-
|
|
1801
|
+
// Reconcile applies route through the same serialized queue as
|
|
1802
|
+
// WS/pull so a stale reconcile response can't interleave with a
|
|
1803
|
+
// newer WS/pull update mid-batch. The synthetic seqs reconcile
|
|
1804
|
+
// fabricates (tombstoneSeq + 1) collide with WS-issued seqs so
|
|
1805
|
+
// we skip the monotonic guard and don't advance the cursor —
|
|
1806
|
+
// the cursor still reflects the server's last_seq, not our
|
|
1807
|
+
// fabricated reconcile seqs.
|
|
1808
|
+
await this.enqueueApply(changes, {
|
|
1809
|
+
skipSeqGuard: true,
|
|
1810
|
+
advanceCursor: false,
|
|
1811
|
+
});
|
|
1738
1812
|
}
|
|
1739
1813
|
// Removals: every local row whose id isn't in the server set is
|
|
1740
1814
|
// stale. Tombstone with the current cursor so future legitimate
|
|
1741
|
-
// re-creations still flow through.
|
|
1815
|
+
// re-creations still flow through. Synthesize Delete events for
|
|
1816
|
+
// each removal and route through the apply queue so the order
|
|
1817
|
+
// relative to WS/pull updates is preserved — a removal here is
|
|
1818
|
+
// really "server says this row is gone as of tombstoneSeq", which
|
|
1819
|
+
// matters if a later WS update reinstates it on the same row id.
|
|
1742
1820
|
const locals = this.store.list(entity);
|
|
1743
|
-
|
|
1821
|
+
const removalChanges: ChangeEvent[] = [];
|
|
1744
1822
|
for (const local of locals) {
|
|
1745
1823
|
const id = (local as { id?: unknown }).id;
|
|
1746
1824
|
if (typeof id !== "string") continue;
|
|
1747
1825
|
if (!serverIds.has(id)) {
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1826
|
+
removalChanges.push({
|
|
1827
|
+
seq: tombstoneSeq,
|
|
1828
|
+
entity,
|
|
1829
|
+
row_id: id,
|
|
1830
|
+
kind: "delete",
|
|
1831
|
+
data: undefined,
|
|
1832
|
+
timestamp: "",
|
|
1833
|
+
});
|
|
1758
1834
|
}
|
|
1759
1835
|
}
|
|
1760
|
-
if (
|
|
1836
|
+
if (removalChanges.length > 0) {
|
|
1837
|
+
await this.enqueueApply(removalChanges, {
|
|
1838
|
+
skipSeqGuard: true,
|
|
1839
|
+
advanceCursor: false,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1761
1842
|
}
|
|
1762
1843
|
|
|
1763
1844
|
private async dropEntity(
|