@procedures/store 0.26.1 → 0.27.0

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.
@@ -41,6 +41,8 @@
41
41
  * - The syncs of one storage run one at a time, in their order: the deltas and the hints of the
42
42
  * feed, the pulls, the pushes, the reads after an own write, and the mode switches. Thus a
43
43
  * remote delta and a full sync never run at the same time (Graph R18).
44
+ * - `aroundRemoteApply` runs each apply of the remote changes inside a hook, for example inside a
45
+ * span of a trace (graph W8).
44
46
  */
45
47
 
46
48
  import type {
@@ -276,6 +278,27 @@ export interface HybridStorageOptions {
276
278
  */
277
279
  feed?: RemoteChangeFeed<any>;
278
280
 
281
+ /**
282
+ * The hook around each apply of the remote changes (graph W8), for example a span of a trace.
283
+ * `RemoteApplyHook` gives the arguments.
284
+ *
285
+ * - The hook wraps the apply of each delta of the feed, each hint of the feed that applies, and
286
+ * each pull that the feed or a pull of the storage causes.
287
+ * - A pull of the storage is the timer of `pullInterval`, `syncFromRemote()`, `sync()` and the
288
+ * first sync. The hint of a pull is undefined.
289
+ * - A pull that many hints asked for runs one time. Its hint is the last hint, and
290
+ * `context.hints` has the last 128 of them.
291
+ * - The hook runs inside the chain of the syncs, so it measures the apply, not the wait.
292
+ * - The sync of a mode switch, the reads of the remote and the new reads of the values that an
293
+ * own write deferred are not in the hook.
294
+ * - An error of `apply` or of the hook makes the pull fail, and the status keeps it. The next
295
+ * pull that succeeds clears it.
296
+ * - Only the leader of a `TabCoordinator` or of a `TabGroup` makes a `HybridStorage`, so only
297
+ * the leader runs the hook.
298
+ * - Without the hook, the applies run as before, with the same timing and no other allocation.
299
+ */
300
+ aroundRemoteApply?: RemoteApplyHook | undefined;
301
+
279
302
  /**
280
303
  * The store of the sync state: the cursor and the base versions (roadmap CLI-21). A new storage
281
304
  * with the same store asks the remote only for the changes since the cursor, and it finds the
@@ -339,6 +362,72 @@ export interface HybridStorageOptions {
339
362
  queryPolicy?: StorageQueryPolicy;
340
363
  }
341
364
 
365
+ /**
366
+ * The cause of an apply of the remote changes (graph W8).
367
+ *
368
+ * - `"delta"`: a delta of the feed.
369
+ * - `"hint"`: one or more hints of the feed.
370
+ * - `"gap"`: a gap of the feed: a lost event, an event of another form, or a new subscription.
371
+ * - `"pull"`: a pull of the storage: the timer, `syncFromRemote()`, `sync()` or the first sync.
372
+ */
373
+ export type RemoteApplyCause = "delta" | "hint" | "gap" | "pull";
374
+
375
+ /** The context of an apply of the remote changes, the third argument of `aroundRemoteApply` (graph W8). */
376
+ export interface RemoteApplyContext {
377
+ /** The cause of the apply. A pull that hints and gaps asked for has the cause `"hint"`. */
378
+ readonly cause: RemoteApplyCause;
379
+ /**
380
+ * The hints of the apply, in their order of arrival. A delta gives one hint with its ids and no
381
+ * values. A gap and a pull of the storage give no hint. A pull keeps the last 128 hints.
382
+ */
383
+ readonly hints: readonly RemoteChangeHint[];
384
+ }
385
+
386
+ /**
387
+ * The hook around an apply of the remote changes (graph W8). The hook calls `apply` one time and
388
+ * gives its promise back, for example from inside a span of a trace.
389
+ *
390
+ * - The storage waits for the hook and for the apply. A hook that does not settle stops the syncs.
391
+ * - The changes apply one time. A second call of `apply` gets the promise of the first call.
392
+ * - When the hook does not call `apply`, the storage applies the changes after the hook. Thus a
393
+ * hook that fails does not stop the sync.
394
+ * - The sync fails with the error of `apply` first. A hook cannot hide or change it.
395
+ * - Then the sync fails with the error of the hook, and then with `RemoteApplyHookError` when the
396
+ * hook did not call `apply` one time.
397
+ * - The status of the storage keeps the error of the sync, with the operation `"pull"`.
398
+ *
399
+ * @param hint - The last hint of `context.hints`, or undefined when the apply has no hint
400
+ * @param apply - The function that reads the changes and applies them to the local copy
401
+ * @param context - The cause of the apply and its hints
402
+ *
403
+ * @example
404
+ * ```typescript
405
+ * aroundRemoteApply: (hint, apply) => {
406
+ * const traceparent = hint?.extra?.["traceparent"];
407
+ * return runInRemoteTrace(typeof traceparent === "string" ? traceparent : undefined, apply, { name: "realtime.echo" });
408
+ * },
409
+ * ```
410
+ */
411
+ export type RemoteApplyHook = (hint: RemoteChangeHint | undefined, apply: () => Promise<void>, context: RemoteApplyContext) => Promise<unknown>;
412
+
413
+ /** The hints of an apply without hints. */
414
+ const NO_HINTS: readonly RemoteChangeHint[] = Object.freeze([]);
415
+
416
+ /** The most hints that a pull keeps for the hook. A pull drops the older ones. */
417
+ const MAX_JOINED_HINTS = 128;
418
+
419
+ /** The hint of a delta of the feed: its versions, its ids, its origin and its other fields, without the values (graph W8). */
420
+ function hintOfDelta<T>(changes: RemoteChanges<T>): RemoteChangeHint {
421
+ return {
422
+ ...(changes.since !== undefined && { since: changes.since }),
423
+ ...(changes.version !== undefined && { version: changes.version }),
424
+ changed: changes.changed.map(([id]) => id),
425
+ deleted: [...changes.deleted],
426
+ ...(changes.origin !== undefined && { origin: changes.origin }),
427
+ ...(changes.extra !== undefined && { extra: changes.extra }),
428
+ };
429
+ }
430
+
342
431
  /** The kinds of the timers of a `HybridStorage`. */
343
432
  interface TimerOptions {
344
433
  readonly clock: Clock;
@@ -522,6 +611,19 @@ export class SyncStateError extends Error {
522
611
  }
523
612
  }
524
613
 
614
+ /**
615
+ * The hook `aroundRemoteApply` did not call `apply` one time (graph W8). The storage applied the
616
+ * changes one time all the same, and the apply succeeded. `calls` is the number of calls of
617
+ * `apply` before the hook settled.
618
+ */
619
+ export class RemoteApplyHookError extends Error {
620
+ override readonly name = "RemoteApplyHookError";
621
+ readonly code = "REMOTE_APPLY_HOOK";
622
+ constructor(readonly calls: number) {
623
+ super(`The hook aroundRemoteApply called apply ${calls} times, not 1 time: the storage applied the changes 1 time`);
624
+ }
625
+ }
626
+
525
627
  const defaultIsUnavailable = (error: unknown): boolean => {
526
628
  if (typeof error !== "object" || error === null) return true;
527
629
  const { retryable, code } = error as { retryable?: unknown; code?: unknown };
@@ -864,6 +966,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
864
966
  private readonly feed: RemoteChangeFeed<T> | undefined;
865
967
  private feedStop: (() => void) | undefined;
866
968
  private feedToken = 0;
969
+ /** The hook around each apply of the remote changes (graph W8) */
970
+ private readonly aroundRemoteApply: RemoteApplyHook | undefined;
971
+ /** With the hook: the hints that asked for the pull that waits */
972
+ private readonly joinedHints: RemoteChangeHint[] = [];
867
973
  /** True while a pull that a gap asked for waits to start */
868
974
  private gapPull = false;
869
975
  /** The stop of the capture of the local copy */
@@ -931,6 +1037,10 @@ export class HybridStorage<T> implements CollectionStorage<T> {
931
1037
  this.syncOrigin = options.syncOrigin ?? "sync";
932
1038
  this.observers = new StorageListeners<StorageChangeBatch<T>>(options.onListenerError);
933
1039
  this.feed = options.feed;
1040
+ if (options.aroundRemoteApply !== undefined && typeof options.aroundRemoteApply !== "function") {
1041
+ throw new TypeError("aroundRemoteApply must be a function");
1042
+ }
1043
+ this.aroundRemoteApply = options.aroundRemoteApply;
934
1044
  this.stateStore = options.syncState;
935
1045
  this.bases.tracked = this.stateStore !== undefined;
936
1046
  this.localGiven = options.local !== undefined;
@@ -1220,7 +1330,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1220
1330
  if (live()) this.onRemoteHint(hint);
1221
1331
  },
1222
1332
  onGap: () => {
1223
- if (live()) this.requestPull();
1333
+ if (live()) this.requestPull(undefined);
1224
1334
  },
1225
1335
  onError: (error) => {
1226
1336
  if (live()) this.fail("pull", error);
@@ -1255,11 +1365,16 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1255
1365
  if (this.loadError) throw this.loadError;
1256
1366
  if (this.closed || (this.mode !== "write-through" && this.mode !== "write-back")) return;
1257
1367
  if (changes.version === this.cursor) return;
1258
- if (changes.since === undefined || changes.since !== this.cursor) {
1368
+ const follows = changes.since !== undefined && changes.since === this.cursor;
1369
+ const hook = this.aroundRemoteApply;
1370
+ if (hook) {
1371
+ const apply = follows ? () => this.applyChanges(changes, (id) => this.changedSince(mark, id)) : () => this.pullRemote(false);
1372
+ await this.hookedApply(hook, "delta", [hintOfDelta(changes)], apply);
1373
+ } else if (follows) {
1374
+ await this.applyChanges(changes, (id) => this.changedSince(mark, id));
1375
+ } else {
1259
1376
  await this.pullRemote(false);
1260
- return;
1261
1377
  }
1262
- await this.applyChanges(changes, (id) => this.changedSince(mark, id));
1263
1378
  }
1264
1379
 
1265
1380
  /**
@@ -1285,30 +1400,88 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1285
1400
  if (this.closed || (this.mode !== "write-through" && this.mode !== "write-back")) return;
1286
1401
  if (hint.version !== undefined && hint.version === this.cursor) return;
1287
1402
  if (hint.since !== undefined && hint.since === this.cursor && hint.version !== undefined && hint.changed.length === 0) {
1288
- await this.applyChanges({ version: hint.version, full: false, changed: [], deleted: [...hint.deleted] }, (id) => this.changedSince(mark, id));
1403
+ const version = hint.version;
1404
+ const hook = this.aroundRemoteApply;
1405
+ if (hook) await this.hookedApply(hook, "hint", [hint], () => this.applyDeletes(version, hint.deleted, mark));
1406
+ else await this.applyDeletes(version, hint.deleted, mark);
1289
1407
  return;
1290
1408
  }
1291
- this.requestPull();
1409
+ this.requestPull(hint);
1292
1410
  }
1293
1411
 
1294
- /** This method asks for a pull after the syncs that run. A request while a pull waits joins it. */
1295
- private requestPull(): void {
1412
+ /** This method applies the deletes of a hint whose `since` is the cursor and that changed no value. */
1413
+ private applyDeletes(version: string, deleted: readonly string[], mark: number): Promise<void> {
1414
+ return this.applyChanges({ version, full: false, changed: [], deleted: [...deleted] }, (id) => this.changedSince(mark, id));
1415
+ }
1416
+
1417
+ /**
1418
+ * This method asks for a pull after the syncs that run. A request while a pull waits joins it.
1419
+ * With `aroundRemoteApply`, the pull keeps the last hints of the requests for the hook.
1420
+ */
1421
+ private requestPull(hint: RemoteChangeHint | undefined): void {
1422
+ if (hint !== undefined && this.aroundRemoteApply) {
1423
+ this.joinedHints.push(hint);
1424
+ if (this.joinedHints.length > MAX_JOINED_HINTS) this.joinedHints.shift();
1425
+ }
1296
1426
  if (this.gapPull) return;
1297
1427
  this.gapPull = true;
1298
1428
  void this.loaded
1299
1429
  .then(() =>
1300
1430
  this.serialize(() => {
1301
1431
  this.gapPull = false;
1432
+ const hints = this.joinedHints.length > 0 ? this.joinedHints.splice(0) : NO_HINTS;
1302
1433
  if (this.closed) return Promise.resolve();
1303
1434
  return this.track("pull", async () => {
1304
1435
  if (this.loadError) throw this.loadError;
1305
- if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
1436
+ if (this.mode === "write-through" || this.mode === "write-back") await this.pullThrough(hints.length > 0 ? "hint" : "gap", hints);
1306
1437
  });
1307
1438
  }),
1308
1439
  )
1309
1440
  .catch(() => undefined);
1310
1441
  }
1311
1442
 
1443
+ /**
1444
+ * This method pulls the changes since the cursor, through the hook `aroundRemoteApply` when the
1445
+ * options give it (graph W8). Without the hook, it is `pullRemote(false)`, with no other step.
1446
+ */
1447
+ private pullThrough(cause: RemoteApplyCause, hints: readonly RemoteChangeHint[]): Promise<void> {
1448
+ const hook = this.aroundRemoteApply;
1449
+ return hook ? this.hookedApply(hook, cause, hints, () => this.pullRemote(false)) : this.pullRemote(false);
1450
+ }
1451
+
1452
+ /**
1453
+ * This method runs an apply of the remote changes through the hook (graph W8).
1454
+ *
1455
+ * - The apply runs one time. A second call of `apply` gets the promise of the first call.
1456
+ * - When the hook does not call `apply`, the apply runs after the hook. Thus no change is lost.
1457
+ * - The error of the apply comes first, then the error of the hook, then the misuse of the hook.
1458
+ */
1459
+ private async hookedApply(hook: RemoteApplyHook, cause: RemoteApplyCause, hints: readonly RemoteChangeHint[], apply: () => Promise<void>): Promise<void> {
1460
+ let run: Promise<void> | undefined;
1461
+ let calls = 0;
1462
+ const once = (): Promise<void> => {
1463
+ calls++;
1464
+ if (run === undefined) {
1465
+ run = apply();
1466
+ // The method reads the result after the hook: this handler only stops a report of an unhandled rejection
1467
+ run.catch(() => undefined);
1468
+ }
1469
+ return run;
1470
+ };
1471
+ let failure: { readonly error: unknown } | undefined;
1472
+ try {
1473
+ await hook(hints.at(-1), once, { cause, hints });
1474
+ } catch (error) {
1475
+ failure = { error };
1476
+ }
1477
+ // The count of the hook. A call of apply after the hook settled gets the promise of the first call.
1478
+ const called = calls;
1479
+ // A hook that did not call apply: the storage applies the changes itself
1480
+ await (run ?? once());
1481
+ if (failure) throw failure.error;
1482
+ if (called !== 1) throw new RemoteApplyHookError(called);
1483
+ }
1484
+
1312
1485
  //
1313
1486
  // ═══ The sync state (roadmap CLI-21) ═══
1314
1487
  //
@@ -2021,7 +2194,7 @@ export class HybridStorage<T> implements CollectionStorage<T> {
2021
2194
  this.track("pull", async () => {
2022
2195
  await this.loaded;
2023
2196
  if (this.loadError) throw this.loadError;
2024
- if (this.mode === "write-through" || this.mode === "write-back") await this.pullRemote(false);
2197
+ if (this.mode === "write-through" || this.mode === "write-back") await this.pullThrough("pull", NO_HINTS);
2025
2198
  }),
2026
2199
  );
2027
2200
  }
@@ -46,8 +46,16 @@ export {
46
46
  ConflictError,
47
47
  ModeSwitchError,
48
48
  SyncStateError,
49
+ RemoteApplyHookError,
50
+ } from "./hybrid.js";
51
+ export type {
52
+ HybridStorageOptions,
53
+ HybridMode,
54
+ LocalCopyStorage,
55
+ RemoteApplyCause,
56
+ RemoteApplyContext,
57
+ RemoteApplyHook,
49
58
  } from "./hybrid.js";
50
- export type { HybridStorageOptions, HybridMode, LocalCopyStorage } from "./hybrid.js";
51
59
  // One leader tab runs the sync, and the other tabs copy its changes (roadmap CLI-25)
52
60
  export { TabCoordinator, TabGroup } from "./tab-coordinator.js";
53
61
  export type {