@pylonsync/sync 0.3.184 → 0.3.186

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 +48 -17
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.184",
6
+ "version": "0.3.186",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1598,23 +1598,38 @@ export class SyncEngine {
1598
1598
  this.lastSeenToken = tokenNow;
1599
1599
 
1600
1600
  try {
1601
- const resp = await this.request<PullResponse>(
1602
- "GET",
1603
- `/api/sync/pull?since=${this.cursor.last_seq}`
1604
- );
1605
- // Successful response clear the 410 circuit breaker.
1606
- this.consecutive_410s = 0;
1607
- // Route through the apply queue so concurrent WS messages and
1608
- // pull responses don't race. The queue's monotonic guard skips
1609
- // any seq we've already applied (e.g. WS already landed the
1610
- // events that pull is now redelivering) and only advances the
1611
- // cursor forward. We still advance even when no changes land,
1612
- // because the server's last_seq may have moved past us due to
1613
- // policy-filtered events.
1614
- await this.enqueueApply(resp.changes, resp.cursor);
1615
- // If there are more, pull again immediately.
1616
- if (resp.has_more) {
1617
- await this.pull();
1601
+ // Snapshot pagination: when the cursor is 0 and the server's
1602
+ // table is larger than a single batch, the response carries
1603
+ // `snapshot_after` for the next page. Loop until exhausted
1604
+ // BEFORE returning so a fresh client always observes a
1605
+ // consistent full snapshot, not a 1k-row prefix it mistakes
1606
+ // for the whole replica.
1607
+ let snapshotAfter: string | undefined;
1608
+ let firstPass = true;
1609
+ while (firstPass || snapshotAfter) {
1610
+ firstPass = false;
1611
+ const params = new URLSearchParams();
1612
+ params.set("since", String(this.cursor.last_seq));
1613
+ if (snapshotAfter) {
1614
+ params.set("snapshot_after", snapshotAfter);
1615
+ }
1616
+ const resp = await this.request<
1617
+ PullResponse & { snapshot_after?: string | null }
1618
+ >("GET", `/api/sync/pull?${params.toString()}`);
1619
+ this.consecutive_410s = 0;
1620
+ await this.enqueueApply(resp.changes, resp.cursor);
1621
+ // `snapshot_after` is only set when the server is mid-snapshot.
1622
+ // Continue paginating in the same loop iteration so we don't
1623
+ // leave a fresh client with a partial replica.
1624
+ snapshotAfter = resp.snapshot_after ?? undefined;
1625
+ // The change-log tail also paginates via `has_more` — handle
1626
+ // that one recursively after the snapshot loop completes so
1627
+ // backpressure on the change-log path uses the existing
1628
+ // tail-pull semantics.
1629
+ if (!snapshotAfter && resp.has_more) {
1630
+ await this.pull();
1631
+ break;
1632
+ }
1618
1633
  }
1619
1634
  } catch (err) {
1620
1635
  // Swallow network + transient errors so the poll/reconnect loop
@@ -1735,6 +1750,13 @@ export class SyncEngine {
1735
1750
  // bypass the tombstone — re-creation server-side still propagates.
1736
1751
  const tombstoneSeq = this.cursor.last_seq;
1737
1752
  for (const entity of names) {
1753
+ // Capture cursor BEFORE the fetch so we can detect drift mid-
1754
+ // reconcile. If a WS event lands while this entity is being
1755
+ // pulled, our snapshot is already stale — applying it would
1756
+ // overwrite a newer authoritative row. Skip apply in that case
1757
+ // and rely on the WS event (which has the correct seq) plus the
1758
+ // next reconcile trigger to converge. Codex P1.
1759
+ const cursorBeforeFetch = this.cursor.last_seq;
1738
1760
  let serverRows: Row[];
1739
1761
  try {
1740
1762
  serverRows = await this.fetchEntityRows(entity);
@@ -1750,6 +1772,15 @@ export class SyncEngine {
1750
1772
  }
1751
1773
  continue;
1752
1774
  }
1775
+ if (this.cursor.last_seq !== cursorBeforeFetch) {
1776
+ // Cursor moved during fetch — at least one WS event for this
1777
+ // (or another) entity landed and might have a fresher value
1778
+ // for a row our snapshot just captured. Bail out for this
1779
+ // entity; reconcile() is triggered again on visibility-change
1780
+ // and reconnect, and the WS event already carried the latest
1781
+ // state for the affected row.
1782
+ continue;
1783
+ }
1753
1784
  await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
1754
1785
  }
1755
1786
  }