@procedures/store 0.2.3 → 0.4.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +109 -2
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/indexeddb/idb.d.ts +100 -0
  5. package/dist/indexeddb/idb.d.ts.map +1 -0
  6. package/dist/indexeddb/idb.js +137 -0
  7. package/dist/indexeddb/idb.js.map +1 -0
  8. package/dist/indexeddb/program-store.d.ts +42 -0
  9. package/dist/indexeddb/program-store.d.ts.map +1 -0
  10. package/dist/indexeddb/program-store.js +71 -0
  11. package/dist/indexeddb/program-store.js.map +1 -0
  12. package/dist/indexeddb/queue-store.d.ts +38 -0
  13. package/dist/indexeddb/queue-store.d.ts.map +1 -0
  14. package/dist/indexeddb/queue-store.js +73 -0
  15. package/dist/indexeddb/queue-store.js.map +1 -0
  16. package/dist/indexeddb.d.ts +19 -0
  17. package/dist/indexeddb.d.ts.map +1 -0
  18. package/dist/indexeddb.js +17 -0
  19. package/dist/indexeddb.js.map +1 -0
  20. package/dist/program-store/file.d.ts +5 -0
  21. package/dist/program-store/file.d.ts.map +1 -1
  22. package/dist/program-store/file.js +5 -2
  23. package/dist/program-store/file.js.map +1 -1
  24. package/dist/storage/api.d.ts +18 -7
  25. package/dist/storage/api.d.ts.map +1 -1
  26. package/dist/storage/api.js +34 -3
  27. package/dist/storage/api.js.map +1 -1
  28. package/dist/storage/factory.d.ts +13 -2
  29. package/dist/storage/factory.d.ts.map +1 -1
  30. package/dist/storage/factory.js +12 -4
  31. package/dist/storage/factory.js.map +1 -1
  32. package/dist/storage/file-queue.d.ts +32 -0
  33. package/dist/storage/file-queue.d.ts.map +1 -0
  34. package/dist/storage/file-queue.js +101 -0
  35. package/dist/storage/file-queue.js.map +1 -0
  36. package/dist/storage/hybrid.d.ts +361 -42
  37. package/dist/storage/hybrid.d.ts.map +1 -1
  38. package/dist/storage/hybrid.js +1102 -251
  39. package/dist/storage/hybrid.js.map +1 -1
  40. package/dist/storage/index.d.ts +4 -2
  41. package/dist/storage/index.d.ts.map +1 -1
  42. package/dist/storage/index.js +5 -1
  43. package/dist/storage/index.js.map +1 -1
  44. package/dist/storage/queue.d.ts +67 -0
  45. package/dist/storage/queue.d.ts.map +1 -0
  46. package/dist/storage/queue.js +85 -0
  47. package/dist/storage/queue.js.map +1 -0
  48. package/dist/storage/synced-registry.d.ts +18 -2
  49. package/dist/storage/synced-registry.d.ts.map +1 -1
  50. package/dist/storage/synced-registry.js +37 -3
  51. package/dist/storage/synced-registry.js.map +1 -1
  52. package/dist/storage/types.d.ts +5 -2
  53. package/dist/storage/types.d.ts.map +1 -1
  54. package/package.json +16 -6
  55. package/src/indexeddb/idb.ts +190 -0
  56. package/src/indexeddb/program-store.ts +80 -0
  57. package/src/indexeddb/queue-store.ts +80 -0
  58. package/src/indexeddb.ts +19 -0
  59. package/src/program-store/file.ts +5 -2
  60. package/src/storage/api.ts +50 -10
  61. package/src/storage/factory.ts +21 -4
  62. package/src/storage/file-queue.ts +103 -0
  63. package/src/storage/hybrid.ts +1151 -275
  64. package/src/storage/index.ts +15 -2
  65. package/src/storage/queue.ts +128 -0
  66. package/src/storage/synced-registry.ts +49 -4
  67. package/src/storage/types.ts +6 -2
@@ -36,8 +36,21 @@ export type { SerializeOptions, DeserializeOptions } from "./serialization.js";
36
36
  // The collection storages that need a Client (before: "@procedures/client/collections")
37
37
  export { ApiStorage } from "./api.js";
38
38
  export type { ApiStorageOptions } from "./api.js";
39
- export { HybridStorage } from "./hybrid.js";
40
- export type { HybridStorageOptions } from "./hybrid.js";
39
+ export {
40
+ HybridStorage,
41
+ PendingWriteError,
42
+ QueueFullError,
43
+ MissingKeysError,
44
+ QueueStoreError,
45
+ ConflictError,
46
+ ModeSwitchError,
47
+ } from "./hybrid.js";
48
+ export type { HybridStorageOptions, HybridMode, LocalCopyStorage } from "./hybrid.js";
49
+ // The queue of HybridStorage and its store (roadmap R28). FileQueueStore is in
50
+ // "@procedures/store/file-queue": it uses node:fs. IndexedDbQueueStore is in
51
+ // "@procedures/store/indexeddb": it uses the IndexedDB of a browser (roadmap R45).
52
+ export { MemoryQueueStore } from "./queue.js";
53
+ export type { QueuedOperation, QueueStore } from "./queue.js";
41
54
 
42
55
  // Adapter
43
56
  export { ProcedureStorageAdapter } from "./adapter.js";
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The queue of `HybridStorage` and the store that keeps it (roadmap R28).
3
+ *
4
+ * A write that waits for the remote is a `QueuedOperation`. The storage keeps the queue in
5
+ * memory and writes each change to a `QueueStore` before it acknowledges the write. A new storage
6
+ * with the same store loads the queue again, so a write that waits survives a restart.
7
+ *
8
+ * - `MemoryQueueStore` keeps the queue in memory. It is the default. A new storage that gets the
9
+ * same instance loads its queue, so the tests can simulate a restart.
10
+ * - `FileQueueStore` keeps the queue in a JSON Lines file. It is in `@procedures/store/file-queue`,
11
+ * because it uses `node:fs`. The root of the package stays usable in a browser.
12
+ * - `IndexedDbQueueStore` keeps the queue in IndexedDB, for a browser (roadmap R45). It is in
13
+ * `@procedures/store/indexeddb`, and it implements the four methods of `QueueStore` on one
14
+ * object store.
15
+ */
16
+
17
+ /** A write that waits in the queue for the remote. Each field is JSON. */
18
+ export interface QueuedOperation {
19
+ /** The kind of the write. */
20
+ type: "set" | "delete" | "setBatch" | "deleteBatch" | "clear";
21
+ /**
22
+ * The arguments of the write: `{ id, value }` for set, `{ id }` for delete,
23
+ * `{ items: [[id, value]] }` for setBatch, `{ ids }` for deleteBatch, `{}` for clear.
24
+ */
25
+ data: unknown;
26
+ /** The time of the write, in milliseconds since 1970. */
27
+ timestamp: number;
28
+ /**
29
+ * The remote version that the write changed, for each id with a known version. `null`: the
30
+ * remote had no item. Only a storage with `versionOf` writes it. A conflict test after a restart
31
+ * reads it.
32
+ */
33
+ base?: Array<[string, string | null]>;
34
+ }
35
+
36
+ /**
37
+ * The store that keeps the queue of a `HybridStorage` (roadmap R28). The storage calls the
38
+ * methods one at a time, in the order of the changes.
39
+ */
40
+ export interface QueueStore {
41
+ /** This method gives the operations that the store holds, the oldest first. */
42
+ load(): Promise<QueuedOperation[]>;
43
+ /** This method adds an operation at the end. */
44
+ append(operation: QueuedOperation): Promise<void>;
45
+ /** This method puts these operations in the place of all operations. */
46
+ replace(operations: QueuedOperation[]): Promise<void>;
47
+ /** This method removes all operations. */
48
+ clear(): Promise<void>;
49
+ }
50
+
51
+ /** A queue store in memory: the default. Its queue lives as long as the instance. */
52
+ export class MemoryQueueStore implements QueueStore {
53
+ private operations: QueuedOperation[] = [];
54
+
55
+ load(): Promise<QueuedOperation[]> {
56
+ return Promise.resolve(this.operations.slice());
57
+ }
58
+
59
+ append(operation: QueuedOperation): Promise<void> {
60
+ this.operations.push(operation);
61
+ return Promise.resolve();
62
+ }
63
+
64
+ replace(operations: QueuedOperation[]): Promise<void> {
65
+ this.operations = operations.slice();
66
+ return Promise.resolve();
67
+ }
68
+
69
+ clear(): Promise<void> {
70
+ this.operations = [];
71
+ return Promise.resolve();
72
+ }
73
+
74
+ /** The number of operations that the store holds. */
75
+ get size(): number {
76
+ return this.operations.length;
77
+ }
78
+ }
79
+
80
+ /** The ids that an operation changes ("*" for clear). */
81
+ export function idsOf(op: QueuedOperation): string[] {
82
+ const data = op.data as { id?: string; ids?: string[]; items?: Array<[string, unknown]> };
83
+ switch (op.type) {
84
+ case "set":
85
+ case "delete":
86
+ return data.id === undefined ? [] : [data.id];
87
+ case "setBatch":
88
+ return (data.items ?? []).map(([id]) => id);
89
+ case "deleteBatch":
90
+ return data.ids ?? [];
91
+ case "clear":
92
+ return ["*"];
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The operation without the id, or undefined when nothing stays. A clear cannot lose an id: it
98
+ * stays as it is.
99
+ */
100
+ export function withoutId(op: QueuedOperation, id: string): QueuedOperation | undefined {
101
+ const data = op.data as { id?: string; ids?: string[]; items?: Array<[string, unknown]> };
102
+ switch (op.type) {
103
+ case "set":
104
+ case "delete":
105
+ return data.id === id ? undefined : op;
106
+ case "setBatch": {
107
+ const items = (data.items ?? []).filter(([itemId]) => itemId !== id);
108
+ return items.length === 0 ? undefined : { ...op, data: { items } };
109
+ }
110
+ case "deleteBatch": {
111
+ const ids = (data.ids ?? []).filter((other) => other !== id);
112
+ return ids.length === 0 ? undefined : { ...op, data: { ids } };
113
+ }
114
+ case "clear":
115
+ return op;
116
+ }
117
+ }
118
+
119
+ /** True when a value has the shape of a queued operation. A loaded file can hold anything. */
120
+ export function isQueuedOperation(value: unknown): value is QueuedOperation {
121
+ if (typeof value !== "object" || value === null) return false;
122
+ const { type, data, timestamp } = value as Partial<QueuedOperation>;
123
+ return (
124
+ (type === "set" || type === "delete" || type === "setBatch" || type === "deleteBatch" || type === "clear") &&
125
+ typeof data === "object" && data !== null &&
126
+ typeof timestamp === "number"
127
+ );
128
+ }
@@ -21,6 +21,19 @@ import type {
21
21
  } from "./types.js";
22
22
  import { ProcedureStorageAdapter } from "./adapter.js";
23
23
  import { serializeProcedure, deserializeProcedure, getSerializedKey } from "./serialization.js";
24
+ import { optionalMs, toMs, type Duration } from "@procedures/client";
25
+ import type { HybridMode } from "./hybrid.js";
26
+
27
+ /** A storage with modes: `HybridStorage` (roadmap R28). */
28
+ interface StorageWithModes {
29
+ getMode(): HybridMode;
30
+ setMode(mode: HybridMode): Promise<void>;
31
+ }
32
+
33
+ function hasModes(storage: unknown): storage is StorageWithModes {
34
+ const candidate = storage as Partial<StorageWithModes> | null;
35
+ return typeof candidate?.setMode === "function" && typeof candidate.getMode === "function";
36
+ }
24
37
 
25
38
  // =============================================================================
26
39
  // Extended Registration Options
@@ -100,7 +113,10 @@ export function getProcedureStore(registry: ProcedureRegistry): SyncedProcedureR
100
113
  export class SyncedProcedureRegistry {
101
114
  private readonly baseRegistry: ProcedureRegistry;
102
115
  private readonly adapter: ProcedureStorageAdapter;
103
- private readonly options: SyncedRegistryOptions;
116
+ /** The storage of the records: `setMode` changes its mode */
117
+ private readonly storage: CollectionStorage<SerializedProcedure>;
118
+ /** The options, with the sync interval in milliseconds */
119
+ private readonly options: Omit<SyncedRegistryOptions, "syncInterval"> & { syncInterval: number };
104
120
 
105
121
  /** Pending changes for write-back strategy */
106
122
  private pendingChanges: Map<string, AnyProcedure> = new Map();
@@ -142,13 +158,14 @@ export class SyncedProcedureRegistry {
142
158
  options: Partial<SyncedRegistryOptions> = {}
143
159
  ) {
144
160
  this.baseRegistry = baseRegistry;
161
+ this.storage = storage;
145
162
  this.adapter = new ProcedureStorageAdapter(storage, {
146
163
  handlerLoader: options.handlerLoader,
147
164
  });
148
165
  this.options = {
149
166
  writeStrategy: options.writeStrategy ?? "write-through",
150
167
  conflictResolution: options.conflictResolution ?? "local",
151
- syncInterval: options.syncInterval ?? 0,
168
+ syncInterval: optionalMs(options.syncInterval, "syncInterval") ?? 0,
152
169
  handlerLoader: options.handlerLoader,
153
170
  syncOnInit: options.syncOnInit ?? false,
154
171
  };
@@ -494,9 +511,35 @@ export class SyncedProcedureRegistry {
494
511
  lastSync: this.lastSyncTime,
495
512
  pendingChanges: this.pendingChanges.size + this.pendingDeletes.size,
496
513
  syncing: this.syncing,
514
+ mode: this.getMode(),
497
515
  };
498
516
  }
499
517
 
518
+ // ===========================================================================
519
+ // The mode of the storage (roadmap R28)
520
+ // ===========================================================================
521
+
522
+ /** The mode of the storage, or undefined when the storage has no modes. */
523
+ getMode(): HybridMode | undefined {
524
+ return hasModes(this.storage) ? this.storage.getMode() : undefined;
525
+ }
526
+
527
+ /**
528
+ * This method changes the mode of the storage while the registry operates (roadmap R28). The registry
529
+ * waits for its write-through stores that run, then `HybridStorage.setMode()` changes the
530
+ * mode: it sends the queued records before the change.
531
+ *
532
+ * @param mode - `local-only`, `write-through`, `write-back` or `remote-only`
533
+ * @throws When the storage has no modes, or when the switch fails (`ModeSwitchError`)
534
+ */
535
+ async setMode(mode: HybridMode): Promise<void> {
536
+ if (!hasModes(this.storage)) {
537
+ throw new Error('The storage of this registry has no modes: use createSyncedRegistry({ type: "hybrid" })');
538
+ }
539
+ await this.flushWrites();
540
+ await this.storage.setMode(mode);
541
+ }
542
+
500
543
  // ===========================================================================
501
544
  // Auto-Sync
502
545
  // ===========================================================================
@@ -504,9 +547,11 @@ export class SyncedProcedureRegistry {
504
547
  /**
505
548
  * Start auto-sync at specified interval.
506
549
  *
507
- * @param intervalMs - Sync interval in milliseconds
550
+ * @param interval - The sync interval
508
551
  */
509
- startAutoSync(intervalMs: number): void {
552
+ startAutoSync(interval: Duration | number): void {
553
+ // A number is the internal form of the option, in milliseconds
554
+ const intervalMs = typeof interval === "number" ? interval : toMs(interval, "interval");
510
555
  this.stopAutoSync();
511
556
  const handle = setInterval(() => {
512
557
  void this.sync("both").catch((err) => {
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ProcedurePath, ProcedureMetadata } from "@procedures/client";
9
+ import type { Duration } from "@procedures/client";
9
10
 
10
11
  // =============================================================================
11
12
  // Serialized Procedure
@@ -136,6 +137,9 @@ export interface SyncStatus {
136
137
 
137
138
  /** Whether currently syncing */
138
139
  syncing: boolean;
140
+
141
+ /** The mode of a hybrid storage (roadmap R28), or undefined for a storage without modes */
142
+ mode?: "local-only" | "write-through" | "write-back" | "remote-only" | undefined;
139
143
  }
140
144
 
141
145
  // =============================================================================
@@ -152,8 +156,8 @@ export interface SyncedRegistryOptions {
152
156
  /** How to resolve conflicts when loading from storage */
153
157
  conflictResolution: ConflictResolution;
154
158
 
155
- /** Auto-sync interval in milliseconds (0 to disable) */
156
- syncInterval?: number | undefined;
159
+ /** Auto-sync interval, for example `minutes(1)` (0 to disable) */
160
+ syncInterval?: Duration | undefined;
157
161
 
158
162
  /** Handler loader for restoring handlers from references */
159
163
  handlerLoader?: HandlerLoader | undefined;