@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.
- package/CHANGELOG.md +22 -0
- package/README.md +32 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interface.d.ts +42 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/interface.js.map +1 -1
- package/dist/storage/memory.d.ts +32 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +111 -28
- 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/index.ts +2 -1
- package/src/storage/interface.ts +46 -0
- package/src/storage/memory.ts +124 -28
- 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
|
@@ -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
|
|
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
|
-
|
|
38
|
-
return
|
|
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
|
|
106
|
+
return results;
|
|
49
107
|
}
|
|
50
108
|
|
|
51
|
-
has(id: string): Promise<boolean> {
|
|
52
|
-
return
|
|
109
|
+
async has(id: string): Promise<boolean> {
|
|
110
|
+
return this.data.has(id);
|
|
53
111
|
}
|
|
54
112
|
|
|
55
|
-
size(): Promise<number> {
|
|
56
|
-
return
|
|
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
|
-
|
|
123
|
+
this.noteSet(id);
|
|
66
124
|
}
|
|
67
125
|
|
|
68
|
-
delete(id: string): Promise<boolean> {
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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";
|