@pylonsync/sync 0.3.303 → 0.3.305
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.
- package/dist/test-harness/server.d.ts +11 -0
- package/package.json +1 -1
- package/src/index.ts +11 -4
- package/src/scenarios.test.ts +30 -0
- package/src/test-harness/server.ts +19 -0
- package/src/test-harness/transport.ts +19 -0
|
@@ -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
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
|
-
|
|
1547
|
-
|
|
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
|
-
|
|
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?${
|
|
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
|
package/src/scenarios.test.ts
CHANGED
|
@@ -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()) {
|