@procedures/store 0.17.2 → 0.17.3

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.
@@ -0,0 +1,689 @@
1
+ /**
2
+ * A collection storage in IndexedDB, for a browser (roadmap CLI-22).
3
+ *
4
+ * `IndexedDbStorage` keeps the items of one collection in one database of IndexedDB. It implements
5
+ * `CollectionStorage`. Thus a `HybridStorage` of a browser app without SignalDB keeps its local copy
6
+ * in it, and the items stay after a reload of the page (server stack D9).
7
+ *
8
+ * The database has two object stores:
9
+ * - `items` has one record for each id, with the id as its key. The record of an item is
10
+ * `{ id, order, at, value }`. The record of a deleted id is a tombstone `{ id, deletedAt }`.
11
+ * - `log` has one record: the epoch of the database and the counters of the change log.
12
+ *
13
+ * The rules:
14
+ * - Each change takes the next number of the counter. `order` is the number of the first set of the
15
+ * id. The index `order` thus gives the entries in the order of the first set of each id, as a
16
+ * `Map` does. IndexedDB itself orders the records by their keys.
17
+ * - `at` and `deletedAt` are the numbers of the last change of each id. Their indexes give a delta
18
+ * of `getChangesSince()` without a read of the items that did not change.
19
+ * - Each write is one readwrite transaction: the reads, the test of the expected versions and the
20
+ * writes. Thus the writes of two tabs do not mix, a batch that fails changes nothing, and two
21
+ * patches at the same time keep each field.
22
+ * - The storage checks each record that it reads. A record of another application or of another
23
+ * version is an error, not an item.
24
+ * - Give each collection its own database. A database of one version has a fixed set of object
25
+ * stores: a new object store in an open database needs a version change, and the version change
26
+ * waits for the connections of the other tabs.
27
+ */
28
+
29
+ import type {
30
+ CollectionStorage,
31
+ StorageBatchWriteOptions,
32
+ StorageChangeBatch,
33
+ StorageChangeEvent,
34
+ StorageChanges,
35
+ StorageItemWriteOptions,
36
+ StorageMetadata,
37
+ StorageObserver,
38
+ StorageWriteOptions,
39
+ } from "@procedures/collections";
40
+ import { StorageListeners, type ListenerErrorHandler } from "@procedures/collections";
41
+ import {
42
+ VersionConflictError,
43
+ applyPatch,
44
+ patchProblems,
45
+ preconditionNotSupported,
46
+ queryEntries,
47
+ queryOptionsProblems,
48
+ selectorProblems,
49
+ versionConflicts,
50
+ type CurrentItem,
51
+ type StoragePatch,
52
+ type StorageQueryOptions,
53
+ type StorageQueryPolicy,
54
+ type StorageSelector,
55
+ } from "@procedures/client/query";
56
+ import { IdbConnection, onResult, type IdbIndexLike, type IdbObjectStoreLike, type IdbStoreSchema, type IndexedDbOptions } from "./idb.js";
57
+
58
+ /** The options of an `IndexedDbStorage`. */
59
+ export interface IndexedDbStorageOptions<T = unknown> extends IndexedDbOptions {
60
+ /**
61
+ * The query policy of `query()` for a call without a policy: the operators and the limits. The
62
+ * procedure `find` gives the policy of its host.
63
+ * @default DEFAULT_QUERY_POLICY: no `$regex`
64
+ */
65
+ queryPolicy?: StorageQueryPolicy | undefined;
66
+ /**
67
+ * The version of an item, for example `(item) => item.rev` (roadmap CLI-16). With it, `set`,
68
+ * `delete` and `setBatch` take an expected version: a write whose current item has another
69
+ * version changes nothing and rejects with `VersionConflictError`. The test and the write are one
70
+ * transaction, also for two tabs. Without it, a write with an expected version rejects with a
71
+ * `TypeError`. Give the `versionOf` of the `HybridStorage` to its local copy.
72
+ */
73
+ versionOf?: ((item: T) => string | undefined) | undefined;
74
+ /**
75
+ * The number of deletes that the change log keeps (roadmap R28). After more deletes, the log
76
+ * drops the oldest ones, and `getChangesSince` of an older version gives all items.
77
+ * @default 10000
78
+ */
79
+ maxTombstones?: number;
80
+ /**
81
+ * The function that gets the error of an observer of `observe()`. The default throws the error
82
+ * again in a microtask (`reportListenerError`). The write and the other observers do not fail.
83
+ */
84
+ onObserverError?: ListenerErrorHandler | undefined;
85
+ }
86
+
87
+ /** The names of the object stores, of their indexes and of the counter record. */
88
+ const ITEMS = "items";
89
+ const LOG = "log";
90
+ const COUNTER = "counter";
91
+ const ORDER = "order";
92
+ const AT = "at";
93
+ const DELETED_AT = "deletedAt";
94
+
95
+ /** The object stores of the database. A tombstone has no `order` and no `at`, so those indexes have only the items. */
96
+ const STORES: readonly IdbStoreSchema[] = [
97
+ {
98
+ name: ITEMS,
99
+ options: { keyPath: "id" },
100
+ indexes: [
101
+ { name: ORDER, keyPath: ORDER, unique: true },
102
+ { name: AT, keyPath: AT, unique: true },
103
+ { name: DELETED_AT, keyPath: DELETED_AT, unique: true },
104
+ ],
105
+ },
106
+ { name: LOG },
107
+ ];
108
+
109
+ /** The record of an item. */
110
+ interface ItemRecord {
111
+ readonly id: string;
112
+ /** The number of the first set of the id: the place of the entry. */
113
+ readonly order: number;
114
+ /** The number of the last change of the id. */
115
+ readonly at: number;
116
+ readonly value: unknown;
117
+ }
118
+
119
+ /** The record of a deleted id, for the deletes of the change log. */
120
+ interface TombstoneRecord {
121
+ readonly id: string;
122
+ /** The number of the delete. */
123
+ readonly deletedAt: number;
124
+ }
125
+
126
+ type StoredRecord = ItemRecord | TombstoneRecord;
127
+
128
+ /** The record of the object store `log`. */
129
+ interface Counter {
130
+ /** The epoch of the database: a version of another database does not match it. */
131
+ readonly epoch: string;
132
+ /** The number of the last change. */
133
+ seq: number;
134
+ /** The log does not know the deletes at or below this number: they were dropped. */
135
+ floor: number;
136
+ /** The number of tombstones. */
137
+ tombstones: number;
138
+ }
139
+
140
+ function isTombstone(record: StoredRecord): record is TombstoneRecord {
141
+ return "deletedAt" in record;
142
+ }
143
+
144
+ /** True for a whole number of 0 or more. */
145
+ function isCount(value: unknown): value is number {
146
+ return Number.isSafeInteger(value) && (value as number) >= 0;
147
+ }
148
+
149
+ /** The record of a raw value of the object store `items`, or undefined for a value of another form. */
150
+ function recordOf(raw: unknown): StoredRecord | undefined {
151
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
152
+ const record = raw as Partial<Record<"id" | "order" | "at" | "deletedAt" | "value", unknown>>;
153
+ if (typeof record.id !== "string") return undefined;
154
+ if ("deletedAt" in record) {
155
+ const plain = !("order" in record) && !("at" in record) && !("value" in record);
156
+ return plain && isCount(record.deletedAt) ? { id: record.id, deletedAt: record.deletedAt } : undefined;
157
+ }
158
+ return isCount(record.order) && isCount(record.at) && "value" in record ? { id: record.id, order: record.order, at: record.at, value: record.value } : undefined;
159
+ }
160
+
161
+ /** The counter of a raw value of the object store `log`, or undefined for a value of another form. */
162
+ function counterOf(raw: unknown): Counter | undefined {
163
+ if (typeof raw !== "object" || raw === null) return undefined;
164
+ const { epoch, seq, floor, tombstones } = raw as Partial<Record<keyof Counter, unknown>>;
165
+ if (typeof epoch !== "string" || epoch === "" || !isCount(seq) || !isCount(floor) || !isCount(tombstones) || floor > seq) return undefined;
166
+ return { epoch, seq, floor, tombstones };
167
+ }
168
+
169
+ /** A new epoch: a version of another database does not match it. */
170
+ function newEpoch(): string {
171
+ return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 10);
172
+ }
173
+
174
+ /** The number of a version of the database, or undefined for another or an unknown version. */
175
+ function parseVersion(since: string | undefined, counter: Counter): number | undefined {
176
+ if (since === undefined) return undefined;
177
+ const separator = since.lastIndexOf(":");
178
+ if (separator < 0 || since.slice(0, separator) !== counter.epoch) return undefined;
179
+ const at = Number(since.slice(separator + 1));
180
+ return Number.isInteger(at) && at >= 0 && at <= counter.seq ? at : undefined;
181
+ }
182
+
183
+ /** An id must be a string: the key of a record is its id. */
184
+ function checkId(id: unknown): string {
185
+ if (typeof id !== "string") throw new TypeError(`An id must be a string, not ${typeof id}`);
186
+ return id;
187
+ }
188
+
189
+ /** A change with the origin of the write, when the write has one. */
190
+ function withOrigin<T>(change: StorageChangeEvent<T>, origin: string | undefined): StorageChangeEvent<T> {
191
+ return origin === undefined ? change : { ...change, origin };
192
+ }
193
+
194
+ /**
195
+ * The writes of one transaction. It knows the records that the transaction read, so a second write
196
+ * of an id in the same batch sees the first one. Each change takes the next number of the counter.
197
+ */
198
+ class Writer<T> {
199
+ /** The changes for the observers, in the order of the writes. */
200
+ readonly changes: Array<StorageChangeEvent<T>> = [];
201
+
202
+ constructor(
203
+ private readonly items: IdbObjectStoreLike,
204
+ private readonly counter: Counter,
205
+ private readonly records: Map<string, StoredRecord | undefined>,
206
+ private readonly live: readonly ItemRecord[],
207
+ private readonly origin: string | undefined,
208
+ ) {}
209
+
210
+ /** The current item of an id that the transaction read. */
211
+ current(id: string): CurrentItem<T> {
212
+ const record = this.recordOf(id);
213
+ return record !== undefined && !isTombstone(record) ? { found: true, value: record.value as T } : { found: false };
214
+ }
215
+
216
+ /** This method writes an item. An existing id keeps its place in the order of the entries. */
217
+ set(id: string, value: T): void {
218
+ const before = this.recordOf(id);
219
+ const at = ++this.counter.seq;
220
+ if (before !== undefined && isTombstone(before)) this.counter.tombstones = Math.max(0, this.counter.tombstones - 1);
221
+ const record: ItemRecord = { id, order: before !== undefined && !isTombstone(before) ? before.order : at, at, value };
222
+ this.items.put(record);
223
+ this.records.set(id, record);
224
+ this.changes.push(withOrigin<T>({ type: "set", id, value }, this.origin));
225
+ }
226
+
227
+ /** This method deletes the item of an id, and keeps a tombstone for the change log. True when the item existed. */
228
+ delete(id: string): boolean {
229
+ const before = this.recordOf(id);
230
+ if (before === undefined || isTombstone(before)) return false;
231
+ const record: TombstoneRecord = { id, deletedAt: ++this.counter.seq };
232
+ this.items.put(record);
233
+ this.counter.tombstones++;
234
+ this.records.set(id, record);
235
+ this.changes.push(withOrigin<T>({ type: "delete", id }, this.origin));
236
+ return true;
237
+ }
238
+
239
+ /** This method deletes each item, in the order of the entries. */
240
+ clear(): void {
241
+ for (const { id } of this.live) {
242
+ const record: TombstoneRecord = { id, deletedAt: ++this.counter.seq };
243
+ this.items.put(record);
244
+ this.counter.tombstones++;
245
+ }
246
+ if (this.live.length > 0) this.changes.push(withOrigin<T>({ type: "clear" }, this.origin));
247
+ }
248
+
249
+ private recordOf(id: string): StoredRecord | undefined {
250
+ if (!this.records.has(id)) throw new Error(`The transaction did not read the id ${JSON.stringify(id)}`);
251
+ return this.records.get(id);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * A collection storage in IndexedDB, for a browser (roadmap CLI-22).
257
+ *
258
+ * - The items stay after `close()` and after a reload of the page. A new storage of the same
259
+ * database reads them, and the versions of `getChangesSince()` stay valid.
260
+ * - Each write takes a copy of its value (`structuredClone`), and each read gives a new copy.
261
+ * - `query()` runs a selector, `observe()` tells each change of this storage with the origin of its
262
+ * write, and `patch()` changes an item in one transaction.
263
+ * - `observe()` does not tell the writes of another tab or of another storage of the same database.
264
+ * - After `close()`, each operation rejects, and a second `close()` does nothing.
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * import { ApiStorage, HybridStorage } from "@procedures/store/browser";
269
+ * import { IndexedDbQueueStore, IndexedDbStorage, IndexedDbSyncStateStore } from "@procedures/store/indexeddb";
270
+ *
271
+ * const users = new HybridStorage(new ApiStorage(client, { collection: "users" }), {
272
+ * local: new IndexedDbStorage("users"),
273
+ * queueStore: new IndexedDbQueueStore("users-queue"),
274
+ * syncState: new IndexedDbSyncStateStore("users-sync"),
275
+ * });
276
+ * ```
277
+ */
278
+ export class IndexedDbStorage<T> implements CollectionStorage<T> {
279
+ private readonly connection: IdbConnection;
280
+ private readonly maxTombstones: number;
281
+ /** The query policy of a call without a policy */
282
+ private readonly queryPolicy: StorageQueryPolicy | undefined;
283
+ /** The version of an item, for the preconditions of the writes (roadmap CLI-16) */
284
+ private readonly versionOf: ((item: T) => string | undefined) | undefined;
285
+ /** The observers of `observe()` */
286
+ private readonly observers: StorageListeners<StorageChangeBatch<T>>;
287
+ private closed = false;
288
+
289
+ /**
290
+ * @param name - The name of the database: one database for each collection. The storage makes it at the first operation.
291
+ * @param options - The IndexedDB factory, the query policy, `versionOf` and the limit of the change log.
292
+ */
293
+ constructor(name: string, options: IndexedDbStorageOptions<T> = {}) {
294
+ if (typeof name !== "string" || name === "") throw new TypeError("IndexedDbStorage needs the name of a database");
295
+ this.connection = new IdbConnection(name, STORES, options.indexedDB, (store) => {
296
+ const counter: Counter = { epoch: newEpoch(), seq: 0, floor: 0, tombstones: 0 };
297
+ store(LOG).put(counter, COUNTER);
298
+ });
299
+ this.maxTombstones = Math.max(0, options.maxTombstones ?? 10_000);
300
+ this.queryPolicy = options.queryPolicy;
301
+ this.versionOf = options.versionOf;
302
+ this.observers = new StorageListeners<StorageChangeBatch<T>>(options.onObserverError);
303
+ }
304
+
305
+ /** The name of the database. */
306
+ get name(): string {
307
+ return this.connection.database;
308
+ }
309
+
310
+ //
311
+ // ═══ Read Operations ═══
312
+ //
313
+
314
+ async get(id: string): Promise<T | undefined> {
315
+ this.checkOpen();
316
+ checkId(id);
317
+ return this.connection.transaction("readonly", [ITEMS], (transaction) => {
318
+ const request = transaction.objectStore(ITEMS).get(id);
319
+ return () => {
320
+ const record = this.storedRecord(request.result, id);
321
+ return record === undefined || isTombstone(record) ? undefined : (record.value as T);
322
+ };
323
+ });
324
+ }
325
+
326
+ async getAll(): Promise<T[]> {
327
+ return (await this.liveRecords()).map((record) => record.value as T);
328
+ }
329
+
330
+ /** Every item with its id, in the order of the first set of each id. */
331
+ async getEntries(): Promise<Array<[string, T]>> {
332
+ return (await this.liveRecords()).map((record): [string, T] => [record.id, record.value as T]);
333
+ }
334
+
335
+ /** The ids of the items, in the order of the entries. The storage does not read the values. */
336
+ async getIds(): Promise<string[]> {
337
+ this.checkOpen();
338
+ return this.connection.transaction("readonly", [ITEMS], (transaction) => {
339
+ const request = transaction.objectStore(ITEMS).index(ORDER).getAllKeys();
340
+ return () =>
341
+ request.result.map((key, index) => {
342
+ if (typeof key !== "string") throw this.problem(`a record whose key is not a string (record ${index + 1})`);
343
+ return key;
344
+ });
345
+ });
346
+ }
347
+
348
+ /**
349
+ * The changes after a version (roadmap R28). A version of another database, or older than the
350
+ * dropped deletes, gives all items (`full: true`) in the order of the entries. A delta gives the
351
+ * changed items and the deleted ids in the order of the last change of each id. The reads run in
352
+ * one transaction, and a version stays valid after a reload.
353
+ */
354
+ async getChangesSince(since?: string): Promise<StorageChanges<T>> {
355
+ this.checkOpen();
356
+ return this.connection.transaction("readonly", [ITEMS, LOG], (transaction, fail) => {
357
+ const items = transaction.objectStore(ITEMS);
358
+ let version = "";
359
+ let full: Array<[string, T]> | undefined;
360
+ const changed: Array<[string, T]> = [];
361
+ const deleted: string[] = [];
362
+ onResult(transaction.objectStore(LOG).get(COUNTER), fail, (raw) => {
363
+ const counter = this.counter(raw);
364
+ version = `${counter.epoch}:${counter.seq}`;
365
+ const from = parseVersion(since, counter);
366
+ if (from === undefined || from < counter.floor) {
367
+ onResult(items.index(ORDER).getAll(), fail, (records) => {
368
+ full = records.map((value, index): [string, T] => {
369
+ const record = this.liveRecord(value, index);
370
+ return [record.id, record.value as T];
371
+ });
372
+ });
373
+ return;
374
+ }
375
+ // The newest change first, until the version: the reads skip the items that did not change
376
+ this.scanAfter(items.index(AT), fail, (value) => {
377
+ const record = this.liveRecord(value, changed.length);
378
+ if (record.at <= from) return false;
379
+ changed.push([record.id, record.value as T]);
380
+ return true;
381
+ });
382
+ this.scanAfter(items.index(DELETED_AT), fail, (value) => {
383
+ const record = recordOf(value);
384
+ if (record === undefined || !isTombstone(record)) throw this.problem(`a record that is not a tombstone of an IndexedDbStorage (tombstone ${deleted.length + 1})`);
385
+ if (record.deletedAt <= from) return false;
386
+ deleted.push(record.id);
387
+ return true;
388
+ });
389
+ });
390
+ return (): StorageChanges<T> =>
391
+ full !== undefined ? { version, full: true, changed: full, deleted: [] } : { version, full: false, changed: changed.reverse(), deleted: deleted.reverse() };
392
+ });
393
+ }
394
+
395
+ /** The items for which the predicate gives true, in the order of the entries. The predicate gets a copy of each item. */
396
+ async find(predicate: (item: T) => boolean): Promise<T[]> {
397
+ return (await this.getAll()).filter((item) => predicate(item));
398
+ }
399
+
400
+ /**
401
+ * The entries whose items match the selector, with the order, the skip and the limit of
402
+ * `queryEntries()`, under the policy of the call or of the options. A query that is not correct
403
+ * rejects with a `TypeError` before the storage reads the database.
404
+ */
405
+ async query(selector: StorageSelector, options?: StorageQueryOptions, policy?: StorageQueryPolicy): Promise<Array<[string, T]>> {
406
+ this.checkOpen();
407
+ const limits = policy ?? this.queryPolicy;
408
+ const problems = [...selectorProblems(selector, limits), ...queryOptionsProblems(options, limits)];
409
+ if (problems.length > 0) throw new TypeError(`The query is not correct: ${problems.join(". ")}`);
410
+ return queryEntries(await this.getEntries(), selector, options, limits);
411
+ }
412
+
413
+ /**
414
+ * This method starts to send the changes of each write of this storage to the observer, before the
415
+ * write resolves. The observers share each batch. The writes of another tab do not give a batch.
416
+ */
417
+ observe(observer: StorageObserver<T>): () => void {
418
+ this.checkOpen();
419
+ return this.observers.add(observer);
420
+ }
421
+
422
+ async has(id: string): Promise<boolean> {
423
+ this.checkOpen();
424
+ checkId(id);
425
+ return this.connection.transaction("readonly", [ITEMS], (transaction) => {
426
+ const request = transaction.objectStore(ITEMS).get(id);
427
+ return () => {
428
+ const record = this.storedRecord(request.result, id);
429
+ return record !== undefined && !isTombstone(record);
430
+ };
431
+ });
432
+ }
433
+
434
+ async size(): Promise<number> {
435
+ this.checkOpen();
436
+ return this.connection.transaction("readonly", [ITEMS], (transaction) => {
437
+ const request = transaction.objectStore(ITEMS).index(ORDER).count();
438
+ return () => request.result;
439
+ });
440
+ }
441
+
442
+ //
443
+ // ═══ Write Operations ═══
444
+ //
445
+
446
+ async set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void> {
447
+ this.checkOpen();
448
+ checkId(id);
449
+ const expected = this.preconditions(options?.expectedVersion === undefined ? [] : [[id, options.expectedVersion]]);
450
+ const stored = structuredClone(value);
451
+ await this.write([id], false, options?.origin, (writer) => {
452
+ this.checkVersions(writer, expected);
453
+ writer.set(id, stored);
454
+ });
455
+ }
456
+
457
+ async delete(id: string, options?: StorageItemWriteOptions): Promise<boolean> {
458
+ this.checkOpen();
459
+ checkId(id);
460
+ const expected = this.preconditions(options?.expectedVersion === undefined ? [] : [[id, options.expectedVersion]]);
461
+ return this.write([id], false, options?.origin, (writer) => {
462
+ this.checkVersions(writer, expected);
463
+ return writer.delete(id);
464
+ });
465
+ }
466
+
467
+ /** This method deletes each item. The change log gets a delete of each id, in the order of the entries. */
468
+ async clear(options?: StorageWriteOptions): Promise<void> {
469
+ this.checkOpen();
470
+ await this.write([], true, options?.origin, (writer) => writer.clear());
471
+ }
472
+
473
+ /**
474
+ * This method changes the item of the id in one transaction (a JSON Merge Patch, or the operations
475
+ * `$set`, `$unset`, `$remove` and `$insert`), and gives a copy of the new item. A missing id stays
476
+ * missing, and the result is undefined. A patch that cannot apply to the item changes nothing.
477
+ */
478
+ async patch(id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<T | undefined> {
479
+ this.checkOpen();
480
+ checkId(id);
481
+ const [problem] = patchProblems(fields);
482
+ if (problem !== undefined) throw new TypeError(`The patch of ${JSON.stringify(id)} is not correct: ${problem}`);
483
+ const result = await this.write([id], false, options?.origin, (writer) => {
484
+ const current = writer.current(id);
485
+ if (!current.found) return undefined;
486
+ const next = applyPatch(current.value, fields) as unknown as T;
487
+ writer.set(id, next);
488
+ return next;
489
+ });
490
+ // The observers got the new item: the caller gets its own copy
491
+ return result !== undefined && this.observers.size > 0 ? structuredClone(result) : result;
492
+ }
493
+
494
+ //
495
+ // ═══ Bulk Operations ═══
496
+ //
497
+
498
+ /**
499
+ * This method sets several items in one transaction. When one value has no copy, or one expected
500
+ * version has a conflict, nothing changes.
501
+ */
502
+ async setBatch(items: Array<[string, T]>, options?: StorageBatchWriteOptions): Promise<void> {
503
+ this.checkOpen();
504
+ for (const [id] of items) checkId(id);
505
+ const expected = this.preconditions(options?.expectedVersions ?? []);
506
+ const copies = items.map(([id, value]): [string, T] => [id, structuredClone(value)]);
507
+ if (copies.length === 0 && expected.length === 0) return;
508
+ const ids = [...copies.map(([id]) => id), ...expected.map(([id]) => id)];
509
+ await this.write(ids, false, options?.origin, (writer) => {
510
+ this.checkVersions(writer, expected);
511
+ for (const [id, value] of copies) writer.set(id, value);
512
+ });
513
+ }
514
+
515
+ /** This method deletes several items in one transaction. */
516
+ async deleteBatch(ids: string[], options?: StorageWriteOptions): Promise<number> {
517
+ this.checkOpen();
518
+ for (const id of ids) checkId(id);
519
+ if (ids.length === 0) return 0;
520
+ return this.write(ids, false, options?.origin, (writer) => {
521
+ let deleted = 0;
522
+ for (const id of ids) if (writer.delete(id)) deleted++;
523
+ return deleted;
524
+ });
525
+ }
526
+
527
+ async getBatch(ids: string[]): Promise<Map<string, T>> {
528
+ this.checkOpen();
529
+ for (const id of ids) checkId(id);
530
+ if (ids.length === 0) return new Map();
531
+ return this.connection.transaction("readonly", [ITEMS], (transaction) => {
532
+ const store = transaction.objectStore(ITEMS);
533
+ const requests = [...new Set(ids)].map((id) => [id, store.get(id)] as const);
534
+ return () => {
535
+ const result = new Map<string, T>();
536
+ for (const [id, request] of requests) {
537
+ const record = this.storedRecord(request.result, id);
538
+ if (record !== undefined && !isTombstone(record)) result.set(id, record.value as T);
539
+ }
540
+ return result;
541
+ };
542
+ });
543
+ }
544
+
545
+ //
546
+ // ═══ Lifecycle & Metadata ═══
547
+ //
548
+
549
+ /**
550
+ * This method closes the database. The items stay in it: a new storage of the same database reads
551
+ * them. After `close()`, each operation rejects, and a second `close()` does nothing.
552
+ */
553
+ async close(): Promise<void> {
554
+ if (this.closed) return;
555
+ this.closed = true;
556
+ this.observers.clear();
557
+ await this.connection.close();
558
+ }
559
+
560
+ async getMetadata(): Promise<StorageMetadata> {
561
+ const size = await this.size();
562
+ return { type: "custom", size, stats: { backend: "indexeddb", database: this.name } };
563
+ }
564
+
565
+ //
566
+ // ═══ Internal Helpers ═══
567
+ //
568
+
569
+ /** This method throws after `close()`. In an async method, the throw rejects the promise. */
570
+ private checkOpen(): void {
571
+ if (this.closed) throw new Error(`The IndexedDbStorage ${this.name} is closed`);
572
+ }
573
+
574
+ private problem(what: string): Error {
575
+ return new Error(`The IndexedDB database ${this.name} has ${what}`);
576
+ }
577
+
578
+ /** The record of an id, or undefined for no record. A record of another form throws. */
579
+ private storedRecord(raw: unknown, id: string): StoredRecord | undefined {
580
+ if (raw === undefined) return undefined;
581
+ const record = recordOf(raw);
582
+ if (record === undefined || record.id !== id) throw this.problem(`a record that is not an item of an IndexedDbStorage (id ${JSON.stringify(id)})`);
583
+ return record;
584
+ }
585
+
586
+ /** The records of the items, in the order of the entries. */
587
+ private async liveRecords(): Promise<ItemRecord[]> {
588
+ this.checkOpen();
589
+ return this.connection.transaction("readonly", [ITEMS], (transaction) => {
590
+ const request = transaction.objectStore(ITEMS).index(ORDER).getAll();
591
+ return () => request.result.map((value, index) => this.liveRecord(value, index));
592
+ });
593
+ }
594
+
595
+ /** The record of an entry of the index `order` or `at`. A record of another form throws. */
596
+ private liveRecord(raw: unknown, index: number): ItemRecord {
597
+ const record = recordOf(raw);
598
+ if (record === undefined || isTombstone(record)) throw this.problem(`a record that is not an item of an IndexedDbStorage (record ${index + 1})`);
599
+ return record;
600
+ }
601
+
602
+ /** The counter of the object store `log`. A missing counter or a counter of another form throws. */
603
+ private counter(raw: unknown): Counter {
604
+ const counter = counterOf(raw);
605
+ if (counter === undefined) throw this.problem("no counter of an IndexedDbStorage in its object store log");
606
+ return counter;
607
+ }
608
+
609
+ /** This method reads the records of an index from the highest key, while `take` gives true. */
610
+ private scanAfter(index: IdbIndexLike, fail: (error: unknown) => void, take: (value: unknown) => boolean): void {
611
+ onResult(index.openCursor(null, "prev"), fail, (cursor) => {
612
+ if (cursor !== null && take(cursor.value)) cursor.continue();
613
+ });
614
+ }
615
+
616
+ /**
617
+ * The preconditions of a write. A storage without `versionOf` throws a `TypeError` before the
618
+ * write starts (roadmap CLI-16).
619
+ */
620
+ private preconditions(expected: ReadonlyArray<readonly [string, string | null]>): ReadonlyArray<readonly [string, string | null]> {
621
+ if (expected.length === 0) return expected;
622
+ if (!this.versionOf) throw preconditionNotSupported("This IndexedDbStorage");
623
+ for (const [id] of expected) checkId(id);
624
+ return expected;
625
+ }
626
+
627
+ /**
628
+ * This method tests the expected versions in the transaction of the write (roadmap CLI-16). A
629
+ * conflict throws `VersionConflictError`, and the transaction aborts.
630
+ */
631
+ private checkVersions(writer: Writer<T>, expected: ReadonlyArray<readonly [string, string | null]>): void {
632
+ if (expected.length === 0 || !this.versionOf) return;
633
+ const conflicts = versionConflicts(expected, (id) => writer.current(id), this.versionOf);
634
+ if (conflicts.length > 0) throw new VersionConflictError(conflicts);
635
+ }
636
+
637
+ /**
638
+ * One write in one readwrite transaction. The transaction reads the records of the ids, every
639
+ * item when `all` is true, and the counter. Then `change` writes with a `Writer`, the change log
640
+ * drops the oldest tombstones above the limit, and the counter is written. The observers get the
641
+ * changes after the commit, before the promise resolves. A throw of `change` aborts the
642
+ * transaction, and the promise rejects with its error.
643
+ */
644
+ private async write<R>(ids: readonly string[], all: boolean, origin: string | undefined, change: (writer: Writer<T>) => R): Promise<R> {
645
+ const outcome = await this.connection.transaction("readwrite", [ITEMS, LOG], (transaction, fail) => {
646
+ const items = transaction.objectStore(ITEMS);
647
+ const log = transaction.objectStore(LOG);
648
+ const reads = [...new Set(ids)].map((id) => [id, items.get(id)] as const);
649
+ const every = all ? items.index(ORDER).getAll() : undefined;
650
+ let done: { readonly value: R; readonly changes: Array<StorageChangeEvent<T>> } | undefined;
651
+ // The requests of a transaction run in order: the reads before it have their results here
652
+ onResult(log.get(COUNTER), fail, (raw) => {
653
+ const counter = this.counter(raw);
654
+ const records = new Map<string, StoredRecord | undefined>();
655
+ for (const [id, request] of reads) records.set(id, this.storedRecord(request.result, id));
656
+ const live = every ? every.result.map((value, index) => this.liveRecord(value, index)) : [];
657
+ const writer = new Writer<T>(items, counter, records, live, origin);
658
+ done = { value: change(writer), changes: writer.changes };
659
+ if (writer.changes.length === 0) return;
660
+ this.trim(items, counter, fail, () => log.put(counter, COUNTER));
661
+ });
662
+ return () => {
663
+ if (!done) throw new Error(`The write to the IndexedDB database ${this.name} did not run`);
664
+ return done;
665
+ };
666
+ });
667
+ if (outcome.changes.length > 0 && this.observers.size > 0) this.observers.emit(outcome.changes);
668
+ return outcome.value;
669
+ }
670
+
671
+ /** This method drops the oldest tombstones above the limit, moves the floor of the change log, then calls `then`. */
672
+ private trim(items: IdbObjectStoreLike, counter: Counter, fail: (error: unknown) => void, then: () => void): void {
673
+ const excess = counter.tombstones - this.maxTombstones;
674
+ if (excess <= 0) {
675
+ then();
676
+ return;
677
+ }
678
+ onResult(items.index(DELETED_AT).getAll(null, excess), fail, (records) => {
679
+ records.forEach((value, index) => {
680
+ const record = recordOf(value);
681
+ if (record === undefined || !isTombstone(record)) throw this.problem(`a record that is not a tombstone of an IndexedDbStorage (tombstone ${index + 1})`);
682
+ items.delete(record.id);
683
+ counter.floor = Math.max(counter.floor, record.deletedAt);
684
+ counter.tombstones = Math.max(0, counter.tombstones - 1);
685
+ });
686
+ then();
687
+ });
688
+ }
689
+ }