@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +175 -55
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.178",
6
+ "version": "0.3.180",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
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
- const results = changes.map((c) => this._persistFn!(this.hydrateFromMemory(c)));
268
- await Promise.all(results.map((r) => (r instanceof Promise ? r : Promise.resolve())));
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
- // Client-side deletes dominate any concurrent server replay until the
368
- // server confirms; use MAX_SAFE_INTEGER as the tombstone seq. When the
369
- // server's real delete event arrives it will refresh the tombstone with
370
- // the authoritative seq (via `recordTombstone`'s max-of).
371
- this.recordTombstone(entity, id, Number.MAX_SAFE_INTEGER);
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 crash
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
- if (change.seq > this.cursor.last_seq) {
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
- if (change.seq > this.cursor.last_seq) {
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
- if (resp.changes.length > 0) {
1484
- // Await disk writes before touching the cursor so a crash here can't
1485
- // persist a cursor that's ahead of what actually landed in IndexedDB.
1486
- await this.store.applyChangesAsync(resp.changes);
1487
- }
1488
- // Always advance the cursor to whatever the server reports, not just
1489
- // when changes land. If a read policy filters out every event in a
1490
- // window the server still moves its last_seq forward; clamping to only
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
- await this.store.applyChangesAsync(changes);
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
- let removed = false;
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
- if (this.store.reconcileRemove(entity, id, tombstoneSeq)) {
1710
- removed = true;
1711
- if (this.persistence) {
1712
- try {
1713
- await this.persistence.deleteRow(entity, id);
1714
- } catch {
1715
- /* best-effort */
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 (removed) this.store.notify();
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(