@procedures/store 0.8.0 → 0.8.1

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +88 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/indexeddb/sync-state-store.d.ts +40 -0
  5. package/dist/indexeddb/sync-state-store.d.ts.map +1 -0
  6. package/dist/indexeddb/sync-state-store.js +94 -0
  7. package/dist/indexeddb/sync-state-store.js.map +1 -0
  8. package/dist/indexeddb.d.ts +7 -2
  9. package/dist/indexeddb.d.ts.map +1 -1
  10. package/dist/indexeddb.js +6 -2
  11. package/dist/indexeddb.js.map +1 -1
  12. package/dist/storage/api.d.ts +84 -11
  13. package/dist/storage/api.d.ts.map +1 -1
  14. package/dist/storage/api.js +148 -25
  15. package/dist/storage/api.js.map +1 -1
  16. package/dist/storage/feed.d.ts +133 -0
  17. package/dist/storage/feed.d.ts.map +1 -0
  18. package/dist/storage/feed.js +159 -0
  19. package/dist/storage/feed.js.map +1 -0
  20. package/dist/storage/hybrid.d.ts +367 -20
  21. package/dist/storage/hybrid.d.ts.map +1 -1
  22. package/dist/storage/hybrid.js +1295 -206
  23. package/dist/storage/hybrid.js.map +1 -1
  24. package/dist/storage/index.d.ts +6 -2
  25. package/dist/storage/index.d.ts.map +1 -1
  26. package/dist/storage/index.js +6 -1
  27. package/dist/storage/index.js.map +1 -1
  28. package/dist/storage/sync-state.d.ts +56 -0
  29. package/dist/storage/sync-state.d.ts.map +1 -0
  30. package/dist/storage/sync-state.js +58 -0
  31. package/dist/storage/sync-state.js.map +1 -0
  32. package/dist/storage/synced-registry.d.ts +22 -4
  33. package/dist/storage/synced-registry.d.ts.map +1 -1
  34. package/dist/storage/synced-registry.js +63 -31
  35. package/dist/storage/synced-registry.js.map +1 -1
  36. package/dist/storage/types.d.ts +22 -1
  37. package/dist/storage/types.d.ts.map +1 -1
  38. package/package.json +4 -3
  39. package/src/indexeddb/sync-state-store.ts +101 -0
  40. package/src/indexeddb.ts +7 -2
  41. package/src/storage/api.ts +210 -29
  42. package/src/storage/feed.ts +251 -0
  43. package/src/storage/hybrid.ts +1404 -211
  44. package/src/storage/index.ts +17 -0
  45. package/src/storage/sync-state.ts +89 -0
  46. package/src/storage/synced-registry.ts +76 -31
  47. package/src/storage/types.ts +25 -1
@@ -16,6 +16,7 @@ export type {
16
16
  SyncConflict,
17
17
  SyncResult,
18
18
  SyncStatus,
19
+ SyncError,
19
20
  SyncedRegistryOptions,
20
21
  ProcedureStorageConfig,
21
22
  } from "./types.js";
@@ -44,8 +45,24 @@ export {
44
45
  QueueStoreError,
45
46
  ConflictError,
46
47
  ModeSwitchError,
48
+ SyncStateError,
47
49
  } from "./hybrid.js";
48
50
  export type { HybridStorageOptions, HybridMode, LocalCopyStorage } from "./hybrid.js";
51
+ // The push feed of the remote changes (roadmap CLI-21, CLI-15)
52
+ export { EventsChangeFeed } from "./feed.js";
53
+ export type {
54
+ ChangeEventSource,
55
+ EventSubscriber,
56
+ EventsChangeFeedOptions,
57
+ RemoteChangeFeed,
58
+ RemoteChangeHint,
59
+ RemoteChangeListener,
60
+ RemoteChanges,
61
+ } from "./feed.js";
62
+ // The sync state of HybridStorage: the cursor and the base versions (roadmap CLI-21).
63
+ // IndexedDbSyncStateStore is in "@procedures/store/indexeddb".
64
+ export { MemorySyncStateStore } from "./sync-state.js";
65
+ export type { SyncState, SyncStateChange, SyncStateStore } from "./sync-state.js";
49
66
  // The queue of HybridStorage and its store (roadmap R28). FileQueueStore is in
50
67
  // "@procedures/store/file-queue": it uses node:fs. IndexedDbQueueStore is in
51
68
  // "@procedures/store/indexeddb": it uses the IndexedDB of a browser (roadmap R45).
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The sync state of a `HybridStorage` and the store that keeps it (roadmap CLI-21).
3
+ *
4
+ * The sync state is the cursor (the remote version of the last pull) and the base versions (the
5
+ * last known remote version of each id). A store keeps them after a reload. A new storage with the
6
+ * same store then asks the remote only for the changes since the cursor, and it finds the conflicts
7
+ * of the writes after the reload.
8
+ *
9
+ * - `MemorySyncStateStore` keeps the state in memory. A new storage that gets the same instance
10
+ * loads the state, so the tests can simulate a reload.
11
+ * - `IndexedDbSyncStateStore` keeps the state in IndexedDB, for a browser. It is in
12
+ * `@procedures/store/indexeddb`.
13
+ *
14
+ * The state belongs to its local copy. Give a store only with a durable local copy, and give each
15
+ * storage its own store.
16
+ */
17
+
18
+ /** The sync state of a `HybridStorage`. Each field is JSON. */
19
+ export interface SyncState {
20
+ /** The remote version of the last pull, or undefined: the next pull reads all items. */
21
+ readonly cursor?: string | undefined;
22
+ /** The last known remote version of each id. `null`: the remote has no item. */
23
+ readonly bases: ReadonlyArray<readonly [string, string | null]>;
24
+ }
25
+
26
+ /** A change of the sync state, from a `HybridStorage` to its store. */
27
+ export interface SyncStateChange extends SyncState {
28
+ /** True: the change replaces the whole state. False: it changes the cursor and the ids that it names. */
29
+ readonly reset: boolean;
30
+ /** The ids whose base is not known any more. Empty when `reset` is true. */
31
+ readonly forgotten: readonly string[];
32
+ }
33
+
34
+ /**
35
+ * The store of the sync state of a `HybridStorage` (roadmap CLI-21). The storage calls the methods
36
+ * one at a time, in the order of the changes.
37
+ */
38
+ export interface SyncStateStore {
39
+ /** This method gives the state that the store holds, or undefined when it holds none. */
40
+ load(): Promise<SyncState | undefined>;
41
+ /** This method writes a change of the state. */
42
+ write(change: SyncStateChange): Promise<void>;
43
+ /** This method removes the state. */
44
+ clear(): Promise<void>;
45
+ }
46
+
47
+ /** A sync state store in memory. Its state lives as long as the instance. */
48
+ export class MemorySyncStateStore implements SyncStateStore {
49
+ private cursor: string | undefined;
50
+ private readonly bases = new Map<string, string | null>();
51
+ private written = false;
52
+
53
+ load(): Promise<SyncState | undefined> {
54
+ if (!this.written) return Promise.resolve(undefined);
55
+ return Promise.resolve({ ...(this.cursor !== undefined && { cursor: this.cursor }), bases: [...this.bases] });
56
+ }
57
+
58
+ write(change: SyncStateChange): Promise<void> {
59
+ if (change.reset) this.bases.clear();
60
+ this.cursor = change.cursor;
61
+ for (const [id, version] of change.bases) this.bases.set(id, version);
62
+ for (const id of change.forgotten) this.bases.delete(id);
63
+ this.written = true;
64
+ return Promise.resolve();
65
+ }
66
+
67
+ clear(): Promise<void> {
68
+ this.cursor = undefined;
69
+ this.bases.clear();
70
+ this.written = false;
71
+ return Promise.resolve();
72
+ }
73
+
74
+ /** The number of base versions that the store holds. */
75
+ get size(): number {
76
+ return this.bases.size;
77
+ }
78
+ }
79
+
80
+ /** True when a value has the shape of a sync state. A loaded store can hold anything. */
81
+ export function isSyncState(value: unknown): value is SyncState {
82
+ if (typeof value !== "object" || value === null) return false;
83
+ const { cursor, bases } = value as { cursor?: unknown; bases?: unknown };
84
+ return (
85
+ (cursor === undefined || typeof cursor === "string") &&
86
+ Array.isArray(bases) &&
87
+ bases.every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && (entry[1] === null || typeof entry[1] === "string"))
88
+ );
89
+ }
@@ -5,13 +5,14 @@
5
5
  * Enables procedure persistence and sync across backends.
6
6
  */
7
7
 
8
- import type { CollectionStorage } from "@procedures/collections";
8
+ import { StorageListeners, type CollectionStorage } from "@procedures/collections";
9
9
  import { ProcedureRegistry } from "@procedures/client";
10
10
  import type { AnyProcedure, ProcedurePath, RegistrationOptions } from "@procedures/client";
11
11
  import { pathToKey } from "@procedures/client";
12
12
  import type {
13
13
  SerializedProcedure,
14
14
  SyncedRegistryOptions,
15
+ SyncError,
15
16
  SyncResult,
16
17
  SyncStatus,
17
18
  SyncDirection,
@@ -21,7 +22,7 @@ import type {
21
22
  } from "./types.js";
22
23
  import { ProcedureStorageAdapter } from "./adapter.js";
23
24
  import { serializeProcedure, deserializeProcedure, getSerializedKey } from "./serialization.js";
24
- import { optionalMs, toMs, type Duration } from "@procedures/client";
25
+ import { optionalMs, realClock, toMs, type Cancel, type Clock, type Duration } from "@procedures/client";
25
26
  import type { HybridMode } from "./hybrid.js";
26
27
 
27
28
  /** A storage with modes: `HybridStorage` (roadmap R28). */
@@ -155,8 +156,17 @@ export class SyncedProcedureRegistry {
155
156
  /** Whether currently syncing */
156
157
  private syncing = false;
157
158
 
158
- /** Auto-sync interval handle */
159
- private syncIntervalHandle: ReturnType<typeof setInterval> | undefined;
159
+ /** The clock of the auto-sync */
160
+ private readonly clock: Clock;
161
+
162
+ /** The auto-sync that runs: the timer of its next sync */
163
+ private autoSync: { cancel: Cancel | undefined } | undefined;
164
+
165
+ /** The failures of the auto-sync and of the write-through writes that a later success did not clear */
166
+ private readonly failures = new Map<SyncError["operation"], SyncError>();
167
+
168
+ /** The listeners of `onStatus()` */
169
+ private readonly statusListeners = new StorageListeners<SyncStatus>();
160
170
 
161
171
  constructor(
162
172
  baseRegistry: ProcedureRegistry,
@@ -175,6 +185,7 @@ export class SyncedProcedureRegistry {
175
185
  handlerLoader: options.handlerLoader,
176
186
  syncOnInit: options.syncOnInit ?? false,
177
187
  };
188
+ this.clock = options.clock ?? realClock;
178
189
 
179
190
  // The storage procedures of this registry find this wrapper
180
191
  STORES.set(baseRegistry, this);
@@ -216,11 +227,12 @@ export class SyncedProcedureRegistry {
216
227
  }
217
228
  this.pendingDeletes.delete(key);
218
229
  if (this.options.writeStrategy === "write-through") {
219
- // Write-through: persist now. flushWrites() waits for these writes.
230
+ // Write-through: persist now. flushWrites() waits for these writes. A failure goes to the status.
220
231
  this.track(
221
- this.storeRecords([registered]).catch((err) => {
222
- console.error(`Failed to persist procedure ${key}:`, err);
223
- })
232
+ this.storeRecords([registered]).then(
233
+ () => this.succeed("push"),
234
+ (error: unknown) => this.fail("push", new Error(`Failed to persist procedure ${key}`, { cause: error })),
235
+ )
224
236
  );
225
237
  } else {
226
238
  // Write-back: queue for later sync
@@ -244,9 +256,10 @@ export class SyncedProcedureRegistry {
244
256
  this.touch(key);
245
257
  if (this.options.writeStrategy === "write-through") {
246
258
  this.track(
247
- this.adapter.remove(path).catch((err) => {
248
- console.error(`Failed to remove procedure ${key} from storage:`, err);
249
- })
259
+ this.adapter.remove(path).then(
260
+ () => this.succeed("push"),
261
+ (error: unknown) => this.fail("push", new Error(`Failed to remove procedure ${key} from storage`, { cause: error })),
262
+ )
250
263
  );
251
264
  } else {
252
265
  this.pendingDeletes.set(key, path);
@@ -509,9 +522,11 @@ export class SyncedProcedureRegistry {
509
522
  }
510
523
 
511
524
  /**
512
- * Get current sync status.
525
+ * Get current sync status. `error` is the newest failure of the auto-sync or of a write-through
526
+ * write that a later success did not clear.
513
527
  */
514
528
  getStatus(): SyncStatus {
529
+ const error = [...this.failures.values()].at(-1);
515
530
  return {
516
531
  connected: this.connected,
517
532
  endpoint: this.endpoint,
@@ -519,9 +534,32 @@ export class SyncedProcedureRegistry {
519
534
  pendingChanges: this.pendingChanges.size + this.pendingDeletes.size,
520
535
  syncing: this.syncing,
521
536
  mode: this.getMode(),
537
+ ...(error !== undefined && { error }),
522
538
  };
523
539
  }
524
540
 
541
+ /**
542
+ * This method sends the status to a listener after each failure and each success of the auto-sync
543
+ * and of the write-through writes (roadmap CLI-13). Before, the failures went to the console.
544
+ *
545
+ * @returns The function that stops the status. A second use of it does nothing.
546
+ */
547
+ onStatus(listener: (status: SyncStatus) => void): () => void {
548
+ return this.statusListeners.add(listener);
549
+ }
550
+
551
+ /** This method keeps a failure for the status, and sends the status. */
552
+ private fail(operation: SyncError["operation"], error: unknown): void {
553
+ this.failures.delete(operation);
554
+ this.failures.set(operation, { operation, error });
555
+ this.statusListeners.emit(this.getStatus());
556
+ }
557
+
558
+ /** This method clears the failure of an operation after a success, and sends the status. */
559
+ private succeed(operation: SyncError["operation"]): void {
560
+ if (this.failures.delete(operation)) this.statusListeners.emit(this.getStatus());
561
+ }
562
+
525
563
  // ===========================================================================
526
564
  // The mode of the storage (roadmap R28)
527
565
  // ===========================================================================
@@ -560,31 +598,38 @@ export class SyncedProcedureRegistry {
560
598
  // A number is the internal form of the option, in milliseconds
561
599
  const intervalMs = typeof interval === "number" ? interval : toMs(interval, "interval");
562
600
  this.stopAutoSync();
563
- const handle = setInterval(() => {
564
- // One auto-sync at a time, and none after close()
565
- if (this.closed || this.autoSyncRun) return;
566
- const run = this.sync("both")
567
- .catch((err) => {
568
- console.error("Auto-sync failed:", err);
569
- })
570
- .finally(() => {
571
- if (this.autoSyncRun === run) this.autoSyncRun = undefined;
572
- });
573
- this.autoSyncRun = run;
574
- }, intervalMs);
575
- // Don't let the auto-sync timer keep the process alive. See BUGS-2026-07.md (M4).
576
- handle.unref?.();
577
- this.syncIntervalHandle = handle;
601
+ const loop: { cancel: Cancel | undefined } = { cancel: undefined };
602
+ this.autoSync = loop;
603
+ // The next sync waits for the interval after the end of the last one: one auto-sync at a time
604
+ const arm = (): void => {
605
+ const cancel = this.clock.after(intervalMs, () => {
606
+ loop.cancel = undefined;
607
+ // None after close() or stopAutoSync()
608
+ if (this.closed || this.autoSync !== loop) return;
609
+ const run = this.sync("both")
610
+ .then(
611
+ () => this.succeed("sync"),
612
+ (error: unknown) => this.fail("sync", error),
613
+ )
614
+ .finally(() => {
615
+ if (this.autoSyncRun === run) this.autoSyncRun = undefined;
616
+ if (!this.closed && this.autoSync === loop) arm();
617
+ });
618
+ this.autoSyncRun = run;
619
+ });
620
+ // Don't let the auto-sync timer keep the process alive. See BUGS-2026-07.md (M4).
621
+ cancel.unref?.();
622
+ loop.cancel = cancel;
623
+ };
624
+ arm();
578
625
  }
579
626
 
580
627
  /**
581
628
  * Stop auto-sync.
582
629
  */
583
630
  stopAutoSync(): void {
584
- if (this.syncIntervalHandle) {
585
- clearInterval(this.syncIntervalHandle);
586
- this.syncIntervalHandle = undefined;
587
- }
631
+ this.autoSync?.cancel?.();
632
+ this.autoSync = undefined;
588
633
  }
589
634
 
590
635
  // ===========================================================================
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ProcedurePath, ProcedureMetadata } from "@procedures/client";
9
- import type { Duration } from "@procedures/client";
9
+ import type { Clock, Duration } from "@procedures/client";
10
10
 
11
11
  // =============================================================================
12
12
  // Serialized Procedure
@@ -140,6 +140,23 @@ export interface SyncStatus {
140
140
 
141
141
  /** The mode of a hybrid storage (roadmap R28), or undefined for a storage without modes */
142
142
  mode?: "local-only" | "write-through" | "write-back" | "remote-only" | undefined;
143
+
144
+ /**
145
+ * The newest failure of the sync that a later success of the same work did not clear (roadmap
146
+ * CLI-13), or undefined. Before, the failures went to the console.
147
+ */
148
+ error?: SyncError | undefined;
149
+ }
150
+
151
+ /** A failure of a sync, for its status. */
152
+ export interface SyncError {
153
+ /**
154
+ * The work that failed: the load of the queue, a push of the queue or of the records, a pull, a
155
+ * sync of both directions, or the queue store or the sync state store.
156
+ */
157
+ readonly operation: "load" | "push" | "pull" | "sync" | "store";
158
+ /** The error of the work. */
159
+ readonly error: unknown;
143
160
  }
144
161
 
145
162
  // =============================================================================
@@ -159,6 +176,13 @@ export interface SyncedRegistryOptions {
159
176
  /** Auto-sync interval, for example `minutes(1)` (0 to disable) */
160
177
  syncInterval?: Duration | undefined;
161
178
 
179
+ /**
180
+ * The clock of the auto-sync. A test gives `fakeClock` of `@mark1russell7/service/testing`. The
181
+ * timer does not keep a Node process alive.
182
+ * @default the clock of the platform (`realClock` of `@procedures/client`)
183
+ */
184
+ clock?: Clock | undefined;
185
+
162
186
  /** Handler loader for restoring handlers from references */
163
187
  handlerLoader?: HandlerLoader | undefined;
164
188