@procedures/store 0.17.2 → 0.17.4

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 (39) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +92 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/browser.d.ts +6 -2
  5. package/dist/browser.d.ts.map +1 -1
  6. package/dist/browser.js +5 -2
  7. package/dist/browser.js.map +1 -1
  8. package/dist/indexeddb/document-storage.d.ts +186 -0
  9. package/dist/indexeddb/document-storage.d.ts.map +1 -0
  10. package/dist/indexeddb/document-storage.js +598 -0
  11. package/dist/indexeddb/document-storage.js.map +1 -0
  12. package/dist/indexeddb/idb.d.ts +82 -9
  13. package/dist/indexeddb/idb.d.ts.map +1 -1
  14. package/dist/indexeddb/idb.js +100 -22
  15. package/dist/indexeddb/idb.js.map +1 -1
  16. package/dist/indexeddb.d.ts +8 -3
  17. package/dist/indexeddb.d.ts.map +1 -1
  18. package/dist/indexeddb.js +8 -3
  19. package/dist/indexeddb.js.map +1 -1
  20. package/dist/storage/hybrid.d.ts +9 -0
  21. package/dist/storage/hybrid.d.ts.map +1 -1
  22. package/dist/storage/hybrid.js +14 -0
  23. package/dist/storage/hybrid.js.map +1 -1
  24. package/dist/storage/index.d.ts +2 -0
  25. package/dist/storage/index.d.ts.map +1 -1
  26. package/dist/storage/index.js +2 -0
  27. package/dist/storage/index.js.map +1 -1
  28. package/dist/storage/tab-coordinator.d.ts +284 -0
  29. package/dist/storage/tab-coordinator.d.ts.map +1 -0
  30. package/dist/storage/tab-coordinator.js +584 -0
  31. package/dist/storage/tab-coordinator.js.map +1 -0
  32. package/package.json +5 -5
  33. package/src/browser.ts +13 -2
  34. package/src/indexeddb/document-storage.ts +689 -0
  35. package/src/indexeddb/idb.ts +136 -21
  36. package/src/indexeddb.ts +8 -3
  37. package/src/storage/hybrid.ts +14 -0
  38. package/src/storage/index.ts +10 -0
  39. package/src/storage/tab-coordinator.ts +692 -0
@@ -30,8 +30,8 @@ export interface IdbFactoryLike {
30
30
  /** An open database. */
31
31
  export interface IdbDatabaseLike {
32
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;
33
+ createObjectStore(name: string, options?: { keyPath?: string; autoIncrement?: boolean }): IdbObjectStoreLike;
34
+ transaction(names: string | string[], mode?: "readonly" | "readwrite"): IdbTransactionLike;
35
35
  close(): void;
36
36
  onversionchange: ((event: any) => void) | null;
37
37
  }
@@ -55,6 +55,30 @@ export interface IdbObjectStoreLike {
55
55
  add(value: unknown, key?: string): IdbRequestLike<unknown>;
56
56
  delete(key: string): IdbRequestLike<unknown>;
57
57
  clear(): IdbRequestLike<unknown>;
58
+ index(name: string): IdbIndexLike;
59
+ createIndex(name: string, keyPath: string, options?: { unique?: boolean }): unknown;
60
+ }
61
+
62
+ /**
63
+ * An index of an object store. The results follow the order of the keys of the index. A record
64
+ * without the key path of the index is not in the index.
65
+ */
66
+ export interface IdbIndexLike {
67
+ /** The records, the first `count` of them when `count` is given. */
68
+ getAll(query?: null, count?: number): IdbRequestLike<unknown[]>;
69
+ /** The primary keys of the records, without their values. */
70
+ getAllKeys(query?: null, count?: number): IdbRequestLike<unknown[]>;
71
+ count(): IdbRequestLike<number>;
72
+ /** A cursor over the records. `"prev"` starts at the highest key of the index. */
73
+ openCursor(query?: null, direction?: "next" | "prev"): IdbRequestLike<IdbCursorLike | null>;
74
+ }
75
+
76
+ /** A cursor of an index, with the value of its record. */
77
+ export interface IdbCursorLike {
78
+ /** The key of the index. */
79
+ readonly key: unknown;
80
+ readonly value: unknown;
81
+ continue(): void;
58
82
  }
59
83
 
60
84
  /** The options of the IndexedDB stores. */
@@ -85,48 +109,95 @@ function errorOf(cause: unknown, message: string): Error {
85
109
  }
86
110
 
87
111
  /**
88
- * One object store in its own database, version 1. The connection opens at the first operation,
112
+ * This function calls `then` with the result of the request when the request succeeds. An error of
113
+ * `then` goes to `fail`, which aborts the transaction. Thus a check in a step of a transaction
114
+ * undoes each request of the transaction.
115
+ */
116
+ export function onResult<R>(request: IdbRequestLike<R>, fail: (error: unknown) => void, then: (result: R) => void): void {
117
+ request.onsuccess = () => {
118
+ try {
119
+ then(request.result);
120
+ } catch (error) {
121
+ fail(error);
122
+ }
123
+ };
124
+ }
125
+
126
+ /** An object store of a database, with its indexes. */
127
+ export interface IdbStoreSchema {
128
+ readonly name: string;
129
+ readonly options?: { keyPath?: string; autoIncrement?: boolean };
130
+ readonly indexes?: ReadonlyArray<{ readonly name: string; readonly keyPath: string; readonly unique?: boolean }>;
131
+ }
132
+
133
+ /**
134
+ * The work of one transaction. It makes the requests, and it gives a function that reads their
135
+ * results after the commit. `fail` aborts the transaction: the promise of the transaction then
136
+ * rejects with the error of `fail`, and each request is undone.
137
+ */
138
+ export type IdbWork<R> = (transaction: IdbTransactionLike, fail: (error: unknown) => void) => () => R;
139
+
140
+ /**
141
+ * One database, version 1, with its object stores. The connection opens at the first operation,
89
142
  * and a failed open tries again at the next operation.
90
143
  */
91
- export class IdbStoreConnection {
144
+ export class IdbConnection {
92
145
  private connection: Promise<IdbDatabaseLike> | undefined;
93
146
  private closed = false;
94
147
 
95
148
  constructor(
96
149
  /** The name of the database. */
97
150
  readonly database: string,
98
- /** The name of the object store. */
99
- readonly store: string,
100
- private readonly storeOptions: { keyPath?: string; autoIncrement?: boolean },
151
+ /** The object stores of the database. */
152
+ private readonly stores: readonly IdbStoreSchema[],
101
153
  private readonly factory: IdbFactoryLike | undefined,
154
+ /** The function that writes the first records when the open makes the object stores. */
155
+ private readonly onCreate?: (store: (name: string) => IdbObjectStoreLike) => void,
102
156
  ) {}
103
157
 
104
158
  /**
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.
159
+ * This method runs work in one transaction over the object stores of `names`. The promise resolves
160
+ * after the transaction commits, so a write is durable when the promise resolves. An error of a
161
+ * request, a throw of the work and a call of `fail` abort the transaction: each request is undone.
108
162
  */
109
- async run<R>(mode: "readonly" | "readwrite", work: (store: IdbObjectStoreLike) => () => R): Promise<R> {
163
+ async transaction<R>(mode: "readonly" | "readwrite", names: readonly string[], work: IdbWork<R>): Promise<R> {
110
164
  const db = await this.open();
111
165
  return new Promise<R>((resolve, reject) => {
112
- let transaction: IdbTransactionLike;
166
+ let transaction: IdbTransactionLike | undefined;
167
+ let failure: { readonly error: unknown } | undefined;
168
+ const abort = (): void => {
169
+ try {
170
+ transaction?.abort();
171
+ } catch {
172
+ // The transaction ended already
173
+ }
174
+ };
175
+ const fail = (error: unknown): void => {
176
+ if (failure) return;
177
+ failure = { error };
178
+ abort();
179
+ reject(error);
180
+ };
113
181
  let read: () => R;
114
182
  try {
115
- transaction = db.transaction(this.store, mode);
116
- read = work(transaction.objectStore(this.store));
183
+ transaction = db.transaction(names.length === 1 ? names[0]! : [...names], mode);
184
+ read = work(transaction, fail);
117
185
  } catch (error) {
118
- reject(error);
186
+ // A request that the work made before the throw does not commit
187
+ fail(error);
119
188
  return;
120
189
  }
121
- transaction.oncomplete = () => {
190
+ const current = transaction;
191
+ current.oncomplete = () => {
122
192
  try {
123
193
  resolve(read());
124
194
  } catch (error) {
125
195
  reject(error);
126
196
  }
127
197
  };
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`));
198
+ current.onerror = (event?: { target?: { error?: unknown } }) =>
199
+ reject(failure ? failure.error : errorOf(event?.target?.error ?? current.error, `The IndexedDB transaction on ${this.database} failed`));
200
+ current.onabort = () => reject(failure ? failure.error : errorOf(current.error, `The IndexedDB transaction on ${this.database} was aborted`));
130
201
  });
131
202
  }
132
203
 
@@ -168,13 +239,27 @@ export class IdbStoreConnection {
168
239
  }
169
240
  request.onupgradeneeded = () => {
170
241
  const db = request.result;
171
- if (!db.objectStoreNames.contains(this.store)) db.createObjectStore(this.store, this.storeOptions);
242
+ const created = new Map<string, IdbObjectStoreLike>();
243
+ for (const schema of this.stores) {
244
+ if (db.objectStoreNames.contains(schema.name)) continue;
245
+ const store = db.createObjectStore(schema.name, schema.options ?? {});
246
+ for (const index of schema.indexes ?? []) store.createIndex(index.name, index.keyPath, { unique: index.unique ?? false });
247
+ created.set(schema.name, store);
248
+ }
249
+ if (this.onCreate && created.size > 0) {
250
+ this.onCreate((name) => {
251
+ const store = created.get(name);
252
+ if (!store) throw new Error(`The object store ${name} of ${this.database} existed before the open`);
253
+ return store;
254
+ });
255
+ }
172
256
  };
173
257
  request.onsuccess = () => {
174
258
  const db = request.result;
175
- if (!db.objectStoreNames.contains(this.store)) {
259
+ const missing = this.stores.find((schema) => !db.objectStoreNames.contains(schema.name));
260
+ if (missing) {
176
261
  db.close();
177
- reject(new Error(`The IndexedDB database ${this.database} exists, and it has no object store ${this.store}`));
262
+ reject(new Error(`The IndexedDB database ${this.database} exists, and it has no object store ${missing.name}`));
178
263
  return;
179
264
  }
180
265
  // Another tab wants a new version: close this connection, so the upgrade can run
@@ -188,3 +273,33 @@ export class IdbStoreConnection {
188
273
  });
189
274
  }
190
275
  }
276
+
277
+ /** One object store in its own database, version 1. The stores of programs, queues and sync states use it. */
278
+ export class IdbStoreConnection {
279
+ private readonly connection: IdbConnection;
280
+
281
+ constructor(
282
+ /** The name of the database. */
283
+ readonly database: string,
284
+ /** The name of the object store. */
285
+ readonly store: string,
286
+ storeOptions: { keyPath?: string; autoIncrement?: boolean },
287
+ factory: IdbFactoryLike | undefined,
288
+ ) {
289
+ this.connection = new IdbConnection(database, [{ name: store, options: storeOptions }], factory);
290
+ }
291
+
292
+ /**
293
+ * This method runs work in one transaction. The work makes the requests, and it gives a function that reads
294
+ * their results. The promise resolves after the transaction commits, so a write is durable when
295
+ * the promise resolves. An error of a request aborts the transaction: each request is undone.
296
+ */
297
+ run<R>(mode: "readonly" | "readwrite", work: (store: IdbObjectStoreLike) => () => R): Promise<R> {
298
+ return this.connection.transaction(mode, [this.store], (transaction) => work(transaction.objectStore(this.store)));
299
+ }
300
+
301
+ /** This method closes the connection. A later operation throws. */
302
+ close(): Promise<void> {
303
+ return this.connection.close();
304
+ }
305
+ }
package/src/indexeddb.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  /**
2
2
  * The entry `@procedures/store/indexeddb`: the stores of a browser (roadmap R45).
3
3
  *
4
+ * - `IndexedDbStorage` is a collection storage in IndexedDB (roadmap CLI-22). It is the durable
5
+ * local copy of a `HybridStorage` in a browser app without SignalDB.
4
6
  * - `IndexedDbProgramStore` keeps programs in IndexedDB.
5
7
  * - `IndexedDbQueueStore` keeps the queue of a `HybridStorage` in IndexedDB.
6
8
  * - `IndexedDbSyncStateStore` keeps the sync state of a `HybridStorage` in IndexedDB: the cursor
7
9
  * and the base versions (roadmap CLI-21).
8
10
  * - `MemoryProgramStore`, `MemoryQueueStore` and `MemorySyncStateStore` are the fallbacks for a
9
- * browser without IndexedDB. `hasIndexedDb()` tells which one to use.
11
+ * browser without IndexedDB. `hasIndexedDb()` tells which one to use. `InMemoryStorage` of
12
+ * `@procedures/collections` is the fallback of `IndexedDbStorage`.
10
13
  *
11
- * The package root does not import this entry, and this entry does not import the client or zod:
12
- * a page that loads it gets only these stores.
14
+ * The package root does not import this entry. This entry does not import the root of the client or
15
+ * zod: a page that loads it gets only these stores. It imports `@procedures/client/query`, a module
16
+ * without imports, and `@procedures/collections` (the contract of the storages and its listeners).
13
17
  */
14
18
 
19
+ export { IndexedDbStorage, type IndexedDbStorageOptions } from "./indexeddb/document-storage.js";
15
20
  export { IndexedDbProgramStore, DEFAULT_PROGRAM_DATABASE } from "./indexeddb/program-store.js";
16
21
  export { IndexedDbQueueStore } from "./indexeddb/queue-store.js";
17
22
  export { IndexedDbSyncStateStore } from "./indexeddb/sync-state-store.js";
@@ -1993,6 +1993,20 @@ export class HybridStorage<T> implements CollectionStorage<T> {
1993
1993
  return this.syncToRemote();
1994
1994
  }
1995
1995
 
1996
+ /**
1997
+ * This method resolves when the queue store holds the operations that the queue has at the call
1998
+ * (roadmap CLI-25): the loaded queue, the captured changes and the queued writes. It sends
1999
+ * nothing to the remote. A failed write of the store resolves it too, and the status keeps the
2000
+ * failure. A `TabCoordinator` acknowledges the batches of another tab after it.
2001
+ *
2002
+ * @throws The error of the load, when the storage could not load its queue
2003
+ */
2004
+ async whenQueued(): Promise<void> {
2005
+ await this.loaded;
2006
+ if (this.loadError) throw this.loadError;
2007
+ await this.storeChain;
2008
+ }
2009
+
1996
2010
  /**
1997
2011
  * Force a full sync (both directions).
1998
2012
  */
@@ -48,6 +48,16 @@ export {
48
48
  SyncStateError,
49
49
  } from "./hybrid.js";
50
50
  export type { HybridStorageOptions, HybridMode, LocalCopyStorage } from "./hybrid.js";
51
+ // One leader tab runs the sync, and the other tabs copy its changes (roadmap CLI-25)
52
+ export { TabCoordinator } from "./tab-coordinator.js";
53
+ export type {
54
+ TabAbortSignalLike,
55
+ TabChannelFactory,
56
+ TabChannelLike,
57
+ TabCoordinatorOptions,
58
+ TabLockManagerLike,
59
+ TabRole,
60
+ } from "./tab-coordinator.js";
51
61
  // The push feed of the remote changes (roadmap CLI-21, CLI-15)
52
62
  export { EventsChangeFeed } from "./feed.js";
53
63
  export type {