@pylonsync/sync 0.3.179 → 0.3.181

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 +123 -38
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.179",
6
+ "version": "0.3.181",
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
  }
@@ -991,16 +1031,40 @@ export class SyncEngine {
991
1031
  */
992
1032
  private enqueueApply(
993
1033
  changes: ChangeEvent[],
994
- targetCursor?: SyncCursor,
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 = changes.filter(
999
- (c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
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
@@ -1096,18 +1160,22 @@ export class SyncEngine {
1096
1160
  this.reconnectAttempts = 0;
1097
1161
  this.wsStableTimer = null;
1098
1162
  }, 5_000);
1099
- // Client-side keepalive ping. The server's per-client reader
1100
- // thread blocks on a synchronous WS read for as long as no client
1101
- // message arrives. On the HTTP-multiplexed `/api/sync/ws` path
1102
- // tiny_http doesn't expose stream-level read timeouts, so the
1103
- // reader's mutex hold is bounded only by client activity. Without
1104
- // these pings the broadcaster contends for the same mutex and
1105
- // wedges Insert events never reach the tab. A 1s cadence makes
1106
- // worst-case broadcast latency ~1s even when the user is idle.
1107
- // Browsers don't expose WebSocket-level PING frames; a JSON
1108
- // payload with type:"ping" achieves the same effect (the server
1109
- // reads, looks up an unknown "ping" type, loops back releasing
1110
- // the mutex; broadcaster grabs it during the gap).
1163
+ // Client-side keepalive ping at 200ms. The server's per-client
1164
+ // reader thread blocks synchronously on `ws.read()` and holds the
1165
+ // per-client mutex for the duration of the call. On the dedicated
1166
+ // `:port+1` listener that block is bounded by a 200ms TCP read
1167
+ // timeout (`stream.set_read_timeout`), so the broadcaster gets a
1168
+ // window every 200ms. The HTTP-multiplexed `/api/sync/ws` path
1169
+ // goes through tiny_http's `CustomStream`, which doesn't expose
1170
+ // the underlying TcpStream, so we can't set a timeout there.
1171
+ // These periodic JSON pings give the broadcaster the same 200ms
1172
+ // window by causing the read to return (the server treats
1173
+ // unknown `type` values as no-ops; the side effect is releasing
1174
+ // the mutex between iterations). Browsers don't expose WS-level
1175
+ // PING frames at the application layer, so we send a Text frame.
1176
+ // Tradeoff: ~5 inbound frames/sec/client of background traffic
1177
+ // for sub-second broadcast latency — worth it for the demo
1178
+ // experience and idle tabs alike.
1111
1179
  if (this.pingTimer) clearInterval(this.pingTimer);
1112
1180
  this.pingTimer = setInterval(() => {
1113
1181
  if (this.ws?.readyState !== WebSocket.OPEN) return;
@@ -1116,7 +1184,7 @@ export class SyncEngine {
1116
1184
  } catch {
1117
1185
  // ignore — onclose will trigger reconnect
1118
1186
  }
1119
- }, 1_000);
1187
+ }, 200);
1120
1188
  // Re-send any active CRDT subscriptions across the new socket.
1121
1189
  // The server purged them on disconnect (`unsubscribe_all`), so
1122
1190
  // without this resync a tab that was subscribed before a network
@@ -1734,30 +1802,47 @@ export class SyncEngine {
1734
1802
  }
1735
1803
  }
1736
1804
  if (changes.length > 0) {
1737
- await this.store.applyChangesAsync(changes);
1805
+ // Reconcile applies route through the same serialized queue as
1806
+ // WS/pull so a stale reconcile response can't interleave with a
1807
+ // newer WS/pull update mid-batch. The synthetic seqs reconcile
1808
+ // fabricates (tombstoneSeq + 1) collide with WS-issued seqs so
1809
+ // we skip the monotonic guard and don't advance the cursor —
1810
+ // the cursor still reflects the server's last_seq, not our
1811
+ // fabricated reconcile seqs.
1812
+ await this.enqueueApply(changes, {
1813
+ skipSeqGuard: true,
1814
+ advanceCursor: false,
1815
+ });
1738
1816
  }
1739
1817
  // Removals: every local row whose id isn't in the server set is
1740
1818
  // stale. Tombstone with the current cursor so future legitimate
1741
- // re-creations still flow through.
1819
+ // re-creations still flow through. Synthesize Delete events for
1820
+ // each removal and route through the apply queue so the order
1821
+ // relative to WS/pull updates is preserved — a removal here is
1822
+ // really "server says this row is gone as of tombstoneSeq", which
1823
+ // matters if a later WS update reinstates it on the same row id.
1742
1824
  const locals = this.store.list(entity);
1743
- let removed = false;
1825
+ const removalChanges: ChangeEvent[] = [];
1744
1826
  for (const local of locals) {
1745
1827
  const id = (local as { id?: unknown }).id;
1746
1828
  if (typeof id !== "string") continue;
1747
1829
  if (!serverIds.has(id)) {
1748
- if (this.store.reconcileRemove(entity, id, tombstoneSeq)) {
1749
- removed = true;
1750
- if (this.persistence) {
1751
- try {
1752
- await this.persistence.deleteRow(entity, id);
1753
- } catch {
1754
- /* best-effort */
1755
- }
1756
- }
1757
- }
1830
+ removalChanges.push({
1831
+ seq: tombstoneSeq,
1832
+ entity,
1833
+ row_id: id,
1834
+ kind: "delete",
1835
+ data: undefined,
1836
+ timestamp: "",
1837
+ });
1758
1838
  }
1759
1839
  }
1760
- if (removed) this.store.notify();
1840
+ if (removalChanges.length > 0) {
1841
+ await this.enqueueApply(removalChanges, {
1842
+ skipSeqGuard: true,
1843
+ advanceCursor: false,
1844
+ });
1845
+ }
1761
1846
  }
1762
1847
 
1763
1848
  private async dropEntity(