@pylonsync/sync 0.3.178 → 0.3.179

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 +73 -34
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.179",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -716,6 +716,17 @@ export class SyncEngine {
716
716
  private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
717
717
  private pingTimer: ReturnType<typeof setInterval> | null = null;
718
718
 
719
+ /**
720
+ * Serialized apply queue. Every change-event apply — from WS onmessage,
721
+ * pull(), or session-changed catchup — chains onto this promise so
722
+ * applies execute in arrival order. Without this, two WS messages or
723
+ * two concurrent pull()s race: seq 3's persistence can land before
724
+ * seq 2's, leaving the row at the older value AND the cursor briefly
725
+ * regressing if writes complete out of order. The queue also gates
726
+ * the cursor advance so `last_seq` only moves forward.
727
+ */
728
+ private applyQueue: Promise<void> = Promise.resolve();
729
+
719
730
  /**
720
731
  * Registered consumers for binary WebSocket frames. SyncEngine itself
721
732
  * doesn't decode binary — it just owns the WS connection and routes
@@ -965,6 +976,53 @@ export class SyncEngine {
965
976
 
966
977
  private pollTimer: ReturnType<typeof setInterval> | null = null;
967
978
 
979
+ /**
980
+ * Serialize a batch of change applies behind any in-flight applies, and
981
+ * advance the cursor monotonically when the batch lands. Both the WS
982
+ * onmessage path and pull() funnel through here so seq 3's persistence
983
+ * can't race ahead of seq 2's. The returned promise resolves after
984
+ * THIS batch is applied (not after later batches), so a caller awaiting
985
+ * pull() still completes deterministically.
986
+ *
987
+ * Per-event monotonic filter: re-applies of an already-seen seq are
988
+ * skipped before touching the store. Without that, a retransmit
989
+ * (WS + pull window overlap) would have us run applyChange twice
990
+ * against the local store.
991
+ */
992
+ private enqueueApply(
993
+ changes: ChangeEvent[],
994
+ targetCursor?: SyncCursor,
995
+ ): Promise<void> {
996
+ const prev = this.applyQueue;
997
+ const next = prev.then(async () => {
998
+ const filtered = changes.filter(
999
+ (c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
1000
+ );
1001
+ if (filtered.length > 0) {
1002
+ await this.store.applyChangesAsync(filtered);
1003
+ }
1004
+ // Pick the cursor target. Explicit `targetCursor` (from pull) wins
1005
+ // — pull's response carries the server's authoritative current_seq
1006
+ // even when no changes landed in this window. Otherwise derive
1007
+ // from the last applied seq.
1008
+ const candidate =
1009
+ targetCursor ??
1010
+ (filtered.length > 0
1011
+ ? { last_seq: filtered[filtered.length - 1].seq }
1012
+ : null);
1013
+ if (candidate && candidate.last_seq > this.cursor.last_seq) {
1014
+ this.cursor = candidate;
1015
+ if (this.persistence) {
1016
+ await this.persistence.saveCursor(this.cursor);
1017
+ }
1018
+ }
1019
+ });
1020
+ // Errors stay scoped to this batch — don't poison the chain for
1021
+ // future applies.
1022
+ this.applyQueue = next.catch(() => {});
1023
+ return next;
1024
+ }
1025
+
968
1026
  private startPolling(): void {
969
1027
  const interval = this.config.pollInterval ?? 1000;
970
1028
  this.pollTimer = setInterval(() => {
@@ -1118,18 +1176,14 @@ export class SyncEngine {
1118
1176
  try {
1119
1177
  const msg = JSON.parse(event.data as string);
1120
1178
 
1121
- // Sync change event. Persist BEFORE advancing the cursor so a crash
1122
- // can't leave `last_seq` ahead of the replica on disk.
1179
+ // Sync change event. Persist BEFORE advancing the cursor so a
1180
+ // crash can't leave `last_seq` ahead of the replica on disk.
1181
+ // The shared apply queue serializes WS messages with each other
1182
+ // AND with concurrent pull() calls, so seq order is preserved
1183
+ // and the cursor only advances monotonically.
1123
1184
  if (msg.seq && msg.entity && msg.kind) {
1124
1185
  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
- }
1186
+ void this.enqueueApply([change]);
1133
1187
  return;
1134
1188
  }
1135
1189
 
@@ -1259,14 +1313,7 @@ export class SyncEngine {
1259
1313
  const msg = JSON.parse(event.data);
1260
1314
  if (msg.seq && msg.entity && msg.kind) {
1261
1315
  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
- }
1316
+ void this.enqueueApply([change]);
1270
1317
  }
1271
1318
  } catch {
1272
1319
  // Ignore malformed events.
@@ -1480,22 +1527,14 @@ export class SyncEngine {
1480
1527
  );
1481
1528
  // Successful response — clear the 410 circuit breaker.
1482
1529
  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
- }
1530
+ // Route through the apply queue so concurrent WS messages and
1531
+ // pull responses don't race. The queue's monotonic guard skips
1532
+ // any seq we've already applied (e.g. WS already landed the
1533
+ // events that pull is now redelivering) and only advances the
1534
+ // cursor forward. We still advance even when no changes land,
1535
+ // because the server's last_seq may have moved past us due to
1536
+ // policy-filtered events.
1537
+ await this.enqueueApply(resp.changes, resp.cursor);
1499
1538
  // If there are more, pull again immediately.
1500
1539
  if (resp.has_more) {
1501
1540
  await this.pull();