@pylonsync/sync 0.3.177 → 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 +103 -34
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.177",
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
@@ -714,6 +714,18 @@ export class SyncEngine {
714
714
  * the client can't hammer the server on auth failures.
715
715
  */
716
716
  private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
717
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
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();
717
729
 
718
730
  /**
719
731
  * Registered consumers for binary WebSocket frames. SyncEngine itself
@@ -964,6 +976,53 @@ export class SyncEngine {
964
976
 
965
977
  private pollTimer: ReturnType<typeof setInterval> | null = null;
966
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
+
967
1026
  private startPolling(): void {
968
1027
  const interval = this.config.pollInterval ?? 1000;
969
1028
  this.pollTimer = setInterval(() => {
@@ -986,6 +1045,10 @@ export class SyncEngine {
986
1045
  clearInterval(this.pollTimer);
987
1046
  this.pollTimer = null;
988
1047
  }
1048
+ if (this.pingTimer) {
1049
+ clearInterval(this.pingTimer);
1050
+ this.pingTimer = null;
1051
+ }
989
1052
  if (this.visibilityHandler && typeof document !== "undefined") {
990
1053
  document.removeEventListener("visibilitychange", this.visibilityHandler);
991
1054
  this.visibilityHandler = null;
@@ -1033,6 +1096,27 @@ export class SyncEngine {
1033
1096
  this.reconnectAttempts = 0;
1034
1097
  this.wsStableTimer = null;
1035
1098
  }, 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).
1111
+ if (this.pingTimer) clearInterval(this.pingTimer);
1112
+ this.pingTimer = setInterval(() => {
1113
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
1114
+ try {
1115
+ this.ws.send('{"type":"ping"}');
1116
+ } catch {
1117
+ // ignore — onclose will trigger reconnect
1118
+ }
1119
+ }, 1_000);
1036
1120
  // Re-send any active CRDT subscriptions across the new socket.
1037
1121
  // The server purged them on disconnect (`unsubscribe_all`), so
1038
1122
  // without this resync a tab that was subscribed before a network
@@ -1092,18 +1176,14 @@ export class SyncEngine {
1092
1176
  try {
1093
1177
  const msg = JSON.parse(event.data as string);
1094
1178
 
1095
- // Sync change event. Persist BEFORE advancing the cursor so a crash
1096
- // 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.
1097
1184
  if (msg.seq && msg.entity && msg.kind) {
1098
1185
  const change = msg as ChangeEvent;
1099
- if (change.seq > this.cursor.last_seq) {
1100
- void this.store.applyChangesAsync([change]).then(async () => {
1101
- this.cursor = { last_seq: change.seq };
1102
- if (this.persistence) {
1103
- await this.persistence.saveCursor(this.cursor);
1104
- }
1105
- });
1106
- }
1186
+ void this.enqueueApply([change]);
1107
1187
  return;
1108
1188
  }
1109
1189
 
@@ -1162,6 +1242,10 @@ export class SyncEngine {
1162
1242
  clearTimeout(this.wsStableTimer);
1163
1243
  this.wsStableTimer = null;
1164
1244
  }
1245
+ if (this.pingTimer) {
1246
+ clearInterval(this.pingTimer);
1247
+ this.pingTimer = null;
1248
+ }
1165
1249
  // Surface the disconnect to UI consumers immediately. If
1166
1250
  // `running` flipped to false (engine stopped), `stop()` already
1167
1251
  // set "offline" — don't override that.
@@ -1229,14 +1313,7 @@ export class SyncEngine {
1229
1313
  const msg = JSON.parse(event.data);
1230
1314
  if (msg.seq && msg.entity && msg.kind) {
1231
1315
  const change = msg as ChangeEvent;
1232
- if (change.seq > this.cursor.last_seq) {
1233
- void this.store.applyChangesAsync([change]).then(async () => {
1234
- this.cursor = { last_seq: change.seq };
1235
- if (this.persistence) {
1236
- await this.persistence.saveCursor(this.cursor);
1237
- }
1238
- });
1239
- }
1316
+ void this.enqueueApply([change]);
1240
1317
  }
1241
1318
  } catch {
1242
1319
  // Ignore malformed events.
@@ -1450,22 +1527,14 @@ export class SyncEngine {
1450
1527
  );
1451
1528
  // Successful response — clear the 410 circuit breaker.
1452
1529
  this.consecutive_410s = 0;
1453
- if (resp.changes.length > 0) {
1454
- // Await disk writes before touching the cursor so a crash here can't
1455
- // persist a cursor that's ahead of what actually landed in IndexedDB.
1456
- await this.store.applyChangesAsync(resp.changes);
1457
- }
1458
- // Always advance the cursor to whatever the server reports, not just
1459
- // when changes land. If a read policy filters out every event in a
1460
- // window the server still moves its last_seq forward; clamping to only
1461
- // "non-empty" responses pins the client at `since=0` forever and turns
1462
- // every reconnect into another pull for the same empty window.
1463
- if (resp.cursor && resp.cursor.last_seq > this.cursor.last_seq) {
1464
- this.cursor = resp.cursor;
1465
- if (this.persistence) {
1466
- await this.persistence.saveCursor(this.cursor);
1467
- }
1468
- }
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);
1469
1538
  // If there are more, pull again immediately.
1470
1539
  if (resp.has_more) {
1471
1540
  await this.pull();