@pylonsync/sync 0.3.227 → 0.3.229

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.227",
6
+ "version": "0.3.229",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -423,4 +423,148 @@ describe("IDB warm-load hydration", () => {
423
423
  // Fast path — no warning expected on a trivial load.
424
424
  expect(warned).toBe(false);
425
425
  });
426
+
427
+ // IDB WRITE HANG (pins persistence.commit). A write tx that ABORTS
428
+ // (quota-exceeded, or any storage error) must let the persist promise
429
+ // SETTLE — not hang. The engine awaits the persist before advancing
430
+ // the cursor in enqueueApply, so a hung write would wedge the whole
431
+ // apply queue and silently kill live sync. Pre-fix saveRow/deleteRow/
432
+ // saveCursor registered only `oncomplete`, so an abort never resolved.
433
+ test("a write tx that aborts resolves (degrades) instead of hanging", async () => {
434
+ const origWarn = console.warn;
435
+ console.warn = () => {};
436
+ try {
437
+ const p = new IndexedDBPersistence("idb-abort-degrade");
438
+ await p.open();
439
+ const db = p.connection!;
440
+ const realTx = db.transaction.bind(db);
441
+ // Abort the NEXT readwrite tx right after handing it back.
442
+ let armed = true;
443
+ (db as unknown as { transaction: typeof db.transaction }).transaction = ((
444
+ ...args: Parameters<typeof db.transaction>
445
+ ) => {
446
+ const tx = realTx(...args);
447
+ if (armed && String(args[1]) === "readwrite") {
448
+ armed = false;
449
+ queueMicrotask(() => {
450
+ try {
451
+ tx.abort();
452
+ } catch {
453
+ /* already settled */
454
+ }
455
+ });
456
+ }
457
+ return tx;
458
+ }) as typeof db.transaction;
459
+
460
+ // Pre-fix: this promise never settles → the race rejects.
461
+ await Promise.race([
462
+ p.saveRow("Note", "n1", { id: "n1", title: "x" } as Row),
463
+ new Promise((_, reject) =>
464
+ setTimeout(() => reject(new Error("saveRow hung on abort")), 1000),
465
+ ),
466
+ ]);
467
+
468
+ // A subsequent (un-armed) write still commits — the engine degrades,
469
+ // it isn't permanently broken.
470
+ await p.saveRow("Note", "n2", { id: "n2", title: "y" } as Row);
471
+ const rows = await p.loadAll("Note");
472
+ expect(rows.some((r) => (r as { id?: string }).id === "n2")).toBe(true);
473
+ } finally {
474
+ console.warn = origWarn;
475
+ }
476
+ });
477
+
478
+ // CURSOR-AHEAD-OF-DISK (pins the persistDegraded gate). When a row write
479
+ // ABORTS, the row never reaches disk — so the engine MUST NOT persist a
480
+ // cursor past it, or the next cold start's warm-load skips that row
481
+ // forever (cursor ahead of replica). The in-memory cursor still advances
482
+ // (live session stays correct); only the ON-DISK cursor is held back so
483
+ // a restart re-pulls the gap. This is the regression for the IDB-hang
484
+ // fix that (before this gate) traded a hang for silent data loss.
485
+ test("a row write that aborts holds the on-disk cursor back", async () => {
486
+ const origWarn = console.warn;
487
+ console.warn = () => {};
488
+ try {
489
+ const appName = "idb-cursor-drift";
490
+ const engine = makeEngine(appName);
491
+ await engine.start();
492
+
493
+ const internal = engine as unknown as {
494
+ persistence: IndexedDBPersistence;
495
+ cursor: { last_seq: number };
496
+ persistDegraded: boolean;
497
+ enqueueApply(
498
+ changes: unknown[],
499
+ targetCursor?: { last_seq: number },
500
+ ): Promise<void>;
501
+ };
502
+ const persistence = internal.persistence;
503
+ // Cursor on disk after start() (server.serverSeq seed) — capture it
504
+ // so we assert it does NOT advance to 50 below.
505
+ const onDiskBefore = (await persistence.loadCursor())?.last_seq ?? 0;
506
+
507
+ // Abort the next ENTITIES (row) readwrite, leaving the separate
508
+ // CURSOR-store tx alone — mirrors a quota abort on a row write.
509
+ const db = persistence.connection!;
510
+ const realTx = db.transaction.bind(db);
511
+ let armed = true;
512
+ (db as unknown as { transaction: typeof db.transaction }).transaction = ((
513
+ ...args: Parameters<typeof db.transaction>
514
+ ) => {
515
+ const tx = realTx(...args);
516
+ const stores = args[0];
517
+ const touchesEntities = Array.isArray(stores)
518
+ ? stores.includes("entities")
519
+ : stores === "entities";
520
+ const touchesCursors = Array.isArray(stores)
521
+ ? stores.includes("cursors")
522
+ : stores === "cursors";
523
+ if (
524
+ armed &&
525
+ String(args[1]) === "readwrite" &&
526
+ touchesEntities &&
527
+ !touchesCursors
528
+ ) {
529
+ armed = false;
530
+ queueMicrotask(() => {
531
+ try {
532
+ tx.abort();
533
+ } catch {
534
+ /* already settled */
535
+ }
536
+ });
537
+ }
538
+ return tx;
539
+ }) as typeof db.transaction;
540
+
541
+ // Apply a change with a target cursor far ahead. The row write
542
+ // aborts; the on-disk cursor must stay where it was.
543
+ await internal.enqueueApply(
544
+ [
545
+ {
546
+ seq: 50,
547
+ entity: "Note",
548
+ row_id: "n1",
549
+ kind: "insert",
550
+ data: { id: "n1", title: "x" },
551
+ timestamp: "",
552
+ },
553
+ ],
554
+ { last_seq: 50 },
555
+ );
556
+
557
+ // In-memory cursor advanced (live sync correct); degrade flag latched.
558
+ expect(internal.cursor.last_seq).toBe(50);
559
+ expect(internal.persistDegraded).toBe(true);
560
+ // The on-disk cursor did NOT advance past the un-persisted row.
561
+ const onDiskAfter = (await persistence.loadCursor())?.last_seq ?? 0;
562
+ expect(onDiskAfter).toBe(onDiskBefore);
563
+ expect(onDiskAfter).toBeLessThan(50);
564
+
565
+ engine.stop();
566
+ } finally {
567
+ console.warn = origWarn;
568
+ }
569
+ });
426
570
  });