@procedures/collections 0.3.0 → 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.
@@ -30,6 +30,7 @@ function newEpoch(): string {
30
30
  * - No persistence (data lost on restart)
31
31
  * - Memory-bound (limited by available RAM)
32
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
33
34
  * - A change log: `getChangesSince(version)` gives the changes after a version (roadmap R28)
34
35
  *
35
36
  * @example
@@ -62,27 +63,27 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
62
63
  // ═══ Read Operations ═══
63
64
  //
64
65
 
65
- get(id: string): Promise<T | undefined> {
66
- return Promise.resolve(this.data.get(id));
66
+ async get(id: string): Promise<T | undefined> {
67
+ return this.data.get(id);
67
68
  }
68
69
 
69
- getAll(): Promise<T[]> {
70
- return Promise.resolve(Array.from(this.data.values()));
70
+ async getAll(): Promise<T[]> {
71
+ return Array.from(this.data.values());
71
72
  }
72
73
 
73
- getEntries(): Promise<Array<[string, T]>> {
74
- return Promise.resolve(Array.from(this.data.entries()));
74
+ async getEntries(): Promise<Array<[string, T]>> {
75
+ return Array.from(this.data.entries());
75
76
  }
76
77
 
77
78
  /**
78
79
  * The changes after a version (roadmap R28). A version of another storage, of a closed
79
80
  * storage, or older than the dropped deletes gives all items (`full: true`).
80
81
  */
81
- getChangesSince(since?: string): Promise<StorageChanges<T>> {
82
+ async getChangesSince(since?: string): Promise<StorageChanges<T>> {
82
83
  const version = `${this.epoch}:${this.seq}`;
83
84
  const from = this.parseVersion(since);
84
85
  if (from === undefined || from < this.floor) {
85
- return Promise.resolve({ version, full: true, changed: Array.from(this.data.entries()), deleted: [] });
86
+ return { version, full: true, changed: Array.from(this.data.entries()), deleted: [] };
86
87
  }
87
88
  const changed: Array<[string, T]> = [];
88
89
  for (const [id, at] of this.changedAt) {
@@ -92,62 +93,59 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
92
93
  for (const [id, at] of this.deletedAt) {
93
94
  if (at > from) deleted.push(id);
94
95
  }
95
- return Promise.resolve({ version, full: false, changed, deleted });
96
+ return { version, full: false, changed, deleted };
96
97
  }
97
98
 
98
- find(predicate: (item: T) => boolean): Promise<T[]> {
99
+ async find(predicate: (item: T) => boolean): Promise<T[]> {
99
100
  const results: T[] = [];
100
101
  for (const item of this.data.values()) {
101
102
  if (predicate(item)) {
102
103
  results.push(item);
103
104
  }
104
105
  }
105
- return Promise.resolve(results);
106
+ return results;
106
107
  }
107
108
 
108
- has(id: string): Promise<boolean> {
109
- return Promise.resolve(this.data.has(id));
109
+ async has(id: string): Promise<boolean> {
110
+ return this.data.has(id);
110
111
  }
111
112
 
112
- size(): Promise<number> {
113
- return Promise.resolve(this.data.size);
113
+ async size(): Promise<number> {
114
+ return this.data.size;
114
115
  }
115
116
 
116
117
  //
117
118
  // ═══ Write Operations ═══
118
119
  //
119
120
 
120
- set(id: string, value: T): Promise<void> {
121
+ async set(id: string, value: T): Promise<void> {
121
122
  this.data.set(id, value);
122
123
  this.noteSet(id);
123
- return Promise.resolve();
124
124
  }
125
125
 
126
- delete(id: string): Promise<boolean> {
126
+ async delete(id: string): Promise<boolean> {
127
127
  const existed = this.data.delete(id);
128
128
  if (existed) this.noteDelete(id);
129
- return Promise.resolve(existed);
129
+ return existed;
130
130
  }
131
131
 
132
- clear(): Promise<void> {
132
+ async clear(): Promise<void> {
133
133
  for (const id of this.data.keys()) this.noteDelete(id);
134
134
  this.data.clear();
135
- return Promise.resolve();
136
135
  }
137
136
 
138
137
  //
139
138
  // ═══ Bulk Operations ═══
140
139
  //
141
140
 
142
- setBatch(items: Array<[string, T]>): Promise<void> {
141
+ async setBatch(items: Array<[string, T]>): Promise<void> {
143
142
  for (const [id, value] of items) {
144
143
  this.data.set(id, value);
145
144
  this.noteSet(id);
146
145
  }
147
- return Promise.resolve();
148
146
  }
149
147
 
150
- deleteBatch(ids: string[]): Promise<number> {
148
+ async deleteBatch(ids: string[]): Promise<number> {
151
149
  let deleted = 0;
152
150
  for (const id of ids) {
153
151
  if (this.data.delete(id)) {
@@ -155,10 +153,10 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
155
153
  deleted++;
156
154
  }
157
155
  }
158
- return Promise.resolve(deleted);
156
+ return deleted;
159
157
  }
160
158
 
161
- getBatch(ids: string[]): Promise<Map<string, T>> {
159
+ async getBatch(ids: string[]): Promise<Map<string, T>> {
162
160
  const result = new Map<string, T>();
163
161
  for (const id of ids) {
164
162
  const value = this.data.get(id);
@@ -166,14 +164,14 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
166
164
  result.set(id, value);
167
165
  }
168
166
  }
169
- return Promise.resolve(result);
167
+ return result;
170
168
  }
171
169
 
172
170
  //
173
171
  // ═══ Lifecycle & Metadata ═══
174
172
  //
175
173
 
176
- close(): Promise<void> {
174
+ async close(): Promise<void> {
177
175
  // No cleanup needed for in-memory storage
178
176
  this.data.clear();
179
177
  // A new epoch: a version of the closed storage gives all items
@@ -182,22 +180,21 @@ export class InMemoryStorage<T> implements CollectionStorage<T> {
182
180
  this.epoch = newEpoch();
183
181
  this.seq = 0;
184
182
  this.floor = 0;
185
- return Promise.resolve();
186
183
  }
187
184
 
188
- getMetadata(): Promise<StorageMetadata> {
185
+ async getMetadata(): Promise<StorageMetadata> {
189
186
  // Rough memory estimation:
190
187
  // Each entry has ~overhead of 100 bytes (key string + Map entry overhead)
191
188
  const estimatedEntrySize = 100;
192
189
  const memoryUsage = this.data.size * estimatedEntrySize;
193
190
 
194
- return Promise.resolve({
191
+ return {
195
192
  type: "memory",
196
193
  size: this.data.size,
197
194
  stats: {
198
195
  memoryUsage,
199
196
  },
200
- });
197
+ };
201
198
  }
202
199
 
203
200
  //
@@ -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";