@procedures/signaldb 0.0.0-stage → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/codec.d.ts +53 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +51 -0
- package/dist/codec.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/storage.d.ts +224 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +485 -0
- package/dist/storage.js.map +1 -0
- package/package.json +75 -3
- package/src/codec.ts +74 -0
- package/src/index.ts +13 -0
- package/src/storage.ts +566 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@procedures/signaldb`: a `CollectionStorage` over the SignalDB collection of an app (roadmap
|
|
3
|
+
* CLI-23, server stack D9). The collection is the local copy of a `HybridStorage`, and the app keeps
|
|
4
|
+
* its cursors and its reactivity.
|
|
5
|
+
*
|
|
6
|
+
* The package has classes and no procedures, so it has no `./module` or `./register` entry. An
|
|
7
|
+
* import of the package registers nothing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { SignalDbStorage } from "./storage.js";
|
|
11
|
+
export type { SignalDbCollection, SignalDbStorageOptions } from "./storage.js";
|
|
12
|
+
export { VALUE_FIELD, documentCodec } from "./codec.js";
|
|
13
|
+
export type { SignalDbItem, SignalDbItemCodec } from "./codec.js";
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A collection storage over the SignalDB collection of an app (roadmap CLI-23, server stack D9).
|
|
3
|
+
*
|
|
4
|
+
* The app owns the SignalDB `Collection`: its persistence, its indexes, its cursors and its
|
|
5
|
+
* reactivity. `SignalDbStorage` reads and writes the items of that collection through the
|
|
6
|
+
* `CollectionStorage` contract. Thus the collection is the local copy of a `HybridStorage`, and
|
|
7
|
+
* the `HybridStorage` owns the sync: the queue, the sync state and the remote.
|
|
8
|
+
*
|
|
9
|
+
* The rules:
|
|
10
|
+
* - Each operation waits for the load of the persistence of the collection (`isReady()`). A failed
|
|
11
|
+
* load does not stop the storage: the collection then keeps its items in memory, as SignalDB does.
|
|
12
|
+
* - A write of SignalDB is synchronous. Each write of the storage reads, tests and writes in one
|
|
13
|
+
* synchronous step. Thus two writes do not mix, and a patch and a precondition are atomic.
|
|
14
|
+
* - A write of several items is one `batch()` of the collection: the indexes and the cursors update
|
|
15
|
+
* one time. The validators of the collection examine each item before the first write. Thus an
|
|
16
|
+
* item that a validator refuses changes nothing.
|
|
17
|
+
* - Each write takes a copy of its value, and each read gives a new copy. A cursor of SignalDB gives
|
|
18
|
+
* copies (`structuredClone`) of the items, and the storage reads only through cursors.
|
|
19
|
+
* - `observe()` gives the batches of the writes of the storage, with their origins. It also gives
|
|
20
|
+
* each change that the app makes directly on the collection: one batch for each change, with no
|
|
21
|
+
* origin. Thus a `HybridStorage` with `capture: "observe"` sends the writes of the app.
|
|
22
|
+
* - `query()` applies `queryEntries()` to the values. It does not give the selector to SignalDB.
|
|
23
|
+
* SignalDB applies Mongo selectors to the items with `mingo`. The contract needs the query
|
|
24
|
+
* language of the client on the values.
|
|
25
|
+
* - `close()` stops the storage. The collection stays open, with its items: the app disposes it.
|
|
26
|
+
*
|
|
27
|
+
* The limits:
|
|
28
|
+
* - A load of the persistence (`persistence.received`) changes the items without the events of a
|
|
29
|
+
* change. `observe()` thus does not tell it. The order of the entries after a load is the order of
|
|
30
|
+
* the persistence adapter.
|
|
31
|
+
* - The collection must not have a `transform`: the storage reads the items through cursors.
|
|
32
|
+
* - Each id is a string. An item of the collection whose `id` is not a string is an error.
|
|
33
|
+
* - The storage keeps no change log, so it has no `getChangesSince()`. A cursor copies each item,
|
|
34
|
+
* so the storage has no cheap `getIds()` either.
|
|
35
|
+
* - A listener of the app that throws in a write of the storage stops the write, and the write
|
|
36
|
+
* rejects. SignalDB keeps the changes before the error. The observers get the changes that the
|
|
37
|
+
* storage knows.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { Collection } from "@signaldb/core";
|
|
41
|
+
import type {
|
|
42
|
+
CollectionStorage,
|
|
43
|
+
StorageBatchWriteOptions,
|
|
44
|
+
StorageChangeBatch,
|
|
45
|
+
StorageChangeEvent,
|
|
46
|
+
StorageItemWriteOptions,
|
|
47
|
+
StorageMetadata,
|
|
48
|
+
StorageObserver,
|
|
49
|
+
StorageWriteOptions,
|
|
50
|
+
} from "@procedures/collections";
|
|
51
|
+
import { StorageListeners, reportListenerError, type ListenerErrorHandler } from "@procedures/collections";
|
|
52
|
+
import {
|
|
53
|
+
VersionConflictError,
|
|
54
|
+
applyPatch,
|
|
55
|
+
patchProblems,
|
|
56
|
+
preconditionNotSupported,
|
|
57
|
+
queryEntries,
|
|
58
|
+
queryOptionsProblems,
|
|
59
|
+
selectorProblems,
|
|
60
|
+
versionConflicts,
|
|
61
|
+
type CurrentItem,
|
|
62
|
+
type StoragePatch,
|
|
63
|
+
type StorageQueryOptions,
|
|
64
|
+
type StorageQueryPolicy,
|
|
65
|
+
type StorageSelector,
|
|
66
|
+
} from "@procedures/client/query";
|
|
67
|
+
import { documentCodec, isPlainObject, type SignalDbItem, type SignalDbItemCodec } from "./codec.js";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A SignalDB collection of any item type. The storage reads the items as they are in memory, so the
|
|
71
|
+
* collection must not have a `transform`.
|
|
72
|
+
*/
|
|
73
|
+
export type SignalDbCollection = Collection<any, any, any, any>;
|
|
74
|
+
|
|
75
|
+
/** The options of a `SignalDbStorage`. */
|
|
76
|
+
export interface SignalDbStorageOptions<T = unknown> {
|
|
77
|
+
/**
|
|
78
|
+
* The change of a value to an item of the collection and back.
|
|
79
|
+
* @default documentCodec(): a document whose `id` is its key is the item, and each other value goes into an envelope
|
|
80
|
+
*/
|
|
81
|
+
codec?: SignalDbItemCodec<T> | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* The query policy of `query()` for a call without a policy: the operators and the limits. The
|
|
84
|
+
* procedure `find` gives the policy of its host.
|
|
85
|
+
* @default DEFAULT_QUERY_POLICY: no `$regex`
|
|
86
|
+
*/
|
|
87
|
+
queryPolicy?: StorageQueryPolicy | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* The version of a value, for example `(item) => item.rev` (roadmap CLI-16). With it, `set`,
|
|
90
|
+
* `delete` and `setBatch` take an expected version: a write whose current value has another
|
|
91
|
+
* version changes nothing and rejects with `VersionConflictError`. Without it, a write with an
|
|
92
|
+
* expected version rejects with a `TypeError`. Give the `versionOf` of the `HybridStorage` to its
|
|
93
|
+
* local copy.
|
|
94
|
+
*/
|
|
95
|
+
versionOf?: ((item: T) => string | undefined) | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* The function that gets the error of an observer of `observe()`, and the error of a change of
|
|
98
|
+
* the app that the storage cannot read. The default throws the error again in a microtask
|
|
99
|
+
* (`reportListenerError`). The write and the other observers do not fail.
|
|
100
|
+
*/
|
|
101
|
+
onObserverError?: ListenerErrorHandler | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The options of the reads: no reactivity and no field tracking, also in a reactive scope of the app. */
|
|
105
|
+
const READ = { reactive: false, fieldTracking: false } as const;
|
|
106
|
+
|
|
107
|
+
/** An id must be a string: it is the key of an item. */
|
|
108
|
+
function checkId(id: unknown): string {
|
|
109
|
+
if (typeof id !== "string") throw new TypeError(`An id must be a string, not ${typeof id}`);
|
|
110
|
+
return id;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A change with the origin of the write, when the write has one. */
|
|
114
|
+
function withOrigin<T>(change: StorageChangeEvent<T>, origin: string | undefined): StorageChangeEvent<T> {
|
|
115
|
+
return origin === undefined ? change : { ...change, origin };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A value and its item, for a write. */
|
|
119
|
+
interface Prepared<T> {
|
|
120
|
+
readonly id: string;
|
|
121
|
+
/** The copy of the value that the write took */
|
|
122
|
+
readonly value: T;
|
|
123
|
+
readonly item: SignalDbItem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A collection storage over the SignalDB collection of an app (roadmap CLI-23).
|
|
128
|
+
*
|
|
129
|
+
* - The collection keeps the items. The storage keeps nothing of its own, and a new storage of the
|
|
130
|
+
* same collection reads the same items.
|
|
131
|
+
* - `observe()` tells the writes of the storage and the changes that the app makes on the collection.
|
|
132
|
+
* - `query()` applies a selector, and `patch()` changes an item in one step.
|
|
133
|
+
* - After `close()`, each operation rejects, and a second `close()` does nothing.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* import { Collection } from "@signaldb/core";
|
|
138
|
+
* import { ApiStorage, HybridStorage } from "@procedures/store/browser";
|
|
139
|
+
* import { SignalDbStorage } from "@procedures/signaldb";
|
|
140
|
+
*
|
|
141
|
+
* const todos = new Collection<Todo>({ persistence: createIndexedDBAdapter("todos") });
|
|
142
|
+
* const storage = new HybridStorage<Todo>(new ApiStorage(client, { collection: "todos" }), {
|
|
143
|
+
* mode: "write-back",
|
|
144
|
+
* local: new SignalDbStorage<Todo>(todos),
|
|
145
|
+
* capture: "observe",
|
|
146
|
+
* });
|
|
147
|
+
* todos.insert({ id: "t1", title: "Write the docs" }); // the storage sends it to the server
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export class SignalDbStorage<T> implements CollectionStorage<T> {
|
|
151
|
+
private readonly collection: SignalDbCollection;
|
|
152
|
+
private readonly codec: SignalDbItemCodec<T>;
|
|
153
|
+
/** The query policy of a call without a policy */
|
|
154
|
+
private readonly queryPolicy: StorageQueryPolicy | undefined;
|
|
155
|
+
/** The version of a value, for the preconditions of the writes (roadmap CLI-16) */
|
|
156
|
+
private readonly versionOf: ((item: T) => string | undefined) | undefined;
|
|
157
|
+
private readonly onObserverError: ListenerErrorHandler;
|
|
158
|
+
/** The observers of `observe()` */
|
|
159
|
+
private readonly observers: StorageListeners<StorageChangeBatch<T>>;
|
|
160
|
+
/** The load of the persistence of the collection. A failed load also resolves. */
|
|
161
|
+
private readonly ready: Promise<void>;
|
|
162
|
+
/** True while a write of this storage runs: its events are not changes of the app */
|
|
163
|
+
private writing = false;
|
|
164
|
+
/** True while the storage listens to the events of the collection */
|
|
165
|
+
private listening = false;
|
|
166
|
+
private closed = false;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param collection - The SignalDB collection of the app. The app owns it, and `close()` does not dispose it.
|
|
170
|
+
* @param options - The codec, the query policy, `versionOf` and the handler of the errors of the observers.
|
|
171
|
+
*/
|
|
172
|
+
constructor(collection: SignalDbCollection, options: SignalDbStorageOptions<T> = {}) {
|
|
173
|
+
if (typeof collection?.find !== "function" || typeof collection.replaceOne !== "function" || typeof collection.on !== "function") {
|
|
174
|
+
throw new TypeError("SignalDbStorage needs a SignalDB Collection");
|
|
175
|
+
}
|
|
176
|
+
this.collection = collection;
|
|
177
|
+
this.codec = options.codec ?? documentCodec<T>();
|
|
178
|
+
this.queryPolicy = options.queryPolicy;
|
|
179
|
+
this.versionOf = options.versionOf;
|
|
180
|
+
this.onObserverError = options.onObserverError ?? reportListenerError;
|
|
181
|
+
this.observers = new StorageListeners<StorageChangeBatch<T>>(this.onObserverError);
|
|
182
|
+
// The collection still operates in memory after a failed load: the app gets `persistence.error`
|
|
183
|
+
this.ready = collection.isReady().then(
|
|
184
|
+
() => undefined,
|
|
185
|
+
() => undefined,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The name of the collection. */
|
|
190
|
+
get name(): string {
|
|
191
|
+
return this.collection.name;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
//
|
|
195
|
+
// ═══ Read Operations ═══
|
|
196
|
+
//
|
|
197
|
+
|
|
198
|
+
async get(id: string): Promise<T | undefined> {
|
|
199
|
+
this.checkOpen();
|
|
200
|
+
checkId(id);
|
|
201
|
+
await this.whenReady();
|
|
202
|
+
const current = this.current(id);
|
|
203
|
+
return current.found ? current.value : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async getAll(): Promise<T[]> {
|
|
207
|
+
return (await this.getEntries()).map(([, value]) => value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Every item with its id, in the order of the collection: the order of the first set of each id. */
|
|
211
|
+
async getEntries(): Promise<Array<[string, T]>> {
|
|
212
|
+
this.checkOpen();
|
|
213
|
+
await this.whenReady();
|
|
214
|
+
return this.collection
|
|
215
|
+
.find({}, READ)
|
|
216
|
+
.fetch()
|
|
217
|
+
.map((raw: unknown, index: number) => this.entryOf(raw, index));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The items for which the predicate gives true, in the order of the entries. The predicate gets a copy of each item. */
|
|
221
|
+
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
222
|
+
return (await this.getAll()).filter((item) => predicate(item));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The entries whose values match the selector, with the order, the skip and the limit of
|
|
227
|
+
* `queryEntries()`. The policy is the policy of the call, or else of the options. A query that is
|
|
228
|
+
* not correct rejects with a `TypeError` before the storage reads the collection.
|
|
229
|
+
*/
|
|
230
|
+
async query(selector: StorageSelector, options?: StorageQueryOptions, policy?: StorageQueryPolicy): Promise<Array<[string, T]>> {
|
|
231
|
+
this.checkOpen();
|
|
232
|
+
const limits = policy ?? this.queryPolicy;
|
|
233
|
+
const problems = [...selectorProblems(selector, limits), ...queryOptionsProblems(options, limits)];
|
|
234
|
+
if (problems.length > 0) throw new TypeError(`The query is not correct: ${problems.join(". ")}`);
|
|
235
|
+
return queryEntries(await this.getEntries(), selector, options, limits);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* This method starts to send the changes of the collection to the observer.
|
|
240
|
+
*
|
|
241
|
+
* - A write of this storage gives one batch, with the origin of its options, before the write resolves.
|
|
242
|
+
* - A change that the app makes directly on the collection gives one batch with one change and no
|
|
243
|
+
* origin, at once. Examples are `insert`, `updateOne`, `replaceOne` and `removeOne`.
|
|
244
|
+
* - The observers share each batch. A load of the persistence gives no batch.
|
|
245
|
+
*
|
|
246
|
+
* @returns The function that stops the batches. A second use of it does nothing.
|
|
247
|
+
* @throws Error after `close()`
|
|
248
|
+
*/
|
|
249
|
+
observe(observer: StorageObserver<T>): () => void {
|
|
250
|
+
this.checkOpen();
|
|
251
|
+
const remove = this.observers.add(observer);
|
|
252
|
+
this.listen();
|
|
253
|
+
let stopped = false;
|
|
254
|
+
return () => {
|
|
255
|
+
if (stopped) return;
|
|
256
|
+
stopped = true;
|
|
257
|
+
remove();
|
|
258
|
+
if (this.observers.size === 0) this.unlisten();
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async has(id: string): Promise<boolean> {
|
|
263
|
+
this.checkOpen();
|
|
264
|
+
checkId(id);
|
|
265
|
+
await this.whenReady();
|
|
266
|
+
return this.collection.find({ id }, READ).count() > 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async size(): Promise<number> {
|
|
270
|
+
this.checkOpen();
|
|
271
|
+
await this.whenReady();
|
|
272
|
+
return this.collection.find({}, READ).count();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
//
|
|
276
|
+
// ═══ Write Operations ═══
|
|
277
|
+
//
|
|
278
|
+
|
|
279
|
+
async set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void> {
|
|
280
|
+
this.checkOpen();
|
|
281
|
+
checkId(id);
|
|
282
|
+
const expected = this.preconditions(options?.expectedVersion === undefined ? [] : [[id, options.expectedVersion]]);
|
|
283
|
+
const write = this.prepare(id, value);
|
|
284
|
+
await this.whenReady();
|
|
285
|
+
this.write(options?.origin, (changes) => {
|
|
286
|
+
this.checkVersions(expected);
|
|
287
|
+
this.put(write, changes);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async delete(id: string, options?: StorageItemWriteOptions): Promise<boolean> {
|
|
292
|
+
this.checkOpen();
|
|
293
|
+
checkId(id);
|
|
294
|
+
const expected = this.preconditions(options?.expectedVersion === undefined ? [] : [[id, options.expectedVersion]]);
|
|
295
|
+
await this.whenReady();
|
|
296
|
+
return this.write(options?.origin, (changes) => {
|
|
297
|
+
this.checkVersions(expected);
|
|
298
|
+
return this.remove(id, changes);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** This method deletes each item of the collection in one batch. A collection with items gives one `clear` change. */
|
|
303
|
+
async clear(options?: StorageWriteOptions): Promise<void> {
|
|
304
|
+
this.checkOpen();
|
|
305
|
+
await this.whenReady();
|
|
306
|
+
this.write(options?.origin, (changes) => {
|
|
307
|
+
let removed = 0;
|
|
308
|
+
this.batch(() => {
|
|
309
|
+
removed = this.collection.removeMany({});
|
|
310
|
+
});
|
|
311
|
+
if (removed > 0) changes.push({ type: "clear" });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* This method changes the item of the id in one step (a JSON Merge Patch, or the operations
|
|
317
|
+
* `$set`, `$unset`, `$remove` and `$insert`), and gives a copy of the new item. A missing id stays
|
|
318
|
+
* missing, and the result is undefined.
|
|
319
|
+
*/
|
|
320
|
+
async patch(id: string, fields: StoragePatch, options?: StorageWriteOptions): Promise<T | undefined> {
|
|
321
|
+
this.checkOpen();
|
|
322
|
+
checkId(id);
|
|
323
|
+
const [problem] = patchProblems(fields);
|
|
324
|
+
if (problem !== undefined) throw new TypeError(`The patch of ${JSON.stringify(id)} is not correct: ${problem}`);
|
|
325
|
+
// A copy: a change of the fields during the wait does not change the patch
|
|
326
|
+
const own = structuredClone(fields);
|
|
327
|
+
await this.whenReady();
|
|
328
|
+
return this.write(options?.origin, (changes) => {
|
|
329
|
+
const current = this.current(id);
|
|
330
|
+
if (!current.found) return undefined;
|
|
331
|
+
const next = applyPatch(current.value, own) as unknown as T;
|
|
332
|
+
this.put({ id, value: next, item: this.itemOf(id, next) }, changes);
|
|
333
|
+
// The collection keeps `next`: the caller gets its own copy
|
|
334
|
+
return structuredClone(next);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
//
|
|
339
|
+
// ═══ Bulk Operations ═══
|
|
340
|
+
//
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* This method sets several items in one batch of the collection. When one expected version has a
|
|
344
|
+
* conflict, or a validator of the collection refuses one item, nothing changes.
|
|
345
|
+
*/
|
|
346
|
+
async setBatch(items: Array<[string, T]>, options?: StorageBatchWriteOptions): Promise<void> {
|
|
347
|
+
this.checkOpen();
|
|
348
|
+
for (const [id] of items) checkId(id);
|
|
349
|
+
const expected = this.preconditions(options?.expectedVersions ?? []);
|
|
350
|
+
const writes = items.map(([id, value]) => this.prepare(id, value));
|
|
351
|
+
if (writes.length === 0 && expected.length === 0) return;
|
|
352
|
+
await this.whenReady();
|
|
353
|
+
this.write(options?.origin, (changes) => {
|
|
354
|
+
this.checkVersions(expected);
|
|
355
|
+
// The validators of the app refuse an item before the first write, so a refusal changes nothing
|
|
356
|
+
if (writes.length > 1) for (const { item } of writes) this.collection.emit("validate", item);
|
|
357
|
+
this.batch(() => {
|
|
358
|
+
for (const write of writes) this.put(write, changes);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** This method deletes several items in one batch of the collection. */
|
|
364
|
+
async deleteBatch(ids: string[], options?: StorageWriteOptions): Promise<number> {
|
|
365
|
+
this.checkOpen();
|
|
366
|
+
for (const id of ids) checkId(id);
|
|
367
|
+
if (ids.length === 0) return 0;
|
|
368
|
+
await this.whenReady();
|
|
369
|
+
return this.write(options?.origin, (changes) => {
|
|
370
|
+
let deleted = 0;
|
|
371
|
+
this.batch(() => {
|
|
372
|
+
for (const id of new Set(ids)) if (this.remove(id, changes)) deleted++;
|
|
373
|
+
});
|
|
374
|
+
return deleted;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async getBatch(ids: string[]): Promise<Map<string, T>> {
|
|
379
|
+
this.checkOpen();
|
|
380
|
+
for (const id of ids) checkId(id);
|
|
381
|
+
await this.whenReady();
|
|
382
|
+
const result = new Map<string, T>();
|
|
383
|
+
for (const id of new Set(ids)) {
|
|
384
|
+
const current = this.current(id);
|
|
385
|
+
if (current.found) result.set(id, current.value);
|
|
386
|
+
}
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
//
|
|
391
|
+
// ═══ Lifecycle & Metadata ═══
|
|
392
|
+
//
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* This method stops the storage. The collection stays open, with its items: a new storage of the
|
|
396
|
+
* collection reads them. After `close()`, each operation rejects, and a second `close()` does nothing.
|
|
397
|
+
*/
|
|
398
|
+
async close(): Promise<void> {
|
|
399
|
+
if (this.closed) return;
|
|
400
|
+
this.closed = true;
|
|
401
|
+
this.observers.clear();
|
|
402
|
+
this.unlisten();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async getMetadata(): Promise<StorageMetadata> {
|
|
406
|
+
const size = await this.size();
|
|
407
|
+
return { type: "custom", size, stats: { backend: "signaldb", collection: this.name } };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
//
|
|
411
|
+
// ═══ Internal Helpers ═══
|
|
412
|
+
//
|
|
413
|
+
|
|
414
|
+
/** This method throws after `close()`. In an async method, the throw rejects the promise. */
|
|
415
|
+
private checkOpen(): void {
|
|
416
|
+
if (this.closed) throw new Error(`The SignalDbStorage of the collection ${this.name} is closed`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** This method waits for the load of the persistence. A close during the wait rejects. */
|
|
420
|
+
private async whenReady(): Promise<void> {
|
|
421
|
+
await this.ready;
|
|
422
|
+
this.checkOpen();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private problem(what: string): Error {
|
|
426
|
+
return new Error(`The SignalDB collection ${this.name} has ${what}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The id and the value of an item that a cursor gave. An item without a string id throws. */
|
|
430
|
+
private entryOf(raw: unknown, index: number): [string, T] {
|
|
431
|
+
if (!isPlainObject(raw) || typeof raw["id"] !== "string") throw this.problem(`an item without a string id (item ${index + 1})`);
|
|
432
|
+
return [raw["id"], this.codec.fromItem(raw as SignalDbItem)];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The current value of an id: a copy. */
|
|
436
|
+
private current(id: string): CurrentItem<T> {
|
|
437
|
+
const raw: unknown = this.collection.findOne({ id }, READ);
|
|
438
|
+
if (raw === undefined) return { found: false };
|
|
439
|
+
return { found: true, value: this.entryOf(raw, 0)[1] };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The item of a value. The codec must give a plain object with the id. */
|
|
443
|
+
private itemOf(id: string, value: T): SignalDbItem {
|
|
444
|
+
const item: unknown = this.codec.toItem(id, value);
|
|
445
|
+
if (!isPlainObject(item) || item["id"] !== id) {
|
|
446
|
+
throw new TypeError(`The codec of the SignalDbStorage ${this.name} gave no item with the id ${JSON.stringify(id)}`);
|
|
447
|
+
}
|
|
448
|
+
return item as SignalDbItem;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** A write of a value: a copy of the value, and its item. */
|
|
452
|
+
private prepare(id: string, value: T): Prepared<T> {
|
|
453
|
+
const copy = structuredClone(value);
|
|
454
|
+
return { id, value: copy, item: this.itemOf(id, copy) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** This method writes an item. An existing id keeps its place in the order of the entries. */
|
|
458
|
+
private put(write: Prepared<T>, changes: Array<StorageChangeEvent<T>>): void {
|
|
459
|
+
this.collection.replaceOne({ id: write.id }, write.item, { upsert: true });
|
|
460
|
+
changes.push({ type: "set", id: write.id, value: write.value });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** This method deletes the item of an id. True when the item existed. */
|
|
464
|
+
private remove(id: string, changes: Array<StorageChangeEvent<T>>): boolean {
|
|
465
|
+
if (this.collection.removeOne({ id }) === 0) return false;
|
|
466
|
+
changes.push({ type: "delete", id });
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* One write of the storage: `body` reads, tests and writes in one synchronous step. The events of
|
|
472
|
+
* the collection during the step are not changes of the app. The observers get the changes of the
|
|
473
|
+
* step after it, also when it fails after a change, with copies of the values.
|
|
474
|
+
*/
|
|
475
|
+
private write<R>(origin: string | undefined, body: (changes: Array<StorageChangeEvent<T>>) => R): R {
|
|
476
|
+
const changes: Array<StorageChangeEvent<T>> = [];
|
|
477
|
+
this.writing = true;
|
|
478
|
+
try {
|
|
479
|
+
return body(changes);
|
|
480
|
+
} finally {
|
|
481
|
+
this.writing = false;
|
|
482
|
+
if (changes.length > 0 && this.observers.size > 0) {
|
|
483
|
+
this.observers.emit(changes.map((change) => withOrigin(change.type === "set" ? { ...change, value: structuredClone(change.value) } : change, origin)));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* One batch of the collection. `Collection.batch()` of SignalDB does not end its batch when the
|
|
490
|
+
* callback throws, so the error leaves the batch first, and the batch ends.
|
|
491
|
+
*/
|
|
492
|
+
private batch(body: () => void): void {
|
|
493
|
+
let failure: { readonly error: unknown } | undefined;
|
|
494
|
+
this.collection.batch(() => {
|
|
495
|
+
try {
|
|
496
|
+
body();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
failure = { error };
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
if (failure) throw failure.error;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The preconditions of a write. A storage without `versionOf` throws a `TypeError` before the
|
|
506
|
+
* write starts (roadmap CLI-16).
|
|
507
|
+
*/
|
|
508
|
+
private preconditions(expected: ReadonlyArray<readonly [string, string | null]>): ReadonlyArray<readonly [string, string | null]> {
|
|
509
|
+
if (expected.length === 0) return expected;
|
|
510
|
+
if (!this.versionOf) throw preconditionNotSupported("This SignalDbStorage");
|
|
511
|
+
for (const [id] of expected) checkId(id);
|
|
512
|
+
return expected;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** This method tests the expected versions in the step of the write. A conflict throws `VersionConflictError`. */
|
|
516
|
+
private checkVersions(expected: ReadonlyArray<readonly [string, string | null]>): void {
|
|
517
|
+
if (expected.length === 0 || !this.versionOf) return;
|
|
518
|
+
const conflicts = versionConflicts(expected, (id) => this.current(id), this.versionOf);
|
|
519
|
+
if (conflicts.length > 0) throw new VersionConflictError(conflicts);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
//
|
|
523
|
+
// ═══ The changes of the app ═══
|
|
524
|
+
//
|
|
525
|
+
|
|
526
|
+
private readonly onAdded = (item: unknown): void => this.appChange("set", item);
|
|
527
|
+
private readonly onChanged = (item: unknown): void => this.appChange("set", item);
|
|
528
|
+
private readonly onRemoved = (item: unknown): void => this.appChange("delete", item);
|
|
529
|
+
|
|
530
|
+
/** This method starts to listen to the changes of the collection. */
|
|
531
|
+
private listen(): void {
|
|
532
|
+
if (this.listening) return;
|
|
533
|
+
this.listening = true;
|
|
534
|
+
this.collection.on("added", this.onAdded);
|
|
535
|
+
this.collection.on("changed", this.onChanged);
|
|
536
|
+
this.collection.on("removed", this.onRemoved);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** This method stops to listen to the changes of the collection. */
|
|
540
|
+
private unlisten(): void {
|
|
541
|
+
if (!this.listening) return;
|
|
542
|
+
this.listening = false;
|
|
543
|
+
this.collection.off("added", this.onAdded);
|
|
544
|
+
this.collection.off("changed", this.onChanged);
|
|
545
|
+
this.collection.off("removed", this.onRemoved);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* This method gives a change that the app made on the collection to the observers. It does not
|
|
550
|
+
* throw, because it operates in the write of the app. An item that the storage cannot read goes to
|
|
551
|
+
* `onObserverError`.
|
|
552
|
+
*/
|
|
553
|
+
private appChange(type: "set" | "delete", item: unknown): void {
|
|
554
|
+
if (this.writing || this.closed || this.observers.size === 0) return;
|
|
555
|
+
let change: StorageChangeEvent<T>;
|
|
556
|
+
try {
|
|
557
|
+
const raw: unknown = structuredClone(item);
|
|
558
|
+
if (!isPlainObject(raw) || typeof raw["id"] !== "string") throw this.problem("a changed item without a string id");
|
|
559
|
+
change = type === "set" ? { type, id: raw["id"], value: this.codec.fromItem(raw as SignalDbItem) } : { type, id: raw["id"] };
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.onObserverError(error);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this.observers.emit([change]);
|
|
565
|
+
}
|
|
566
|
+
}
|