@procedures/store 0.2.2 → 0.3.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 (71) hide show
  1. package/CHANGELOG.md +63 -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/program-store/procedures.d.ts.map +1 -1
  25. package/dist/program-store/procedures.js +5 -2
  26. package/dist/program-store/procedures.js.map +1 -1
  27. package/dist/storage/api.d.ts +18 -7
  28. package/dist/storage/api.d.ts.map +1 -1
  29. package/dist/storage/api.js +34 -3
  30. package/dist/storage/api.js.map +1 -1
  31. package/dist/storage/factory.d.ts +13 -2
  32. package/dist/storage/factory.d.ts.map +1 -1
  33. package/dist/storage/factory.js +12 -4
  34. package/dist/storage/factory.js.map +1 -1
  35. package/dist/storage/file-queue.d.ts +32 -0
  36. package/dist/storage/file-queue.d.ts.map +1 -0
  37. package/dist/storage/file-queue.js +101 -0
  38. package/dist/storage/file-queue.js.map +1 -0
  39. package/dist/storage/hybrid.d.ts +355 -42
  40. package/dist/storage/hybrid.d.ts.map +1 -1
  41. package/dist/storage/hybrid.js +1093 -250
  42. package/dist/storage/hybrid.js.map +1 -1
  43. package/dist/storage/index.d.ts +4 -2
  44. package/dist/storage/index.d.ts.map +1 -1
  45. package/dist/storage/index.js +5 -1
  46. package/dist/storage/index.js.map +1 -1
  47. package/dist/storage/queue.d.ts +67 -0
  48. package/dist/storage/queue.d.ts.map +1 -0
  49. package/dist/storage/queue.js +85 -0
  50. package/dist/storage/queue.js.map +1 -0
  51. package/dist/storage/synced-registry.d.ts +18 -2
  52. package/dist/storage/synced-registry.d.ts.map +1 -1
  53. package/dist/storage/synced-registry.js +37 -3
  54. package/dist/storage/synced-registry.js.map +1 -1
  55. package/dist/storage/types.d.ts +5 -2
  56. package/dist/storage/types.d.ts.map +1 -1
  57. package/package.json +16 -6
  58. package/src/indexeddb/idb.ts +190 -0
  59. package/src/indexeddb/program-store.ts +80 -0
  60. package/src/indexeddb/queue-store.ts +80 -0
  61. package/src/indexeddb.ts +19 -0
  62. package/src/program-store/file.ts +5 -2
  63. package/src/program-store/procedures.ts +5 -2
  64. package/src/storage/api.ts +50 -10
  65. package/src/storage/factory.ts +21 -4
  66. package/src/storage/file-queue.ts +103 -0
  67. package/src/storage/hybrid.ts +1142 -274
  68. package/src/storage/index.ts +15 -2
  69. package/src/storage/queue.ts +128 -0
  70. package/src/storage/synced-registry.ts +49 -4
  71. package/src/storage/types.ts +6 -2
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@procedures/store",
4
- "version": "0.2.2",
5
- "description": "A program store for procedure-as-data: save the programs that procedure.define makes, load them back after a restart, in memory, a JSON file or a remote collection storage.",
4
+ "version": "0.3.0",
5
+ "description": "A program store for procedure-as-data: save the programs that procedure.define makes, load them back after a restart, in memory, a JSON file, IndexedDB or a remote collection storage.",
6
6
  "keywords": [
7
7
  "program-store",
8
8
  "procedures",
@@ -37,6 +37,14 @@
37
37
  "./register": {
38
38
  "types": "./dist/register.d.ts",
39
39
  "import": "./dist/register.js"
40
+ },
41
+ "./file-queue": {
42
+ "types": "./dist/storage/file-queue.d.ts",
43
+ "import": "./dist/storage/file-queue.js"
44
+ },
45
+ "./indexeddb": {
46
+ "types": "./dist/indexeddb.d.ts",
47
+ "import": "./dist/indexeddb.js"
40
48
  }
41
49
  },
42
50
  "files": [
@@ -61,14 +69,16 @@
61
69
  "procedures": "./dist/register.js"
62
70
  },
63
71
  "dependencies": {
64
- "@procedures/client": "0.2.2",
65
- "@procedures/collections": "0.2.2",
72
+ "@procedures/client": "0.3.0",
73
+ "@procedures/collections": "0.3.0",
66
74
  "zod": "^4.6.5"
67
75
  },
68
76
  "devDependencies": {
69
- "typescript": "^7.0.2",
70
- "@types/node": "^22.0.0",
71
77
  "@mark1russell7/cue": "^1.0.0",
78
+ "@types/node": "^22.0.0",
79
+ "fake-indexeddb": "^6.2.5",
80
+ "fast-check": "^4.10.2",
81
+ "typescript": "^7.0.2",
72
82
  "vitest": "^5.0.3"
73
83
  },
74
84
  "scripts": {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The part of IndexedDB that the stores of `@procedures/store/indexeddb` use (roadmap R45).
3
+ *
4
+ * The package compiles without the DOM types, so this module declares the shapes that it uses.
5
+ * The `indexedDB` of a browser fits them, and the `IDBFactory` of `fake-indexeddb` too. Nothing in
6
+ * this module reads a global value when it loads: Node can import it.
7
+ */
8
+
9
+ // The event handlers take `any`: the DOM handlers take their own event types, and a parameter of
10
+ // the type `unknown` does not accept them.
11
+
12
+ /** A request of IndexedDB. */
13
+ export interface IdbRequestLike<R = unknown> {
14
+ readonly result: R;
15
+ readonly error: unknown;
16
+ onsuccess: ((event: any) => void) | null;
17
+ onerror: ((event: any) => void) | null;
18
+ }
19
+
20
+ /** The request that opens a database. */
21
+ export interface IdbOpenRequestLike extends IdbRequestLike<IdbDatabaseLike> {
22
+ onupgradeneeded: ((event: any) => void) | null;
23
+ }
24
+
25
+ /** The factory of the databases: `indexedDB` in a browser. */
26
+ export interface IdbFactoryLike {
27
+ open(name: string, version?: number): IdbOpenRequestLike;
28
+ }
29
+
30
+ /** An open database. */
31
+ export interface IdbDatabaseLike {
32
+ readonly objectStoreNames: { contains(name: string): boolean };
33
+ createObjectStore(name: string, options?: { keyPath?: string; autoIncrement?: boolean }): unknown;
34
+ transaction(names: string, mode?: "readonly" | "readwrite"): IdbTransactionLike;
35
+ close(): void;
36
+ onversionchange: ((event: any) => void) | null;
37
+ }
38
+
39
+ /** A transaction. */
40
+ export interface IdbTransactionLike {
41
+ readonly error: unknown;
42
+ objectStore(name: string): IdbObjectStoreLike;
43
+ abort(): void;
44
+ oncomplete: ((event: any) => void) | null;
45
+ onerror: ((event: any) => void) | null;
46
+ onabort: ((event: any) => void) | null;
47
+ }
48
+
49
+ /** An object store in a transaction. */
50
+ export interface IdbObjectStoreLike {
51
+ get(key: string): IdbRequestLike<unknown>;
52
+ getAll(): IdbRequestLike<unknown[]>;
53
+ count(key?: string): IdbRequestLike<number>;
54
+ put(value: unknown, key?: string): IdbRequestLike<unknown>;
55
+ add(value: unknown, key?: string): IdbRequestLike<unknown>;
56
+ delete(key: string): IdbRequestLike<unknown>;
57
+ clear(): IdbRequestLike<unknown>;
58
+ }
59
+
60
+ /** The options of the IndexedDB stores. */
61
+ export interface IndexedDbOptions {
62
+ /**
63
+ * The factory of the databases. The tests give the one of `fake-indexeddb`.
64
+ * @default the `indexedDB` of the global scope
65
+ */
66
+ indexedDB?: IdbFactoryLike;
67
+ }
68
+
69
+ /** The `indexedDB` of the global scope, or undefined (Node, an old browser, some private windows). */
70
+ function globalFactory(): IdbFactoryLike | undefined {
71
+ return (globalThis as { indexedDB?: IdbFactoryLike }).indexedDB ?? undefined;
72
+ }
73
+
74
+ /**
75
+ * This function gives true when the global scope has IndexedDB. A browser can still refuse to open a database,
76
+ * for example in some private windows: the first operation of a store then fails.
77
+ */
78
+ export function hasIndexedDb(): boolean {
79
+ return globalFactory() !== undefined;
80
+ }
81
+
82
+ /** The error of a request or a transaction, as an `Error`. */
83
+ function errorOf(cause: unknown, message: string): Error {
84
+ return cause instanceof Error ? cause : new Error(message, { cause });
85
+ }
86
+
87
+ /**
88
+ * One object store in its own database, version 1. The connection opens at the first operation,
89
+ * and a failed open tries again at the next operation.
90
+ */
91
+ export class IdbStoreConnection {
92
+ private connection: Promise<IdbDatabaseLike> | undefined;
93
+ private closed = false;
94
+
95
+ constructor(
96
+ /** The name of the database. */
97
+ readonly database: string,
98
+ /** The name of the object store. */
99
+ readonly store: string,
100
+ private readonly storeOptions: { keyPath?: string; autoIncrement?: boolean },
101
+ private readonly factory: IdbFactoryLike | undefined,
102
+ ) {}
103
+
104
+ /**
105
+ * This method runs work in one transaction. The work makes the requests, and it gives a function that reads
106
+ * their results. The promise resolves after the transaction commits, so a write is durable when
107
+ * the promise resolves. An error of a request aborts the transaction: each request is undone.
108
+ */
109
+ async run<R>(mode: "readonly" | "readwrite", work: (store: IdbObjectStoreLike) => () => R): Promise<R> {
110
+ const db = await this.open();
111
+ return new Promise<R>((resolve, reject) => {
112
+ let transaction: IdbTransactionLike;
113
+ let read: () => R;
114
+ try {
115
+ transaction = db.transaction(this.store, mode);
116
+ read = work(transaction.objectStore(this.store));
117
+ } catch (error) {
118
+ reject(error);
119
+ return;
120
+ }
121
+ transaction.oncomplete = () => {
122
+ try {
123
+ resolve(read());
124
+ } catch (error) {
125
+ reject(error);
126
+ }
127
+ };
128
+ transaction.onerror = () => reject(errorOf(transaction.error, `The IndexedDB transaction on ${this.database} failed`));
129
+ transaction.onabort = () => reject(errorOf(transaction.error, `The IndexedDB transaction on ${this.database} was aborted`));
130
+ });
131
+ }
132
+
133
+ /** This method closes the connection. A later operation throws. */
134
+ async close(): Promise<void> {
135
+ this.closed = true;
136
+ const connection = this.connection;
137
+ this.connection = undefined;
138
+ if (!connection) return;
139
+ try {
140
+ (await connection).close();
141
+ } catch {
142
+ // The open failed: there is nothing to close
143
+ }
144
+ }
145
+
146
+ private open(): Promise<IdbDatabaseLike> {
147
+ if (this.closed) return Promise.reject(new Error(`The IndexedDB store ${this.database} is closed`));
148
+ if (!this.connection) {
149
+ const connection = this.connect();
150
+ this.connection = connection;
151
+ connection.catch(() => {
152
+ if (this.connection === connection) this.connection = undefined;
153
+ });
154
+ }
155
+ return this.connection;
156
+ }
157
+
158
+ private connect(): Promise<IdbDatabaseLike> {
159
+ const factory = this.factory ?? globalFactory();
160
+ if (!factory) return Promise.reject(new Error("IndexedDB is not available in this environment"));
161
+ return new Promise<IdbDatabaseLike>((resolve, reject) => {
162
+ let request: IdbOpenRequestLike;
163
+ try {
164
+ request = factory.open(this.database, 1);
165
+ } catch (error) {
166
+ reject(error);
167
+ return;
168
+ }
169
+ request.onupgradeneeded = () => {
170
+ const db = request.result;
171
+ if (!db.objectStoreNames.contains(this.store)) db.createObjectStore(this.store, this.storeOptions);
172
+ };
173
+ request.onsuccess = () => {
174
+ const db = request.result;
175
+ if (!db.objectStoreNames.contains(this.store)) {
176
+ db.close();
177
+ reject(new Error(`The IndexedDB database ${this.database} exists, and it has no object store ${this.store}`));
178
+ return;
179
+ }
180
+ // Another tab wants a new version: close this connection, so the upgrade can run
181
+ db.onversionchange = () => {
182
+ db.close();
183
+ this.connection = undefined;
184
+ };
185
+ resolve(db);
186
+ };
187
+ request.onerror = () => reject(errorOf(request.error, `IndexedDB could not open the database ${this.database}`));
188
+ });
189
+ }
190
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A program store in IndexedDB, for a browser (roadmap R45).
3
+ *
4
+ * The programs are in the object store `programs` of one database, with the key of each program
5
+ * as the key of its record. IndexedDB keeps a structured clone of each record, so a caller that
6
+ * changes a result does not change the store. The store checks each record that it reads: the
7
+ * database of a browser can hold records of an older version or of another application.
8
+ */
9
+
10
+ import { byKey, checkStoredProgram, keyMatches, type ProgramStore, type StoredProgram } from "../program-store/types.js";
11
+ import { IdbStoreConnection, type IndexedDbOptions } from "./idb.js";
12
+
13
+ /** The default name of the database of the programs. */
14
+ export const DEFAULT_PROGRAM_DATABASE = "procedures-programs";
15
+
16
+ /**
17
+ * A program store in IndexedDB.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { IndexedDbProgramStore } from "@procedures/store/indexeddb";
22
+ *
23
+ * const store = new IndexedDbProgramStore("my-app-programs");
24
+ * await store.put({ key: "reports.weekly", path: ["reports", "weekly"], program, storedAt: Date.now() });
25
+ * const programs = await store.list("reports");
26
+ * ```
27
+ */
28
+ export class IndexedDbProgramStore implements ProgramStore {
29
+ readonly kind = "indexeddb";
30
+ private readonly connection: IdbStoreConnection;
31
+
32
+ /**
33
+ * @param name - The name of the database. The store makes it at the first operation.
34
+ * @param options - The IndexedDB factory. The default is the `indexedDB` of the global scope.
35
+ */
36
+ constructor(name: string = DEFAULT_PROGRAM_DATABASE, options: IndexedDbOptions = {}) {
37
+ this.connection = new IdbStoreConnection(name, "programs", { keyPath: "key" }, options.indexedDB);
38
+ }
39
+
40
+ /** The name of the database. */
41
+ get name(): string {
42
+ return this.connection.database;
43
+ }
44
+
45
+ get(key: string): Promise<StoredProgram | undefined> {
46
+ return this.connection.run("readonly", (store) => {
47
+ const request = store.get(key);
48
+ return () => (request.result === undefined ? undefined : checkStoredProgram(request.result));
49
+ });
50
+ }
51
+
52
+ list(prefix?: string): Promise<StoredProgram[]> {
53
+ return this.connection.run("readonly", (store) => {
54
+ const request = store.getAll();
55
+ return () => byKey(request.result.map((record) => checkStoredProgram(record)).filter((program) => keyMatches(program.key, prefix)));
56
+ });
57
+ }
58
+
59
+ async put(program: StoredProgram): Promise<void> {
60
+ const checked = checkStoredProgram(structuredClone(program));
61
+ await this.connection.run("readwrite", (store) => {
62
+ store.put(checked);
63
+ return () => undefined;
64
+ });
65
+ }
66
+
67
+ delete(key: string): Promise<boolean> {
68
+ return this.connection.run("readwrite", (store) => {
69
+ // The requests of one transaction run in order: the count sees the record before the delete
70
+ const count = store.count(key);
71
+ store.delete(key);
72
+ return () => count.result > 0;
73
+ });
74
+ }
75
+
76
+ /** This method closes the database. The programs stay in it. */
77
+ close(): Promise<void> {
78
+ return this.connection.close();
79
+ }
80
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A queue store in IndexedDB, for the `HybridStorage` of a browser (roadmap R28, R45).
3
+ *
4
+ * The operations are in the object store `operations` of one database, under keys that IndexedDB
5
+ * makes in increasing order. `load` gives them in the order of the keys: the oldest first. A
6
+ * `replace` clears the store and adds the operations in one transaction, so a reader never sees a
7
+ * part of it. Give each storage its own database.
8
+ */
9
+
10
+ import { isQueuedOperation, type QueuedOperation, type QueueStore } from "../storage/queue.js";
11
+ import { IdbStoreConnection, type IndexedDbOptions } from "./idb.js";
12
+
13
+ /**
14
+ * A queue store in IndexedDB.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { HybridStorage } from "@procedures/store";
19
+ * import { IndexedDbQueueStore } from "@procedures/store/indexeddb";
20
+ *
21
+ * const users = new HybridStorage(remote, { mode: "write-back", queueStore: new IndexedDbQueueStore("users-queue") });
22
+ * ```
23
+ */
24
+ export class IndexedDbQueueStore implements QueueStore {
25
+ private readonly connection: IdbStoreConnection;
26
+
27
+ /**
28
+ * @param name - The name of the database: one database for each storage. The store makes it at the first operation.
29
+ * @param options - The IndexedDB factory. The default is the `indexedDB` of the global scope.
30
+ */
31
+ constructor(name: string, options: IndexedDbOptions = {}) {
32
+ if (typeof name !== "string" || name === "") throw new TypeError("IndexedDbQueueStore needs the name of a database");
33
+ this.connection = new IdbStoreConnection(name, "operations", { autoIncrement: true }, options.indexedDB);
34
+ }
35
+
36
+ /** The name of the database. */
37
+ get name(): string {
38
+ return this.connection.database;
39
+ }
40
+
41
+ load(): Promise<QueuedOperation[]> {
42
+ return this.connection.run("readonly", (store) => {
43
+ const request = store.getAll();
44
+ return () =>
45
+ request.result.map((value, index) => {
46
+ if (!isQueuedOperation(value)) {
47
+ throw new Error(`The queue database ${this.name} has a record that is not a queued operation (record ${index + 1})`);
48
+ }
49
+ return value;
50
+ });
51
+ });
52
+ }
53
+
54
+ async append(operation: QueuedOperation): Promise<void> {
55
+ await this.connection.run("readwrite", (store) => {
56
+ store.add(operation);
57
+ return () => undefined;
58
+ });
59
+ }
60
+
61
+ async replace(operations: QueuedOperation[]): Promise<void> {
62
+ await this.connection.run("readwrite", (store) => {
63
+ store.clear();
64
+ for (const operation of operations) store.add(operation);
65
+ return () => undefined;
66
+ });
67
+ }
68
+
69
+ async clear(): Promise<void> {
70
+ await this.connection.run("readwrite", (store) => {
71
+ store.clear();
72
+ return () => undefined;
73
+ });
74
+ }
75
+
76
+ /** This method closes the database. The operations stay in it. */
77
+ close(): Promise<void> {
78
+ return this.connection.close();
79
+ }
80
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The entry `@procedures/store/indexeddb`: the stores of a browser (roadmap R45).
3
+ *
4
+ * - `IndexedDbProgramStore` keeps programs in IndexedDB.
5
+ * - `IndexedDbQueueStore` keeps the queue of a `HybridStorage` in IndexedDB.
6
+ * - `MemoryProgramStore` and `MemoryQueueStore` are the fallbacks for a browser without
7
+ * IndexedDB. `hasIndexedDb()` tells which one to use.
8
+ *
9
+ * The package root does not import this entry, and this entry does not import the client or zod:
10
+ * a page that loads it gets only these stores.
11
+ */
12
+
13
+ export { IndexedDbProgramStore, DEFAULT_PROGRAM_DATABASE } from "./indexeddb/program-store.js";
14
+ export { IndexedDbQueueStore } from "./indexeddb/queue-store.js";
15
+ export { hasIndexedDb, type IdbFactoryLike, type IndexedDbOptions } from "./indexeddb/idb.js";
16
+ export { MemoryProgramStore } from "./program-store/memory.js";
17
+ export { MemoryQueueStore } from "./storage/queue.js";
18
+ export type { ProgramStore, StoredProgram } from "./program-store/types.js";
19
+ export type { QueuedOperation, QueueStore } from "./storage/queue.js";
@@ -34,8 +34,11 @@ function isContention(error: unknown): boolean {
34
34
 
35
35
  const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
36
36
 
37
- /** Run a file operation again while Windows holds the file for a moment. */
38
- async function retryOnContention<T>(operation: () => Promise<T>): Promise<T> {
37
+ /**
38
+ * This function runs a file operation again while Windows holds the file for a moment. `FileQueueStore` uses it
39
+ * too.
40
+ */
41
+ export async function retryOnContention<T>(operation: () => Promise<T>): Promise<T> {
39
42
  for (let attempt = 1; ; attempt++) {
40
43
  try {
41
44
  return await operation();
@@ -43,10 +43,13 @@ function toPath(path: ProcedurePath | string): ProcedurePath {
43
43
  return typeof path === "string" ? keyToPath(path) : path;
44
44
  }
45
45
 
46
- /** The metadata to store: the procedure's metadata without the program (the store keeps that). */
46
+ /**
47
+ * The metadata to store: the procedure's metadata without the program (the store keeps that)
48
+ * and without the effects (procedure.define sets them).
49
+ */
47
50
  function storedMetadata(metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
48
51
  if (!metadata) return undefined;
49
- const { aggregation: _aggregation, generatedFrom: _generatedFrom, ...rest } = metadata;
52
+ const { aggregation: _aggregation, generatedFrom: _generatedFrom, effects: _effects, ...rest } = metadata;
50
53
  return Object.keys(rest).length > 0 ? rest : undefined;
51
54
  }
52
55
 
@@ -7,10 +7,11 @@
7
7
  * This is the KEY integration between Collections and Universal Client!
8
8
  */
9
9
 
10
- import type { CollectionStorage, StorageMetadata } from "@procedures/collections";
10
+ import type { CollectionStorage, StorageChanges, StorageMetadata } from "@procedures/collections";
11
11
  import type { Client } from "@procedures/client";
12
12
  import type { Method } from "@procedures/client";
13
13
  import type { CallOptions } from "@procedures/client/client";
14
+ import { optionalMs, type Duration } from "@procedures/client";
14
15
 
15
16
  /**
16
17
  * API storage configuration options.
@@ -42,6 +43,8 @@ export interface ApiStorageOptions {
42
43
  operations?: {
43
44
  get?: string;
44
45
  getAll?: string;
46
+ entries?: string;
47
+ changes?: string;
45
48
  find?: string;
46
49
  has?: string;
47
50
  size?: string;
@@ -54,10 +57,10 @@ export interface ApiStorageOptions {
54
57
  };
55
58
 
56
59
  /**
57
- * Request timeout in milliseconds. A call that takes longer fails. 0: no timeout.
58
- * @default 30000 (30 seconds)
60
+ * Request timeout, for example `seconds(30)`. A call that takes longer fails. 0: no timeout.
61
+ * @default 30 seconds
59
62
  */
60
- timeout?: number;
63
+ timeout?: Duration;
61
64
 
62
65
  /**
63
66
  * Retry a call that fails with a retryable error: true (3 attempts), false (1 attempt), or the
@@ -67,10 +70,10 @@ export interface ApiStorageOptions {
67
70
  retry?: boolean | number;
68
71
 
69
72
  /**
70
- * The first delay between attempts in milliseconds. Each later delay is two times longer.
71
- * @default 100
73
+ * The first delay between attempts. Each later delay is two times longer.
74
+ * @default 100 milliseconds
72
75
  */
73
- retryDelay?: number;
76
+ retryDelay?: Duration;
74
77
 
75
78
  /**
76
79
  * AbortSignal for cancelling requests
@@ -165,6 +168,8 @@ export class ApiStorage<T> implements CollectionStorage<T> {
165
168
  operations: {
166
169
  get: "get",
167
170
  getAll: "getAll",
171
+ entries: "entries",
172
+ changes: "changes",
168
173
  find: "find",
169
174
  has: "has",
170
175
  size: "size",
@@ -176,10 +181,10 @@ export class ApiStorage<T> implements CollectionStorage<T> {
176
181
  getBatch: "getBatch",
177
182
  ...options.operations,
178
183
  },
179
- timeout: options.timeout ?? 30000,
184
+ timeout: optionalMs(options.timeout, "timeout") ?? 30000,
180
185
  attempts:
181
186
  options.retry === false ? 1 : options.retry === true || options.retry === undefined ? 3 : Math.max(1, options.retry),
182
- retryDelay: options.retryDelay ?? 100,
187
+ retryDelay: optionalMs(options.retryDelay, "retryDelay") ?? 100,
183
188
  ...(options.signal !== undefined && { signal: options.signal }),
184
189
  closeClient: options.closeClient ?? false,
185
190
  };
@@ -202,6 +207,39 @@ export class ApiStorage<T> implements CollectionStorage<T> {
202
207
  return values;
203
208
  }
204
209
 
210
+ /** Every item with its id, from the `entries` procedure of the collection (roadmap R2). */
211
+ async getEntries(): Promise<Array<[string, T]>> {
212
+ const entries = await this.call<Array<{ id: string; value: T }>>("entries", {});
213
+ if (!Array.isArray(entries)) {
214
+ throw new Error(`${this.options.service}.entries did not return an array`);
215
+ }
216
+ return entries.map((entry) => [entry.id, entry.value]);
217
+ }
218
+
219
+ /**
220
+ * The changes after a version, from the `changes` procedure of the collection (roadmap R28).
221
+ * A server without the procedure answers `NOT_FOUND`, and `HybridStorage` then uses
222
+ * `getEntries()`.
223
+ */
224
+ async getChangesSince(since?: string): Promise<StorageChanges<T>> {
225
+ const result = await this.call<{ version: string; full: boolean; changed: Array<{ id: string; value: T }>; deleted: string[] }>(
226
+ "changes",
227
+ since === undefined ? {} : { since },
228
+ );
229
+ if (
230
+ typeof result !== "object" || result === null || typeof result.version !== "string" ||
231
+ typeof result.full !== "boolean" || !Array.isArray(result.changed) || !Array.isArray(result.deleted)
232
+ ) {
233
+ throw new Error(`${this.options.service}.changes did not return { version, full, changed, deleted }`);
234
+ }
235
+ return {
236
+ version: result.version,
237
+ full: result.full,
238
+ changed: result.changed.map((entry) => [entry.id, entry.value]),
239
+ deleted: result.deleted,
240
+ };
241
+ }
242
+
205
243
  async find(predicate: (item: T) => boolean): Promise<T[]> {
206
244
  // A predicate cannot go over the network: read all items, then filter here
207
245
  const all = await this.getAll();
@@ -323,7 +361,9 @@ export class ApiStorage<T> implements CollectionStorage<T> {
323
361
 
324
362
  // The signal goes in the call options, so the transport can stop the request too. (Before,
325
363
  // it went into the metadata, and the timeout was dropped: deep dive CORE-9.)
326
- const callOptions: CallOptions<unknown> = {};
364
+ // The storage retries itself: one attempt for a retry middleware of the client, so 3 attempts
365
+ // here and 4 there cannot make 12 calls (roadmap R12)
366
+ const callOptions: CallOptions<unknown> = { context: { retry: { maxAttempts: 1 } } };
327
367
  if (signal) callOptions.signal = signal;
328
368
  const call = this.client.call<unknown, TRes>(method, payload, callOptions);
329
369
  return untilAborted(call, signal, () =>
@@ -9,7 +9,7 @@ import type { CollectionStorage } from "@procedures/collections";
9
9
  import { InMemoryStorage } from "@procedures/collections";
10
10
  import { ApiStorage } from "./api.js";
11
11
  import { HybridStorage } from "./hybrid.js";
12
- import type { HybridStorageOptions } from "./hybrid.js";
12
+ import type { HybridMode, HybridStorageOptions } from "./hybrid.js";
13
13
  import { ProcedureRegistry, PROCEDURE_REGISTRY } from "@procedures/client";
14
14
  import type { SerializedProcedure, SyncedRegistryOptions, HandlerLoader } from "./types.js";
15
15
  import { SyncedProcedureRegistry } from "./synced-registry.js";
@@ -49,6 +49,14 @@ export interface CreateSyncedRegistryConfig {
49
49
  */
50
50
  service?: string | undefined;
51
51
 
52
+ /**
53
+ * The first mode of the hybrid storage (roadmap R28): `local-only`, `write-through`,
54
+ * `write-back` or `remote-only`. `setMode()` of the registry changes it later. Only the type
55
+ * "hybrid" has modes.
56
+ * @default "write-through"
57
+ */
58
+ mode?: HybridMode | undefined;
59
+
52
60
  /**
53
61
  * Additional options for hybrid storage.
54
62
  */
@@ -99,11 +107,15 @@ export interface CreateSyncedRegistryConfig {
99
107
  * type: 'hybrid',
100
108
  * client: myClient,
101
109
  * service: 'procedures',
110
+ * mode: 'write-through',
102
111
  * hybridOptions: {
103
- * writeStrategy: 'write-through',
104
112
  * conflictResolution: 'remote',
105
113
  * },
106
114
  * });
115
+ *
116
+ * // Offline, then online again: the switch sends the queued records
117
+ * await registry.setMode('local-only');
118
+ * await registry.setMode('write-through');
107
119
  * ```
108
120
  */
109
121
  export function createSyncedRegistry(config: CreateSyncedRegistryConfig): SyncedProcedureRegistry {
@@ -112,6 +124,7 @@ export function createSyncedRegistry(config: CreateSyncedRegistryConfig): Synced
112
124
  client,
113
125
  collection = "procedures",
114
126
  service,
127
+ mode,
115
128
  hybridOptions,
116
129
  registryOptions = {},
117
130
  baseRegistry = PROCEDURE_REGISTRY,
@@ -124,6 +137,9 @@ export function createSyncedRegistry(config: CreateSyncedRegistryConfig): Synced
124
137
  if ((type === "api" || type === "hybrid") && !client) {
125
138
  throw new Error(`Client is required for storage type: ${type}`);
126
139
  }
140
+ if (mode !== undefined && type !== "hybrid") {
141
+ throw new Error(`Only the storage type "hybrid" has modes, not "${type}"`);
142
+ }
127
143
 
128
144
  // Create the appropriate storage backend
129
145
  let storage: CollectionStorage<SerializedProcedure>;
@@ -145,14 +161,15 @@ export function createSyncedRegistry(config: CreateSyncedRegistryConfig): Synced
145
161
  collection,
146
162
  ...(service !== undefined && { service }),
147
163
  });
164
+ const { mode: optionMode, writeStrategy, ...otherOptions } = hybridOptions ?? {};
148
165
  storage = new HybridStorage<SerializedProcedure>(
149
166
  remoteStorage,
150
167
  {
151
- writeStrategy: hybridOptions?.writeStrategy ?? "write-through",
152
168
  conflictResolution: hybridOptions?.conflictResolution ?? "local",
153
169
  // A procedure record has no id: its key is its path (deep dive DATA-9)
154
170
  keyOf: getSerializedKey,
155
- ...hybridOptions,
171
+ ...otherOptions,
172
+ mode: mode ?? optionMode ?? writeStrategy ?? "write-through",
156
173
  }
157
174
  );
158
175
  break;