@pylonsync/sync 0.3.363 → 0.3.365

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.
@@ -73,6 +73,11 @@ export declare class TestServer {
73
73
  /** Count of snapshot pulls served (since = 0). The egress storm was a
74
74
  * runaway count here; the regression test bounds it. */
75
75
  snapshotPullCount: number;
76
+ /** When set, delta pulls (since > 0) page their response to this many
77
+ * events per request with a real per-page cursor + has_more — models
78
+ * the production DELTA_BATCH_LIMIT so catch-up pagination and
79
+ * fetch/apply pipelining can be exercised. */
80
+ deltaPageSize: number | null;
76
81
  /** Count of /api/sync/push requests received. Lets a test assert the
77
82
  * engine actually shipped a batch (e.g. hydrated offline writes that
78
83
  * must drain once leader-elected), independent of the no-op push
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.363",
6
+ "version": "0.3.365",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -1714,16 +1714,40 @@ export class SyncEngine {
1714
1714
  // has_more, 410 recursion) run at a non-zero cursor → they don't touch
1715
1715
  // this, and their applies pass `isPull` so they're never held.
1716
1716
  if (startedFromZero) this.snapshotHold = [];
1717
+ // Declared outside the try: on a mid-loop fetch error the previous
1718
+ // page's apply may still be in flight, and the catch (410 reset →
1719
+ // re-pull) MUST let it settle first — otherwise the orphaned apply
1720
+ // races the replica wipe and re-writes stale rows into the fresh
1721
+ // store (and drags the cursor forward under the recursive pull).
1722
+ let pendingApply: Promise<void> | null = null;
1717
1723
  try {
1718
1724
  // Snapshot pagination: when the cursor is 0 and the server's
1719
1725
  // table is larger than a single batch, the response carries
1720
- // `snapshot_after` for the next page. Loop until exhausted
1721
- // BEFORE returning so a fresh client always observes a
1722
- // consistent full snapshot, not a 1k-row prefix it mistakes
1723
- // for the whole replica.
1726
+ // `snapshot_after` for the next page. The change-log tail
1727
+ // paginates via `has_more`. Both loop until exhausted BEFORE
1728
+ // returning so a fresh client always observes a consistent full
1729
+ // snapshot (not a 1k-row prefix it mistakes for the whole
1730
+ // replica) and a catching-up client drains the whole tail.
1731
+ //
1732
+ // Fetch/apply pipelining: applying a page (IndexedDB writes) and
1733
+ // fetching the next one are independent, so page N's apply runs
1734
+ // WHILE page N+1 is in flight — catch-up latency is
1735
+ // max(network, apply) per page instead of their sum. Ordering is
1736
+ // safe because enqueueApply chains every batch onto the same
1737
+ // applyQueue in call order; page N+1 is only ENQUEUED after page
1738
+ // N's apply resolved, so a failed apply aborts the loop instead
1739
+ // of letting later pages advance the cursor over a hole.
1740
+ //
1741
+ // The next `since` comes from each RESPONSE's cursor, not
1742
+ // `this.cursor` — the apply is what advances `this.cursor`, and
1743
+ // waiting on it is exactly what pipelining removes. Mid-snapshot
1744
+ // the server pins the response cursor at 0, which keeps routing
1745
+ // to the snapshot path.
1724
1746
  let snapshotAfter: string | undefined;
1747
+ let hasMore = false;
1725
1748
  let firstPass = true;
1726
- while (firstPass || snapshotAfter) {
1749
+ let nextSince = this.cursor.last_seq;
1750
+ while (firstPass || snapshotAfter || hasMore) {
1727
1751
  firstPass = false;
1728
1752
  // `snapshot_after` is an OPAQUE cursor the server already URL-encoded
1729
1753
  // (it `url_encode`s the JSON payload). It MUST be appended raw — running
@@ -1733,33 +1757,28 @@ export class SyncEngine {
1733
1757
  // restart the snapshot from row 0 — an infinite re-snapshot loop for any
1734
1758
  // table larger than one page (SNAPSHOT_BATCH_LIMIT rows). `since` is a
1735
1759
  // plain integer, so it's safe to inline.
1736
- let query = `since=${this.cursor.last_seq}`;
1760
+ let query = `since=${nextSince}`;
1737
1761
  if (snapshotAfter) {
1738
1762
  query += `&snapshot_after=${snapshotAfter}`;
1739
1763
  }
1740
1764
  const resp = await this.request<
1741
1765
  PullResponse & { snapshot_after?: string | null }
1742
1766
  >("GET", `/api/sync/pull?${query}`);
1743
- await this.enqueueApply(resp.changes, resp.cursor, { isPull: true });
1767
+ if (pendingApply) await pendingApply;
1768
+ pendingApply = this.enqueueApply(resp.changes, resp.cursor, {
1769
+ isPull: true,
1770
+ });
1771
+ // Guard against a server that reports more pages without
1772
+ // advancing the cursor (a transient no-progress page): break
1773
+ // rather than refetch the same page forever. The next poll /
1774
+ // change event re-drives the pull.
1775
+ const advanced = resp.cursor.last_seq > nextSince;
1776
+ nextSince = resp.cursor.last_seq;
1744
1777
  // `snapshot_after` is only set when the server is mid-snapshot.
1745
- // Continue paginating in the same loop iteration so we don't
1746
- // leave a fresh client with a partial replica.
1747
1778
  snapshotAfter = resp.snapshot_after ?? undefined;
1748
- // The change-log tail also paginates via `has_more` — drain it
1749
- // by recursing into `pullInner` directly. We are INSIDE the
1750
- // `pull` op-queue slot right now; calling the public `pull()`
1751
- // would re-enqueue under the same "pull" key, which coalesces
1752
- // to the promise we're currently running inside (op-queue.ts
1753
- // deletes the key only after `fn` resolves) and `await` it →
1754
- // permanent self-deadlock that bricks the entire pull path for
1755
- // the session. This is the exact hazard the 410 handler avoids;
1756
- // `pullInner` re-reads `this.cursor.last_seq` (already advanced
1757
- // by enqueueApply) so the recursion resumes at the right cursor.
1758
- if (!snapshotAfter && resp.has_more) {
1759
- await this.pullInner();
1760
- break;
1761
- }
1779
+ hasMore = !snapshotAfter && resp.has_more && advanced;
1762
1780
  }
1781
+ if (pendingApply) await pendingApply;
1763
1782
  // Clear the resync circuit breaker ONLY on a successful DELTA
1764
1783
  // pull — one that started from a real, non-zero cursor the server
1765
1784
  // honored. A snapshot pull from cursor=0 succeeding does NOT prove
@@ -1795,6 +1814,12 @@ export class SyncEngine {
1795
1814
  if (held.length > 0) await this.enqueueApply(held);
1796
1815
  }
1797
1816
  } catch (err) {
1817
+ // Settle any in-flight page apply before acting on the error —
1818
+ // the 410 path below wipes the replica, and an apply landing
1819
+ // after the wipe would resurrect stale rows and advance the
1820
+ // cursor under the recursive re-pull. Failures are already
1821
+ // handled batch-locally; only settlement matters here.
1822
+ if (pendingApply) await pendingApply.catch(() => {});
1798
1823
  // Swallow network + transient errors so the poll/reconnect loop
1799
1824
  // keeps trying — but on 429 bump the backoff counter so the next
1800
1825
  // reconnect waits noticeably longer. Without this, a rate-limited
@@ -555,6 +555,74 @@ describe("sync scenarios", () => {
555
555
  expect(env.engine.store.get("Note", "n2")).not.toBeNull();
556
556
  });
557
557
 
558
+ // CATCH-UP PIPELINING (pins the pullInner fetch/apply overlap). A
559
+ // multi-page delta catch-up used to serialize fetch page N → apply
560
+ // page N → fetch page N+1, paying network + apply per page in SUM.
561
+ // The pipelined loop derives the next `since` from each RESPONSE's
562
+ // cursor and starts the next fetch while the previous page is still
563
+ // applying, so per-page cost is max(network, apply). This test slows
564
+ // applies down and asserts (a) a later page's fetch arrives at the
565
+ // server WHILE an apply is still in flight, and (b) every page still
566
+ // lands, in order, with nothing skipped.
567
+ test("multi-page delta catch-up overlaps fetch with apply and drains completely", async () => {
568
+ let deltaFetches = 0;
569
+ let applyEnds = 0;
570
+ let overlapped = false;
571
+ env = createTestEnv({
572
+ transport: "poll",
573
+ beforePull: (_auth, since) => {
574
+ if (since > 0) {
575
+ deltaFetches++;
576
+ // The serialized loop finishes apply k-1 BEFORE issuing
577
+ // fetch k, so it always arrives with applyEnds == k-1. A
578
+ // fetch arriving with fewer applies completed is the
579
+ // pipeline overlap.
580
+ if (deltaFetches >= 2 && applyEnds < deltaFetches - 1) {
581
+ overlapped = true;
582
+ }
583
+ }
584
+ },
585
+ });
586
+ env.signIn({ userId: "u1" });
587
+ env.server.seed("Note", [{ id: "n0", title: "seed" }]);
588
+ await env.start();
589
+ await env.flush();
590
+
591
+ // 30 changes behind, served 10 per page → a 3-page catch-up.
592
+ for (let i = 1; i <= 30; i++) {
593
+ env.server.insert("Note", { id: `n${i}`, title: `t${i}` });
594
+ }
595
+ env.server.deltaPageSize = 10;
596
+
597
+ // Make applies observably slow so the overlap window is real.
598
+ const store = env.engine.store as unknown as {
599
+ applyChangesAsync: (c: unknown[]) => Promise<boolean>;
600
+ };
601
+ const realApply = store.applyChangesAsync.bind(store);
602
+ store.applyChangesAsync = async (changes) => {
603
+ await new Promise((r) => setTimeout(r, 25));
604
+ const out = await realApply(changes);
605
+ applyEnds++;
606
+ return out;
607
+ };
608
+
609
+ await env.engine.pull();
610
+ await env.flush();
611
+
612
+ // Every page landed — first, middle, and last row all present.
613
+ expect(env.engine.store.get("Note", "n1")).not.toBeNull();
614
+ expect(env.engine.store.get("Note", "n15")).not.toBeNull();
615
+ expect(env.engine.store.get("Note", "n30")).not.toBeNull();
616
+ // It really paginated (≥3 delta pages), and at least one later
617
+ // fetch overlapped an in-flight apply. A serialized loop never
618
+ // trips `overlapped` because each fetch waits for the apply.
619
+ const deltaPulls = env.server.pullUrls.filter(
620
+ (u) => !u.includes("since=0"),
621
+ ).length;
622
+ expect(deltaPulls).toBeGreaterThanOrEqual(3);
623
+ expect(overlapped).toBe(true);
624
+ });
625
+
558
626
  // OFFLINE WRITES (pins the transient/permanent split in pushInner).
559
627
  // A push that fails with a NETWORK error (offline — fetch rejects, no
560
628
  // HTTP status) must keep the mutation `pending` and the optimistic
@@ -102,6 +102,11 @@ export class TestServer {
102
102
  /** Count of snapshot pulls served (since = 0). The egress storm was a
103
103
  * runaway count here; the regression test bounds it. */
104
104
  snapshotPullCount = 0;
105
+ /** When set, delta pulls (since > 0) page their response to this many
106
+ * events per request with a real per-page cursor + has_more — models
107
+ * the production DELTA_BATCH_LIMIT so catch-up pagination and
108
+ * fetch/apply pipelining can be exercised. */
109
+ deltaPageSize: number | null = null;
105
110
  /** Count of /api/sync/push requests received. Lets a test assert the
106
111
  * engine actually shipped a batch (e.g. hydrated offline writes that
107
112
  * must drain once leader-elected), independent of the no-op push
@@ -278,6 +278,23 @@ async function handle(
278
278
  // terminating snapshot (no snapshot_after → client exits the loop).
279
279
  }
280
280
  const resp = await server.pull(token, since);
281
+ // Delta paging sim: slice to `deltaPageSize` events with a real
282
+ // per-page cursor + has_more, like the production DELTA_BATCH_LIMIT.
283
+ if (
284
+ since > 0 &&
285
+ server.deltaPageSize != null &&
286
+ resp.changes.length > server.deltaPageSize
287
+ ) {
288
+ const page = resp.changes.slice(0, server.deltaPageSize);
289
+ return {
290
+ status: 200,
291
+ body: {
292
+ changes: page,
293
+ cursor: { last_seq: page[page.length - 1]!.seq },
294
+ has_more: true,
295
+ },
296
+ };
297
+ }
281
298
  // One-shot has_more on a delta pull → drives the tail-pull recursion.
282
299
  if (since > 0 && server.consumeNextPullHasMore()) {
283
300
  return { status: 200, body: { ...resp, has_more: true } };