@pylonsync/sync 0.22.41 → 0.22.43

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/index.d.ts CHANGED
@@ -733,6 +733,35 @@ export declare class SyncEngine {
733
733
  */
734
734
  observeEntity(entity: string): void;
735
735
  reconcile(entities?: string[]): Promise<void>;
736
+ /** Entities a server hint (`sync-reconcile`) asked to reconcile that
737
+ * have not reconciled yet. */
738
+ private hintPending;
739
+ private hintTimer;
740
+ private hintRetries;
741
+ private lastHintReconcileAt;
742
+ /** Hints that arrive this close together share one reconcile. */
743
+ private static readonly HINT_DEBOUNCE_MS;
744
+ /** At most one hinted reconcile per this interval. */
745
+ private static readonly HINT_MIN_INTERVAL_MS;
746
+ /** Backoff for entities whose fetch raced a newer change: doubling from
747
+ * the base, capped. */
748
+ private static readonly HINT_RETRY_BASE_MS;
749
+ private static readonly HINT_RETRY_MAX_MS;
750
+ /**
751
+ * The server said a change elsewhere (a share, an unshare, a folder
752
+ * membership) changed which rows of `entities` this user may read
753
+ * (`sync-reconcile`). No other event will tell this replica, so the
754
+ * entities stay pending until a reconcile of them completes: hints are
755
+ * merged, run at most once a second, and an entity whose fetch raced a
756
+ * newer change is retried with backoff until it lands or the engine
757
+ * stops.
758
+ */
759
+ private queueHintReconcile;
760
+ private scheduleHintReconcile;
761
+ private flushHintReconcile;
762
+ /** `reconcile`, resolving with the entities skipped because the cursor
763
+ * moved during their fetch. */
764
+ private reconcileReportingSkips;
736
765
  /** Scoped reconcile batch that has not started running yet. */
737
766
  private scopedReconcileBatch;
738
767
  private scopedReconcileSeq;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.41",
6
+ "version": "0.22.43",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -1651,6 +1651,12 @@ export class SyncEngine {
1651
1651
  /** Stop the sync engine. */
1652
1652
  stop(): void {
1653
1653
  this.running = false;
1654
+ if (this.hintTimer !== null) {
1655
+ clearTimeout(this.hintTimer);
1656
+ this.hintTimer = null;
1657
+ }
1658
+ this.hintPending.clear();
1659
+ this.hintRetries = 0;
1654
1660
  if (this.identityRetryTimer !== null) {
1655
1661
  clearTimeout(this.identityRetryTimer);
1656
1662
  this.identityRetryTimer = null;
@@ -2303,10 +2309,75 @@ export class SyncEngine {
2303
2309
  }
2304
2310
 
2305
2311
  async reconcile(entities?: string[]): Promise<void> {
2312
+ await this.reconcileReportingSkips(entities);
2313
+ }
2314
+
2315
+ /** Entities a server hint (`sync-reconcile`) asked to reconcile that
2316
+ * have not reconciled yet. */
2317
+ private hintPending = new Set<string>();
2318
+ private hintTimer: ReturnType<typeof setTimeout> | null = null;
2319
+ private hintRetries = 0;
2320
+ private lastHintReconcileAt = 0;
2321
+ /** Hints that arrive this close together share one reconcile. */
2322
+ private static readonly HINT_DEBOUNCE_MS = 100;
2323
+ /** At most one hinted reconcile per this interval. */
2324
+ private static readonly HINT_MIN_INTERVAL_MS = 1_000;
2325
+ /** Backoff for entities whose fetch raced a newer change: doubling from
2326
+ * the base, capped. */
2327
+ private static readonly HINT_RETRY_BASE_MS = 250;
2328
+ private static readonly HINT_RETRY_MAX_MS = 10_000;
2329
+
2330
+ /**
2331
+ * The server said a change elsewhere (a share, an unshare, a folder
2332
+ * membership) changed which rows of `entities` this user may read
2333
+ * (`sync-reconcile`). No other event will tell this replica, so the
2334
+ * entities stay pending until a reconcile of them completes: hints are
2335
+ * merged, run at most once a second, and an entity whose fetch raced a
2336
+ * newer change is retried with backoff until it lands or the engine
2337
+ * stops.
2338
+ */
2339
+ private queueHintReconcile(entities: string[]): void {
2340
+ for (const e of entities) this.hintPending.add(e);
2341
+ this.scheduleHintReconcile(SyncEngine.HINT_DEBOUNCE_MS);
2342
+ }
2343
+
2344
+ private scheduleHintReconcile(delayMs: number): void {
2345
+ if (this.hintTimer !== null || !this.running) return;
2346
+ const wait = Math.max(delayMs, this.lastHintReconcileAt + SyncEngine.HINT_MIN_INTERVAL_MS - Date.now());
2347
+ this.hintTimer = setTimeout(() => {
2348
+ this.hintTimer = null;
2349
+ void this.flushHintReconcile();
2350
+ }, wait);
2351
+ }
2352
+
2353
+ private async flushHintReconcile(): Promise<void> {
2354
+ if (!this.running || this.hintPending.size === 0) return;
2355
+ const entities = [...this.hintPending];
2356
+ this.hintPending.clear();
2357
+ this.lastHintReconcileAt = Date.now();
2358
+ let skipped: string[];
2359
+ try {
2360
+ skipped = await this.reconcileReportingSkips(entities);
2361
+ } catch {
2362
+ skipped = entities;
2363
+ }
2364
+ for (const e of skipped) this.hintPending.add(e);
2365
+ this.hintRetries = skipped.length > 0 ? this.hintRetries + 1 : 0;
2366
+ if (this.hintPending.size === 0) return;
2367
+ const delay =
2368
+ skipped.length > 0
2369
+ ? Math.min(SyncEngine.HINT_RETRY_BASE_MS * 2 ** (this.hintRetries - 1), SyncEngine.HINT_RETRY_MAX_MS)
2370
+ : SyncEngine.HINT_DEBOUNCE_MS;
2371
+ this.scheduleHintReconcile(delay);
2372
+ }
2373
+
2374
+ /** `reconcile`, resolving with the entities skipped because the cursor
2375
+ * moved during their fetch. */
2376
+ private async reconcileReportingSkips(entities?: string[]): Promise<string[]> {
2306
2377
  const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
2307
2378
  const now = Date.now();
2308
2379
  if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
2309
- return;
2380
+ return [];
2310
2381
  }
2311
2382
  // Reconcile waits behind any in-flight pull / refresh / push so it
2312
2383
  // can't apply rows captured under a stale session.
@@ -2315,7 +2386,7 @@ export class SyncEngine {
2315
2386
  // the same window share one fetch per entity.
2316
2387
  return this.opQueue.enqueue("reconcile", async () => {
2317
2388
  try {
2318
- await this.reconcileInner();
2389
+ return await this.reconcileInner();
2319
2390
  } finally {
2320
2391
  this.lastReconcileAt = Date.now();
2321
2392
  }
@@ -2330,9 +2401,9 @@ export class SyncEngine {
2330
2401
  for (const e of entities) open.entities.add(e);
2331
2402
  return open.promise;
2332
2403
  }
2333
- const batch: { entities: Set<string>; promise: Promise<void> } = {
2404
+ const batch: { entities: Set<string>; promise: Promise<string[]> } = {
2334
2405
  entities: new Set(entities),
2335
- promise: Promise.resolve(),
2406
+ promise: Promise.resolve([]),
2336
2407
  };
2337
2408
  this.scopedReconcileBatch = batch;
2338
2409
  this.scopedReconcileSeq += 1;
@@ -2341,7 +2412,7 @@ export class SyncEngine {
2341
2412
  async () => {
2342
2413
  if (this.scopedReconcileBatch === batch) this.scopedReconcileBatch = null;
2343
2414
  try {
2344
- await this.reconcileInner([...batch.entities]);
2415
+ return await this.reconcileInner([...batch.entities]);
2345
2416
  } finally {
2346
2417
  this.lastReconcileAt = Date.now();
2347
2418
  }
@@ -2353,14 +2424,14 @@ export class SyncEngine {
2353
2424
  /** Scoped reconcile batch that has not started running yet. */
2354
2425
  private scopedReconcileBatch: {
2355
2426
  entities: Set<string>;
2356
- promise: Promise<void>;
2427
+ promise: Promise<string[]>;
2357
2428
  } | null = null;
2358
2429
  private scopedReconcileSeq = 0;
2359
2430
 
2360
- private async reconcileInner(entities?: string[]): Promise<void> {
2431
+ private async reconcileInner(entities?: string[]): Promise<string[]> {
2361
2432
  // Same reasoning as pullInner: the leader reconciles, broadcasts
2362
2433
  // results, and follower replicas converge via the channel.
2363
- if (!this.isMultiTabLeader) return;
2434
+ if (!this.isMultiTabLeader) return [];
2364
2435
  // Sweep entities with local rows PLUS entities the app has observed
2365
2436
  // via useQuery (even when empty locally). Without the observed set,
2366
2437
  // a server row in a never-cached entity is never reconciled and
@@ -2368,7 +2439,8 @@ export class SyncEngine {
2368
2439
  const names =
2369
2440
  entities ??
2370
2441
  [...new Set([...this.store.entityNames(), ...this.observedEntities])];
2371
- if (names.length === 0) return;
2442
+ if (names.length === 0) return [];
2443
+ const skipped: string[] = [];
2372
2444
  // Tombstone seq for any local row the server doesn't return. Using
2373
2445
  // the current cursor means future inserts (which have higher seqs)
2374
2446
  // bypass the tombstone — re-creation server-side still propagates.
@@ -2444,11 +2516,16 @@ export class SyncEngine {
2444
2516
  const streak = (this.reconcile403Streak.get(entity) ?? 0) + 1;
2445
2517
  if (streak < 2) {
2446
2518
  this.reconcile403Streak.set(entity, streak);
2519
+ skipped.push(entity);
2447
2520
  return;
2448
2521
  }
2449
2522
  this.reconcile403Streak.delete(entity);
2450
2523
  await this.dropEntity(entity, tombstoneSeq);
2524
+ return;
2451
2525
  }
2526
+ // Not reconciled (offline, a 5xx): reported, so a server hint
2527
+ // stays pending and retries.
2528
+ skipped.push(entity);
2452
2529
  return;
2453
2530
  }
2454
2531
  // Successful fetch — the entity is readable, so any prior 403 streak
@@ -2460,7 +2537,9 @@ export class SyncEngine {
2460
2537
  // for a row our snapshot just captured. Bail out for this
2461
2538
  // entity; reconcile() is triggered again on visibility-change
2462
2539
  // and reconnect, and the WS event already carried the latest
2463
- // state for the affected row.
2540
+ // state for the affected row. Reported, so a server hint can
2541
+ // retry (flushHintReconcile).
2542
+ skipped.push(entity);
2464
2543
  return;
2465
2544
  }
2466
2545
  if (this.session.signature() !== sessionBeforeFetch) {
@@ -2479,6 +2558,7 @@ export class SyncEngine {
2479
2558
  );
2480
2559
  }),
2481
2560
  );
2561
+ return skipped;
2482
2562
  }
2483
2563
 
2484
2564
  /** Fetch every row for an entity. Uses cursor pagination so big tables
@@ -4105,6 +4185,15 @@ export class SyncEngine {
4105
4185
  // fresh /api/auth/me read which updates the cached session AND,
4106
4186
  // on tenant flip, resets the replica so stale rows from the
4107
4187
  // previous tenant disappear.
4188
+ // A change elsewhere (a share, an unshare, a folder membership)
4189
+ // changed which rows of these entities this user may read, without a
4190
+ // change event for the rows themselves. Reconcile them.
4191
+ if (msg.type === "sync-reconcile" && Array.isArray(msg.entities)) {
4192
+ const entities = msg.entities.filter((e): e is string => typeof e === "string");
4193
+ if (entities.length > 0) this.queueHintReconcile(entities);
4194
+ return;
4195
+ }
4196
+
4108
4197
  if (msg.type === "session-changed") {
4109
4198
  void this.refreshResolvedSession();
4110
4199
  return;
@@ -668,3 +668,148 @@ describe("SyncEngine cookie auth", () => {
668
668
  expect(notifications).toBe(1);
669
669
  });
670
670
  });
671
+
672
+ // The server sends `sync-reconcile` when a row of another entity changed
673
+ // which rows of these entities the user may read (a share, an unshare):
674
+ // no change event of the rows themselves will come.
675
+ describe("sync-reconcile hint", () => {
676
+ let restore: (() => void) | null = null;
677
+
678
+ afterEach(() => {
679
+ restore?.();
680
+ restore = null;
681
+ });
682
+
683
+ type Internals = {
684
+ dispatchInboundMessage(m: Record<string, unknown>): void;
685
+ running: boolean;
686
+ cursor: { last_seq: number };
687
+ hintPending: Set<string>;
688
+ };
689
+ const inner = (engine: SyncEngine) => engine as unknown as Internals;
690
+ const deliver = (engine: SyncEngine, msg: Record<string, unknown>) => inner(engine).dispatchInboundMessage(msg);
691
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
692
+
693
+ function documentServer(onFetch: (n: number) => void = () => {}) {
694
+ let fetches = 0;
695
+ restore = installFetch(async (url) => {
696
+ if (url.includes("/api/entities/Document/cursor")) {
697
+ fetches += 1;
698
+ onFetch(fetches);
699
+ return {
700
+ status: 200,
701
+ body: { data: [{ id: "shared", title: "Now shared" }], next_cursor: null, has_more: false },
702
+ };
703
+ }
704
+ return { status: 404, body: {} };
705
+ });
706
+ return () => fetches;
707
+ }
708
+
709
+ test("reconciles the named entities: the shared row arrives, the unshared one leaves", async () => {
710
+ const fetches = documentServer();
711
+ const engine = makeEngine();
712
+ inner(engine).running = true;
713
+ seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
714
+
715
+ deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
716
+ await sleep(250);
717
+
718
+ expect(fetches()).toBe(1);
719
+ expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
720
+ engine.stop();
721
+ });
722
+
723
+ test("hints that arrive together share one fetch", async () => {
724
+ const fetches = documentServer();
725
+ const engine = makeEngine();
726
+ inner(engine).running = true;
727
+ for (let i = 0; i < 5; i++) deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
728
+ await sleep(250);
729
+ expect(fetches()).toBe(1);
730
+ engine.stop();
731
+ });
732
+
733
+ test("keeps retrying while newer changes race the fetch, then lands", async () => {
734
+ const engine = makeEngine();
735
+ // The first two fetches race a change event: the cursor moves.
736
+ const fetches = documentServer((n) => {
737
+ if (n <= 2) inner(engine).cursor.last_seq += 1;
738
+ });
739
+ inner(engine).running = true;
740
+
741
+ deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
742
+ await sleep(250);
743
+ expect(fetches()).toBe(1);
744
+ expect(engine.store.list("Document")).toEqual([]);
745
+ expect([...inner(engine).hintPending]).toEqual(["Document"]);
746
+
747
+ // Retries run at most once a second.
748
+ await sleep(2300);
749
+ expect(fetches()).toBe(3);
750
+ expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
751
+ expect(inner(engine).hintPending.size).toBe(0);
752
+ engine.stop();
753
+ });
754
+
755
+ test("stop drops pending hints", async () => {
756
+ const fetches = documentServer();
757
+ const engine = makeEngine();
758
+ inner(engine).running = true;
759
+ deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
760
+ engine.stop();
761
+ await sleep(250);
762
+ expect(fetches()).toBe(0);
763
+ expect(inner(engine).hintPending.size).toBe(0);
764
+ });
765
+
766
+ test("ignores a hint without entity names", async () => {
767
+ const fetches = documentServer();
768
+ const engine = makeEngine();
769
+ inner(engine).running = true;
770
+ deliver(engine, { type: "sync-reconcile", entities: [42, null] });
771
+ deliver(engine, { type: "sync-reconcile" });
772
+ await sleep(250);
773
+ expect(fetches()).toBe(0);
774
+ engine.stop();
775
+ });
776
+ });
777
+
778
+ describe("sync-reconcile hint after a failed fetch", () => {
779
+ let restore: (() => void) | null = null;
780
+ afterEach(() => {
781
+ restore?.();
782
+ restore = null;
783
+ });
784
+
785
+ test("a 5xx keeps the hint pending and the retry lands", async () => {
786
+ let fetches = 0;
787
+ restore = installFetch(async (url) => {
788
+ if (url.includes("/api/entities/Document/cursor")) {
789
+ fetches += 1;
790
+ if (fetches === 1) return { status: 503, body: {} };
791
+ return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
792
+ }
793
+ return { status: 404, body: {} };
794
+ });
795
+ const engine = makeEngine();
796
+ const inner = engine as unknown as {
797
+ running: boolean;
798
+ hintPending: Set<string>;
799
+ dispatchInboundMessage(m: Record<string, unknown>): void;
800
+ };
801
+ inner.running = true;
802
+ seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
803
+
804
+ inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
805
+ await new Promise((r) => setTimeout(r, 250));
806
+ expect(fetches).toBe(1);
807
+ expect([...inner.hintPending]).toEqual(["Document"]);
808
+ expect(engine.store.list("Document").length).toBe(1);
809
+
810
+ await new Promise((r) => setTimeout(r, 1200));
811
+ expect(fetches).toBe(2);
812
+ expect(engine.store.list("Document")).toEqual([]);
813
+ engine.stop();
814
+ });
815
+ });