@procedures/collections 0.4.0 → 0.5.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.
- package/CHANGELOG.md +16 -0
- package/README.md +28 -10
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/interface.d.ts +25 -4
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/interface.js.map +1 -1
- package/dist/storage/memory.d.ts +38 -7
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +79 -25
- package/dist/storage/memory.js.map +1 -1
- package/dist/testing/check.d.ts +0 -2
- package/dist/testing/check.d.ts.map +1 -1
- package/dist/testing/check.js +0 -4
- package/dist/testing/check.js.map +1 -1
- package/dist/testing/storage-contract.d.ts +11 -15
- package/dist/testing/storage-contract.d.ts.map +1 -1
- package/dist/testing/storage-contract.js +167 -45
- package/dist/testing/storage-contract.js.map +1 -1
- package/package.json +1 -1
- package/src/storage/interface.ts +26 -4
- package/src/storage/memory.ts +95 -26
- package/src/testing/check.ts +0 -5
- package/src/testing/storage-contract.ts +174 -60
package/src/storage/memory.ts
CHANGED
|
@@ -15,13 +15,28 @@ export interface InMemoryStorageOptions {
|
|
|
15
15
|
* @default 10000
|
|
16
16
|
*/
|
|
17
17
|
maxTombstones?: number;
|
|
18
|
+
/**
|
|
19
|
+
* True: the storage keeps a copy of each value that a write takes, and each read gives a new
|
|
20
|
+
* copy (`structuredClone`). Then a change of a written or a read value does not change the
|
|
21
|
+
* stored item, as in each other storage.
|
|
22
|
+
*
|
|
23
|
+
* False: the storage keeps the values themselves, and the reads give them. This is faster.
|
|
24
|
+
* Give it only when no caller changes a value after a write or a read.
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
copies?: boolean;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
/** A new epoch: a version of another storage
|
|
30
|
+
/** A new epoch: a version of another storage does not match it. */
|
|
21
31
|
function newEpoch(): string {
|
|
22
32
|
return Math.random().toString(36).slice(2, 10);
|
|
23
33
|
}
|
|
24
34
|
|
|
35
|
+
/** The copy of a value: `structuredClone`, or the value itself without copies. */
|
|
36
|
+
type Copy = <V>(value: V) => V;
|
|
37
|
+
const same: Copy = (value) => value;
|
|
38
|
+
const clone: Copy = (value) => structuredClone(value);
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* In-memory storage backed by JavaScript Map.
|
|
27
42
|
*
|
|
@@ -32,6 +47,10 @@ function newEpoch(): string {
|
|
|
32
47
|
* - Returns Promises for API consistency with remote storage
|
|
33
48
|
* - Each error is a rejected promise: an error of a `find` predicate does not throw at once
|
|
34
49
|
* - A change log: `getChangesSince(version)` gives the changes after a version (roadmap R28)
|
|
50
|
+
* - Copies: a change of a written or a read value does not change the stored item (the option
|
|
51
|
+
* `copies: false` turns this off)
|
|
52
|
+
* - `getIds()` gives the ids without a copy of the values
|
|
53
|
+
* - After `close()`, each operation rejects, and a second `close()` does nothing
|
|
35
54
|
*
|
|
36
55
|
* @example
|
|
37
56
|
* ```typescript
|
|
@@ -45,18 +64,21 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
45
64
|
|
|
46
65
|
// The change log (roadmap R28). Each write gets the next number of `seq`. A version is
|
|
47
66
|
// `<epoch>:<seq>`.
|
|
48
|
-
private epoch = newEpoch();
|
|
67
|
+
private readonly epoch = newEpoch();
|
|
49
68
|
private seq = 0;
|
|
50
|
-
/** The number of the last write of each id that has an item */
|
|
69
|
+
/** The number of the last write of each id that has an item, the oldest first */
|
|
51
70
|
private readonly changedAt = new Map<string, number>();
|
|
52
71
|
/** The number of the delete of each id that has no item, the oldest first (the tombstones) */
|
|
53
72
|
private readonly deletedAt = new Map<string, number>();
|
|
54
73
|
/** The log does not know the deletes at or below this number: they were dropped */
|
|
55
74
|
private floor = 0;
|
|
56
75
|
private readonly maxTombstones: number;
|
|
76
|
+
private readonly copy: Copy;
|
|
77
|
+
private closed = false;
|
|
57
78
|
|
|
58
79
|
constructor(options: InMemoryStorageOptions = {}) {
|
|
59
80
|
this.maxTombstones = Math.max(0, options.maxTombstones ?? 10_000);
|
|
81
|
+
this.copy = options.copies === false ? same : clone;
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
//
|
|
@@ -64,30 +86,41 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
64
86
|
//
|
|
65
87
|
|
|
66
88
|
async get(id: string): Promise<T | undefined> {
|
|
67
|
-
|
|
89
|
+
this.checkOpen();
|
|
90
|
+
return this.copy(this.data.get(id));
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
async getAll(): Promise<T[]> {
|
|
71
|
-
|
|
94
|
+
this.checkOpen();
|
|
95
|
+
return Array.from(this.data.values(), this.copy);
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
async getEntries(): Promise<Array<[string, T]>> {
|
|
75
|
-
|
|
99
|
+
this.checkOpen();
|
|
100
|
+
return this.copiedEntries();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The ids of the items, in the order of the entries. This method copies no value. */
|
|
104
|
+
async getIds(): Promise<string[]> {
|
|
105
|
+
this.checkOpen();
|
|
106
|
+
return Array.from(this.data.keys());
|
|
76
107
|
}
|
|
77
108
|
|
|
78
109
|
/**
|
|
79
|
-
* The changes after a version (roadmap R28). A version of another storage,
|
|
80
|
-
*
|
|
110
|
+
* The changes after a version (roadmap R28). A version of another storage, or older than the
|
|
111
|
+
* dropped deletes, gives all items (`full: true`) in the order of the entries. A delta gives
|
|
112
|
+
* the changed items and the deleted ids in the order of the last change of each id.
|
|
81
113
|
*/
|
|
82
114
|
async getChangesSince(since?: string): Promise<StorageChanges<T>> {
|
|
115
|
+
this.checkOpen();
|
|
83
116
|
const version = `${this.epoch}:${this.seq}`;
|
|
84
117
|
const from = this.parseVersion(since);
|
|
85
118
|
if (from === undefined || from < this.floor) {
|
|
86
|
-
return { version, full: true, changed:
|
|
119
|
+
return { version, full: true, changed: this.copiedEntries(), deleted: [] };
|
|
87
120
|
}
|
|
88
121
|
const changed: Array<[string, T]> = [];
|
|
89
122
|
for (const [id, at] of this.changedAt) {
|
|
90
|
-
if (at > from) changed.push([id, this.data.get(id) as T]);
|
|
123
|
+
if (at > from) changed.push([id, this.copy(this.data.get(id) as T)]);
|
|
91
124
|
}
|
|
92
125
|
const deleted: string[] = [];
|
|
93
126
|
for (const [id, at] of this.deletedAt) {
|
|
@@ -96,9 +129,12 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
96
129
|
return { version, full: false, changed, deleted };
|
|
97
130
|
}
|
|
98
131
|
|
|
132
|
+
/** The items for which the predicate gives true. The predicate gets a copy of each item. */
|
|
99
133
|
async find(predicate: (item: T) => boolean): Promise<T[]> {
|
|
134
|
+
this.checkOpen();
|
|
100
135
|
const results: T[] = [];
|
|
101
|
-
for (const
|
|
136
|
+
for (const stored of this.data.values()) {
|
|
137
|
+
const item = this.copy(stored);
|
|
102
138
|
if (predicate(item)) {
|
|
103
139
|
results.push(item);
|
|
104
140
|
}
|
|
@@ -107,10 +143,12 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
107
143
|
}
|
|
108
144
|
|
|
109
145
|
async has(id: string): Promise<boolean> {
|
|
146
|
+
this.checkOpen();
|
|
110
147
|
return this.data.has(id);
|
|
111
148
|
}
|
|
112
149
|
|
|
113
150
|
async size(): Promise<number> {
|
|
151
|
+
this.checkOpen();
|
|
114
152
|
return this.data.size;
|
|
115
153
|
}
|
|
116
154
|
|
|
@@ -119,17 +157,20 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
119
157
|
//
|
|
120
158
|
|
|
121
159
|
async set(id: string, value: T): Promise<void> {
|
|
122
|
-
this.
|
|
160
|
+
this.checkOpen();
|
|
161
|
+
this.data.set(id, this.copy(value));
|
|
123
162
|
this.noteSet(id);
|
|
124
163
|
}
|
|
125
164
|
|
|
126
165
|
async delete(id: string): Promise<boolean> {
|
|
166
|
+
this.checkOpen();
|
|
127
167
|
const existed = this.data.delete(id);
|
|
128
168
|
if (existed) this.noteDelete(id);
|
|
129
169
|
return existed;
|
|
130
170
|
}
|
|
131
171
|
|
|
132
172
|
async clear(): Promise<void> {
|
|
173
|
+
this.checkOpen();
|
|
133
174
|
for (const id of this.data.keys()) this.noteDelete(id);
|
|
134
175
|
this.data.clear();
|
|
135
176
|
}
|
|
@@ -138,14 +179,18 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
138
179
|
// ═══ Bulk Operations ═══
|
|
139
180
|
//
|
|
140
181
|
|
|
182
|
+
/** This method sets several items. When one value has no copy, nothing changes. */
|
|
141
183
|
async setBatch(items: Array<[string, T]>): Promise<void> {
|
|
142
|
-
|
|
184
|
+
this.checkOpen();
|
|
185
|
+
const copies = items.map(([id, value]): [string, T] => [id, this.copy(value)]);
|
|
186
|
+
for (const [id, value] of copies) {
|
|
143
187
|
this.data.set(id, value);
|
|
144
188
|
this.noteSet(id);
|
|
145
189
|
}
|
|
146
190
|
}
|
|
147
191
|
|
|
148
192
|
async deleteBatch(ids: string[]): Promise<number> {
|
|
193
|
+
this.checkOpen();
|
|
149
194
|
let deleted = 0;
|
|
150
195
|
for (const id of ids) {
|
|
151
196
|
if (this.data.delete(id)) {
|
|
@@ -157,11 +202,12 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
157
202
|
}
|
|
158
203
|
|
|
159
204
|
async getBatch(ids: string[]): Promise<Map<string, T>> {
|
|
205
|
+
this.checkOpen();
|
|
160
206
|
const result = new Map<string, T>();
|
|
161
207
|
for (const id of ids) {
|
|
162
208
|
const value = this.data.get(id);
|
|
163
209
|
if (value !== undefined) {
|
|
164
|
-
result.set(id, value);
|
|
210
|
+
result.set(id, this.copy(value));
|
|
165
211
|
}
|
|
166
212
|
}
|
|
167
213
|
return result;
|
|
@@ -171,18 +217,20 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
171
217
|
// ═══ Lifecycle & Metadata ═══
|
|
172
218
|
//
|
|
173
219
|
|
|
220
|
+
/**
|
|
221
|
+
* This method closes the storage and drops its items. After it, each operation rejects (and
|
|
222
|
+
* each method of the `Map` interface throws). A second `close()` does nothing.
|
|
223
|
+
*/
|
|
174
224
|
async close(): Promise<void> {
|
|
175
|
-
|
|
225
|
+
if (this.closed) return;
|
|
226
|
+
this.closed = true;
|
|
176
227
|
this.data.clear();
|
|
177
|
-
// A new epoch: a version of the closed storage gives all items
|
|
178
228
|
this.changedAt.clear();
|
|
179
229
|
this.deletedAt.clear();
|
|
180
|
-
this.epoch = newEpoch();
|
|
181
|
-
this.seq = 0;
|
|
182
|
-
this.floor = 0;
|
|
183
230
|
}
|
|
184
231
|
|
|
185
232
|
async getMetadata(): Promise<StorageMetadata> {
|
|
233
|
+
this.checkOpen();
|
|
186
234
|
// Rough memory estimation:
|
|
187
235
|
// Each entry has ~overhead of 100 bytes (key string + Map entry overhead)
|
|
188
236
|
const estimatedEntrySize = 100;
|
|
@@ -207,48 +255,69 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
|
|
|
207
255
|
* @returns Iterator of keys
|
|
208
256
|
*/
|
|
209
257
|
keys(): IterableIterator<string> {
|
|
258
|
+
this.checkOpen();
|
|
210
259
|
return this.data.keys();
|
|
211
260
|
}
|
|
212
261
|
|
|
213
262
|
/**
|
|
214
|
-
*
|
|
263
|
+
* The values in storage: copies, unless the option `copies` is false.
|
|
215
264
|
*
|
|
216
265
|
* @returns Iterator of values
|
|
217
266
|
*/
|
|
218
267
|
values(): IterableIterator<T> {
|
|
219
|
-
|
|
268
|
+
this.checkOpen();
|
|
269
|
+
return this.copiedEntries().map(([, value]) => value)[Symbol.iterator]();
|
|
220
270
|
}
|
|
221
271
|
|
|
222
272
|
/**
|
|
223
|
-
*
|
|
273
|
+
* The entries in storage: copies of the values, unless the option `copies` is false.
|
|
224
274
|
*
|
|
225
275
|
* @returns Iterator of [id, value] tuples
|
|
226
276
|
*/
|
|
227
277
|
entries(): IterableIterator<[string, T]> {
|
|
228
|
-
|
|
278
|
+
this.checkOpen();
|
|
279
|
+
return this.copiedEntries()[Symbol.iterator]();
|
|
229
280
|
}
|
|
230
281
|
|
|
231
282
|
/**
|
|
232
|
-
* Iterate over storage entries.
|
|
283
|
+
* Iterate over storage entries. The callback gets a copy of each value, and a `Map` of the
|
|
284
|
+
* copies, unless the option `copies` is false.
|
|
233
285
|
*
|
|
234
286
|
* @param callback - Function to call for each entry
|
|
235
287
|
*/
|
|
236
288
|
forEach(callback: (value: T, key: string, map: Map<string, T>) => void): void {
|
|
237
|
-
this.
|
|
289
|
+
this.checkOpen();
|
|
290
|
+
new Map(this.copiedEntries()).forEach(callback);
|
|
238
291
|
}
|
|
239
292
|
|
|
240
293
|
/**
|
|
241
294
|
* Make storage iterable.
|
|
242
295
|
*/
|
|
243
296
|
[Symbol.iterator](): IterableIterator<[string, T]> {
|
|
244
|
-
return this.
|
|
297
|
+
return this.entries();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
//
|
|
301
|
+
// ═══ Internal Helpers ═══
|
|
302
|
+
//
|
|
303
|
+
|
|
304
|
+
/** This method throws after `close()`. In an async method, the throw rejects the promise. */
|
|
305
|
+
private checkOpen(): void {
|
|
306
|
+
if (this.closed) throw new Error("The InMemoryStorage is closed");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The entries with copies of the values. */
|
|
310
|
+
private copiedEntries(): Array<[string, T]> {
|
|
311
|
+
return Array.from(this.data, ([id, value]): [string, T] => [id, this.copy(value)]);
|
|
245
312
|
}
|
|
246
313
|
|
|
247
314
|
//
|
|
248
315
|
// ═══ The change log ═══
|
|
249
316
|
//
|
|
250
317
|
|
|
318
|
+
/** A set moves the id to the end of the order of the log: the order of the last change. */
|
|
251
319
|
private noteSet(id: string): void {
|
|
320
|
+
this.changedAt.delete(id);
|
|
252
321
|
this.changedAt.set(id, ++this.seq);
|
|
253
322
|
this.deletedAt.delete(id);
|
|
254
323
|
}
|
package/src/testing/check.ts
CHANGED
|
@@ -79,8 +79,3 @@ export async function rejects(start: () => Promise<unknown>, what: string): Prom
|
|
|
79
79
|
}
|
|
80
80
|
throw new ContractViolation(`${what}: the promise did not reject`);
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
/** The entries sorted by id, for a comparison where the order is not part of the contract. */
|
|
84
|
-
export function byId<T>(entries: ReadonlyArray<readonly [string, T]>): Array<[string, T]> {
|
|
85
|
-
return [...entries].map(([id, value]): [string, T] => [id, value]).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
86
|
-
}
|