@pylonsync/sync 0.3.178 → 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 +175 -55
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
|
}
|
|
@@ -716,6 +756,17 @@ export class SyncEngine {
|
|
|
716
756
|
private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
717
757
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
718
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Serialized apply queue. Every change-event apply — from WS onmessage,
|
|
761
|
+
* pull(), or session-changed catchup — chains onto this promise so
|
|
762
|
+
* applies execute in arrival order. Without this, two WS messages or
|
|
763
|
+
* two concurrent pull()s race: seq 3's persistence can land before
|
|
764
|
+
* seq 2's, leaving the row at the older value AND the cursor briefly
|
|
765
|
+
* regressing if writes complete out of order. The queue also gates
|
|
766
|
+
* the cursor advance so `last_seq` only moves forward.
|
|
767
|
+
*/
|
|
768
|
+
private applyQueue: Promise<void> = Promise.resolve();
|
|
769
|
+
|
|
719
770
|
/**
|
|
720
771
|
* Registered consumers for binary WebSocket frames. SyncEngine itself
|
|
721
772
|
* doesn't decode binary — it just owns the WS connection and routes
|
|
@@ -965,6 +1016,77 @@ export class SyncEngine {
|
|
|
965
1016
|
|
|
966
1017
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
967
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Serialize a batch of change applies behind any in-flight applies, and
|
|
1021
|
+
* advance the cursor monotonically when the batch lands. Both the WS
|
|
1022
|
+
* onmessage path and pull() funnel through here so seq 3's persistence
|
|
1023
|
+
* can't race ahead of seq 2's. The returned promise resolves after
|
|
1024
|
+
* THIS batch is applied (not after later batches), so a caller awaiting
|
|
1025
|
+
* pull() still completes deterministically.
|
|
1026
|
+
*
|
|
1027
|
+
* Per-event monotonic filter: re-applies of an already-seen seq are
|
|
1028
|
+
* skipped before touching the store. Without that, a retransmit
|
|
1029
|
+
* (WS + pull window overlap) would have us run applyChange twice
|
|
1030
|
+
* against the local store.
|
|
1031
|
+
*/
|
|
1032
|
+
private enqueueApply(
|
|
1033
|
+
changes: ChangeEvent[],
|
|
1034
|
+
options:
|
|
1035
|
+
| SyncCursor
|
|
1036
|
+
| { targetCursor?: SyncCursor; skipSeqGuard?: boolean; advanceCursor?: boolean } = {},
|
|
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
|
+
|
|
1057
|
+
const prev = this.applyQueue;
|
|
1058
|
+
const next = prev.then(async () => {
|
|
1059
|
+
const filtered = skipSeqGuard
|
|
1060
|
+
? changes
|
|
1061
|
+
: changes.filter(
|
|
1062
|
+
(c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
|
|
1063
|
+
);
|
|
1064
|
+
if (filtered.length > 0) {
|
|
1065
|
+
await this.store.applyChangesAsync(filtered);
|
|
1066
|
+
}
|
|
1067
|
+
if (!advanceCursor) return;
|
|
1068
|
+
// Pick the cursor target. Explicit `targetCursor` (from pull) wins
|
|
1069
|
+
// — pull's response carries the server's authoritative current_seq
|
|
1070
|
+
// even when no changes landed in this window. Otherwise derive
|
|
1071
|
+
// from the last applied seq.
|
|
1072
|
+
const candidate =
|
|
1073
|
+
targetCursor ??
|
|
1074
|
+
(filtered.length > 0
|
|
1075
|
+
? { last_seq: filtered[filtered.length - 1].seq }
|
|
1076
|
+
: null);
|
|
1077
|
+
if (candidate && candidate.last_seq > this.cursor.last_seq) {
|
|
1078
|
+
this.cursor = candidate;
|
|
1079
|
+
if (this.persistence) {
|
|
1080
|
+
await this.persistence.saveCursor(this.cursor);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
// Errors stay scoped to this batch — don't poison the chain for
|
|
1085
|
+
// future applies.
|
|
1086
|
+
this.applyQueue = next.catch(() => {});
|
|
1087
|
+
return next;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
968
1090
|
private startPolling(): void {
|
|
969
1091
|
const interval = this.config.pollInterval ?? 1000;
|
|
970
1092
|
this.pollTimer = setInterval(() => {
|
|
@@ -1118,18 +1240,14 @@ export class SyncEngine {
|
|
|
1118
1240
|
try {
|
|
1119
1241
|
const msg = JSON.parse(event.data as string);
|
|
1120
1242
|
|
|
1121
|
-
// Sync change event. Persist BEFORE advancing the cursor so a
|
|
1122
|
-
// can't leave `last_seq` ahead of the replica on disk.
|
|
1243
|
+
// Sync change event. Persist BEFORE advancing the cursor so a
|
|
1244
|
+
// crash can't leave `last_seq` ahead of the replica on disk.
|
|
1245
|
+
// The shared apply queue serializes WS messages with each other
|
|
1246
|
+
// AND with concurrent pull() calls, so seq order is preserved
|
|
1247
|
+
// and the cursor only advances monotonically.
|
|
1123
1248
|
if (msg.seq && msg.entity && msg.kind) {
|
|
1124
1249
|
const change = msg as ChangeEvent;
|
|
1125
|
-
|
|
1126
|
-
void this.store.applyChangesAsync([change]).then(async () => {
|
|
1127
|
-
this.cursor = { last_seq: change.seq };
|
|
1128
|
-
if (this.persistence) {
|
|
1129
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1130
|
-
}
|
|
1131
|
-
});
|
|
1132
|
-
}
|
|
1250
|
+
void this.enqueueApply([change]);
|
|
1133
1251
|
return;
|
|
1134
1252
|
}
|
|
1135
1253
|
|
|
@@ -1259,14 +1377,7 @@ export class SyncEngine {
|
|
|
1259
1377
|
const msg = JSON.parse(event.data);
|
|
1260
1378
|
if (msg.seq && msg.entity && msg.kind) {
|
|
1261
1379
|
const change = msg as ChangeEvent;
|
|
1262
|
-
|
|
1263
|
-
void this.store.applyChangesAsync([change]).then(async () => {
|
|
1264
|
-
this.cursor = { last_seq: change.seq };
|
|
1265
|
-
if (this.persistence) {
|
|
1266
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1267
|
-
}
|
|
1268
|
-
});
|
|
1269
|
-
}
|
|
1380
|
+
void this.enqueueApply([change]);
|
|
1270
1381
|
}
|
|
1271
1382
|
} catch {
|
|
1272
1383
|
// Ignore malformed events.
|
|
@@ -1480,22 +1591,14 @@ export class SyncEngine {
|
|
|
1480
1591
|
);
|
|
1481
1592
|
// Successful response — clear the 410 circuit breaker.
|
|
1482
1593
|
this.consecutive_410s = 0;
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
// "non-empty" responses pins the client at `since=0` forever and turns
|
|
1492
|
-
// every reconnect into another pull for the same empty window.
|
|
1493
|
-
if (resp.cursor && resp.cursor.last_seq > this.cursor.last_seq) {
|
|
1494
|
-
this.cursor = resp.cursor;
|
|
1495
|
-
if (this.persistence) {
|
|
1496
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1594
|
+
// Route through the apply queue so concurrent WS messages and
|
|
1595
|
+
// pull responses don't race. The queue's monotonic guard skips
|
|
1596
|
+
// any seq we've already applied (e.g. WS already landed the
|
|
1597
|
+
// events that pull is now redelivering) and only advances the
|
|
1598
|
+
// cursor forward. We still advance even when no changes land,
|
|
1599
|
+
// because the server's last_seq may have moved past us due to
|
|
1600
|
+
// policy-filtered events.
|
|
1601
|
+
await this.enqueueApply(resp.changes, resp.cursor);
|
|
1499
1602
|
// If there are more, pull again immediately.
|
|
1500
1603
|
if (resp.has_more) {
|
|
1501
1604
|
await this.pull();
|
|
@@ -1695,30 +1798,47 @@ export class SyncEngine {
|
|
|
1695
1798
|
}
|
|
1696
1799
|
}
|
|
1697
1800
|
if (changes.length > 0) {
|
|
1698
|
-
|
|
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
|
+
});
|
|
1699
1812
|
}
|
|
1700
1813
|
// Removals: every local row whose id isn't in the server set is
|
|
1701
1814
|
// stale. Tombstone with the current cursor so future legitimate
|
|
1702
|
-
// 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.
|
|
1703
1820
|
const locals = this.store.list(entity);
|
|
1704
|
-
|
|
1821
|
+
const removalChanges: ChangeEvent[] = [];
|
|
1705
1822
|
for (const local of locals) {
|
|
1706
1823
|
const id = (local as { id?: unknown }).id;
|
|
1707
1824
|
if (typeof id !== "string") continue;
|
|
1708
1825
|
if (!serverIds.has(id)) {
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1826
|
+
removalChanges.push({
|
|
1827
|
+
seq: tombstoneSeq,
|
|
1828
|
+
entity,
|
|
1829
|
+
row_id: id,
|
|
1830
|
+
kind: "delete",
|
|
1831
|
+
data: undefined,
|
|
1832
|
+
timestamp: "",
|
|
1833
|
+
});
|
|
1719
1834
|
}
|
|
1720
1835
|
}
|
|
1721
|
-
if (
|
|
1836
|
+
if (removalChanges.length > 0) {
|
|
1837
|
+
await this.enqueueApply(removalChanges, {
|
|
1838
|
+
skipSeqGuard: true,
|
|
1839
|
+
advanceCursor: false,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1722
1842
|
}
|
|
1723
1843
|
|
|
1724
1844
|
private async dropEntity(
|