@pylonsync/sync 0.3.302 → 0.3.304

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.
@@ -140,6 +140,17 @@ export declare class TestServer {
140
140
  private nextPullHasMore;
141
141
  primeNextPullHasMore(): void;
142
142
  consumeNextPullHasMore(): boolean;
143
+ /** Every `/api/sync/pull` URL the engine requested, in order. Lets a test
144
+ * assert how the engine encodes query params (e.g. that `snapshot_after` is
145
+ * not double-URL-encoded — the bug that caused an infinite re-snapshot loop). */
146
+ pullUrls: string[];
147
+ /** One-shot: make the next from-0 snapshot pull return this `snapshot_after`
148
+ * token (a continuation cursor), so a test can drive the engine's snapshot
149
+ * pagination + assert the resume request echoes the token verbatim. The token
150
+ * is what the real server returns: already URL-encoded JSON. */
151
+ private nextSnapshotAfter;
152
+ primeSnapshotAfter(token: string): void;
153
+ consumeSnapshotAfter(): string | null;
143
154
  /** Bulk-seed rows for an entity AND emit insert events into the
144
155
  * change log so the engine discovers them via /api/sync/pull at
145
156
  * since=0 — same shape production clients see when they boot
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.302",
6
+ "version": "0.3.304",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -1543,14 +1543,21 @@ export class SyncEngine {
1543
1543
  let firstPass = true;
1544
1544
  while (firstPass || snapshotAfter) {
1545
1545
  firstPass = false;
1546
- const params = new URLSearchParams();
1547
- params.set("since", String(this.cursor.last_seq));
1546
+ // `snapshot_after` is an OPAQUE cursor the server already URL-encoded
1547
+ // (it `url_encode`s the JSON payload). It MUST be appended raw — running
1548
+ // it back through URLSearchParams would double-encode it, the server's
1549
+ // single `url_decode` would leave it still-encoded, `serde_json::from_str`
1550
+ // would fail, and the server would treat the page as "no resume" and
1551
+ // restart the snapshot from row 0 — an infinite re-snapshot loop for any
1552
+ // table larger than one page (SNAPSHOT_BATCH_LIMIT rows). `since` is a
1553
+ // plain integer, so it's safe to inline.
1554
+ let query = `since=${this.cursor.last_seq}`;
1548
1555
  if (snapshotAfter) {
1549
- params.set("snapshot_after", snapshotAfter);
1556
+ query += `&snapshot_after=${snapshotAfter}`;
1550
1557
  }
1551
1558
  const resp = await this.request<
1552
1559
  PullResponse & { snapshot_after?: string | null }
1553
- >("GET", `/api/sync/pull?${params.toString()}`);
1560
+ >("GET", `/api/sync/pull?${query}`);
1554
1561
  await this.enqueueApply(resp.changes, resp.cursor, { isPull: true });
1555
1562
  // `snapshot_after` is only set when the server is mid-snapshot.
1556
1563
  // Continue paginating in the same loop iteration so we don't
@@ -941,4 +941,34 @@ describe("sync scenarios", () => {
941
941
  // follower broadcast and nothing re-pushed.
942
942
  expect(env.server.receivedPushKeys).toContain("Note/n1");
943
943
  });
944
+
945
+ // Pins: the snapshot_after continuation cursor must be appended to the pull
946
+ // URL VERBATIM. The server returns it already URL-encoded; the client used to
947
+ // run it back through URLSearchParams, double-encoding it. The server's single
948
+ // url_decode then left it encoded, serde_json::from_str failed, the resume was
949
+ // ignored, and the snapshot restarted from row 0 on EVERY page — an infinite
950
+ // re-snapshot loop (and egress storm) for any synced table past one page
951
+ // (SNAPSHOT_BATCH_LIMIT rows). Reproduced live on the 10k-product store demo.
952
+ test("snapshot pagination echoes the cursor verbatim (no double-encode loop)", async () => {
953
+ env = createTestEnv();
954
+ // The real server's cursor: url_encode(JSON) — full of %-escapes.
955
+ const cursor = "%7B%22a%22%3A%22r1%22%2C%22e%22%3A%22Recording%22%2C%22s%22%3A1%7D";
956
+ env.server.primeSnapshotAfter(cursor);
957
+
958
+ await env.start();
959
+
960
+ const snapshotPulls = env.server.pullUrls.filter((u) => u.includes("since=0"));
961
+ // Page 1 (no cursor) + page 2 (resume) — BOUNDED. A regressed engine would
962
+ // loop forever on page 1; the mock terminates the resume so this stays a
963
+ // fast assertion instead of a hang.
964
+ expect(snapshotPulls.length).toBeGreaterThanOrEqual(2);
965
+ expect(snapshotPulls.length).toBeLessThan(6);
966
+
967
+ const resume = snapshotPulls.find((u) => u.includes("snapshot_after="));
968
+ expect(resume).toBeDefined();
969
+ const sent = resume!.split("snapshot_after=")[1]!.split("&")[0];
970
+ // VERBATIM. Double-encoding would have produced `%2522`/`%257B`.
971
+ expect(sent).toBe(cursor);
972
+ expect(sent).not.toContain("%2522");
973
+ });
944
974
  });
@@ -243,6 +243,25 @@ export class TestServer {
243
243
  return v;
244
244
  }
245
245
 
246
+ /** Every `/api/sync/pull` URL the engine requested, in order. Lets a test
247
+ * assert how the engine encodes query params (e.g. that `snapshot_after` is
248
+ * not double-URL-encoded — the bug that caused an infinite re-snapshot loop). */
249
+ pullUrls: string[] = [];
250
+
251
+ /** One-shot: make the next from-0 snapshot pull return this `snapshot_after`
252
+ * token (a continuation cursor), so a test can drive the engine's snapshot
253
+ * pagination + assert the resume request echoes the token verbatim. The token
254
+ * is what the real server returns: already URL-encoded JSON. */
255
+ private nextSnapshotAfter: string | null = null;
256
+ primeSnapshotAfter(token: string): void {
257
+ this.nextSnapshotAfter = token;
258
+ }
259
+ consumeSnapshotAfter(): string | null {
260
+ const v = this.nextSnapshotAfter;
261
+ this.nextSnapshotAfter = null;
262
+ return v;
263
+ }
264
+
246
265
  // ---- Entity data --------------------------------------------------------
247
266
 
248
267
  /** Bulk-seed rows for an entity AND emit insert events into the
@@ -249,6 +249,7 @@ async function handle(
249
249
  body: { error: { code: "RESYNC_REQUIRED" } },
250
250
  };
251
251
  }
252
+ server.pullUrls.push(url);
252
253
  const since = Number(new URL(url, "http://test").searchParams.get("since") ?? "0");
253
254
  // Cluster-divergence sim: a delta pull lands on an instance that
254
255
  // can't serve our cursor → 410. A snapshot (since=0) still succeeds.
@@ -258,6 +259,24 @@ async function handle(
258
259
  body: { error: { code: "RESYNC_REQUIRED" } },
259
260
  };
260
261
  }
262
+ // Snapshot pagination sim (since=0). A primed from-0 pull returns an
263
+ // already-URL-encoded `snapshot_after` continuation (page 1); the resume
264
+ // request (page 2) carries it back and gets the final page. The regression
265
+ // guard lives in the TEST, which inspects the captured page-2 URL: the client
266
+ // must echo the cursor VERBATIM, not re-encode it (double-encoding broke the
267
+ // server's single url_decode and restarted the snapshot forever).
268
+ if (since === 0) {
269
+ const hasResume = url.includes("snapshot_after=");
270
+ if (!hasResume) {
271
+ const token = server.consumeSnapshotAfter();
272
+ if (token) {
273
+ const resp = await server.pull(token, since);
274
+ return { status: 200, body: { ...resp, snapshot_after: token } };
275
+ }
276
+ }
277
+ // Resume page (or no pagination primed): fall through to a normal,
278
+ // terminating snapshot (no snapshot_after → client exits the loop).
279
+ }
261
280
  const resp = await server.pull(token, since);
262
281
  // One-shot has_more on a delta pull → drives the tail-pull recursion.
263
282
  if (since > 0 && server.consumeNextPullHasMore()) {