@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.
@@ -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