@procedures/collections 0.2.3 → 0.4.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.
@@ -5,7 +5,22 @@
5
5
  * All operations return Promises for interface consistency.
6
6
  */
7
7
 
8
- import type { CollectionStorage, StorageMetadata } from "./interface.js";
8
+ import type { CollectionStorage, StorageChanges, StorageMetadata } from "./interface.js";
9
+
10
+ /** Options of an in-memory storage. */
11
+ export interface InMemoryStorageOptions {
12
+ /**
13
+ * The number of deletes that the change log keeps (roadmap R28). After more deletes, the log
14
+ * drops the oldest ones, and `getChangesSince` of an older version gives all items.
15
+ * @default 10000
16
+ */
17
+ maxTombstones?: number;
18
+ }
19
+
20
+ /** A new epoch: a version of another storage, or of a closed one, does not match it. */
21
+ function newEpoch(): string {
22
+ return Math.random().toString(36).slice(2, 10);
23
+ }
9
24
 
10
25
  /**
11
26
  * In-memory storage backed by JavaScript Map.
@@ -15,6 +30,8 @@ import type { CollectionStorage, StorageMetadata } from "./interface.js";
15
30
  * - No persistence (data lost on restart)
16
31
  * - Memory-bound (limited by available RAM)
17
32
  * - Returns Promises for API consistency with remote storage
33
+ * - Each error is a rejected promise: an error of a `find` predicate does not throw at once
34
+ * - A change log: `getChangesSince(version)` gives the changes after a version (roadmap R28)
18
35
  *
19
36
  * @example
20
37
  * ```typescript
@@ -26,76 +43,120 @@ import type { CollectionStorage, StorageMetadata } from "./interface.js";
26
43
  export class InMemoryStorage<T> implements CollectionStorage<T> {
27
44
  private data = new Map<string, T>();
28
45
 
46
+ // The change log (roadmap R28). Each write gets the next number of `seq`. A version is
47
+ // `<epoch>:<seq>`.
48
+ private epoch = newEpoch();
49
+ private seq = 0;
50
+ /** The number of the last write of each id that has an item */
51
+ private readonly changedAt = new Map<string, number>();
52
+ /** The number of the delete of each id that has no item, the oldest first (the tombstones) */
53
+ private readonly deletedAt = new Map<string, number>();
54
+ /** The log does not know the deletes at or below this number: they were dropped */
55
+ private floor = 0;
56
+ private readonly maxTombstones: number;
57
+
58
+ constructor(options: InMemoryStorageOptions = {}) {
59
+ this.maxTombstones = Math.max(0, options.maxTombstones ?? 10_000);
60
+ }
61
+
29
62
  //
30
63
  // ═══ Read Operations ═══
31
64
  //
32
65
 
33
- get(id: string): Promise<T | undefined> {
34
- return Promise.resolve(this.data.get(id));
66
+ async get(id: string): Promise<T | undefined> {
67
+ return this.data.get(id);
68
+ }
69
+
70
+ async getAll(): Promise<T[]> {
71
+ return Array.from(this.data.values());
35
72
  }
36
73
 
37
- getAll(): Promise<T[]> {
38
- return Promise.resolve(Array.from(this.data.values()));
74
+ async getEntries(): Promise<Array<[string, T]>> {
75
+ return Array.from(this.data.entries());
76
+ }
77
+
78
+ /**
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`).
81
+ */
82
+ async getChangesSince(since?: string): Promise<StorageChanges<T>> {
83
+ const version = `${this.epoch}:${this.seq}`;
84
+ const from = this.parseVersion(since);
85
+ if (from === undefined || from < this.floor) {
86
+ return { version, full: true, changed: Array.from(this.data.entries()), deleted: [] };
87
+ }
88
+ const changed: Array<[string, T]> = [];
89
+ for (const [id, at] of this.changedAt) {
90
+ if (at > from) changed.push([id, this.data.get(id) as T]);
91
+ }
92
+ const deleted: string[] = [];
93
+ for (const [id, at] of this.deletedAt) {
94
+ if (at > from) deleted.push(id);
95
+ }
96
+ return { version, full: false, changed, deleted };
39
97
  }
40
98
 
41
- find(predicate: (item: T) => boolean): Promise<T[]> {
99
+ async find(predicate: (item: T) => boolean): Promise<T[]> {
42
100
  const results: T[] = [];
43
101
  for (const item of this.data.values()) {
44
102
  if (predicate(item)) {
45
103
  results.push(item);
46
104
  }
47
105
  }
48
- return Promise.resolve(results);
106
+ return results;
49
107
  }
50
108
 
51
- has(id: string): Promise<boolean> {
52
- return Promise.resolve(this.data.has(id));
109
+ async has(id: string): Promise<boolean> {
110
+ return this.data.has(id);
53
111
  }
54
112
 
55
- size(): Promise<number> {
56
- return Promise.resolve(this.data.size);
113
+ async size(): Promise<number> {
114
+ return this.data.size;
57
115
  }
58
116
 
59
117
  //
60
118
  // ═══ Write Operations ═══
61
119
  //
62
120
 
63
- set(id: string, value: T): Promise<void> {
121
+ async set(id: string, value: T): Promise<void> {
64
122
  this.data.set(id, value);
65
- return Promise.resolve();
123
+ this.noteSet(id);
66
124
  }
67
125
 
68
- delete(id: string): Promise<boolean> {
69
- return Promise.resolve(this.data.delete(id));
126
+ async delete(id: string): Promise<boolean> {
127
+ const existed = this.data.delete(id);
128
+ if (existed) this.noteDelete(id);
129
+ return existed;
70
130
  }
71
131
 
72
- clear(): Promise<void> {
132
+ async clear(): Promise<void> {
133
+ for (const id of this.data.keys()) this.noteDelete(id);
73
134
  this.data.clear();
74
- return Promise.resolve();
75
135
  }
76
136
 
77
137
  //
78
138
  // ═══ Bulk Operations ═══
79
139
  //
80
140
 
81
- setBatch(items: Array<[string, T]>): Promise<void> {
141
+ async setBatch(items: Array<[string, T]>): Promise<void> {
82
142
  for (const [id, value] of items) {
83
143
  this.data.set(id, value);
144
+ this.noteSet(id);
84
145
  }
85
- return Promise.resolve();
86
146
  }
87
147
 
88
- deleteBatch(ids: string[]): Promise<number> {
148
+ async deleteBatch(ids: string[]): Promise<number> {
89
149
  let deleted = 0;
90
150
  for (const id of ids) {
91
151
  if (this.data.delete(id)) {
152
+ this.noteDelete(id);
92
153
  deleted++;
93
154
  }
94
155
  }
95
- return Promise.resolve(deleted);
156
+ return deleted;
96
157
  }
97
158
 
98
- getBatch(ids: string[]): Promise<Map<string, T>> {
159
+ async getBatch(ids: string[]): Promise<Map<string, T>> {
99
160
  const result = new Map<string, T>();
100
161
  for (const id of ids) {
101
162
  const value = this.data.get(id);
@@ -103,32 +164,37 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
103
164
  result.set(id, value);
104
165
  }
105
166
  }
106
- return Promise.resolve(result);
167
+ return result;
107
168
  }
108
169
 
109
170
  //
110
171
  // ═══ Lifecycle & Metadata ═══
111
172
  //
112
173
 
113
- close(): Promise<void> {
174
+ async close(): Promise<void> {
114
175
  // No cleanup needed for in-memory storage
115
176
  this.data.clear();
116
- return Promise.resolve();
177
+ // A new epoch: a version of the closed storage gives all items
178
+ this.changedAt.clear();
179
+ this.deletedAt.clear();
180
+ this.epoch = newEpoch();
181
+ this.seq = 0;
182
+ this.floor = 0;
117
183
  }
118
184
 
119
- getMetadata(): Promise<StorageMetadata> {
185
+ async getMetadata(): Promise<StorageMetadata> {
120
186
  // Rough memory estimation:
121
187
  // Each entry has ~overhead of 100 bytes (key string + Map entry overhead)
122
188
  const estimatedEntrySize = 100;
123
189
  const memoryUsage = this.data.size * estimatedEntrySize;
124
190
 
125
- return Promise.resolve({
191
+ return {
126
192
  type: "memory",
127
193
  size: this.data.size,
128
194
  stats: {
129
195
  memoryUsage,
130
196
  },
131
- });
197
+ };
132
198
  }
133
199
 
134
200
  //
@@ -177,4 +243,34 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
177
243
  [Symbol.iterator](): IterableIterator<[string, T]> {
178
244
  return this.data.entries();
179
245
  }
246
+
247
+ //
248
+ // ═══ The change log ═══
249
+ //
250
+
251
+ private noteSet(id: string): void {
252
+ this.changedAt.set(id, ++this.seq);
253
+ this.deletedAt.delete(id);
254
+ }
255
+
256
+ private noteDelete(id: string): void {
257
+ this.changedAt.delete(id);
258
+ // Delete, then set: the tombstone goes to the end of the order (the newest)
259
+ this.deletedAt.delete(id);
260
+ this.deletedAt.set(id, ++this.seq);
261
+ while (this.deletedAt.size > this.maxTombstones) {
262
+ const [oldest, at] = this.deletedAt.entries().next().value as [string, number];
263
+ this.deletedAt.delete(oldest);
264
+ this.floor = Math.max(this.floor, at);
265
+ }
266
+ }
267
+
268
+ /** The number of a version of this storage, or undefined for another or an unknown version. */
269
+ private parseVersion(since: string | undefined): number | undefined {
270
+ if (since === undefined) return undefined;
271
+ const separator = since.lastIndexOf(":");
272
+ if (separator < 0 || since.slice(0, separator) !== this.epoch) return undefined;
273
+ const at = Number(since.slice(separator + 1));
274
+ return Number.isInteger(at) && at >= 0 && at <= this.seq ? at : undefined;
275
+ }
180
276
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The checks of the contract suite. The suite does not import a test framework, so a runtime
3
+ * entry of this package never loads one. A failed check throws `ContractViolation`.
4
+ */
5
+
6
+ /** The error of a failed check of the contract suite. */
7
+ export class ContractViolation extends Error {
8
+ override readonly name = "ContractViolation";
9
+ }
10
+
11
+ /** This function gives the text of a value for a message: JSON, with a `Map` as its entries. */
12
+ export function show(value: unknown): string {
13
+ if (value === undefined) return "undefined";
14
+ try {
15
+ return JSON.stringify(value, (_key, item: unknown) => (item instanceof Map ? { Map: [...item.entries()] } : item === undefined ? "<undefined>" : item));
16
+ } catch {
17
+ return String(value);
18
+ }
19
+ }
20
+
21
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
22
+ if (typeof value !== "object" || value === null) return false;
23
+ const prototype = Object.getPrototypeOf(value) as unknown;
24
+ return prototype === Object.prototype || prototype === null;
25
+ }
26
+
27
+ /**
28
+ * This function compares two values deeply: JSON values, arrays, plain objects and maps. Two maps
29
+ * are equal when they have the same keys with equal values, in any order.
30
+ */
31
+ export function deepEqual(actual: unknown, expected: unknown): boolean {
32
+ if (Object.is(actual, expected)) return true;
33
+ if (actual instanceof Map && expected instanceof Map) {
34
+ if (actual.size !== expected.size) return false;
35
+ for (const [key, value] of actual) {
36
+ if (!expected.has(key) || !deepEqual(value, expected.get(key))) return false;
37
+ }
38
+ return true;
39
+ }
40
+ if (Array.isArray(actual) && Array.isArray(expected)) {
41
+ return actual.length === expected.length && actual.every((item, index) => deepEqual(item, expected[index]));
42
+ }
43
+ if (isPlainObject(actual) && isPlainObject(expected)) {
44
+ const keys = Object.keys(actual);
45
+ if (keys.length !== Object.keys(expected).length) return false;
46
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(expected, key) && deepEqual(actual[key], expected[key]));
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /** This function throws `ContractViolation` when the two values are not deeply equal. */
52
+ export function same(actual: unknown, expected: unknown, what: string): void {
53
+ if (!deepEqual(actual, expected)) {
54
+ throw new ContractViolation(`${what}: expected ${show(expected)}, got ${show(actual)}`);
55
+ }
56
+ }
57
+
58
+ /** This function throws `ContractViolation` when the condition is false. */
59
+ export function holds(condition: boolean, what: string): void {
60
+ if (!condition) throw new ContractViolation(what);
61
+ }
62
+
63
+ /**
64
+ * This function starts an operation that must fail with a rejected promise, and gives the error.
65
+ * A synchronous throw breaks the contract too: each method of a storage gives a promise.
66
+ */
67
+ export async function rejects(start: () => Promise<unknown>, what: string): Promise<unknown> {
68
+ let promise: Promise<unknown>;
69
+ try {
70
+ promise = start();
71
+ } catch (error) {
72
+ throw new ContractViolation(`${what}: the method threw at once (${show(error instanceof Error ? error.message : error)}), and did not give a rejected promise`);
73
+ }
74
+ holds(typeof promise?.then === "function", `${what}: the method did not give a promise`);
75
+ try {
76
+ await promise;
77
+ } catch (error) {
78
+ return error;
79
+ }
80
+ throw new ContractViolation(`${what}: the promise did not reject`);
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
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@procedures/collections/testing`: the test-only entry of this package.
3
+ *
4
+ * This entry holds the contract suite of `CollectionStorage`. A storage package runs it in its
5
+ * tests with a factory of its storage. The entry imports no test framework: the caller gives
6
+ * `describe` and `it`. The runtime entries of the package do not import this entry.
7
+ */
8
+
9
+ export {
10
+ collectionStorageContract,
11
+ describeCollectionStorageContract,
12
+ type CollectionStorageContractOptions,
13
+ type ContractCase,
14
+ type ContractItem,
15
+ type ContractStorage,
16
+ type ContractTestApi,
17
+ type ContractTestContext,
18
+ } from "./storage-contract.js";
19
+ export { ContractViolation, deepEqual } from "./check.js";