@pylonsync/sync 0.22.43 → 0.22.44

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
@@ -106,29 +106,6 @@ export interface SyncEngineConfig {
106
106
  */
107
107
  multiTab?: boolean;
108
108
  }
109
- /**
110
- * Generate a stable client_id. Prefers a persisted id from `storage`
111
- * (so a reload keeps the same identifier) and falls back to a fresh UUID.
112
- */
113
- /**
114
- * Generate a Pylon-shaped row id (40-char lowercase hex).
115
- *
116
- * Mirrors the runtime's `generate_id` shape: 32 hex of milliseconds
117
- * since epoch (extended to nanos so it lex-sorts alongside server-
118
- * generated ids) + 8 hex of a per-tab counter. Lex-sortable, monotonic
119
- * within a tab, statistically unique across tabs (the timestamp
120
- * disambiguates almost every cross-tab collision; the counter handles
121
- * the rest within a single tick).
122
- *
123
- * Used by `useMutation({ optimistic })` to mint client-side ids that
124
- * the runtime will accept verbatim — so the optimistic ghost and the
125
- * canonical row share the same `row_id` and the WS broadcast is an
126
- * idempotent merge instead of a delete-then-replace flash.
127
- *
128
- * Apps can call this directly when they need a stable id earlier than
129
- * the mutation (e.g. to reference the row from another optimistic
130
- * insert in the same gesture).
131
- */
132
109
  export declare class SyncEngine {
133
110
  private config;
134
111
  private cursor;
@@ -733,34 +710,41 @@ export declare class SyncEngine {
733
710
  */
734
711
  observeEntity(entity: string): void;
735
712
  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;
713
+ /** Entities to reconcile that have not reconciled yet: named by a server
714
+ * hint (`sync-reconcile`), or skipped by an earlier reconcile. */
715
+ private pendingReconcile;
716
+ /** Numbers each pending request, so a pass clears only older ones. */
717
+ private pendingReconcileSeq;
718
+ private pendingReconcileTimer;
719
+ private pendingReconcileRetries;
720
+ /** Bumped by stop() and resetReplica(): pending work queued before it
721
+ * does not run. */
722
+ private pendingReconcileEpoch;
723
+ private lastPendingReconcileAt;
742
724
  /** Hints that arrive this close together share one reconcile. */
743
- private static readonly HINT_DEBOUNCE_MS;
725
+ private static readonly PENDING_RECONCILE_DEBOUNCE_MS;
744
726
  /** At most one hinted reconcile per this interval. */
745
- private static readonly HINT_MIN_INTERVAL_MS;
727
+ private static readonly PENDING_RECONCILE_MIN_INTERVAL_MS;
746
728
  /** Backoff for entities whose fetch raced a newer change: doubling from
747
729
  * the base, capped. */
748
- private static readonly HINT_RETRY_BASE_MS;
749
- private static readonly HINT_RETRY_MAX_MS;
730
+ private static readonly PENDING_RECONCILE_RETRY_BASE_MS;
731
+ private static readonly PENDING_RECONCILE_RETRY_MAX_MS;
750
732
  /**
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.
733
+ * Keep `entities` pending until a reconcile of them completes. Used for
734
+ * server hints (`sync-reconcile`: a share, an unshare, a folder
735
+ * membership changed which rows this user may read, and no other event
736
+ * will tell this replica) and for entities an earlier reconcile skipped.
737
+ * Requests are merged, run at most once a second, and an entity whose
738
+ * fetch failed or raced a newer change is retried with backoff until it
739
+ * lands or the engine stops.
758
740
  */
759
- private queueHintReconcile;
760
- private scheduleHintReconcile;
761
- private flushHintReconcile;
762
- /** `reconcile`, resolving with the entities skipped because the cursor
763
- * moved during their fetch. */
741
+ private queuePendingReconcile;
742
+ private schedulePendingReconcile;
743
+ private flushPendingReconcile;
744
+ /** Drop pending reconciles, including queued ones (stop, replica reset). */
745
+ private cancelPendingReconcile;
746
+ /** `reconcile`, resolving with the entities it reconciled and the ones
747
+ * it skipped (the fetch failed, or the cursor moved during it). */
764
748
  private reconcileReportingSkips;
765
749
  /** Scoped reconcile batch that has not started running yet. */
766
750
  private scopedReconcileBatch;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.43",
6
+ "version": "0.22.44",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -230,6 +230,17 @@ export interface SyncEngineConfig {
230
230
  * insert in the same gesture).
231
231
  */
232
232
 
233
+ /** What a reconcile pass did per entity. */
234
+ interface ReconcileOutcome {
235
+ /** Reconciled: the replica matches the server for these. */
236
+ done: string[];
237
+ /** Not reconciled: the fetch failed, or a newer change raced it. */
238
+ skipped: string[];
239
+ /** The pending-request sequence when the pass began fetching. Callers
240
+ * that share the pass clear only requests at or below it. */
241
+ startedAt: number;
242
+ }
243
+
233
244
  export class SyncEngine {
234
245
  private config: SyncEngineConfig;
235
246
  private cursor: SyncCursor = { last_seq: 0 };
@@ -1651,12 +1662,7 @@ export class SyncEngine {
1651
1662
  /** Stop the sync engine. */
1652
1663
  stop(): void {
1653
1664
  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;
1665
+ this.cancelPendingReconcile();
1660
1666
  if (this.identityRetryTimer !== null) {
1661
1667
  clearTimeout(this.identityRetryTimer);
1662
1668
  this.identityRetryTimer = null;
@@ -1698,6 +1704,8 @@ export class SyncEngine {
1698
1704
  * in-memory state could fix.
1699
1705
  */
1700
1706
  async resetReplica(opts: { wipeMutations?: boolean } = {}): Promise<void> {
1707
+ // Rows fetched for the old replica must not land in the new one.
1708
+ this.cancelPendingReconcile();
1701
1709
  // Public callers go through the queue so a reset can't race with
1702
1710
  // an in-flight pull / push / reconcile. Internal callers that
1703
1711
  // already hold the queue slot use `resetReplicaInner` directly.
@@ -2309,75 +2317,123 @@ export class SyncEngine {
2309
2317
  }
2310
2318
 
2311
2319
  async reconcile(entities?: string[]): Promise<void> {
2312
- await this.reconcileReportingSkips(entities);
2320
+ const epoch = this.pendingReconcileEpoch;
2321
+ const { done, skipped, startedAt } = await this.reconcileReportingSkips(entities);
2322
+ // stop() or resetReplica() ran meanwhile: leave pending work alone.
2323
+ if (epoch !== this.pendingReconcileEpoch) return;
2324
+ // An entity skipped because its fetch failed or raced a newer change
2325
+ // stays pending until a reconcile of it completes. Otherwise a row that
2326
+ // became readable while this replica was away (a share, a new folder)
2327
+ // stays missing until the next focus or reconnect.
2328
+ // Clear only requests this pass covers: one made after it started (a
2329
+ // hint during the fetch) may name rows its response does not hold.
2330
+ for (const e of done) {
2331
+ const requestedAt = this.pendingReconcile.get(e);
2332
+ if (requestedAt !== undefined && requestedAt <= startedAt) this.pendingReconcile.delete(e);
2333
+ }
2334
+ if (skipped.length > 0) this.queuePendingReconcile(skipped);
2313
2335
  }
2314
2336
 
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;
2337
+ /** Entities to reconcile that have not reconciled yet: named by a server
2338
+ * hint (`sync-reconcile`), or skipped by an earlier reconcile. */
2339
+ private pendingReconcile = new Map<string, number>();
2340
+ /** Numbers each pending request, so a pass clears only older ones. */
2341
+ private pendingReconcileSeq = 0;
2342
+ private pendingReconcileTimer: ReturnType<typeof setTimeout> | null = null;
2343
+ private pendingReconcileRetries = 0;
2344
+ /** Bumped by stop() and resetReplica(): pending work queued before it
2345
+ * does not run. */
2346
+ private pendingReconcileEpoch = 0;
2347
+ private lastPendingReconcileAt = 0;
2321
2348
  /** Hints that arrive this close together share one reconcile. */
2322
- private static readonly HINT_DEBOUNCE_MS = 100;
2349
+ private static readonly PENDING_RECONCILE_DEBOUNCE_MS = 100;
2323
2350
  /** At most one hinted reconcile per this interval. */
2324
- private static readonly HINT_MIN_INTERVAL_MS = 1_000;
2351
+ private static readonly PENDING_RECONCILE_MIN_INTERVAL_MS = 1_000;
2325
2352
  /** Backoff for entities whose fetch raced a newer change: doubling from
2326
2353
  * the base, capped. */
2327
- private static readonly HINT_RETRY_BASE_MS = 250;
2328
- private static readonly HINT_RETRY_MAX_MS = 10_000;
2354
+ private static readonly PENDING_RECONCILE_RETRY_BASE_MS = 250;
2355
+ private static readonly PENDING_RECONCILE_RETRY_MAX_MS = 10_000;
2329
2356
 
2330
2357
  /**
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.
2358
+ * Keep `entities` pending until a reconcile of them completes. Used for
2359
+ * server hints (`sync-reconcile`: a share, an unshare, a folder
2360
+ * membership changed which rows this user may read, and no other event
2361
+ * will tell this replica) and for entities an earlier reconcile skipped.
2362
+ * Requests are merged, run at most once a second, and an entity whose
2363
+ * fetch failed or raced a newer change is retried with backoff until it
2364
+ * lands or the engine stops.
2338
2365
  */
2339
- private queueHintReconcile(entities: string[]): void {
2340
- for (const e of entities) this.hintPending.add(e);
2341
- this.scheduleHintReconcile(SyncEngine.HINT_DEBOUNCE_MS);
2366
+ private queuePendingReconcile(entities: string[]): void {
2367
+ for (const e of entities) this.pendingReconcile.set(e, ++this.pendingReconcileSeq);
2368
+ this.schedulePendingReconcile(SyncEngine.PENDING_RECONCILE_DEBOUNCE_MS);
2342
2369
  }
2343
2370
 
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();
2371
+ private schedulePendingReconcile(delayMs: number): void {
2372
+ if (this.pendingReconcileTimer !== null || !this.running) return;
2373
+ const wait = Math.max(delayMs, this.lastPendingReconcileAt + SyncEngine.PENDING_RECONCILE_MIN_INTERVAL_MS - Date.now());
2374
+ this.pendingReconcileTimer = setTimeout(() => {
2375
+ this.pendingReconcileTimer = null;
2376
+ void this.flushPendingReconcile();
2350
2377
  }, wait);
2351
2378
  }
2352
2379
 
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();
2380
+ private async flushPendingReconcile(): Promise<void> {
2381
+ if (!this.running || this.pendingReconcile.size === 0) return;
2382
+ const epoch = this.pendingReconcileEpoch;
2383
+ const entities = [...this.pendingReconcile.keys()];
2384
+ this.pendingReconcile.clear();
2385
+ this.lastPendingReconcileAt = Date.now();
2358
2386
  let skipped: string[];
2359
2387
  try {
2360
- skipped = await this.reconcileReportingSkips(entities);
2388
+ // Queued like any reconcile, and dropped if stop() or resetReplica()
2389
+ // ran while it waited.
2390
+ const result = await this.opQueue.enqueue(`reconcile-pending:${epoch}:${Date.now()}`, async () => {
2391
+ if (epoch !== this.pendingReconcileEpoch || !this.running) return null;
2392
+ try {
2393
+ return await this.reconcileInner(entities);
2394
+ } finally {
2395
+ this.lastReconcileAt = Date.now();
2396
+ }
2397
+ });
2398
+ if (result === null) return;
2399
+ skipped = result.skipped;
2361
2400
  } catch {
2362
2401
  skipped = entities;
2363
2402
  }
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;
2403
+ if (epoch !== this.pendingReconcileEpoch) return;
2404
+ for (const e of skipped) {
2405
+ if (!this.pendingReconcile.has(e)) this.pendingReconcile.set(e, ++this.pendingReconcileSeq);
2406
+ }
2407
+ this.pendingReconcileRetries = skipped.length > 0 ? this.pendingReconcileRetries + 1 : 0;
2408
+ if (this.pendingReconcile.size === 0) return;
2367
2409
  const delay =
2368
2410
  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);
2411
+ ? Math.min(
2412
+ SyncEngine.PENDING_RECONCILE_RETRY_BASE_MS * 2 ** (this.pendingReconcileRetries - 1),
2413
+ SyncEngine.PENDING_RECONCILE_RETRY_MAX_MS,
2414
+ )
2415
+ : SyncEngine.PENDING_RECONCILE_DEBOUNCE_MS;
2416
+ this.schedulePendingReconcile(delay);
2417
+ }
2418
+
2419
+ /** Drop pending reconciles, including queued ones (stop, replica reset). */
2420
+ private cancelPendingReconcile(): void {
2421
+ this.pendingReconcileEpoch += 1;
2422
+ if (this.pendingReconcileTimer !== null) {
2423
+ clearTimeout(this.pendingReconcileTimer);
2424
+ this.pendingReconcileTimer = null;
2425
+ }
2426
+ this.pendingReconcile.clear();
2427
+ this.pendingReconcileRetries = 0;
2372
2428
  }
2373
2429
 
2374
- /** `reconcile`, resolving with the entities skipped because the cursor
2375
- * moved during their fetch. */
2376
- private async reconcileReportingSkips(entities?: string[]): Promise<string[]> {
2430
+ /** `reconcile`, resolving with the entities it reconciled and the ones
2431
+ * it skipped (the fetch failed, or the cursor moved during it). */
2432
+ private async reconcileReportingSkips(entities?: string[]): Promise<ReconcileOutcome> {
2377
2433
  const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
2378
2434
  const now = Date.now();
2379
2435
  if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
2380
- return [];
2436
+ return { done: [], skipped: [], startedAt: this.pendingReconcileSeq };
2381
2437
  }
2382
2438
  // Reconcile waits behind any in-flight pull / refresh / push so it
2383
2439
  // can't apply rows captured under a stale session.
@@ -2401,9 +2457,9 @@ export class SyncEngine {
2401
2457
  for (const e of entities) open.entities.add(e);
2402
2458
  return open.promise;
2403
2459
  }
2404
- const batch: { entities: Set<string>; promise: Promise<string[]> } = {
2460
+ const batch: { entities: Set<string>; promise: Promise<ReconcileOutcome> } = {
2405
2461
  entities: new Set(entities),
2406
- promise: Promise.resolve([]),
2462
+ promise: Promise.resolve({ done: [], skipped: [], startedAt: this.pendingReconcileSeq }),
2407
2463
  };
2408
2464
  this.scopedReconcileBatch = batch;
2409
2465
  this.scopedReconcileSeq += 1;
@@ -2424,14 +2480,15 @@ export class SyncEngine {
2424
2480
  /** Scoped reconcile batch that has not started running yet. */
2425
2481
  private scopedReconcileBatch: {
2426
2482
  entities: Set<string>;
2427
- promise: Promise<string[]>;
2483
+ promise: Promise<ReconcileOutcome>;
2428
2484
  } | null = null;
2429
2485
  private scopedReconcileSeq = 0;
2430
2486
 
2431
- private async reconcileInner(entities?: string[]): Promise<string[]> {
2487
+ private async reconcileInner(entities?: string[]): Promise<ReconcileOutcome> {
2488
+ const startedAt = this.pendingReconcileSeq;
2432
2489
  // Same reasoning as pullInner: the leader reconciles, broadcasts
2433
2490
  // results, and follower replicas converge via the channel.
2434
- if (!this.isMultiTabLeader) return [];
2491
+ if (!this.isMultiTabLeader) return { done: [], skipped: [], startedAt };
2435
2492
  // Sweep entities with local rows PLUS entities the app has observed
2436
2493
  // via useQuery (even when empty locally). Without the observed set,
2437
2494
  // a server row in a never-cached entity is never reconciled and
@@ -2439,8 +2496,9 @@ export class SyncEngine {
2439
2496
  const names =
2440
2497
  entities ??
2441
2498
  [...new Set([...this.store.entityNames(), ...this.observedEntities])];
2442
- if (names.length === 0) return [];
2499
+ if (names.length === 0) return { done: [], skipped: [], startedAt };
2443
2500
  const skipped: string[] = [];
2501
+ const done: string[] = [];
2444
2502
  // Tombstone seq for any local row the server doesn't return. Using
2445
2503
  // the current cursor means future inserts (which have higher seqs)
2446
2504
  // bypass the tombstone — re-creation server-side still propagates.
@@ -2495,6 +2553,7 @@ export class SyncEngine {
2495
2553
  // Entity removed from the manifest — definitive. Drop its rows.
2496
2554
  this.reconcile403Streak.delete(entity);
2497
2555
  await this.dropEntity(entity, tombstoneSeq);
2556
+ done.push(entity);
2498
2557
  return;
2499
2558
  }
2500
2559
  if (status === 403) {
@@ -2521,6 +2580,7 @@ export class SyncEngine {
2521
2580
  }
2522
2581
  this.reconcile403Streak.delete(entity);
2523
2582
  await this.dropEntity(entity, tombstoneSeq);
2583
+ done.push(entity);
2524
2584
  return;
2525
2585
  }
2526
2586
  // Not reconciled (offline, a 5xx): reported, so a server hint
@@ -2538,7 +2598,7 @@ export class SyncEngine {
2538
2598
  // entity; reconcile() is triggered again on visibility-change
2539
2599
  // and reconnect, and the WS event already carried the latest
2540
2600
  // state for the affected row. Reported, so a server hint can
2541
- // retry (flushHintReconcile).
2601
+ // retry (flushPendingReconcile).
2542
2602
  skipped.push(entity);
2543
2603
  return;
2544
2604
  }
@@ -2556,9 +2616,10 @@ export class SyncEngine {
2556
2616
  tombstoneSeq,
2557
2617
  fetchTruncated,
2558
2618
  );
2619
+ done.push(entity);
2559
2620
  }),
2560
2621
  );
2561
- return skipped;
2622
+ return { done, skipped, startedAt };
2562
2623
  }
2563
2624
 
2564
2625
  /** Fetch every row for an entity. Uses cursor pagination so big tables
@@ -4190,7 +4251,7 @@ export class SyncEngine {
4190
4251
  // change event for the rows themselves. Reconcile them.
4191
4252
  if (msg.type === "sync-reconcile" && Array.isArray(msg.entities)) {
4192
4253
  const entities = msg.entities.filter((e): e is string => typeof e === "string");
4193
- if (entities.length > 0) this.queueHintReconcile(entities);
4254
+ if (entities.length > 0) this.queuePendingReconcile(entities);
4194
4255
  return;
4195
4256
  }
4196
4257
 
@@ -684,7 +684,7 @@ describe("sync-reconcile hint", () => {
684
684
  dispatchInboundMessage(m: Record<string, unknown>): void;
685
685
  running: boolean;
686
686
  cursor: { last_seq: number };
687
- hintPending: Set<string>;
687
+ pendingReconcile: Map<string, number>;
688
688
  };
689
689
  const inner = (engine: SyncEngine) => engine as unknown as Internals;
690
690
  const deliver = (engine: SyncEngine, msg: Record<string, unknown>) => inner(engine).dispatchInboundMessage(msg);
@@ -742,13 +742,13 @@ describe("sync-reconcile hint", () => {
742
742
  await sleep(250);
743
743
  expect(fetches()).toBe(1);
744
744
  expect(engine.store.list("Document")).toEqual([]);
745
- expect([...inner(engine).hintPending]).toEqual(["Document"]);
745
+ expect([...inner(engine).pendingReconcile.keys()]).toEqual(["Document"]);
746
746
 
747
747
  // Retries run at most once a second.
748
748
  await sleep(2300);
749
749
  expect(fetches()).toBe(3);
750
750
  expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
751
- expect(inner(engine).hintPending.size).toBe(0);
751
+ expect(inner(engine).pendingReconcile.size).toBe(0);
752
752
  engine.stop();
753
753
  });
754
754
 
@@ -760,7 +760,7 @@ describe("sync-reconcile hint", () => {
760
760
  engine.stop();
761
761
  await sleep(250);
762
762
  expect(fetches()).toBe(0);
763
- expect(inner(engine).hintPending.size).toBe(0);
763
+ expect(inner(engine).pendingReconcile.size).toBe(0);
764
764
  });
765
765
 
766
766
  test("ignores a hint without entity names", async () => {
@@ -795,7 +795,7 @@ describe("sync-reconcile hint after a failed fetch", () => {
795
795
  const engine = makeEngine();
796
796
  const inner = engine as unknown as {
797
797
  running: boolean;
798
- hintPending: Set<string>;
798
+ pendingReconcile: Map<string, number>;
799
799
  dispatchInboundMessage(m: Record<string, unknown>): void;
800
800
  };
801
801
  inner.running = true;
@@ -804,7 +804,7 @@ describe("sync-reconcile hint after a failed fetch", () => {
804
804
  inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
805
805
  await new Promise((r) => setTimeout(r, 250));
806
806
  expect(fetches).toBe(1);
807
- expect([...inner.hintPending]).toEqual(["Document"]);
807
+ expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
808
808
  expect(engine.store.list("Document").length).toBe(1);
809
809
 
810
810
  await new Promise((r) => setTimeout(r, 1200));
@@ -813,3 +813,207 @@ describe("sync-reconcile hint after a failed fetch", () => {
813
813
  engine.stop();
814
814
  });
815
815
  });
816
+
817
+ // Any reconcile, not only a hinted one: an entity skipped because its fetch
818
+ // raced a newer change is retried, so a row that became readable while the
819
+ // page was away (a new folder, a share) still arrives.
820
+ describe("a skipped reconcile retries", () => {
821
+ let restore: (() => void) | null = null;
822
+ afterEach(() => {
823
+ restore?.();
824
+ restore = null;
825
+ });
826
+
827
+ test("a startup reconcile that raced a change lands on the retry", async () => {
828
+ const engine = makeEngine();
829
+ const inner = engine as unknown as { running: boolean; cursor: { last_seq: number }; pendingReconcile: Set<string> };
830
+ let fetches = 0;
831
+ restore = installFetch(async (url) => {
832
+ if (url.includes("/api/entities/Folder/cursor")) {
833
+ fetches += 1;
834
+ if (fetches === 1) inner.cursor.last_seq += 1;
835
+ return { status: 200, body: { data: [{ id: "f1", name: "Team" }], next_cursor: null, has_more: false } };
836
+ }
837
+ return { status: 404, body: {} };
838
+ });
839
+ inner.running = true;
840
+
841
+ await engine.reconcile(["Folder"]);
842
+ expect(engine.store.list("Folder")).toEqual([]);
843
+ expect([...inner.pendingReconcile.keys()]).toEqual(["Folder"]);
844
+
845
+ await new Promise((r) => setTimeout(r, 1300));
846
+ expect(fetches).toBe(2);
847
+ expect(engine.store.list("Folder").map((r) => (r as { id: string }).id)).toEqual(["f1"]);
848
+ engine.stop();
849
+ });
850
+ });
851
+
852
+ describe("pending reconciles end when they should", () => {
853
+ let restore: (() => void) | null = null;
854
+ afterEach(() => {
855
+ restore?.();
856
+ restore = null;
857
+ });
858
+
859
+ type Internals = {
860
+ running: boolean;
861
+ pendingReconcile: Map<string, number>;
862
+ opQueue: { enqueue<T>(key: string, fn: () => Promise<T>): Promise<T> };
863
+ dispatchInboundMessage(m: Record<string, unknown>): void;
864
+ };
865
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
866
+
867
+ function server(status: () => number) {
868
+ let fetches = 0;
869
+ restore = installFetch(async (url) => {
870
+ if (url.includes("/api/entities/Document/cursor")) {
871
+ fetches += 1;
872
+ const s = status();
873
+ if (s !== 200) return { status: s, body: {} };
874
+ return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
875
+ }
876
+ return { status: 404, body: {} };
877
+ });
878
+ return () => fetches;
879
+ }
880
+
881
+ test("a later successful reconcile clears the pending retry", async () => {
882
+ let failing = true;
883
+ const fetches = server(() => (failing ? 503 : 200));
884
+ const engine = makeEngine();
885
+ const inner = engine as unknown as Internals;
886
+ inner.running = true;
887
+ await engine.reconcile(["Document"]);
888
+ expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
889
+ failing = false;
890
+ await engine.reconcile(["Document"]);
891
+ expect(inner.pendingReconcile.size).toBe(0);
892
+ await sleep(1300);
893
+ expect(fetches()).toBe(2);
894
+ engine.stop();
895
+ });
896
+
897
+ test("stop cancels a retry already waiting in the queue", async () => {
898
+ const fetches = server(() => 200);
899
+ const engine = makeEngine();
900
+ const inner = engine as unknown as Internals;
901
+ inner.running = true;
902
+ // A slow operation holds the queue while the retry's timer fires.
903
+ void inner.opQueue.enqueue("slow", () => sleep(400));
904
+ inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
905
+ await sleep(200);
906
+ engine.stop();
907
+ await sleep(400);
908
+ expect(fetches()).toBe(0);
909
+ });
910
+
911
+ test("a replica reset drops pending retries", async () => {
912
+ const fetches = server(() => 503);
913
+ const engine = makeEngine();
914
+ const inner = engine as unknown as Internals;
915
+ inner.running = true;
916
+ await engine.reconcile(["Document"]);
917
+ expect(fetches()).toBe(1);
918
+ await engine.resetReplica();
919
+ expect(inner.pendingReconcile.size).toBe(0);
920
+ await sleep(1300);
921
+ expect(fetches()).toBe(1);
922
+ engine.stop();
923
+ });
924
+ });
925
+
926
+ describe("pending reconciles and newer requests", () => {
927
+ let restore: (() => void) | null = null;
928
+ afterEach(() => {
929
+ restore?.();
930
+ restore = null;
931
+ });
932
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
933
+ type Internals = {
934
+ running: boolean;
935
+ pendingReconcile: Map<string, number>;
936
+ dispatchInboundMessage(m: Record<string, unknown>): void;
937
+ };
938
+
939
+ test("a hint that arrives during a fetch survives that fetch", async () => {
940
+ const engine = makeEngine();
941
+ const inner = engine as unknown as Internals;
942
+ let fetches = 0;
943
+ restore = installFetch(async (url) => {
944
+ if (url.includes("/api/entities/Document/cursor")) {
945
+ fetches += 1;
946
+ // The server already built this response when the share lands.
947
+ if (fetches === 1) inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
948
+ return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
949
+ }
950
+ return { status: 404, body: {} };
951
+ });
952
+ inner.running = true;
953
+ await engine.reconcile(["Document"]);
954
+ expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
955
+ await sleep(1300);
956
+ expect(fetches).toBe(2);
957
+ engine.stop();
958
+ });
959
+
960
+ test("a reconcile in flight during a reset does not bring retries back", async () => {
961
+ const engine = makeEngine();
962
+ const inner = engine as unknown as Internals;
963
+ let fetches = 0;
964
+ let release: () => void = () => {};
965
+ restore = installFetch(async (url) => {
966
+ if (url.includes("/api/entities/Document/cursor")) {
967
+ fetches += 1;
968
+ if (fetches === 1) await new Promise<void>((r) => (release = r));
969
+ return { status: 503, body: {} };
970
+ }
971
+ return { status: 404, body: {} };
972
+ });
973
+ inner.running = true;
974
+ const pass = engine.reconcile(["Document"]);
975
+ await sleep(20);
976
+ const reset = engine.resetReplica();
977
+ release();
978
+ await pass;
979
+ await reset;
980
+ expect(inner.pendingReconcile.size).toBe(0);
981
+ await sleep(1300);
982
+ expect(fetches).toBe(1);
983
+ engine.stop();
984
+ });
985
+ });
986
+
987
+ test("two callers sharing one full reconcile keep a hint that arrived between them", async () => {
988
+ const engine = makeEngine();
989
+ const inner = engine as unknown as {
990
+ running: boolean;
991
+ pendingReconcile: Map<string, number>;
992
+ store: { applyChange(ev: ChangeEvent): void };
993
+ dispatchInboundMessage(m: Record<string, unknown>): void;
994
+ };
995
+ seedStore(engine, "Document", [{ id: "d0", title: "seed" }]);
996
+ let fetches = 0;
997
+ let release: () => void = () => {};
998
+ const restore = installFetch(async (url) => {
999
+ if (url.includes("/api/entities/Document/cursor")) {
1000
+ fetches += 1;
1001
+ if (fetches === 1) await new Promise<void>((r) => (release = r));
1002
+ return { status: 200, body: { data: [{ id: "d0", title: "seed" }], next_cursor: null, has_more: false } };
1003
+ }
1004
+ return { status: 404, body: {} };
1005
+ });
1006
+ try {
1007
+ inner.running = true;
1008
+ const first = engine.reconcile();
1009
+ await new Promise((r) => setTimeout(r, 20));
1010
+ inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
1011
+ const second = engine.reconcile();
1012
+ release();
1013
+ await Promise.all([first, second]);
1014
+ expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
1015
+ } finally {
1016
+ engine.stop();
1017
+ restore();
1018
+ }
1019
+ });