@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.
@@ -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, or of a closed one, does not match it. */
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
- return this.data.get(id);
89
+ this.checkOpen();
90
+ return this.copy(this.data.get(id));
68
91
  }
69
92
 
70
93
  async getAll(): Promise<T[]> {
71
- return Array.from(this.data.values());
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
- return Array.from(this.data.entries());
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, of a closed
80
- * storage, or older than the dropped deletes gives all items (`full: true`).
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: Array.from(this.data.entries()), deleted: [] };
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 item of this.data.values()) {
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.data.set(id, value);
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
- for (const [id, value] of items) {
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
- // No cleanup needed for in-memory storage
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
- * Get all values in storage.
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
- return this.data.values();
268
+ this.checkOpen();
269
+ return this.copiedEntries().map(([, value]) => value)[Symbol.iterator]();
220
270
  }
221
271
 
222
272
  /**
223
- * Get all entries in storage.
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
- return this.data.entries();
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.data.forEach(callback);
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.data.entries();
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
  }
@@ -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
- }