@pylonsync/sync 0.3.228 → 0.3.230
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/package.json +1 -1
- package/src/idb-warm-load.test.ts +144 -0
- package/src/index.ts +261 -64
- package/src/local-store.ts +57 -5
- package/src/multi-tab-orchestrator.ts +31 -5
- package/src/mutation-queue.ts +32 -3
- package/src/persistence.ts +69 -30
- package/src/round6-codex.test.ts +157 -0
- package/src/scenarios.test.ts +146 -0
- package/src/test-harness/server.ts +36 -0
- package/src/test-harness/transport.ts +16 -0
package/src/scenarios.test.ts
CHANGED
|
@@ -525,6 +525,152 @@ describe("sync scenarios", () => {
|
|
|
525
525
|
expect(env.engine.store.get("Domain", "d1")).not.toBeNull();
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
+
// TAIL-PULL DEADLOCK (pins index.ts:1310). When a delta pull reports
|
|
529
|
+
// has_more (the catch-up exceeds the server's per-pull limit), pullInner
|
|
530
|
+
// drains the tail by recursing. Pre-fix it called the PUBLIC pull(),
|
|
531
|
+
// which re-enters the op queue under key "pull" and coalesces onto the
|
|
532
|
+
// promise it's currently running inside → permanent self-deadlock that
|
|
533
|
+
// bricks the whole pull path. Post-fix it recurses into pullInner().
|
|
534
|
+
test("delta pull with has_more drains the tail without self-deadlocking", async () => {
|
|
535
|
+
env = createTestEnv({ transport: "poll" });
|
|
536
|
+
env.signIn({ userId: "u1" });
|
|
537
|
+
env.server.seed("Note", [{ id: "n1", title: "a" }]);
|
|
538
|
+
await env.start();
|
|
539
|
+
await env.flush();
|
|
540
|
+
|
|
541
|
+
// A later change exists; the next delta pull reports has_more once,
|
|
542
|
+
// forcing the tail recursion.
|
|
543
|
+
env.server.insert("Note", { id: "n2", title: "b" });
|
|
544
|
+
env.server.primeNextPullHasMore();
|
|
545
|
+
|
|
546
|
+
// Must complete, not hang. Pre-fix the pull never resolves and the
|
|
547
|
+
// race rejects with "pull deadlocked".
|
|
548
|
+
const e = env;
|
|
549
|
+
await Promise.race([
|
|
550
|
+
e.engine.pull().then(() => e.flush()),
|
|
551
|
+
new Promise((_, reject) =>
|
|
552
|
+
setTimeout(() => reject(new Error("pull deadlocked")), 3000),
|
|
553
|
+
),
|
|
554
|
+
]);
|
|
555
|
+
expect(env.engine.store.get("Note", "n2")).not.toBeNull();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// OFFLINE WRITES (pins the transient/permanent split in pushInner).
|
|
559
|
+
// A push that fails with a NETWORK error (offline — fetch rejects, no
|
|
560
|
+
// HTTP status) must keep the mutation `pending` and the optimistic
|
|
561
|
+
// ghost intact, then re-send on reconnect. Pre-fix every transport
|
|
562
|
+
// failure marked the mutation `failed` + rolled back the ghost, so an
|
|
563
|
+
// offline insert vanished and was never re-sent.
|
|
564
|
+
test("offline push keeps the mutation pending and the ghost intact (not failed)", async () => {
|
|
565
|
+
env = createTestEnv({ transport: "poll" });
|
|
566
|
+
env.signIn({ userId: "u1" });
|
|
567
|
+
await env.start();
|
|
568
|
+
await env.flush();
|
|
569
|
+
|
|
570
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
571
|
+
const id = await env.engine.insert("Note", { title: "offline" });
|
|
572
|
+
await env.flush();
|
|
573
|
+
|
|
574
|
+
// Offline (network throw, no status): the ghost survives and the
|
|
575
|
+
// mutation stays PENDING — not `failed`. Pre-fix it was marked failed
|
|
576
|
+
// and the ghost rolled back, so the offline write vanished and (being
|
|
577
|
+
// `failed`, not `pending`) was never re-sent. Pending is exactly what
|
|
578
|
+
// makes the standard reconnect push re-ship it. (insert() returns the
|
|
579
|
+
// ROW id; match the mutation by row_id, not its op_id.)
|
|
580
|
+
expect(env.engine.store.get("Note", id)).not.toBeNull();
|
|
581
|
+
const mine = (env.engine.mutations as unknown as {
|
|
582
|
+
queue: { change: { row_id: string }; status: string }[];
|
|
583
|
+
}).queue.filter((m) => m.change.row_id === id);
|
|
584
|
+
expect(mine).toHaveLength(1);
|
|
585
|
+
expect(mine[0]!.status).toBe("pending");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// FAILED UPDATE ROLLBACK (pins failPushedMutation update path). A
|
|
589
|
+
// PERMANENT rejection (403) of an update must restore the row to its
|
|
590
|
+
// pre-update value. Pre-fix only inserts rolled back; a rejected update
|
|
591
|
+
// left the local row permanently wrong.
|
|
592
|
+
test("a permanently-rejected update restores the prior value", async () => {
|
|
593
|
+
env = createTestEnv({ transport: "poll" });
|
|
594
|
+
env.signIn({ userId: "u1" });
|
|
595
|
+
env.server.seed("Note", [{ id: "n1", title: "original" }]);
|
|
596
|
+
await env.start();
|
|
597
|
+
await env.flush();
|
|
598
|
+
|
|
599
|
+
env.server.primeNextPushOutcome({ kind: "status", status: 403 });
|
|
600
|
+
await env.engine.update("Note", "n1", { title: "edited" });
|
|
601
|
+
await env.flush();
|
|
602
|
+
|
|
603
|
+
const row = env.engine.store.get("Note", "n1") as { title?: string } | null;
|
|
604
|
+
expect(row?.title).toBe("original");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// FAILED DELETE ROLLBACK (pins failPushedMutation delete path). A
|
|
608
|
+
// rejected delete must bring the row back AND clear the optimistic
|
|
609
|
+
// tombstone, so a later server insert of the same id isn't fenced out.
|
|
610
|
+
// Pre-fix the row vanished and the tombstone blocked re-creation
|
|
611
|
+
// forever.
|
|
612
|
+
test("a permanently-rejected delete restores the row and un-fences it", async () => {
|
|
613
|
+
env = createTestEnv({ transport: "poll" });
|
|
614
|
+
env.signIn({ userId: "u1" });
|
|
615
|
+
env.server.seed("Note", [{ id: "n1", title: "keep" }]);
|
|
616
|
+
await env.start();
|
|
617
|
+
await env.flush();
|
|
618
|
+
|
|
619
|
+
env.server.primeNextPushOutcome({ kind: "status", status: 403 });
|
|
620
|
+
await env.engine.delete("Note", "n1");
|
|
621
|
+
await env.flush();
|
|
622
|
+
expect(env.engine.store.get("Note", "n1")).not.toBeNull();
|
|
623
|
+
|
|
624
|
+
// Un-fenced: a server insert of the same id now lands.
|
|
625
|
+
env.server.insert("Note", { id: "n1", title: "server-updated" });
|
|
626
|
+
await env.engine.pull();
|
|
627
|
+
await env.flush();
|
|
628
|
+
const row = env.engine.store.get("Note", "n1") as { title?: string } | null;
|
|
629
|
+
expect(row?.title).toBe("server-updated");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// CROSS-IDENTITY MUTATION WIPE (pins resetReplica wipeMutations). An
|
|
633
|
+
// identity flip (token / tenant change) must DROP the outgoing
|
|
634
|
+
// identity's pending offline writes — replaying them under the new
|
|
635
|
+
// session would attribute one user's writes to another. A same-user 410
|
|
636
|
+
// RESYNC must KEEP them (they survive the snapshot refresh).
|
|
637
|
+
test("an identity-flip reset wipes the outgoing identity's pending writes", async () => {
|
|
638
|
+
env = createTestEnv({ transport: "poll" });
|
|
639
|
+
env.signIn({ userId: "u1" });
|
|
640
|
+
await env.start();
|
|
641
|
+
await env.flush();
|
|
642
|
+
|
|
643
|
+
// Queue an offline write — network failure keeps it pending.
|
|
644
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
645
|
+
await env.engine.insert("Note", { title: "draft" });
|
|
646
|
+
await env.flush();
|
|
647
|
+
const queue = (env.engine.mutations as unknown as { queue: unknown[] }).queue;
|
|
648
|
+
expect(queue.length).toBeGreaterThan(0);
|
|
649
|
+
|
|
650
|
+
// Identity flip → wipe.
|
|
651
|
+
await env.engine.resetReplica({ wipeMutations: true });
|
|
652
|
+
expect((env.engine.mutations as unknown as { queue: unknown[] }).queue).toHaveLength(0);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("a same-user 410 resync PRESERVES pending offline writes", async () => {
|
|
656
|
+
env = createTestEnv({ transport: "poll" });
|
|
657
|
+
env.signIn({ userId: "u1" });
|
|
658
|
+
await env.start();
|
|
659
|
+
await env.flush();
|
|
660
|
+
|
|
661
|
+
env.server.primeNextPushOutcome({ kind: "network" });
|
|
662
|
+
await env.engine.insert("Note", { title: "draft" });
|
|
663
|
+
await env.flush();
|
|
664
|
+
const before = (env.engine.mutations as unknown as { queue: unknown[] }).queue.length;
|
|
665
|
+
expect(before).toBeGreaterThan(0);
|
|
666
|
+
|
|
667
|
+
// 410 RESYNC path is the default (wipeMutations omitted) — KEEP writes.
|
|
668
|
+
await env.engine.resetReplica();
|
|
669
|
+
expect(
|
|
670
|
+
(env.engine.mutations as unknown as { queue: unknown[] }).queue.length,
|
|
671
|
+
).toBe(before);
|
|
672
|
+
});
|
|
673
|
+
|
|
528
674
|
// Row-revoked envelope: server pushes `row-revoked` to a
|
|
529
675
|
// subscriber whose read policy was revoked for a specific row.
|
|
530
676
|
// The engine must drop the row from the local replica and
|
|
@@ -196,6 +196,42 @@ export class TestServer {
|
|
|
196
196
|
return s;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/** Inject a one-shot outcome for the NEXT push: a `network` failure
|
|
200
|
+
* (the fetch rejects with no status — simulating offline / connection
|
|
201
|
+
* reset) or an HTTP `status` (e.g. 403 permanent rejection, 503
|
|
202
|
+
* transient). Lets tests exercise the transient-vs-permanent
|
|
203
|
+
* classification in pushInner. */
|
|
204
|
+
private nextPushOutcome:
|
|
205
|
+
| { kind: "network" }
|
|
206
|
+
| { kind: "status"; status: number }
|
|
207
|
+
| null = null;
|
|
208
|
+
primeNextPushOutcome(
|
|
209
|
+
o: { kind: "network" } | { kind: "status"; status: number },
|
|
210
|
+
): void {
|
|
211
|
+
this.nextPushOutcome = o;
|
|
212
|
+
}
|
|
213
|
+
consumeNextPushOutcome():
|
|
214
|
+
| { kind: "network" }
|
|
215
|
+
| { kind: "status"; status: number }
|
|
216
|
+
| null {
|
|
217
|
+
const o = this.nextPushOutcome;
|
|
218
|
+
this.nextPushOutcome = null;
|
|
219
|
+
return o;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Make the NEXT delta pull report `has_more: true` once, exercising
|
|
223
|
+
* the change-log tail-pull recursion in pullInner (the path that
|
|
224
|
+
* self-deadlocked before the pullInner() fix). */
|
|
225
|
+
private nextPullHasMore = false;
|
|
226
|
+
primeNextPullHasMore(): void {
|
|
227
|
+
this.nextPullHasMore = true;
|
|
228
|
+
}
|
|
229
|
+
consumeNextPullHasMore(): boolean {
|
|
230
|
+
const v = this.nextPullHasMore;
|
|
231
|
+
this.nextPullHasMore = false;
|
|
232
|
+
return v;
|
|
233
|
+
}
|
|
234
|
+
|
|
199
235
|
// ---- Entity data --------------------------------------------------------
|
|
200
236
|
|
|
201
237
|
/** Bulk-seed rows for an entity AND emit insert events into the
|
|
@@ -259,6 +259,10 @@ async function handle(
|
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
261
|
const resp = await server.pull(token, since);
|
|
262
|
+
// One-shot has_more on a delta pull → drives the tail-pull recursion.
|
|
263
|
+
if (since > 0 && server.consumeNextPullHasMore()) {
|
|
264
|
+
return { status: 200, body: { ...resp, has_more: true } };
|
|
265
|
+
}
|
|
262
266
|
return { status: 200, body: resp };
|
|
263
267
|
}
|
|
264
268
|
|
|
@@ -275,6 +279,18 @@ async function handle(
|
|
|
275
279
|
|
|
276
280
|
// /api/sync/push — accept ops from optimistic mutations.
|
|
277
281
|
if (url.endsWith("/api/sync/push") && method === "POST") {
|
|
282
|
+
const outcome = server.consumeNextPushOutcome();
|
|
283
|
+
if (outcome?.kind === "network") {
|
|
284
|
+
// Reject like a real offline fetch: no HTTP status → the engine
|
|
285
|
+
// classifies it as TRANSIENT (keep pending, retry).
|
|
286
|
+
throw new Error("simulated network failure (offline)");
|
|
287
|
+
}
|
|
288
|
+
if (outcome?.kind === "status") {
|
|
289
|
+
return {
|
|
290
|
+
status: outcome.status,
|
|
291
|
+
body: { error: { code: "PUSH_REJECTED" } },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
278
294
|
return { status: 200, body: { ops: [] } };
|
|
279
295
|
}
|
|
280
296
|
|