@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.
- package/CHANGELOG.md +11 -0
- package/README.md +32 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/memory.d.ts +1 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +29 -32
- package/dist/storage/memory.js.map +1 -1
- package/dist/testing/check.d.ts +27 -0
- package/dist/testing/check.d.ts.map +1 -0
- package/dist/testing/check.js +89 -0
- package/dist/testing/check.js.map +1 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/storage-contract.d.ts +74 -0
- package/dist/testing/storage-contract.d.ts.map +1 -0
- package/dist/testing/storage-contract.js +405 -0
- package/dist/testing/storage-contract.js.map +1 -0
- package/package.json +5 -1
- package/src/storage/memory.ts +29 -32
- package/src/testing/check.ts +86 -0
- package/src/testing/index.ts +19 -0
- package/src/testing/storage-contract.ts +509 -0
package/src/storage/memory.ts
CHANGED
|
@@ -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
|
|
66
|
+
async get(id: string): Promise<T | undefined> {
|
|
67
|
+
return this.data.get(id);
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
getAll(): Promise<T[]> {
|
|
70
|
-
return
|
|
70
|
+
async getAll(): Promise<T[]> {
|
|
71
|
+
return Array.from(this.data.values());
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
getEntries(): Promise<Array<[string, T]>> {
|
|
74
|
-
return
|
|
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
|
|
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
|
|
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
|
|
106
|
+
return results;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
has(id: string): Promise<boolean> {
|
|
109
|
-
return
|
|
109
|
+
async has(id: string): Promise<boolean> {
|
|
110
|
+
return this.data.has(id);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
size(): Promise<number> {
|
|
113
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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";
|