@orkestrel/database 0.0.1
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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/src/core/Clause.d.ts +30 -0
- package/dist/src/core/Cursor.d.ts +21 -0
- package/dist/src/core/Database.d.ts +80 -0
- package/dist/src/core/Query.d.ts +47 -0
- package/dist/src/core/Table.d.ts +69 -0
- package/dist/src/core/constants.d.ts +22 -0
- package/dist/src/core/drivers/MemoryDriver.d.ts +94 -0
- package/dist/src/core/errors.d.ts +38 -0
- package/dist/src/core/factories.d.ts +43 -0
- package/dist/src/core/helpers.d.ts +383 -0
- package/dist/src/core/index.d.ts +11 -0
- package/dist/src/core/index.js +3364 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/core/types.d.ts +739 -0
- package/dist/src/server/compilers.d.ts +169 -0
- package/dist/src/server/drivers/JSONDriver.d.ts +106 -0
- package/dist/src/server/factories.d.ts +31 -0
- package/dist/src/server/helpers.d.ts +222 -0
- package/dist/src/server/index.cjs +1014 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.ts +5 -0
- package/dist/src/server/types.d.ts +36 -0
- package/package.json +84 -0
|
@@ -0,0 +1,3364 @@
|
|
|
1
|
+
//#region src/core/constants.ts
|
|
2
|
+
/**
|
|
3
|
+
* The primary-key column assumed when {@link TableKeys} does not name one.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* `id` is the convention IndexedDB (`keyPath: 'id'`) and SQL (`id` / rowid) both
|
|
7
|
+
* lean on, so a table that omits `key` keys its rows by `id`.
|
|
8
|
+
*/
|
|
9
|
+
var DEFAULT_PRIMARY = "id";
|
|
10
|
+
/**
|
|
11
|
+
* The longest `LIKE` / `GLOB` pattern the wildcard matcher accepts before rejecting it.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* A ReDoS bound (AGENTS §6.5): the SA1–SA4 migration lets a model supply `list`
|
|
15
|
+
* criteria over the wire, so `likeMatch` / `globMatch` run attacker-controlled
|
|
16
|
+
* patterns. The matcher is the LINEAR greedy two-pointer wildcard match — never a
|
|
17
|
+
* backtracking regex (`.*`-segments-separated-by-literals against a long input is the
|
|
18
|
+
* catastrophic shape JS cannot bound without atomic groups), so it is O(value ×
|
|
19
|
+
* pattern). Capping the pattern length bounds that pattern factor, leaving a match
|
|
20
|
+
* linear in the value length whatever the pattern. A longer pattern throws a
|
|
21
|
+
* `VALIDATION` {@link DatabaseError}; the cap is generous for any legitimate search.
|
|
22
|
+
*/
|
|
23
|
+
var MAX_PATTERN_LENGTH = 1024;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/core/errors.ts
|
|
26
|
+
/**
|
|
27
|
+
* An error thrown by the database layer.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* Carries a {@link DatabaseErrorCode} and an optional `context` bag naming the
|
|
31
|
+
* offending table / key. Thrown for: operating on a closed database (`CLOSED`), a
|
|
32
|
+
* `resolve` miss (`NOT_FOUND`), an `add` onto an existing key (`CONFLICT`), a
|
|
33
|
+
* row that fails its table's contract (`VALIDATION`), a cancelled operation whose
|
|
34
|
+
* {@link ReadOptions.signal} aborted (`ABORTED`, carrying `signal.reason` in
|
|
35
|
+
* `context`), an inapplicable {@link Migration} plan (`MIGRATION`), a
|
|
36
|
+
* driver that violates a {@link DriverInterface} invariant, thrown by the
|
|
37
|
+
* `conformDriver` helper (`CONFORMANCE`), and an unexpected infrastructure
|
|
38
|
+
* fault surfaced by a driver seam — e.g. a filesystem failure while
|
|
39
|
+
* persisting (`DRIVER`) — as opposed to expected domain conditions, which
|
|
40
|
+
* keep their specific codes.
|
|
41
|
+
*/
|
|
42
|
+
var DatabaseError = class extends Error {
|
|
43
|
+
code;
|
|
44
|
+
context;
|
|
45
|
+
constructor(code, message, context) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "DatabaseError";
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.context = context;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Narrow an unknown caught value to a {@link DatabaseError}.
|
|
54
|
+
*
|
|
55
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
56
|
+
* @returns `true` when `value` is a {@link DatabaseError}
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* try {
|
|
61
|
+
* await users.add(row)
|
|
62
|
+
* } catch (error) {
|
|
63
|
+
* if (isDatabaseError(error) && error.code === 'CONFLICT') await users.set(row)
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function isDatabaseError(value) {
|
|
68
|
+
return value instanceof DatabaseError;
|
|
69
|
+
}
|
|
70
|
+
Object.freeze([
|
|
71
|
+
"null",
|
|
72
|
+
"boolean",
|
|
73
|
+
"object",
|
|
74
|
+
"array",
|
|
75
|
+
"number",
|
|
76
|
+
"integer",
|
|
77
|
+
"string"
|
|
78
|
+
]);
|
|
79
|
+
/** Determine whether a value is `null`. */
|
|
80
|
+
function isNull(value) {
|
|
81
|
+
return value === null;
|
|
82
|
+
}
|
|
83
|
+
/** Determine whether a value is `undefined`. */
|
|
84
|
+
function isUndefined(value) {
|
|
85
|
+
return value === void 0;
|
|
86
|
+
}
|
|
87
|
+
/** Determine whether a value is a string. */
|
|
88
|
+
function isString(value) {
|
|
89
|
+
return typeof value === "string";
|
|
90
|
+
}
|
|
91
|
+
/** Determine whether a value is a finite number (excludes `NaN` and `±Infinity`). */
|
|
92
|
+
function isFiniteNumber(value) {
|
|
93
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
94
|
+
}
|
|
95
|
+
/** Determine whether a value is a finite integer (excludes `NaN`, `±Infinity`, and fractional numbers). */
|
|
96
|
+
function isInteger(value) {
|
|
97
|
+
return Number.isInteger(value);
|
|
98
|
+
}
|
|
99
|
+
/** Determine whether a value is a boolean. */
|
|
100
|
+
function isBoolean(value) {
|
|
101
|
+
return typeof value === "boolean";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Determine whether a value is a non-null object.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* `true` for arrays, class instances, plain objects, `Map`, `Set`, etc. — use
|
|
108
|
+
* {@link isRecord} when you need a plain-record check.
|
|
109
|
+
*/
|
|
110
|
+
function isObject(value) {
|
|
111
|
+
return typeof value === "object" && value !== null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Determine whether a value is a plain record (object literal or null-prototype),
|
|
115
|
+
* not an array or class instance.
|
|
116
|
+
*
|
|
117
|
+
* @remarks
|
|
118
|
+
* Use instead of {@link isObject} to distinguish a plain `{}` /
|
|
119
|
+
* `Object.create(null)` from arrays, `Date`, `Map`, etc. The prototype-chain
|
|
120
|
+
* test is realm-agnostic: rather than comparing against the current realm's
|
|
121
|
+
* `Object.prototype` (which a plain object from another `vm.Context`, iframe,
|
|
122
|
+
* or worker would fail), it accepts any value whose prototype is `null`, OR
|
|
123
|
+
* whose prototype's own prototype is `null` — the shape every plain object
|
|
124
|
+
* has in every realm, since `Object.prototype` itself always sits one step
|
|
125
|
+
* above `null`. Arrays and class instances are still rejected: an array's
|
|
126
|
+
* prototype chain runs through `Array.prototype` before `null`, and a class
|
|
127
|
+
* instance's runs through the class's own prototype. The whole body runs
|
|
128
|
+
* inside `attempt` (AGENTS §14) so a revoked `Proxy` or a hostile
|
|
129
|
+
* `getPrototypeOf` trap cannot escape as a thrown error.
|
|
130
|
+
*/
|
|
131
|
+
function isRecord(value) {
|
|
132
|
+
const outcome = attempt(() => {
|
|
133
|
+
if (!isObject(value) || isArray(value)) return false;
|
|
134
|
+
const prototype = Object.getPrototypeOf(value);
|
|
135
|
+
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
|
136
|
+
});
|
|
137
|
+
return outcome.success && outcome.value;
|
|
138
|
+
}
|
|
139
|
+
/** Determine whether a value is an array. */
|
|
140
|
+
function isArray(value) {
|
|
141
|
+
return Array.isArray(value);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Determine whether a value is a cycle-safe JSON value.
|
|
145
|
+
*
|
|
146
|
+
* @remarks
|
|
147
|
+
* Total guard: never throws, returns `false` for cycles, functions, `Date`
|
|
148
|
+
* instances, class instances, `NaN`, and `±Infinity`. Arrays and plain records
|
|
149
|
+
* are walked with an ancestor set so recursive input fails instead of hanging.
|
|
150
|
+
* The whole walk runs inside `attempt` (AGENTS §14): a hostile getter on a
|
|
151
|
+
* record property, or a revoked `Proxy` anywhere in the structure, is caught
|
|
152
|
+
* and yields `false` instead of escaping as a thrown error.
|
|
153
|
+
*
|
|
154
|
+
* @param value - The value to test
|
|
155
|
+
* @returns `true` when the value has a JSON representation
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* isJSONValue({ nested: [1, 'x', null] }) // true
|
|
160
|
+
* isJSONValue(Number.NaN) // false
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
function isJSONValue(value) {
|
|
164
|
+
const ancestors = /* @__PURE__ */ new WeakSet();
|
|
165
|
+
const check = (entry) => {
|
|
166
|
+
if (entry === null || isString(entry) || isBoolean(entry) || isFiniteNumber(entry)) return true;
|
|
167
|
+
if (Array.isArray(entry)) {
|
|
168
|
+
if (ancestors.has(entry)) return false;
|
|
169
|
+
ancestors.add(entry);
|
|
170
|
+
const valid = entry.every(check);
|
|
171
|
+
ancestors.delete(entry);
|
|
172
|
+
return valid;
|
|
173
|
+
}
|
|
174
|
+
if (!isRecord(entry)) return false;
|
|
175
|
+
if (ancestors.has(entry)) return false;
|
|
176
|
+
ancestors.add(entry);
|
|
177
|
+
const valid = Object.values(entry).every(check);
|
|
178
|
+
ancestors.delete(entry);
|
|
179
|
+
return valid;
|
|
180
|
+
};
|
|
181
|
+
const outcome = attempt(() => check(value));
|
|
182
|
+
return outcome.success && outcome.value;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Invoke a callback and capture its outcome as a {@link Result}, never letting
|
|
186
|
+
* a throw escape.
|
|
187
|
+
*
|
|
188
|
+
* @remarks
|
|
189
|
+
* The single sanctioned never-throw boundary for the guards (AGENTS §14). The
|
|
190
|
+
* `whereOf`, `lazyOf`, and `transformOf` combinators invoke caller-supplied
|
|
191
|
+
* callbacks *inside* a guard body, yet a guard must NEVER throw — it returns a
|
|
192
|
+
* `boolean`. This converts a throwing callback into a `Failure` so the
|
|
193
|
+
* surrounding guard can treat it as a non-match instead of propagating the
|
|
194
|
+
* exception, written once and shared rather than copy-pasted as ad-hoc
|
|
195
|
+
* `try`/`catch`.
|
|
196
|
+
*
|
|
197
|
+
* @param callback - The callback to invoke with no arguments
|
|
198
|
+
* @returns A `Success` carrying the return value, or a `Failure` carrying the
|
|
199
|
+
* thrown reason normalised to an `Error`
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```ts
|
|
203
|
+
* const outcome = attempt(() => predicate(value))
|
|
204
|
+
* return outcome.success && outcome.value
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
function attempt(callback) {
|
|
208
|
+
try {
|
|
209
|
+
return {
|
|
210
|
+
success: true,
|
|
211
|
+
value: callback()
|
|
212
|
+
};
|
|
213
|
+
} catch (reason) {
|
|
214
|
+
if (reason instanceof Error) return {
|
|
215
|
+
success: false,
|
|
216
|
+
error: reason
|
|
217
|
+
};
|
|
218
|
+
let message = "Unknown thrown value";
|
|
219
|
+
try {
|
|
220
|
+
message = String(reason);
|
|
221
|
+
} catch {}
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
error: new Error(message)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Resolve a (possibly nested) field value from a record by a key or key path.
|
|
230
|
+
*
|
|
231
|
+
* @remarks
|
|
232
|
+
* A single `string` is ONE key (never split on `.`, so dotted keys are safe); a
|
|
233
|
+
* string array descends left-to-right through nested objects. Intermediates may
|
|
234
|
+
* be any object — records, class instances, or arrays indexed by string. Returns
|
|
235
|
+
* `undefined` the moment a segment is missing or lands on a non-object, so the
|
|
236
|
+
* lookup is total — even against a hostile getter or Proxy trap that throws on
|
|
237
|
+
* read, contained via {@link attempt} so the throw never escapes.
|
|
238
|
+
*
|
|
239
|
+
* @param record - The source record
|
|
240
|
+
* @param path - A property key, or a key path descending into nested objects
|
|
241
|
+
* @returns The resolved value, or `undefined`
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```ts
|
|
245
|
+
* resolveField({ user: { name: 'Ada' } }, ['user', 'name']) // 'Ada'
|
|
246
|
+
* resolveField({ 'a.b': 1 }, 'a.b') // 1 (one key)
|
|
247
|
+
* resolveField({ a: 1 }, ['a', 'b']) // undefined
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
function resolveField(record, path) {
|
|
251
|
+
const keys = isString(path) ? [path] : path;
|
|
252
|
+
let current = record;
|
|
253
|
+
for (const key of keys) {
|
|
254
|
+
if (!isObject(current)) return void 0;
|
|
255
|
+
const container = current;
|
|
256
|
+
const outcome = attempt(() => Reflect.get(container, key));
|
|
257
|
+
if (!outcome.success) return void 0;
|
|
258
|
+
current = outcome.value;
|
|
259
|
+
}
|
|
260
|
+
return current;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Build a deterministic pseudo-random source seeded from a single number.
|
|
264
|
+
*
|
|
265
|
+
* @remarks
|
|
266
|
+
* A mulberry32 generator — the same seed always yields the same sequence, so
|
|
267
|
+
* generated seed data is reproducible across runs. Used as the default random
|
|
268
|
+
* source for {@link compileGenerator}, seeded from the wall clock so casual
|
|
269
|
+
* callers still get varied output without passing a source themselves.
|
|
270
|
+
*
|
|
271
|
+
* @param seed - The seed for the sequence
|
|
272
|
+
* @returns A {@link RandomFunction} returning values in `[0, 1)`
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* ```ts
|
|
276
|
+
* const random = seededRandom(42)
|
|
277
|
+
* random() // always the same first value for seed 42
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
function seededRandom(seed) {
|
|
281
|
+
let state = seed >>> 0;
|
|
282
|
+
return () => {
|
|
283
|
+
state = state + 1831565813 >>> 0;
|
|
284
|
+
let t = state;
|
|
285
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
286
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
287
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function arrayOf(elementGuard) {
|
|
291
|
+
return (value) => {
|
|
292
|
+
if (!isArray(value)) return false;
|
|
293
|
+
const outcome = attempt(() => value.every(elementGuard));
|
|
294
|
+
return outcome.success && outcome.value;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Build a guard that accepts values identical (via `Object.is`) to one of the
|
|
299
|
+
* provided literal primitives.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```ts
|
|
303
|
+
* const isRole = literalOf('admin', 'member', 'guest')
|
|
304
|
+
* isRole('admin') // true
|
|
305
|
+
* isRole('owner') // false
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
function literalOf(...literals) {
|
|
309
|
+
return (value) => literals.some((literal) => Object.is(literal, value));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Build a guard that accepts plain records matching a guard shape.
|
|
313
|
+
*
|
|
314
|
+
* @remarks
|
|
315
|
+
* Three calling modes depending on the `optional` argument:
|
|
316
|
+
* - **No `optional`** — all shape keys required; extra keys rejected.
|
|
317
|
+
* - **`optional: K[]`** — the listed keys are optional; all others required.
|
|
318
|
+
* - **`optional: true`** — every shape key is optional.
|
|
319
|
+
*
|
|
320
|
+
* Key presence is tested with `Object.hasOwn`, so a shape key satisfied only by
|
|
321
|
+
* an inherited prototype member (`toString`, `constructor`, …) counts as absent.
|
|
322
|
+
* A non-object / `null` / array input returns `false` rather than throwing. The
|
|
323
|
+
* extra-key check only inspects `Object.keys` (string keys), so an extra
|
|
324
|
+
* enumerable SYMBOL key is never rejected — intentional, for JSON fidelity, and
|
|
325
|
+
* matches the compiled guard.
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```ts
|
|
329
|
+
* const isUser = recordOf({ name: isString, age: isNumber })
|
|
330
|
+
* isUser({ name: 'Ada', age: 36 }) // true
|
|
331
|
+
* isUser({ name: 'Ada' }) // false — age missing
|
|
332
|
+
*
|
|
333
|
+
* const isPartial = recordOf({ name: isString, age: isNumber }, ['age'])
|
|
334
|
+
* isPartial({ name: 'Ada' }) // true
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
function recordOf(shape, optional) {
|
|
338
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
339
|
+
for (const key in shape) if (Object.prototype.hasOwnProperty.call(shape, key)) allowed.add(key);
|
|
340
|
+
const optionalSet = new Set(optional === true ? [...allowed] : isArray(optional) ? optional.map((key) => String(key)) : []);
|
|
341
|
+
return (value) => {
|
|
342
|
+
if (!isRecord(value)) return false;
|
|
343
|
+
const outcome = attempt(() => {
|
|
344
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) return false;
|
|
345
|
+
for (const key in shape) {
|
|
346
|
+
if (!Object.prototype.hasOwnProperty.call(shape, key)) continue;
|
|
347
|
+
const present = Object.hasOwn(value, key);
|
|
348
|
+
if (!optionalSet.has(key) && !present) return false;
|
|
349
|
+
if (present) {
|
|
350
|
+
const guard = shape[key];
|
|
351
|
+
if (!guard(value[key])) return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
});
|
|
356
|
+
return outcome.success && outcome.value;
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function orOf(left, right) {
|
|
360
|
+
return (value) => left(value) || right(value);
|
|
361
|
+
}
|
|
362
|
+
function unionOf(...guards) {
|
|
363
|
+
return (value) => guards.some((guard) => guard(value));
|
|
364
|
+
}
|
|
365
|
+
function intersectionOf(...guards) {
|
|
366
|
+
return (value) => guards.every((guard) => guard(value));
|
|
367
|
+
}
|
|
368
|
+
function whereOf(base, predicate) {
|
|
369
|
+
return (value) => {
|
|
370
|
+
if (!base(value)) return false;
|
|
371
|
+
const outcome = attempt(() => predicate(value));
|
|
372
|
+
return outcome.success && outcome.value;
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Build a guard that accepts finite numbers within an inclusive `[min, max]`
|
|
377
|
+
* range.
|
|
378
|
+
*
|
|
379
|
+
* @remarks
|
|
380
|
+
* Refines {@link isFiniteNumber} with the bound comparison, so `NaN` /
|
|
381
|
+
* `±Infinity` are rejected before any comparison runs. An absent bound never
|
|
382
|
+
* constrains that side. Reused for a number's own value AND, applied to a
|
|
383
|
+
* `.length`, for string and array length refinements — the single source of the
|
|
384
|
+
* bound logic shared by the compiled guard and parser (compilers.ts).
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* ```ts
|
|
388
|
+
* const inRange = boundsOf(1, 5)
|
|
389
|
+
* inRange(3) // true
|
|
390
|
+
* inRange(0) // false — below min
|
|
391
|
+
* inRange(6) // false — above max
|
|
392
|
+
*
|
|
393
|
+
* const atLeastTwo = boundsOf(2)
|
|
394
|
+
* atLeastTwo(2) // true — unbounded above
|
|
395
|
+
* ```
|
|
396
|
+
*/
|
|
397
|
+
function boundsOf(min, max) {
|
|
398
|
+
return whereOf(isFiniteNumber, (value) => (min === void 0 || value >= min) && (max === void 0 || value <= max));
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Build a guard that accepts strings satisfying optional length and pattern
|
|
402
|
+
* refinements — `min` / `max` length and a `pattern`.
|
|
403
|
+
*
|
|
404
|
+
* @remarks
|
|
405
|
+
* Composes {@link isString} with {@link boundsOf} on the string's `.length` and
|
|
406
|
+
* an inline `pattern.test` (the same refinement {@link matchOf} performs). When all three options are absent it returns
|
|
407
|
+
* the bare {@link isString} guard (the unconstrained fast path), so an
|
|
408
|
+
* unrefined string leaf pays no wrapping cost. The single source of the string
|
|
409
|
+
* refinement shared by the compiled guard and parser (compilers.ts).
|
|
410
|
+
*
|
|
411
|
+
* @example
|
|
412
|
+
* ```ts
|
|
413
|
+
* const isSlug = stringOf({ min: 1, max: 32, pattern: /^[a-z-]+$/ })
|
|
414
|
+
* isSlug('hello-world') // true
|
|
415
|
+
* isSlug('') // false — below min
|
|
416
|
+
* isSlug('Hello') // false — pattern miss
|
|
417
|
+
*
|
|
418
|
+
* stringOf() // identical to isString
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
function stringOf(options) {
|
|
422
|
+
const min = options?.min;
|
|
423
|
+
const max = options?.max;
|
|
424
|
+
const pattern = options?.pattern;
|
|
425
|
+
if (min === void 0 && max === void 0 && pattern === void 0) return isString;
|
|
426
|
+
const withinLength = boundsOf(min, max);
|
|
427
|
+
return whereOf(isString, (value) => withinLength(value.length) && (pattern === void 0 || pattern.test(value)));
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Extend a guard to also allow `null`.
|
|
431
|
+
*
|
|
432
|
+
* @example
|
|
433
|
+
* ```ts
|
|
434
|
+
* const isNullableString = nullableOf(isString)
|
|
435
|
+
* isNullableString('hi') // true
|
|
436
|
+
* isNullableString(null) // true
|
|
437
|
+
* isNullableString(42) // false
|
|
438
|
+
* ```
|
|
439
|
+
*/
|
|
440
|
+
function nullableOf(guard) {
|
|
441
|
+
return (value) => value === null || guard(value);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Parse an unknown value to a string.
|
|
445
|
+
*
|
|
446
|
+
* @remarks
|
|
447
|
+
* A string is returned unchanged; a finite number is coerced to its decimal
|
|
448
|
+
* string (`42` → `'42'`). `NaN`, `±Infinity`, and every other type → `undefined`.
|
|
449
|
+
*
|
|
450
|
+
* @param value - The value to parse
|
|
451
|
+
* @returns A string, or `undefined`
|
|
452
|
+
*/
|
|
453
|
+
function parseString(value) {
|
|
454
|
+
if (isString(value)) return value;
|
|
455
|
+
if (isFiniteNumber(value)) return String(value);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Parse an unknown value to a finite number.
|
|
459
|
+
*
|
|
460
|
+
* @remarks
|
|
461
|
+
* A finite number is returned unchanged; a non-blank numeric string is parsed
|
|
462
|
+
* via `Number(...)`. `NaN`, `±Infinity`, blank/non-numeric strings, and every
|
|
463
|
+
* other type → `undefined`.
|
|
464
|
+
*
|
|
465
|
+
* @param value - The value to parse
|
|
466
|
+
* @returns A finite number, or `undefined`
|
|
467
|
+
*/
|
|
468
|
+
function parseNumber(value) {
|
|
469
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
470
|
+
if (isString(value)) {
|
|
471
|
+
if (value.trim() === "") return void 0;
|
|
472
|
+
const parsed = Number(value);
|
|
473
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Parse an unknown value to a finite integer.
|
|
478
|
+
*
|
|
479
|
+
* @remarks
|
|
480
|
+
* Accepts whatever {@link parseNumber} accepts, then requires the result to have
|
|
481
|
+
* no fractional part. `3.14` / `'3.14'` → `undefined`.
|
|
482
|
+
*
|
|
483
|
+
* @param value - The value to parse
|
|
484
|
+
* @returns A finite integer, or `undefined`
|
|
485
|
+
*/
|
|
486
|
+
function parseInteger(value) {
|
|
487
|
+
const parsed = parseNumber(value);
|
|
488
|
+
if (parsed === void 0) return void 0;
|
|
489
|
+
return Number.isInteger(parsed) ? parsed : void 0;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Parse an unknown value to a boolean.
|
|
493
|
+
*
|
|
494
|
+
* @remarks
|
|
495
|
+
* A boolean is returned unchanged. The strings `'true'` / `'false'` / `'1'` /
|
|
496
|
+
* `'0'` and the numbers `1` / `0` coerce to the matching boolean. Everything
|
|
497
|
+
* else → `undefined`.
|
|
498
|
+
*
|
|
499
|
+
* @param value - The value to parse
|
|
500
|
+
* @returns A boolean, or `undefined`
|
|
501
|
+
*/
|
|
502
|
+
function parseBoolean(value) {
|
|
503
|
+
if (typeof value === "boolean") return value;
|
|
504
|
+
if (value === "true" || value === "1" || value === 1) return true;
|
|
505
|
+
if (value === "false" || value === "0" || value === 0) return false;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Parse an unknown value to a plain record — the input reference, never cloned.
|
|
509
|
+
*
|
|
510
|
+
* @param value - The value to parse
|
|
511
|
+
* @returns The record, or `undefined`
|
|
512
|
+
*/
|
|
513
|
+
function parseRecord(value) {
|
|
514
|
+
return isRecord(value) ? value : void 0;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Validate that a {@link ContractShape} tree is well-formed — a pure recursive
|
|
518
|
+
* prepass run before compilation.
|
|
519
|
+
*
|
|
520
|
+
* @remarks
|
|
521
|
+
* Fail-fast, per AGENTS §12: a malformed shape is a programmer error, so this
|
|
522
|
+
* throws a plain `Error` immediately rather than surfacing as a silently-wrong
|
|
523
|
+
* guard, parser, schema, or generator later. Checks, recursively:
|
|
524
|
+
*
|
|
525
|
+
* - An {@link OptionalShape} is only legal as a direct object-property value —
|
|
526
|
+
* `optionalShape` wrapping an array item, a union variant, another
|
|
527
|
+
* optional/nullable's inner shape, `additionalProperties`, or the top-level
|
|
528
|
+
* shape all throw. An object property IS the one legal placement: its value
|
|
529
|
+
* is unwrapped to `.inner` before recursing, so `.inner` itself is validated
|
|
530
|
+
* as a normal (non-optional-wrapping) shape.
|
|
531
|
+
* - A {@link UnionShape} needs at least one variant; a {@link LiteralShape}
|
|
532
|
+
* needs at least one value and rejects non-finite (`NaN` / `Infinity` /
|
|
533
|
+
* `-Infinity`) number values.
|
|
534
|
+
* - A bounded {@link StringShape} / {@link NumberShape} / {@link ArrayShape}
|
|
535
|
+
* needs `min <= max` when both are set.
|
|
536
|
+
* - An integer {@link NumberShape} (`integer: true`) needs a non-empty integer
|
|
537
|
+
* range: `Math.ceil(min ?? -Infinity) <= Math.floor(max ?? Infinity)`.
|
|
538
|
+
* - `null` / `json` / `raw` / `boolean` are always-valid leaves. Recursion
|
|
539
|
+
* continues into array items, object properties (and `additionalProperties`
|
|
540
|
+
* when it is a shape), union variants, and optional/nullable inner shapes.
|
|
541
|
+
*
|
|
542
|
+
* @param shape - The shape to validate
|
|
543
|
+
* @throws {Error} When the shape is malformed
|
|
544
|
+
*/
|
|
545
|
+
function validateShape(shape) {
|
|
546
|
+
switch (shape.type) {
|
|
547
|
+
case "string":
|
|
548
|
+
if (shape.min !== void 0 && shape.max !== void 0 && shape.min > shape.max) throw new Error("validateShape: a string shape has min greater than max");
|
|
549
|
+
return;
|
|
550
|
+
case "number":
|
|
551
|
+
if (shape.min !== void 0 && shape.max !== void 0 && shape.min > shape.max) throw new Error("validateShape: a number shape has min greater than max");
|
|
552
|
+
if (shape.integer === true) {
|
|
553
|
+
if (Math.ceil(shape.min ?? Number.NEGATIVE_INFINITY) > Math.floor(shape.max ?? Number.POSITIVE_INFINITY)) throw new Error("validateShape: an integer number shape has an empty integer range");
|
|
554
|
+
}
|
|
555
|
+
return;
|
|
556
|
+
case "boolean":
|
|
557
|
+
case "null":
|
|
558
|
+
case "json":
|
|
559
|
+
case "raw": return;
|
|
560
|
+
case "literal":
|
|
561
|
+
if (shape.values.length === 0) throw new Error("validateShape: a literal shape needs at least one value");
|
|
562
|
+
for (const value of shape.values) if (typeof value === "number" && !Number.isFinite(value)) throw new Error("validateShape: a literal shape may not contain non-finite number values");
|
|
563
|
+
return;
|
|
564
|
+
case "array":
|
|
565
|
+
if (shape.min !== void 0 && shape.max !== void 0 && shape.min > shape.max) throw new Error("validateShape: an array shape has min greater than max");
|
|
566
|
+
validateShape(shape.items);
|
|
567
|
+
return;
|
|
568
|
+
case "object": {
|
|
569
|
+
for (const key of Object.keys(shape.properties)) {
|
|
570
|
+
const child = shape.properties[key];
|
|
571
|
+
if (child === void 0) continue;
|
|
572
|
+
validateShape(child.type === "optional" ? child.inner : child);
|
|
573
|
+
}
|
|
574
|
+
const extra = shape.additionalProperties;
|
|
575
|
+
if (extra !== void 0 && extra !== true && extra !== false) validateShape(extra);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
case "union":
|
|
579
|
+
if (shape.variants.length === 0) throw new Error("validateShape: a union shape needs at least one variant");
|
|
580
|
+
for (const variant of shape.variants) validateShape(variant);
|
|
581
|
+
return;
|
|
582
|
+
case "optional": throw new Error("validateShape: an optional shape may only appear as a direct object-property value");
|
|
583
|
+
case "nullable":
|
|
584
|
+
validateShape(shape.inner);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Compile a {@link ContractShape} into a JSON Schema document.
|
|
590
|
+
*
|
|
591
|
+
* @remarks
|
|
592
|
+
* Object shapes emit `additionalProperties: false` (unless opened) and list only
|
|
593
|
+
* required keys in `required`; nullable shapes emit an `anyOf` with `{ type:
|
|
594
|
+
* 'null' }`. Emission only — it never inspects a runtime value.
|
|
595
|
+
*
|
|
596
|
+
* @param shape - The shape to compile
|
|
597
|
+
* @returns The emitted JSON Schema
|
|
598
|
+
*/
|
|
599
|
+
function compileSchema(shape) {
|
|
600
|
+
switch (shape.type) {
|
|
601
|
+
case "string": return {
|
|
602
|
+
type: "string",
|
|
603
|
+
...shape.min !== void 0 ? { minLength: shape.min } : {},
|
|
604
|
+
...shape.max !== void 0 ? { maxLength: shape.max } : {},
|
|
605
|
+
...shape.pattern !== void 0 ? { pattern: shape.pattern.source } : {},
|
|
606
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
607
|
+
};
|
|
608
|
+
case "number": return {
|
|
609
|
+
type: shape.integer === true ? "integer" : "number",
|
|
610
|
+
...shape.min !== void 0 ? { minimum: shape.min } : {},
|
|
611
|
+
...shape.max !== void 0 ? { maximum: shape.max } : {},
|
|
612
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
613
|
+
};
|
|
614
|
+
case "boolean": return {
|
|
615
|
+
type: "boolean",
|
|
616
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
617
|
+
};
|
|
618
|
+
case "null": return {
|
|
619
|
+
type: "null",
|
|
620
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
621
|
+
};
|
|
622
|
+
case "json": return { ...shape.description !== void 0 ? { description: shape.description } : {} };
|
|
623
|
+
case "literal": return {
|
|
624
|
+
enum: [...shape.values],
|
|
625
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
626
|
+
};
|
|
627
|
+
case "array": return {
|
|
628
|
+
type: "array",
|
|
629
|
+
items: compileSchema(shape.items),
|
|
630
|
+
...shape.min !== void 0 ? { minItems: shape.min } : {},
|
|
631
|
+
...shape.max !== void 0 ? { maxItems: shape.max } : {},
|
|
632
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
633
|
+
};
|
|
634
|
+
case "object": {
|
|
635
|
+
const properties = {};
|
|
636
|
+
const required = [];
|
|
637
|
+
for (const key of Object.keys(shape.properties)) {
|
|
638
|
+
const child = shape.properties[key];
|
|
639
|
+
if (child === void 0) continue;
|
|
640
|
+
properties[key] = compileSchema(child);
|
|
641
|
+
if (child.type !== "optional") required.push(key);
|
|
642
|
+
}
|
|
643
|
+
const extra = shape.additionalProperties;
|
|
644
|
+
const additionalProperties = extra === true ? true : extra !== void 0 && extra !== false ? compileSchema(extra) : false;
|
|
645
|
+
return {
|
|
646
|
+
type: "object",
|
|
647
|
+
...Object.keys(properties).length > 0 ? { properties } : {},
|
|
648
|
+
...required.length > 0 ? { required } : {},
|
|
649
|
+
additionalProperties,
|
|
650
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
case "union": return {
|
|
654
|
+
...shape.mode === "oneOf" ? { oneOf: shape.variants.map((variant) => compileSchema(variant)) } : { anyOf: shape.variants.map((variant) => compileSchema(variant)) },
|
|
655
|
+
...shape.description !== void 0 ? { description: shape.description } : {}
|
|
656
|
+
};
|
|
657
|
+
case "optional": return compileSchema(shape.inner);
|
|
658
|
+
case "nullable": return { anyOf: [compileSchema(shape.inner), { type: "null" }] };
|
|
659
|
+
case "raw": return shape.schema;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Compile a {@link ContractShape} into a runtime type guard.
|
|
664
|
+
*
|
|
665
|
+
* @remarks
|
|
666
|
+
* Reuses the combinators: `literalOf` for literals, `arrayOf` for arrays,
|
|
667
|
+
* `recordOf` for closed objects, `unionOf` for unions, `nullableOf` for nullable,
|
|
668
|
+
* and `whereOf` for constraint refinement. Like every guard it is total — it
|
|
669
|
+
* never throws (AGENTS §14).
|
|
670
|
+
*
|
|
671
|
+
* @param shape - The shape to compile
|
|
672
|
+
* @returns A guard narrowing to the shape's inferred type
|
|
673
|
+
*/
|
|
674
|
+
function compileGuard(shape) {
|
|
675
|
+
switch (shape.type) {
|
|
676
|
+
case "string": return stringOf({
|
|
677
|
+
min: shape.min,
|
|
678
|
+
max: shape.max,
|
|
679
|
+
pattern: shape.pattern
|
|
680
|
+
});
|
|
681
|
+
case "number": {
|
|
682
|
+
const base = shape.integer === true ? isInteger : isFiniteNumber;
|
|
683
|
+
if (shape.min === void 0 && shape.max === void 0) return base;
|
|
684
|
+
return shape.integer === true ? intersectionOf(isInteger, boundsOf(shape.min, shape.max)) : boundsOf(shape.min, shape.max);
|
|
685
|
+
}
|
|
686
|
+
case "boolean": return isBoolean;
|
|
687
|
+
case "null": return isNull;
|
|
688
|
+
case "json": return isJSONValue;
|
|
689
|
+
case "literal": return literalOf(...shape.values);
|
|
690
|
+
case "array": {
|
|
691
|
+
const base = arrayOf(compileGuard(shape.items));
|
|
692
|
+
if (shape.min === void 0 && shape.max === void 0) return base;
|
|
693
|
+
const withinLength = boundsOf(shape.min, shape.max);
|
|
694
|
+
return whereOf(base, (value) => withinLength(value.length));
|
|
695
|
+
}
|
|
696
|
+
case "object": {
|
|
697
|
+
const map = Object.create(null);
|
|
698
|
+
const optionalKeys = [];
|
|
699
|
+
for (const key of Object.keys(shape.properties)) {
|
|
700
|
+
const child = shape.properties[key];
|
|
701
|
+
if (child === void 0) continue;
|
|
702
|
+
if (child.type === "optional") {
|
|
703
|
+
map[key] = compileGuard(child.inner);
|
|
704
|
+
optionalKeys.push(key);
|
|
705
|
+
} else map[key] = compileGuard(child);
|
|
706
|
+
}
|
|
707
|
+
const extra = shape.additionalProperties;
|
|
708
|
+
if (extra === void 0 || extra === false) return optionalKeys.length > 0 ? recordOf(map, optionalKeys) : recordOf(map);
|
|
709
|
+
const additional = extra === true ? void 0 : compileGuard(extra);
|
|
710
|
+
const required = Object.keys(map).filter((key) => !optionalKeys.includes(key));
|
|
711
|
+
return (value) => {
|
|
712
|
+
if (!isRecord(value)) return false;
|
|
713
|
+
for (const key of required) if (!Object.hasOwn(value, key)) return false;
|
|
714
|
+
const outcome = attempt(() => {
|
|
715
|
+
for (const key of Object.keys(value)) {
|
|
716
|
+
const guard = Object.hasOwn(map, key) ? map[key] : void 0;
|
|
717
|
+
if (guard !== void 0) {
|
|
718
|
+
if (!guard(value[key])) return false;
|
|
719
|
+
} else if (additional !== void 0 && !additional(value[key])) return false;
|
|
720
|
+
}
|
|
721
|
+
return true;
|
|
722
|
+
});
|
|
723
|
+
return outcome.success && outcome.value;
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
case "union": return unionOf(...shape.variants.map((variant) => compileGuard(variant)));
|
|
727
|
+
case "optional": return orOf(isUndefined, compileGuard(shape.inner));
|
|
728
|
+
case "nullable": return nullableOf(compileGuard(shape.inner));
|
|
729
|
+
case "raw": return (_value) => true;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Compile a {@link ContractShape} into an input parser.
|
|
734
|
+
*
|
|
735
|
+
* @remarks
|
|
736
|
+
* Reuses the leaf parsers (`parseString` / `parseInteger` / `parseNumber` /
|
|
737
|
+
* `parseBoolean` / `parseRecord`) and coerces structurally. An object fails as a
|
|
738
|
+
* whole on any required-field failure; a union returns a guard-valid value
|
|
739
|
+
* unchanged, otherwise the first variant that both parses and guards wins.
|
|
740
|
+
*
|
|
741
|
+
* After coercing a leaf, it re-applies that leaf's REFINEMENTS through the same
|
|
742
|
+
* combinators `compileGuard` uses — `stringOf` for a string's length/pattern and
|
|
743
|
+
* `boundsOf` for a number's value and an array's length — so a value that coerces
|
|
744
|
+
* but violates a bound parses to `undefined`. The result is full parse↔guard
|
|
745
|
+
* soundness (AGENTS §14): a non-`undefined` parse always satisfies the contract's
|
|
746
|
+
* `is`, refinements included.
|
|
747
|
+
*
|
|
748
|
+
* @param shape - The shape to compile
|
|
749
|
+
* @returns A parser yielding the shape's inferred type or `undefined`
|
|
750
|
+
*/
|
|
751
|
+
function compileParser(shape) {
|
|
752
|
+
switch (shape.type) {
|
|
753
|
+
case "string": {
|
|
754
|
+
if (shape.min === void 0 && shape.max === void 0 && shape.pattern === void 0) return parseString;
|
|
755
|
+
const guard = stringOf({
|
|
756
|
+
min: shape.min,
|
|
757
|
+
max: shape.max,
|
|
758
|
+
pattern: shape.pattern
|
|
759
|
+
});
|
|
760
|
+
return (value) => {
|
|
761
|
+
const parsed = parseString(value);
|
|
762
|
+
return parsed !== void 0 && guard(parsed) ? parsed : void 0;
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
case "number": {
|
|
766
|
+
const base = shape.integer === true ? parseInteger : parseNumber;
|
|
767
|
+
if (shape.min === void 0 && shape.max === void 0) return base;
|
|
768
|
+
const within = boundsOf(shape.min, shape.max);
|
|
769
|
+
return (value) => {
|
|
770
|
+
const parsed = base(value);
|
|
771
|
+
return parsed !== void 0 && within(parsed) ? parsed : void 0;
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
case "boolean": return parseBoolean;
|
|
775
|
+
case "null": return (value) => value === null ? null : void 0;
|
|
776
|
+
case "json": return (value) => isJSONValue(value) ? value : void 0;
|
|
777
|
+
case "literal": {
|
|
778
|
+
const allowed = new Set(shape.values);
|
|
779
|
+
return (value) => {
|
|
780
|
+
if (allowed.has(value)) return value;
|
|
781
|
+
if (isString(value)) {
|
|
782
|
+
const trimmed = value.trim();
|
|
783
|
+
if (allowed.has(trimmed)) return trimmed;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
case "array": {
|
|
788
|
+
const item = compileParser(shape.items);
|
|
789
|
+
const unbounded = shape.min === void 0 && shape.max === void 0;
|
|
790
|
+
const withinLength = boundsOf(shape.min, shape.max);
|
|
791
|
+
return (value) => {
|
|
792
|
+
if (!isArray(value)) return void 0;
|
|
793
|
+
const result = [];
|
|
794
|
+
for (const entry of value) {
|
|
795
|
+
const parsed = item(entry);
|
|
796
|
+
if (parsed === void 0) return void 0;
|
|
797
|
+
result.push(parsed);
|
|
798
|
+
}
|
|
799
|
+
return unbounded || withinLength(result.length) ? result : void 0;
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
case "object": {
|
|
803
|
+
const entries = [];
|
|
804
|
+
for (const key of Object.keys(shape.properties)) {
|
|
805
|
+
const child = shape.properties[key];
|
|
806
|
+
if (child === void 0) continue;
|
|
807
|
+
const optional = child.type === "optional";
|
|
808
|
+
entries.push({
|
|
809
|
+
key,
|
|
810
|
+
parse: compileParser(optional ? child.inner : child),
|
|
811
|
+
optional
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
const known = new Set(entries.map((entry) => entry.key));
|
|
815
|
+
const extra = shape.additionalProperties;
|
|
816
|
+
const additional = extra === void 0 || extra === false || extra === true ? void 0 : compileParser(extra);
|
|
817
|
+
const open = extra === true || additional !== void 0;
|
|
818
|
+
return (value) => {
|
|
819
|
+
const record = parseRecord(value);
|
|
820
|
+
if (record === void 0) return void 0;
|
|
821
|
+
const outcome = attempt(() => {
|
|
822
|
+
const result = Object.create(null);
|
|
823
|
+
for (const entry of entries) {
|
|
824
|
+
const raw = record[entry.key];
|
|
825
|
+
if (raw === void 0) {
|
|
826
|
+
if (entry.optional) continue;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const parsed = entry.parse(raw);
|
|
830
|
+
if (parsed === void 0) return void 0;
|
|
831
|
+
result[entry.key] = parsed;
|
|
832
|
+
}
|
|
833
|
+
if (open) for (const key of Object.keys(record)) {
|
|
834
|
+
if (known.has(key)) continue;
|
|
835
|
+
if (additional === void 0) result[key] = record[key];
|
|
836
|
+
else {
|
|
837
|
+
const parsed = additional(record[key]);
|
|
838
|
+
if (parsed === void 0) return void 0;
|
|
839
|
+
result[key] = parsed;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return result;
|
|
843
|
+
});
|
|
844
|
+
return outcome.success ? outcome.value : void 0;
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
case "union": {
|
|
848
|
+
const variants = shape.variants.map((variant) => ({
|
|
849
|
+
parse: compileParser(variant),
|
|
850
|
+
guard: compileGuard(variant)
|
|
851
|
+
}));
|
|
852
|
+
return (value) => {
|
|
853
|
+
for (const variant of variants) if (variant.guard(value)) return value;
|
|
854
|
+
for (const variant of variants) {
|
|
855
|
+
const parsed = variant.parse(value);
|
|
856
|
+
if (parsed !== void 0 && variant.guard(parsed)) return parsed;
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
case "optional": {
|
|
861
|
+
const inner = compileParser(shape.inner);
|
|
862
|
+
return (value) => value === void 0 ? void 0 : inner(value);
|
|
863
|
+
}
|
|
864
|
+
case "nullable": {
|
|
865
|
+
const inner = compileParser(shape.inner);
|
|
866
|
+
return (value) => value === null ? null : inner(value);
|
|
867
|
+
}
|
|
868
|
+
case "raw": return (value) => value;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Compile a {@link ContractShape} into a deterministic seed value.
|
|
873
|
+
*
|
|
874
|
+
* @remarks
|
|
875
|
+
* The same shape and the same `random` source always produce the same value, so
|
|
876
|
+
* seed data is reproducible. Defaults to a {@link seededRandom} source seeded
|
|
877
|
+
* from the wall clock when none is supplied. Throws on a degenerate empty
|
|
878
|
+
* `literalShape` / `unionShape`, on a pattern-constrained `stringShape` whose
|
|
879
|
+
* generated sample cannot satisfy the pattern, or on a `rawShape` (its embedded
|
|
880
|
+
* schema is arbitrary and cannot be auto-generated) — a programmer error that
|
|
881
|
+
* cannot generate a value (AGENTS §12). `createContract` runs
|
|
882
|
+
* {@link validateShape} first, so a degenerate `literalShape` / `unionShape` /
|
|
883
|
+
* bounded shape is normally caught there; these throws remain here as defense
|
|
884
|
+
* for standalone `compileGenerator` use.
|
|
885
|
+
*
|
|
886
|
+
* @param shape - The shape to generate from
|
|
887
|
+
* @param random - A seeded random source (defaults to `seededRandom(Date.now())`)
|
|
888
|
+
* @returns A value matching the shape
|
|
889
|
+
*/
|
|
890
|
+
function compileGenerator(shape, random = seededRandom(Date.now())) {
|
|
891
|
+
switch (shape.type) {
|
|
892
|
+
case "string": {
|
|
893
|
+
const min = shape.min ?? 0;
|
|
894
|
+
const max = shape.max ?? Math.max(min, 12);
|
|
895
|
+
const length = Math.max(min, Math.min(max, 8));
|
|
896
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
897
|
+
let value = "";
|
|
898
|
+
for (let index = 0; index < length; index += 1) value += alphabet[Math.floor(random() * 36)];
|
|
899
|
+
if (shape.pattern !== void 0 && !shape.pattern.test(value)) throw new Error("compileGenerator: a pattern-constrained string shape cannot be auto-generated — supply or verify values another way");
|
|
900
|
+
return value;
|
|
901
|
+
}
|
|
902
|
+
case "number": {
|
|
903
|
+
const min = shape.min ?? 0;
|
|
904
|
+
const max = shape.max ?? 100;
|
|
905
|
+
if (shape.integer === true) {
|
|
906
|
+
const lo = Math.ceil(min);
|
|
907
|
+
const hi = Math.floor(max);
|
|
908
|
+
return Math.floor(random() * (hi - lo + 1)) + lo;
|
|
909
|
+
}
|
|
910
|
+
return random() * (max - min) + min;
|
|
911
|
+
}
|
|
912
|
+
case "boolean": return random() >= .5;
|
|
913
|
+
case "null": return null;
|
|
914
|
+
case "json": {
|
|
915
|
+
const pick = Math.floor(random() * 5);
|
|
916
|
+
if (pick === 0) return null;
|
|
917
|
+
if (pick === 1) return random() >= .5;
|
|
918
|
+
if (pick === 2) return Math.floor(random() * 1e3);
|
|
919
|
+
if (pick === 3) {
|
|
920
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
|
921
|
+
let value = "";
|
|
922
|
+
for (let index = 0; index < 6; index += 1) value += alphabet[Math.floor(random() * 26)];
|
|
923
|
+
return value;
|
|
924
|
+
}
|
|
925
|
+
return { value: Math.floor(random() * 1e3) };
|
|
926
|
+
}
|
|
927
|
+
case "literal":
|
|
928
|
+
if (shape.values.length === 0) throw new Error("compileGenerator: a literal shape needs at least one value");
|
|
929
|
+
return shape.values[Math.floor(random() * shape.values.length)];
|
|
930
|
+
case "array": {
|
|
931
|
+
const lo = shape.min ?? Math.min(1, shape.max ?? 1);
|
|
932
|
+
const hi = shape.max ?? Math.max(lo, 3);
|
|
933
|
+
const length = Math.floor(random() * (hi - lo + 1)) + lo;
|
|
934
|
+
const result = [];
|
|
935
|
+
for (let index = 0; index < length; index += 1) result.push(compileGenerator(shape.items, random));
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
case "object": {
|
|
939
|
+
const result = {};
|
|
940
|
+
for (const key of Object.keys(shape.properties)) {
|
|
941
|
+
const child = shape.properties[key];
|
|
942
|
+
if (child === void 0) continue;
|
|
943
|
+
if (child.type === "optional" && random() < .3) continue;
|
|
944
|
+
result[key] = compileGenerator(child, random);
|
|
945
|
+
}
|
|
946
|
+
const extra = shape.additionalProperties;
|
|
947
|
+
if (extra !== void 0 && extra !== true && extra !== false) {
|
|
948
|
+
const count = 1 + Math.floor(random() * 2);
|
|
949
|
+
for (let index = 0; index < count; index += 1) {
|
|
950
|
+
const key = `key${index}`;
|
|
951
|
+
if (Object.hasOwn(result, key)) continue;
|
|
952
|
+
result[key] = compileGenerator(extra, random);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return result;
|
|
956
|
+
}
|
|
957
|
+
case "union":
|
|
958
|
+
if (shape.variants.length === 0) throw new Error("compileGenerator: a union shape needs at least one variant");
|
|
959
|
+
return compileGenerator(shape.variants[Math.floor(random() * shape.variants.length)], random);
|
|
960
|
+
case "optional": return compileGenerator(shape.inner, random);
|
|
961
|
+
case "nullable": return random() < .2 ? null : compileGenerator(shape.inner, random);
|
|
962
|
+
case "raw": throw new Error("compileGenerator: a raw shape embeds an arbitrary JSON Schema and cannot be auto-generated — supply values another way");
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function createContract(shape) {
|
|
966
|
+
validateShape(shape);
|
|
967
|
+
const schema = compileSchema(shape);
|
|
968
|
+
const guard = compileGuard(shape);
|
|
969
|
+
const parser = compileParser(shape);
|
|
970
|
+
return {
|
|
971
|
+
schema,
|
|
972
|
+
is: guard,
|
|
973
|
+
parse(value) {
|
|
974
|
+
return parser(value);
|
|
975
|
+
},
|
|
976
|
+
generate(random) {
|
|
977
|
+
return compileGenerator(shape, random);
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Build an {@link ObjectShape} from a property map.
|
|
983
|
+
*
|
|
984
|
+
* @remarks
|
|
985
|
+
* Wrap any property in {@link optionalShape} to allow its absence. By default
|
|
986
|
+
* the compiled guard rejects unknown keys; pass `additionalProperties` to open
|
|
987
|
+
* the object.
|
|
988
|
+
*
|
|
989
|
+
* @param properties - Map of property names to child shapes
|
|
990
|
+
* @param options - Optional `additionalProperties` and `description`
|
|
991
|
+
* @returns An object shape
|
|
992
|
+
*
|
|
993
|
+
* @example
|
|
994
|
+
* ```ts
|
|
995
|
+
* const user = objectShape({
|
|
996
|
+
* name: stringShape({ min: 1 }),
|
|
997
|
+
* age: integerShape({ min: 0, max: 120 }),
|
|
998
|
+
* bio: optionalShape(stringShape()),
|
|
999
|
+
* })
|
|
1000
|
+
* ```
|
|
1001
|
+
*/
|
|
1002
|
+
function objectShape(properties, options) {
|
|
1003
|
+
return {
|
|
1004
|
+
type: "object",
|
|
1005
|
+
properties,
|
|
1006
|
+
additionalProperties: options?.additionalProperties,
|
|
1007
|
+
description: options?.description
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/core/helpers.ts
|
|
1012
|
+
/**
|
|
1013
|
+
* A total ordering over arbitrary values — the comparator behind sorting and the
|
|
1014
|
+
* range operators.
|
|
1015
|
+
*
|
|
1016
|
+
* @remarks
|
|
1017
|
+
* Values of different types order by a fixed type rank (`undefined` < `null` <
|
|
1018
|
+
* boolean < number < string < other); same-typed values compare naturally.
|
|
1019
|
+
* `NaN` sorts after every other number and equal to itself, so the comparator
|
|
1020
|
+
* is total and never returns `NaN`.
|
|
1021
|
+
*
|
|
1022
|
+
* @param left - The left value
|
|
1023
|
+
* @param right - The right value
|
|
1024
|
+
* @returns `-1`, `0`, or `1`
|
|
1025
|
+
*/
|
|
1026
|
+
function compareValues(left, right) {
|
|
1027
|
+
const rankOf = (value) => {
|
|
1028
|
+
if (value === void 0) return 0;
|
|
1029
|
+
if (value === null) return 1;
|
|
1030
|
+
if (typeof value === "boolean") return 2;
|
|
1031
|
+
if (typeof value === "number") return 3;
|
|
1032
|
+
if (typeof value === "string") return 4;
|
|
1033
|
+
return 5;
|
|
1034
|
+
};
|
|
1035
|
+
const leftRank = rankOf(left);
|
|
1036
|
+
const rightRank = rankOf(right);
|
|
1037
|
+
if (leftRank !== rightRank) return leftRank < rightRank ? -1 : 1;
|
|
1038
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
1039
|
+
if (Number.isNaN(left) || Number.isNaN(right)) return Number.isNaN(left) ? Number.isNaN(right) ? 0 : 1 : -1;
|
|
1040
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof left === "string" && typeof right === "string") return left < right ? -1 : left > right ? 1 : 0;
|
|
1043
|
+
if (typeof left === "boolean" && typeof right === "boolean") return left === right ? 0 : left ? 1 : -1;
|
|
1044
|
+
return 0;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Structural equality by SameValueZero leaves — the comparator behind conformance
|
|
1048
|
+
* checks and any test/fixture that needs "same data", not "same reference".
|
|
1049
|
+
*
|
|
1050
|
+
* @remarks
|
|
1051
|
+
* Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
|
|
1052
|
+
* Arrays compare by index (same length, every element `deepEqual`). Plain
|
|
1053
|
+
* records (via `isRecord`) compare by their OWN enumerable keys: same key
|
|
1054
|
+
* COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
|
|
1055
|
+
* with a `deepEqual` value — so a key present with value `undefined` is NOT
|
|
1056
|
+
* equal to that key being absent (both differ in `Object.keys` membership).
|
|
1057
|
+
* Anything else (functions, class instances, mismatched shapes) falls through
|
|
1058
|
+
* to `false`. There is no cycle detection — a cyclic input recurses forever;
|
|
1059
|
+
* callers pass acyclic data (rows, plans, config).
|
|
1060
|
+
*
|
|
1061
|
+
* @param left - The left value
|
|
1062
|
+
* @param right - The right value
|
|
1063
|
+
* @returns Whether `left` and `right` are structurally equal
|
|
1064
|
+
*
|
|
1065
|
+
* @example
|
|
1066
|
+
* ```ts
|
|
1067
|
+
* deepEqual(Number.NaN, Number.NaN) // true
|
|
1068
|
+
* deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
|
|
1069
|
+
* deepEqual({ a: undefined }, {}) // false — present-undefined ≠ absent
|
|
1070
|
+
* ```
|
|
1071
|
+
*/
|
|
1072
|
+
function deepEqual(left, right) {
|
|
1073
|
+
if (typeof left === "number" && typeof right === "number") return Number.isNaN(left) && Number.isNaN(right) || left === right;
|
|
1074
|
+
if (left === right) return true;
|
|
1075
|
+
if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
|
|
1076
|
+
if (isRecord(left) && isRecord(right)) {
|
|
1077
|
+
const leftKeys = Object.keys(left);
|
|
1078
|
+
const rightKeys = Object.keys(right);
|
|
1079
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
1080
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqual(left[key], right[key]));
|
|
1081
|
+
}
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
|
|
1086
|
+
* engine behind {@link likeMatch} and {@link globMatch}.
|
|
1087
|
+
*
|
|
1088
|
+
* @remarks
|
|
1089
|
+
* A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
|
|
1090
|
+
* `.*` segments separated by literals, matched against a long non-matching input, blow
|
|
1091
|
+
* up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
|
|
1092
|
+
* (AGENTS §6.5, now that the authed server runs model-supplied `list` criteria over the
|
|
1093
|
+
* wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
|
|
1094
|
+
* the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
|
|
1095
|
+
* that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
|
|
1096
|
+
* never the exponential / polynomial backtracking a regex would do. The pattern length
|
|
1097
|
+
* is capped at {@link MAX_PATTERN_LENGTH} (a `VALIDATION` {@link DatabaseError} over it),
|
|
1098
|
+
* bounding the pattern factor so a match stays linear in the value length whatever the
|
|
1099
|
+
* pattern.
|
|
1100
|
+
*
|
|
1101
|
+
* The `any` wildcard matches any run (including empty); `single` matches exactly one
|
|
1102
|
+
* char; every other pattern char matches itself LITERALLY (a pattern `.` / `(` / `\` is
|
|
1103
|
+
* a literal — the regex-metacharacter hazard is gone with the regex). `any` is tested
|
|
1104
|
+
* BEFORE a literal match, so a value that literally contains the wildcard char never
|
|
1105
|
+
* shadows the wildcard. Case folding is applied to BOTH sides when `fold` is set.
|
|
1106
|
+
*
|
|
1107
|
+
* @param value - The value to test
|
|
1108
|
+
* @param pattern - The wildcard pattern
|
|
1109
|
+
* @param any - The any-run wildcard char (`%` for `LIKE`, `*` for `GLOB`)
|
|
1110
|
+
* @param single - The single-char wildcard char (`_` for `LIKE`, `?` for `GLOB`)
|
|
1111
|
+
* @param fold - Whether to match case-INSENSITIVELY (`LIKE` folds; `GLOB` does not)
|
|
1112
|
+
* @returns Whether `value` matches `pattern`
|
|
1113
|
+
* @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
|
|
1114
|
+
*/
|
|
1115
|
+
function wildcardMatch(value, pattern, any, single, fold) {
|
|
1116
|
+
if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
|
|
1117
|
+
length: pattern.length,
|
|
1118
|
+
limit: MAX_PATTERN_LENGTH
|
|
1119
|
+
});
|
|
1120
|
+
const haystack = fold ? value.toLowerCase() : value;
|
|
1121
|
+
const needle = fold ? pattern.toLowerCase() : pattern;
|
|
1122
|
+
let vi = 0;
|
|
1123
|
+
let pi = 0;
|
|
1124
|
+
let star = -1;
|
|
1125
|
+
let mark = 0;
|
|
1126
|
+
while (vi < haystack.length) {
|
|
1127
|
+
const pc = pi < needle.length ? needle[pi] : void 0;
|
|
1128
|
+
if (pc === any) {
|
|
1129
|
+
star = pi;
|
|
1130
|
+
mark = vi;
|
|
1131
|
+
pi += 1;
|
|
1132
|
+
} else if (pc !== void 0 && (pc === single || pc === haystack[vi])) {
|
|
1133
|
+
vi += 1;
|
|
1134
|
+
pi += 1;
|
|
1135
|
+
} else if (star !== -1) {
|
|
1136
|
+
pi = star + 1;
|
|
1137
|
+
mark += 1;
|
|
1138
|
+
vi = mark;
|
|
1139
|
+
} else return false;
|
|
1140
|
+
}
|
|
1141
|
+
while (pi < needle.length && needle[pi] === any) pi += 1;
|
|
1142
|
+
return pi === needle.length;
|
|
1143
|
+
}
|
|
1144
|
+
function likeMatch(value, pattern) {
|
|
1145
|
+
return wildcardMatch(value, pattern, "%", "_", true);
|
|
1146
|
+
}
|
|
1147
|
+
function globMatch(value, pattern) {
|
|
1148
|
+
return wildcardMatch(value, pattern, "*", "?", false);
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Evaluate one {@link Condition} against a row — the per-operator predicate.
|
|
1152
|
+
*
|
|
1153
|
+
* @remarks
|
|
1154
|
+
* Reads the condition's column — a `FieldPath`, resolved with `resolveField` (a
|
|
1155
|
+
* string is one column; an array descends a nested value) — and applies the
|
|
1156
|
+
* operator. Range operators use {@link compareValues}; `like` / `glob` / `starts`
|
|
1157
|
+
* / `ends` match only strings; `any` / `none` test membership by value equality.
|
|
1158
|
+
* Total — a type mismatch is simply a non-match.
|
|
1159
|
+
*
|
|
1160
|
+
* @param row - The row to test
|
|
1161
|
+
* @param condition - The condition to apply
|
|
1162
|
+
* @returns Whether the row satisfies the condition
|
|
1163
|
+
*/
|
|
1164
|
+
function matchesCondition(row, condition) {
|
|
1165
|
+
const value = resolveField(row, condition.column);
|
|
1166
|
+
const first = condition.values[0];
|
|
1167
|
+
const second = condition.values[1];
|
|
1168
|
+
switch (condition.operator) {
|
|
1169
|
+
case "equals": return compareValues(value, first) === 0;
|
|
1170
|
+
case "not": return compareValues(value, first) !== 0;
|
|
1171
|
+
case "above": return compareValues(value, first) > 0;
|
|
1172
|
+
case "below": return compareValues(value, first) < 0;
|
|
1173
|
+
case "from": return compareValues(value, first) >= 0;
|
|
1174
|
+
case "to": return compareValues(value, first) <= 0;
|
|
1175
|
+
case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
|
|
1176
|
+
case "like": return isString(value) && isString(first) && likeMatch(value, first);
|
|
1177
|
+
case "glob": return isString(value) && isString(first) && globMatch(value, first);
|
|
1178
|
+
case "starts": return isString(value) && isString(first) && value.startsWith(first);
|
|
1179
|
+
case "ends": return isString(value) && isString(first) && value.endsWith(first);
|
|
1180
|
+
case "any": return condition.values.some((candidate) => compareValues(value, candidate) === 0);
|
|
1181
|
+
case "none": return !condition.values.some((candidate) => compareValues(value, candidate) === 0);
|
|
1182
|
+
case "absent": return value === void 0 || value === null;
|
|
1183
|
+
case "present": return value !== void 0 && value !== null;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Fold a row through a list of conditions, joining each by its connector.
|
|
1188
|
+
*
|
|
1189
|
+
* @remarks
|
|
1190
|
+
* Evaluated left-to-right: the first condition seeds the result, and each later
|
|
1191
|
+
* condition combines with `&&` (`and`) or `||` (`or`). An empty list matches
|
|
1192
|
+
* every row. There is no operator precedence — conditions combine in the order
|
|
1193
|
+
* the query builder recorded them.
|
|
1194
|
+
*
|
|
1195
|
+
* @param row - The row to test
|
|
1196
|
+
* @param conditions - The conditions to fold
|
|
1197
|
+
* @returns Whether the row satisfies the combined conditions
|
|
1198
|
+
*/
|
|
1199
|
+
function matchesCriteria(row, conditions) {
|
|
1200
|
+
let result = true;
|
|
1201
|
+
let seeded = false;
|
|
1202
|
+
for (const condition of conditions) {
|
|
1203
|
+
const match = matchesCondition(row, condition);
|
|
1204
|
+
if (!seeded) {
|
|
1205
|
+
result = match;
|
|
1206
|
+
seeded = true;
|
|
1207
|
+
} else result = condition.connector === "or" ? result || match : result && match;
|
|
1208
|
+
}
|
|
1209
|
+
return result;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Filter rows by a list of conditions — the shared basis for a table's count
|
|
1213
|
+
* and aggregate paths (no sort/page, unlike {@link applyCriteria}).
|
|
1214
|
+
*
|
|
1215
|
+
* @remarks
|
|
1216
|
+
* An empty condition list matches every row (returned as-is, no copy). Folds
|
|
1217
|
+
* each row through {@link matchesCriteria}.
|
|
1218
|
+
*
|
|
1219
|
+
* @param rows - The rows to filter
|
|
1220
|
+
* @param conditions - The conditions to apply (empty matches everything)
|
|
1221
|
+
* @returns The matching rows
|
|
1222
|
+
*
|
|
1223
|
+
* @example
|
|
1224
|
+
* ```ts
|
|
1225
|
+
* filterRows(
|
|
1226
|
+
* [{ age: 30 }, { age: 12 }],
|
|
1227
|
+
* [{ column: 'age', operator: 'above', values: [18], connector: 'and' }],
|
|
1228
|
+
* ) // => [{ age: 30 }]
|
|
1229
|
+
* ```
|
|
1230
|
+
*/
|
|
1231
|
+
function filterRows(rows, conditions) {
|
|
1232
|
+
if (conditions.length === 0) return rows;
|
|
1233
|
+
return rows.filter((row) => matchesCriteria(row, conditions));
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Sort rows by an ordering specification, leaving the input untouched.
|
|
1237
|
+
*
|
|
1238
|
+
* @remarks
|
|
1239
|
+
* Applies the terms in priority order — the first term that distinguishes two
|
|
1240
|
+
* rows decides — using {@link compareValues}, reversing for `descending`.
|
|
1241
|
+
*
|
|
1242
|
+
* @param rows - The rows to sort
|
|
1243
|
+
* @param order - The ordering terms in priority order
|
|
1244
|
+
* @returns A new, sorted array
|
|
1245
|
+
*/
|
|
1246
|
+
function sortRows(rows, order) {
|
|
1247
|
+
const sorted = [...rows];
|
|
1248
|
+
sorted.sort((left, right) => {
|
|
1249
|
+
for (const term of order) {
|
|
1250
|
+
const comparison = compareValues(resolveField(left, term.column), resolveField(right, term.column));
|
|
1251
|
+
if (comparison !== 0) return term.direction === "descending" ? -comparison : comparison;
|
|
1252
|
+
}
|
|
1253
|
+
return 0;
|
|
1254
|
+
});
|
|
1255
|
+
return sorted;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Apply a {@link Criteria} to rows — filter, then sort, then page.
|
|
1259
|
+
*
|
|
1260
|
+
* @remarks
|
|
1261
|
+
* The whole portable read pipeline in one place: conditions filter, `order`
|
|
1262
|
+
* sorts, and `offset` / `limit` window the result. Each step is skipped when its
|
|
1263
|
+
* part of the criteria is absent. The reference {@link DriverInterface} backends
|
|
1264
|
+
* lean on this rather than each re-deriving it.
|
|
1265
|
+
*
|
|
1266
|
+
* @param rows - The rows to process (typically a table's full `scan`)
|
|
1267
|
+
* @param criteria - The read specification, or `undefined` for all rows as-is
|
|
1268
|
+
* @returns The filtered, sorted, paged rows
|
|
1269
|
+
*/
|
|
1270
|
+
function applyCriteria(rows, criteria) {
|
|
1271
|
+
let result = rows;
|
|
1272
|
+
const conditions = criteria?.conditions;
|
|
1273
|
+
if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesCriteria(row, conditions));
|
|
1274
|
+
const order = criteria?.order;
|
|
1275
|
+
if (order !== void 0 && order.length > 0) result = sortRows(result, order);
|
|
1276
|
+
const offset = criteria?.offset ?? 0;
|
|
1277
|
+
const limit = criteria?.limit;
|
|
1278
|
+
if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
|
|
1279
|
+
return result;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Compute an aggregate over a column across rows.
|
|
1283
|
+
*
|
|
1284
|
+
* @remarks
|
|
1285
|
+
* `count` returns the row count. The numeric aggregates coerce each cell with
|
|
1286
|
+
* the contracts `parseNumber` (so `'42'` counts) and ignore non-numeric cells;
|
|
1287
|
+
* over zero numeric values they return `undefined` — the SQL `NULL` of an empty
|
|
1288
|
+
* aggregate.
|
|
1289
|
+
*
|
|
1290
|
+
* @param rows - The rows to aggregate (non-record entries are ignored)
|
|
1291
|
+
* @param operation - The aggregate to compute
|
|
1292
|
+
* @param column - The column to aggregate
|
|
1293
|
+
* @returns The aggregate value, or `undefined` when undefined for the inputs
|
|
1294
|
+
*/
|
|
1295
|
+
function computeAggregate(rows, operation, column) {
|
|
1296
|
+
if (operation === "count") return rows.length;
|
|
1297
|
+
const numbers = [];
|
|
1298
|
+
for (const row of rows) {
|
|
1299
|
+
if (!isRecord(row)) continue;
|
|
1300
|
+
const value = parseNumber(resolveField(row, column));
|
|
1301
|
+
if (value !== void 0) numbers.push(value);
|
|
1302
|
+
}
|
|
1303
|
+
if (numbers.length === 0) return void 0;
|
|
1304
|
+
if (operation === "sum" || operation === "average") {
|
|
1305
|
+
const total = numbers.reduce((sum, value) => sum + value, 0);
|
|
1306
|
+
return operation === "average" ? total / numbers.length : total;
|
|
1307
|
+
}
|
|
1308
|
+
return operation === "minimum" ? Math.min(...numbers) : Math.max(...numbers);
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Read a row's primary key from a column, when it is a usable {@link Key}.
|
|
1312
|
+
*
|
|
1313
|
+
* @param row - The row to read
|
|
1314
|
+
* @param column - The primary-key column name
|
|
1315
|
+
* @returns The key (a string or finite number), or `undefined`
|
|
1316
|
+
*/
|
|
1317
|
+
function extractKey(row, column) {
|
|
1318
|
+
const value = row[column];
|
|
1319
|
+
if (isString(value)) return value;
|
|
1320
|
+
if (isFiniteNumber(value)) return value;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Map a column's {@link ContractShape} to its portable {@link ColumnType} — the
|
|
1324
|
+
* value a `TableSchema` carries so a native backend can declare a real column.
|
|
1325
|
+
*
|
|
1326
|
+
* @remarks
|
|
1327
|
+
* `string` → `text`; `number` → `integer` when the shape is integer-only, else
|
|
1328
|
+
* `real`; `boolean` → `boolean`. A `literal` takes the type of its values
|
|
1329
|
+
* (all-boolean → `boolean`, all-integer → `integer`, mixed/fractional numbers →
|
|
1330
|
+
* `real`, anything else → `text`). `optional` / `nullable` unwrap to their inner
|
|
1331
|
+
* type (nullability is tracked separately). `null` / `object` / `array` / `union` /
|
|
1332
|
+
* `json` / `raw` → `json`: a backend stores them as JSON text and can `json_extract`
|
|
1333
|
+
* for nested `FieldPath` queries. A scan-only backend ignores the result.
|
|
1334
|
+
*
|
|
1335
|
+
* @param shape - The column's contract shape
|
|
1336
|
+
* @returns The portable column type
|
|
1337
|
+
*
|
|
1338
|
+
* @example
|
|
1339
|
+
* ```ts
|
|
1340
|
+
* shapeToColumnType(stringShape()) // 'text'
|
|
1341
|
+
* shapeToColumnType(integerShape()) // 'integer'
|
|
1342
|
+
* shapeToColumnType(optionalShape(integerShape())) // 'integer'
|
|
1343
|
+
* shapeToColumnType(objectShape({ a: stringShape() })) // 'json'
|
|
1344
|
+
* ```
|
|
1345
|
+
*/
|
|
1346
|
+
function shapeToColumnType(shape) {
|
|
1347
|
+
switch (shape.type) {
|
|
1348
|
+
case "string": return "text";
|
|
1349
|
+
case "number": return shape.integer === true ? "integer" : "real";
|
|
1350
|
+
case "boolean": return "boolean";
|
|
1351
|
+
case "literal":
|
|
1352
|
+
if (shape.values.every((value) => typeof value === "boolean")) return "boolean";
|
|
1353
|
+
if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
|
|
1354
|
+
return "text";
|
|
1355
|
+
case "optional":
|
|
1356
|
+
case "nullable": return shapeToColumnType(shape.inner);
|
|
1357
|
+
case "null":
|
|
1358
|
+
case "object":
|
|
1359
|
+
case "array":
|
|
1360
|
+
case "union":
|
|
1361
|
+
case "json":
|
|
1362
|
+
case "raw": return "json";
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Throw when an {@link ReadOptions.signal | AbortSignal} has fired — the shared
|
|
1367
|
+
* cancellation gate checked at operation boundaries and between streamed rows.
|
|
1368
|
+
*
|
|
1369
|
+
* @remarks
|
|
1370
|
+
* A no-op for `undefined` or a live signal, so callers thread `options?.signal`
|
|
1371
|
+
* straight through. When the signal has aborted, throws an `ABORTED`
|
|
1372
|
+
* {@link DatabaseError} carrying the signal's `reason` in its context — callers
|
|
1373
|
+
* mint signals with whatever tool they like (`AbortSignal.timeout(ms)`,
|
|
1374
|
+
* `new AbortController()`, `@orkestrel/abort`).
|
|
1375
|
+
*
|
|
1376
|
+
* @param signal - The signal to check, if any
|
|
1377
|
+
* @returns Nothing — returns normally while the signal is live
|
|
1378
|
+
* @throws An `ABORTED` {@link DatabaseError} when the signal has aborted
|
|
1379
|
+
*
|
|
1380
|
+
* @example
|
|
1381
|
+
* ```ts
|
|
1382
|
+
* import { checkAbort } from '@orkestrel/database'
|
|
1383
|
+
*
|
|
1384
|
+
* const controller = new AbortController()
|
|
1385
|
+
* checkAbort(controller.signal) // returns
|
|
1386
|
+
* controller.abort('too slow')
|
|
1387
|
+
* checkAbort(controller.signal) // throws DatabaseError('ABORTED', …)
|
|
1388
|
+
* ```
|
|
1389
|
+
*/
|
|
1390
|
+
function checkAbort(signal) {
|
|
1391
|
+
if (signal?.aborted) throw new DatabaseError("ABORTED", "Operation aborted", { reason: signal.reason });
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Structurally diff a deployed and a declared table set into a {@link Migration}
|
|
1395
|
+
* plan.
|
|
1396
|
+
*
|
|
1397
|
+
* @remarks
|
|
1398
|
+
* Tables present in `declared` but not `deployed` become `table.add` steps
|
|
1399
|
+
* (carrying the full declared {@link TableSchema}); tables present in
|
|
1400
|
+
* `deployed` but not `declared` become `table.remove` steps. Tables present in
|
|
1401
|
+
* both are diffed column-by-column (by name) and index-group-by-index-group
|
|
1402
|
+
* (by deep equality of the column-name array), each producing `column.add` /
|
|
1403
|
+
* `column.remove` / `index.add` / `index.remove` steps. Step order is
|
|
1404
|
+
* deterministic: every `table.remove`, then every `table.add`, then each
|
|
1405
|
+
* shared table's column/index changes in `declared` order. `from` / `to` are
|
|
1406
|
+
* plan labels only — version tracking itself is deferred to persistent
|
|
1407
|
+
* backends.
|
|
1408
|
+
*
|
|
1409
|
+
* @param deployed - The table schemas currently applied
|
|
1410
|
+
* @param declared - The table schemas the caller wants applied
|
|
1411
|
+
* @param from - The plan's source version label (defaults to `0`)
|
|
1412
|
+
* @param to - The plan's target version label (defaults to `1`)
|
|
1413
|
+
* @returns The migration plan moving `deployed` toward `declared`
|
|
1414
|
+
*
|
|
1415
|
+
* @example
|
|
1416
|
+
* ```ts
|
|
1417
|
+
* const plan = planMigration(
|
|
1418
|
+
* [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
|
|
1419
|
+
* [{ name: 'users', primary: 'id', columns: [{ name: 'age', type: 'integer', nullable: false }], indexes: [] }],
|
|
1420
|
+
* )
|
|
1421
|
+
* // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
|
|
1422
|
+
* ```
|
|
1423
|
+
*/
|
|
1424
|
+
function planMigration(deployed, declared, from = 0, to = 1) {
|
|
1425
|
+
const deployedByName = new Map(deployed.map((table) => [table.name, table]));
|
|
1426
|
+
const declaredByName = new Map(declared.map((table) => [table.name, table]));
|
|
1427
|
+
const steps = [];
|
|
1428
|
+
for (const table of deployed) if (!declaredByName.has(table.name)) steps.push({
|
|
1429
|
+
operation: "table.remove",
|
|
1430
|
+
table: table.name
|
|
1431
|
+
});
|
|
1432
|
+
for (const table of declared) if (!deployedByName.has(table.name)) steps.push({
|
|
1433
|
+
operation: "table.add",
|
|
1434
|
+
table
|
|
1435
|
+
});
|
|
1436
|
+
for (const table of declared) {
|
|
1437
|
+
const before = deployedByName.get(table.name);
|
|
1438
|
+
if (before === void 0) continue;
|
|
1439
|
+
const beforeColumns = new Map(before.columns.map((column) => [column.name, column]));
|
|
1440
|
+
const afterColumns = new Map(table.columns.map((column) => [column.name, column]));
|
|
1441
|
+
for (const column of before.columns) if (!afterColumns.has(column.name)) steps.push({
|
|
1442
|
+
operation: "column.remove",
|
|
1443
|
+
table: table.name,
|
|
1444
|
+
column: column.name
|
|
1445
|
+
});
|
|
1446
|
+
for (const column of table.columns) if (!beforeColumns.has(column.name)) steps.push({
|
|
1447
|
+
operation: "column.add",
|
|
1448
|
+
table: table.name,
|
|
1449
|
+
column
|
|
1450
|
+
});
|
|
1451
|
+
const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
|
|
1452
|
+
for (const index of before.indexes) if (!table.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
1453
|
+
operation: "index.remove",
|
|
1454
|
+
table: table.name,
|
|
1455
|
+
index
|
|
1456
|
+
});
|
|
1457
|
+
for (const index of table.indexes) if (!before.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
1458
|
+
operation: "index.add",
|
|
1459
|
+
table: table.name,
|
|
1460
|
+
index
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
return {
|
|
1464
|
+
from,
|
|
1465
|
+
to,
|
|
1466
|
+
steps
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
|
|
1471
|
+
*
|
|
1472
|
+
* @remarks
|
|
1473
|
+
* `column.remove` drops that field from every row (a fresh copy — inputs are
|
|
1474
|
+
* never mutated, AGENTS §11); `column.add` leaves rows as-is (an absent field
|
|
1475
|
+
* reads as `undefined`, backfill is application policy). `table.add` /
|
|
1476
|
+
* `table.remove` / `index.add` / `index.remove` are no-ops here (they operate
|
|
1477
|
+
* on storage shape, not row shape). Steps for tables other than the one
|
|
1478
|
+
* `rows` belongs to are ignored — pass only the steps relevant to this table.
|
|
1479
|
+
*
|
|
1480
|
+
* @param rows - The table's current rows
|
|
1481
|
+
* @param steps - The migration steps to apply (typically one table's slice of a {@link Migration})
|
|
1482
|
+
* @returns A new array of transformed rows; `rows` is never mutated
|
|
1483
|
+
*
|
|
1484
|
+
* @example
|
|
1485
|
+
* ```ts
|
|
1486
|
+
* const rows = [{ id: 'a', name: 'Ada', legacy: true }]
|
|
1487
|
+
* migrateRows(rows, [{ operation: 'column.remove', table: 'users', column: 'legacy' }])
|
|
1488
|
+
* // => [{ id: 'a', name: 'Ada' }]
|
|
1489
|
+
* ```
|
|
1490
|
+
*/
|
|
1491
|
+
function migrateRows(rows, steps) {
|
|
1492
|
+
const removed = steps.filter((step) => step.operation === "column.remove").map((step) => step.column);
|
|
1493
|
+
if (removed.length === 0) return rows.map((row) => ({ ...row }));
|
|
1494
|
+
return rows.map((row) => {
|
|
1495
|
+
const next = {};
|
|
1496
|
+
for (const key of Object.keys(row)) if (!removed.includes(key)) next[key] = row[key];
|
|
1497
|
+
return next;
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Run the driver-conformance battery against a fresh {@link DriverInterface}
|
|
1502
|
+
* per phase, yielding one {@link ConformanceFinding} per violated invariant —
|
|
1503
|
+
* the shared invariant suite every backend (in-memory, SQLite, IndexedDB)
|
|
1504
|
+
* must uphold to be a drop-in {@link DriverInterface}.
|
|
1505
|
+
*
|
|
1506
|
+
* @remarks
|
|
1507
|
+
* Framework-agnostic: no test-runner or Node imports, only sibling core
|
|
1508
|
+
* modules — so it runs equally from a unit test, a smoke script, or a new
|
|
1509
|
+
* driver's own README. Opens a fixed two-table schema (`users` keyed by the
|
|
1510
|
+
* default `id`, `posts` keyed by a non-id `slug`) and, calling `factory()`
|
|
1511
|
+
* fresh for each phase so failures stay isolated, verifies: `open`/`close`;
|
|
1512
|
+
* `read` of a missing key returns `undefined`; `write`/`read` round-trip with
|
|
1513
|
+
* copy-in/copy-out isolation (mutating the caller's row after `write`, or the
|
|
1514
|
+
* row `read` returns, never perturbs stored state) and upsert-overwrite;
|
|
1515
|
+
* `delete` returns `true` then `false`; `keys`/`scan` yield in ascending key
|
|
1516
|
+
* order; `clear` empties only its target table; `snapshot`'s rollback thunk
|
|
1517
|
+
* restores pre-snapshot state; a scoped `snapshot(['users'])` rolls back only
|
|
1518
|
+
* the named table, leaving a concurrent mutation to another table intact; a
|
|
1519
|
+
* non-`id` primary key (`posts.slug`) round-trips; a nested-object row
|
|
1520
|
+
* round-trips structurally (via {@link deepEqual}). The optional surface is
|
|
1521
|
+
* presence-gated: when `migrate` exists, a `column.remove` plan strips the
|
|
1522
|
+
* column from stored rows and a plan referencing an unknown table throws
|
|
1523
|
+
* `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
|
|
1524
|
+
* condition-matching rows and honors `offset`/`limit`; when `transaction`
|
|
1525
|
+
* exists, `commit` persists and `rollback` restores; when both `meta` and
|
|
1526
|
+
* `stamp` exist, a fresh store's `meta()` is `undefined`, and after
|
|
1527
|
+
* `stamp({ version, schema })`, `meta()` returns the exact stamped value.
|
|
1528
|
+
*
|
|
1529
|
+
* Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
|
|
1530
|
+
* finding built from the assertion, while an UNEXPECTED throw (a driver
|
|
1531
|
+
* crash mid-phase) is caught and yielded as a finding too, naming the phase
|
|
1532
|
+
* as `check` and carrying the caught error in `context.error` — a broken
|
|
1533
|
+
* driver can never escape the battery as an unhandled rejection. Within a
|
|
1534
|
+
* phase, the FIRST violated assertion yields and the phase stops (matching
|
|
1535
|
+
* the historical fail-fast shape at phase granularity); the generator then
|
|
1536
|
+
* moves on to the next phase regardless. Because this is a **generator**,
|
|
1537
|
+
* consuming only the first yielded value reproduces true fail-fast (later
|
|
1538
|
+
* phases never run) — that is exactly what {@link conformDriver} does.
|
|
1539
|
+
*
|
|
1540
|
+
* @param factory - Mints a fresh, unopened driver instance (called once per phase)
|
|
1541
|
+
* @yields One {@link ConformanceFinding} per violated invariant, in phase order
|
|
1542
|
+
*
|
|
1543
|
+
* @example
|
|
1544
|
+
* ```ts
|
|
1545
|
+
* import { createMemoryDriver, driverFindings } from '@orkestrel/database'
|
|
1546
|
+
*
|
|
1547
|
+
* for await (const finding of driverFindings(() => createMemoryDriver())) {
|
|
1548
|
+
* console.log(finding.check, finding.message)
|
|
1549
|
+
* }
|
|
1550
|
+
* ```
|
|
1551
|
+
*/
|
|
1552
|
+
async function* driverFindings(factory) {
|
|
1553
|
+
const CONFORMANCE_USERS_SCHEMA = {
|
|
1554
|
+
name: "users",
|
|
1555
|
+
primary: "id",
|
|
1556
|
+
columns: [
|
|
1557
|
+
{
|
|
1558
|
+
name: "id",
|
|
1559
|
+
type: "text",
|
|
1560
|
+
nullable: false
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
name: "name",
|
|
1564
|
+
type: "text",
|
|
1565
|
+
nullable: false
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
name: "age",
|
|
1569
|
+
type: "integer",
|
|
1570
|
+
nullable: true
|
|
1571
|
+
}
|
|
1572
|
+
],
|
|
1573
|
+
indexes: []
|
|
1574
|
+
};
|
|
1575
|
+
const CONFORMANCE_SCHEMA = [CONFORMANCE_USERS_SCHEMA, {
|
|
1576
|
+
name: "posts",
|
|
1577
|
+
primary: "slug",
|
|
1578
|
+
columns: [{
|
|
1579
|
+
name: "slug",
|
|
1580
|
+
type: "text",
|
|
1581
|
+
nullable: false
|
|
1582
|
+
}, {
|
|
1583
|
+
name: "title",
|
|
1584
|
+
type: "text",
|
|
1585
|
+
nullable: false
|
|
1586
|
+
}],
|
|
1587
|
+
indexes: []
|
|
1588
|
+
}];
|
|
1589
|
+
const findingOf = (check, message, context) => ({
|
|
1590
|
+
check,
|
|
1591
|
+
message,
|
|
1592
|
+
context
|
|
1593
|
+
});
|
|
1594
|
+
const phases = [
|
|
1595
|
+
{
|
|
1596
|
+
check: "open-close",
|
|
1597
|
+
run: async () => {
|
|
1598
|
+
const driver = factory();
|
|
1599
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1600
|
+
await driver.close();
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
{
|
|
1604
|
+
check: "read-missing",
|
|
1605
|
+
run: async () => {
|
|
1606
|
+
const driver = factory();
|
|
1607
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1608
|
+
const missing = await driver.read("users", "nope");
|
|
1609
|
+
await driver.close();
|
|
1610
|
+
if (missing !== void 0) return findingOf("read-missing", "read of a missing key must return undefined", {
|
|
1611
|
+
table: "users",
|
|
1612
|
+
expected: void 0,
|
|
1613
|
+
actual: missing
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1617
|
+
{
|
|
1618
|
+
check: "write-read",
|
|
1619
|
+
run: async () => {
|
|
1620
|
+
const driver = factory();
|
|
1621
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1622
|
+
const input = {
|
|
1623
|
+
id: "u1",
|
|
1624
|
+
name: "Ada",
|
|
1625
|
+
age: 30
|
|
1626
|
+
};
|
|
1627
|
+
await driver.write("users", "u1", input);
|
|
1628
|
+
input.name = "Mutated after write";
|
|
1629
|
+
const stored = await driver.read("users", "u1");
|
|
1630
|
+
const original = {
|
|
1631
|
+
id: "u1",
|
|
1632
|
+
name: "Ada",
|
|
1633
|
+
age: 30
|
|
1634
|
+
};
|
|
1635
|
+
if (stored === void 0 || !deepEqual(stored, original)) {
|
|
1636
|
+
await driver.close();
|
|
1637
|
+
return findingOf("copy-in", "write must copy the input row rather than store it by reference", {
|
|
1638
|
+
table: "users",
|
|
1639
|
+
expected: original,
|
|
1640
|
+
actual: stored
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
stored.name = "Mutated after read";
|
|
1644
|
+
const reread = await driver.read("users", "u1");
|
|
1645
|
+
if (reread === void 0 || !deepEqual(reread, original)) {
|
|
1646
|
+
await driver.close();
|
|
1647
|
+
return findingOf("copy-out", "read must copy the stored row rather than return it by reference", {
|
|
1648
|
+
table: "users",
|
|
1649
|
+
expected: original,
|
|
1650
|
+
actual: reread
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
const overwrite = {
|
|
1654
|
+
id: "u1",
|
|
1655
|
+
name: "Ada Overwritten",
|
|
1656
|
+
age: 31
|
|
1657
|
+
};
|
|
1658
|
+
await driver.write("users", "u1", overwrite);
|
|
1659
|
+
const overwritten = await driver.read("users", "u1");
|
|
1660
|
+
await driver.close();
|
|
1661
|
+
if (overwritten === void 0 || !deepEqual(overwritten, overwrite)) return findingOf("upsert", "write must upsert-overwrite an existing key", {
|
|
1662
|
+
table: "users",
|
|
1663
|
+
expected: overwrite,
|
|
1664
|
+
actual: overwritten
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
check: "delete",
|
|
1670
|
+
run: async () => {
|
|
1671
|
+
const driver = factory();
|
|
1672
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1673
|
+
await driver.write("users", "u1", {
|
|
1674
|
+
id: "u1",
|
|
1675
|
+
name: "Ada",
|
|
1676
|
+
age: 30
|
|
1677
|
+
});
|
|
1678
|
+
const first = await driver.delete("users", "u1");
|
|
1679
|
+
if (first !== true) {
|
|
1680
|
+
await driver.close();
|
|
1681
|
+
return findingOf("delete-true", "delete of an existing key must return true", {
|
|
1682
|
+
table: "users",
|
|
1683
|
+
expected: true,
|
|
1684
|
+
actual: first
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
const second = await driver.delete("users", "u1");
|
|
1688
|
+
await driver.close();
|
|
1689
|
+
if (second !== false) return findingOf("delete-false", "delete of an already-removed key must return false", {
|
|
1690
|
+
table: "users",
|
|
1691
|
+
expected: false,
|
|
1692
|
+
actual: second
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
},
|
|
1696
|
+
{
|
|
1697
|
+
check: "order",
|
|
1698
|
+
run: async () => {
|
|
1699
|
+
const driver = factory();
|
|
1700
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1701
|
+
for (const row of [
|
|
1702
|
+
{
|
|
1703
|
+
id: "c",
|
|
1704
|
+
name: "C",
|
|
1705
|
+
age: 3
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
id: "a",
|
|
1709
|
+
name: "A",
|
|
1710
|
+
age: 1
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
id: "b",
|
|
1714
|
+
name: "B",
|
|
1715
|
+
age: 2
|
|
1716
|
+
}
|
|
1717
|
+
]) await driver.write("users", row.id, row);
|
|
1718
|
+
const expected = [
|
|
1719
|
+
"a",
|
|
1720
|
+
"b",
|
|
1721
|
+
"c"
|
|
1722
|
+
];
|
|
1723
|
+
const keys = [...await driver.keys("users")];
|
|
1724
|
+
if (!deepEqual(keys, expected)) {
|
|
1725
|
+
await driver.close();
|
|
1726
|
+
return findingOf("keys-order", "keys must be returned in ascending key order", {
|
|
1727
|
+
table: "users",
|
|
1728
|
+
expected,
|
|
1729
|
+
actual: keys
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
const scanned = [];
|
|
1733
|
+
for await (const row of driver.scan("users")) scanned.push(row);
|
|
1734
|
+
const scannedIds = scanned.map((row) => row.id);
|
|
1735
|
+
await driver.close();
|
|
1736
|
+
if (!deepEqual(scannedIds, expected)) return findingOf("scan-order", "scan must yield rows in ascending key order", {
|
|
1737
|
+
table: "users",
|
|
1738
|
+
expected,
|
|
1739
|
+
actual: scannedIds
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
check: "clear",
|
|
1745
|
+
run: async () => {
|
|
1746
|
+
const driver = factory();
|
|
1747
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1748
|
+
await driver.write("users", "u1", {
|
|
1749
|
+
id: "u1",
|
|
1750
|
+
name: "Ada",
|
|
1751
|
+
age: 30
|
|
1752
|
+
});
|
|
1753
|
+
await driver.write("posts", "p1", {
|
|
1754
|
+
slug: "p1",
|
|
1755
|
+
title: "Post"
|
|
1756
|
+
});
|
|
1757
|
+
await driver.clear("users");
|
|
1758
|
+
const usersKeys = await driver.keys("users");
|
|
1759
|
+
const postsKeys = await driver.keys("posts");
|
|
1760
|
+
await driver.close();
|
|
1761
|
+
if (usersKeys.length !== 0) return findingOf("clear-target", "clear must empty the targeted table", {
|
|
1762
|
+
table: "users",
|
|
1763
|
+
expected: [],
|
|
1764
|
+
actual: usersKeys
|
|
1765
|
+
});
|
|
1766
|
+
if (postsKeys.length !== 1) return findingOf("clear-other", "clear must not affect other tables", {
|
|
1767
|
+
table: "posts",
|
|
1768
|
+
expected: 1,
|
|
1769
|
+
actual: postsKeys.length
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
check: "snapshot",
|
|
1775
|
+
run: async () => {
|
|
1776
|
+
const driver = factory();
|
|
1777
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1778
|
+
const original = {
|
|
1779
|
+
id: "u1",
|
|
1780
|
+
name: "Ada",
|
|
1781
|
+
age: 30
|
|
1782
|
+
};
|
|
1783
|
+
await driver.write("users", "u1", original);
|
|
1784
|
+
const rollback = await driver.snapshot();
|
|
1785
|
+
await driver.write("users", "u2", {
|
|
1786
|
+
id: "u2",
|
|
1787
|
+
name: "Grace",
|
|
1788
|
+
age: 40
|
|
1789
|
+
});
|
|
1790
|
+
await driver.delete("users", "u1");
|
|
1791
|
+
await rollback();
|
|
1792
|
+
const keys = [...await driver.keys("users")];
|
|
1793
|
+
if (!deepEqual(keys, ["u1"])) {
|
|
1794
|
+
await driver.close();
|
|
1795
|
+
return findingOf("snapshot-rollback", "snapshot rollback must restore the pre-snapshot key set", {
|
|
1796
|
+
table: "users",
|
|
1797
|
+
expected: ["u1"],
|
|
1798
|
+
actual: keys
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
const restored = await driver.read("users", "u1");
|
|
1802
|
+
await driver.close();
|
|
1803
|
+
if (restored === void 0 || !deepEqual(restored, original)) return findingOf("snapshot-rollback-value", "snapshot rollback must restore pre-snapshot row values", {
|
|
1804
|
+
table: "users",
|
|
1805
|
+
expected: original,
|
|
1806
|
+
actual: restored
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
check: "non-id-primary",
|
|
1812
|
+
run: async () => {
|
|
1813
|
+
const driver = factory();
|
|
1814
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1815
|
+
await driver.write("posts", "hello-world", {
|
|
1816
|
+
slug: "hello-world",
|
|
1817
|
+
title: "Hello"
|
|
1818
|
+
});
|
|
1819
|
+
const post = await driver.read("posts", "hello-world");
|
|
1820
|
+
const key = post === void 0 ? void 0 : extractKey(post, "slug");
|
|
1821
|
+
await driver.close();
|
|
1822
|
+
if (key !== "hello-world") return findingOf("non-id-primary", "a non-id primary key column must round-trip through the store", {
|
|
1823
|
+
table: "posts",
|
|
1824
|
+
expected: "hello-world",
|
|
1825
|
+
actual: key
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
check: "nested-roundtrip",
|
|
1831
|
+
run: async () => {
|
|
1832
|
+
const driver = factory();
|
|
1833
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1834
|
+
const nested = {
|
|
1835
|
+
id: "u3",
|
|
1836
|
+
name: "Nested",
|
|
1837
|
+
age: 20,
|
|
1838
|
+
meta: {
|
|
1839
|
+
tags: ["a", "b"],
|
|
1840
|
+
deep: { flag: true }
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
await driver.write("users", "u3", nested);
|
|
1844
|
+
const readBack = await driver.read("users", "u3");
|
|
1845
|
+
await driver.close();
|
|
1846
|
+
if (readBack === void 0 || !deepEqual(readBack, nested)) return findingOf("nested-roundtrip", "a nested-object row must round-trip structurally", {
|
|
1847
|
+
table: "users",
|
|
1848
|
+
expected: nested,
|
|
1849
|
+
actual: readBack
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
{
|
|
1854
|
+
check: "migrate",
|
|
1855
|
+
run: async () => {
|
|
1856
|
+
const driver = factory();
|
|
1857
|
+
if (driver.migrate === void 0) return void 0;
|
|
1858
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1859
|
+
await driver.write("users", "u1", {
|
|
1860
|
+
id: "u1",
|
|
1861
|
+
name: "Ada",
|
|
1862
|
+
age: 30,
|
|
1863
|
+
legacy: true
|
|
1864
|
+
});
|
|
1865
|
+
const removePlan = planMigration([{
|
|
1866
|
+
...CONFORMANCE_USERS_SCHEMA,
|
|
1867
|
+
columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
|
|
1868
|
+
name: "legacy",
|
|
1869
|
+
type: "boolean",
|
|
1870
|
+
nullable: false
|
|
1871
|
+
}]
|
|
1872
|
+
}], [CONFORMANCE_USERS_SCHEMA]);
|
|
1873
|
+
await driver.migrate(removePlan);
|
|
1874
|
+
const migrated = await driver.read("users", "u1");
|
|
1875
|
+
if (migrated === void 0 || "legacy" in migrated) {
|
|
1876
|
+
await driver.close();
|
|
1877
|
+
return findingOf("migrate-column-remove", "a column.remove migration must strip the column from stored rows", {
|
|
1878
|
+
table: "users",
|
|
1879
|
+
expected: void 0,
|
|
1880
|
+
actual: migrated === void 0 ? void 0 : migrated.legacy
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
let caught;
|
|
1884
|
+
try {
|
|
1885
|
+
await driver.migrate({
|
|
1886
|
+
from: 0,
|
|
1887
|
+
to: 1,
|
|
1888
|
+
steps: [{
|
|
1889
|
+
operation: "table.remove",
|
|
1890
|
+
table: "ghost"
|
|
1891
|
+
}]
|
|
1892
|
+
});
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
caught = error;
|
|
1895
|
+
}
|
|
1896
|
+
await driver.close();
|
|
1897
|
+
if (!isDatabaseError(caught) || caught.code !== "MIGRATION") return findingOf("migrate-unknown-table", "a migration step referencing an unknown table must throw a MIGRATION DatabaseError", {
|
|
1898
|
+
table: "ghost",
|
|
1899
|
+
expected: "MIGRATION",
|
|
1900
|
+
actual: isDatabaseError(caught) ? caught.code : caught
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
},
|
|
1904
|
+
{
|
|
1905
|
+
check: "stream",
|
|
1906
|
+
run: async () => {
|
|
1907
|
+
const driver = factory();
|
|
1908
|
+
if (driver.stream === void 0) return void 0;
|
|
1909
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1910
|
+
for (const row of [
|
|
1911
|
+
{
|
|
1912
|
+
id: "a",
|
|
1913
|
+
name: "A",
|
|
1914
|
+
age: 10
|
|
1915
|
+
},
|
|
1916
|
+
{
|
|
1917
|
+
id: "b",
|
|
1918
|
+
name: "B",
|
|
1919
|
+
age: 20
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
id: "c",
|
|
1923
|
+
name: "C",
|
|
1924
|
+
age: 30
|
|
1925
|
+
}
|
|
1926
|
+
]) await driver.write("users", row.id, row);
|
|
1927
|
+
const criteria = { conditions: [{
|
|
1928
|
+
column: "age",
|
|
1929
|
+
operator: "above",
|
|
1930
|
+
values: [10],
|
|
1931
|
+
connector: "and"
|
|
1932
|
+
}] };
|
|
1933
|
+
const matched = [];
|
|
1934
|
+
for await (const row of driver.stream("users", criteria)) matched.push(row);
|
|
1935
|
+
const matchedIds = matched.map((row) => row.id).sort();
|
|
1936
|
+
if (!deepEqual(matchedIds, ["b", "c"])) {
|
|
1937
|
+
await driver.close();
|
|
1938
|
+
return findingOf("stream-match", "stream must yield only condition-matching rows", {
|
|
1939
|
+
table: "users",
|
|
1940
|
+
expected: ["b", "c"],
|
|
1941
|
+
actual: matchedIds
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
const paged = [];
|
|
1945
|
+
for await (const row of driver.stream("users", {
|
|
1946
|
+
offset: 1,
|
|
1947
|
+
limit: 1
|
|
1948
|
+
})) paged.push(row);
|
|
1949
|
+
await driver.close();
|
|
1950
|
+
if (paged.length !== 1) return findingOf("stream-page", "stream must honor offset and limit", {
|
|
1951
|
+
table: "users",
|
|
1952
|
+
expected: 1,
|
|
1953
|
+
actual: paged.length
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
check: "transaction",
|
|
1959
|
+
run: async () => {
|
|
1960
|
+
const driver = factory();
|
|
1961
|
+
if (driver.transaction === void 0) return void 0;
|
|
1962
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1963
|
+
await driver.write("users", "u1", {
|
|
1964
|
+
id: "u1",
|
|
1965
|
+
name: "Ada",
|
|
1966
|
+
age: 30
|
|
1967
|
+
});
|
|
1968
|
+
const committing = await driver.transaction();
|
|
1969
|
+
await driver.write("users", "u2", {
|
|
1970
|
+
id: "u2",
|
|
1971
|
+
name: "Grace",
|
|
1972
|
+
age: 40
|
|
1973
|
+
});
|
|
1974
|
+
await committing.commit();
|
|
1975
|
+
const afterCommit = [...await driver.keys("users")].sort();
|
|
1976
|
+
if (!deepEqual(afterCommit, ["u1", "u2"])) {
|
|
1977
|
+
await driver.close();
|
|
1978
|
+
return findingOf("transaction-commit", "transaction commit must persist writes made during the scope", {
|
|
1979
|
+
table: "users",
|
|
1980
|
+
expected: ["u1", "u2"],
|
|
1981
|
+
actual: afterCommit
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
const rollingBack = await driver.transaction();
|
|
1985
|
+
await driver.write("users", "u3", {
|
|
1986
|
+
id: "u3",
|
|
1987
|
+
name: "Marie",
|
|
1988
|
+
age: 50
|
|
1989
|
+
});
|
|
1990
|
+
await rollingBack.rollback();
|
|
1991
|
+
const afterRollback = [...await driver.keys("users")].sort();
|
|
1992
|
+
await driver.close();
|
|
1993
|
+
if (!deepEqual(afterRollback, ["u1", "u2"])) return findingOf("transaction-rollback", "transaction rollback must restore pre-transaction state", {
|
|
1994
|
+
table: "users",
|
|
1995
|
+
expected: ["u1", "u2"],
|
|
1996
|
+
actual: afterRollback
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
check: "meta-stamp",
|
|
2002
|
+
run: async () => {
|
|
2003
|
+
const driver = factory();
|
|
2004
|
+
if (driver.meta === void 0 || driver.stamp === void 0) return void 0;
|
|
2005
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
2006
|
+
const fresh = await driver.meta();
|
|
2007
|
+
if (fresh !== void 0) {
|
|
2008
|
+
await driver.close();
|
|
2009
|
+
return findingOf("meta-fresh", "a fresh store must report undefined meta", {
|
|
2010
|
+
expected: void 0,
|
|
2011
|
+
actual: fresh
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
const stamped = {
|
|
2015
|
+
version: 1,
|
|
2016
|
+
schema: CONFORMANCE_SCHEMA
|
|
2017
|
+
};
|
|
2018
|
+
await driver.stamp(stamped);
|
|
2019
|
+
const read = await driver.meta();
|
|
2020
|
+
await driver.close();
|
|
2021
|
+
if (read === void 0 || !deepEqual(read, stamped)) return findingOf("meta-stamp", "meta() must return exactly the last-stamped value", {
|
|
2022
|
+
expected: stamped,
|
|
2023
|
+
actual: read
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
},
|
|
2027
|
+
{
|
|
2028
|
+
check: "snapshot-scoped",
|
|
2029
|
+
run: async () => {
|
|
2030
|
+
const driver = factory();
|
|
2031
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
2032
|
+
await driver.write("users", "u1", {
|
|
2033
|
+
id: "u1",
|
|
2034
|
+
name: "Ada",
|
|
2035
|
+
age: 30
|
|
2036
|
+
});
|
|
2037
|
+
await driver.write("posts", "p1", {
|
|
2038
|
+
slug: "p1",
|
|
2039
|
+
title: "Post"
|
|
2040
|
+
});
|
|
2041
|
+
const rollback = await driver.snapshot(["users"]);
|
|
2042
|
+
await driver.write("users", "u2", {
|
|
2043
|
+
id: "u2",
|
|
2044
|
+
name: "Grace",
|
|
2045
|
+
age: 40
|
|
2046
|
+
});
|
|
2047
|
+
await driver.write("posts", "p2", {
|
|
2048
|
+
slug: "p2",
|
|
2049
|
+
title: "Another post"
|
|
2050
|
+
});
|
|
2051
|
+
await rollback();
|
|
2052
|
+
const usersKeys = [...await driver.keys("users")];
|
|
2053
|
+
if (!deepEqual(usersKeys, ["u1"])) {
|
|
2054
|
+
await driver.close();
|
|
2055
|
+
return findingOf("snapshot-scoped-users", "a scoped snapshot must roll back only the named table", {
|
|
2056
|
+
table: "users",
|
|
2057
|
+
expected: ["u1"],
|
|
2058
|
+
actual: usersKeys
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
const postsKeys = [...await driver.keys("posts")].sort();
|
|
2062
|
+
await driver.close();
|
|
2063
|
+
if (!deepEqual(postsKeys, ["p1", "p2"])) return findingOf("snapshot-scoped-posts", "a scoped snapshot must leave an unnamed table's mutations intact", {
|
|
2064
|
+
table: "posts",
|
|
2065
|
+
expected: ["p1", "p2"],
|
|
2066
|
+
actual: postsKeys
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
];
|
|
2071
|
+
for (const phase of phases) try {
|
|
2072
|
+
const finding = await phase.run();
|
|
2073
|
+
if (finding !== void 0) yield finding;
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
yield findingOf(phase.check, error instanceof Error ? error.message : String(error), { error });
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Run the driver-conformance battery, throwing on the first violated
|
|
2080
|
+
* invariant — the fail-fast entry point most callers (test setup, CI smoke
|
|
2081
|
+
* checks) want.
|
|
2082
|
+
*
|
|
2083
|
+
* @remarks
|
|
2084
|
+
* A thin driver over {@link driverFindings}: because that generator is
|
|
2085
|
+
* lazy, consuming only its first yielded value means every LATER phase
|
|
2086
|
+
* never runs — true fail-fast, not merely "report only the first". The
|
|
2087
|
+
* thrown error is byte-compatible with the historical shape: a
|
|
2088
|
+
* `CONFORMANCE` {@link DatabaseError} whose `message` is the finding's
|
|
2089
|
+
* `message` and whose `context` is `{ check, ...finding.context }`.
|
|
2090
|
+
*
|
|
2091
|
+
* @param factory - Mints a fresh, unopened driver instance (called once per phase)
|
|
2092
|
+
* @returns Nothing — resolves once every phase has passed
|
|
2093
|
+
* @throws A `CONFORMANCE` {@link DatabaseError} on the first violated invariant
|
|
2094
|
+
*
|
|
2095
|
+
* @example
|
|
2096
|
+
* ```ts
|
|
2097
|
+
* import { conformDriver, createMemoryDriver } from '@orkestrel/database'
|
|
2098
|
+
*
|
|
2099
|
+
* await conformDriver(() => createMemoryDriver()) // resolves when every invariant holds
|
|
2100
|
+
* ```
|
|
2101
|
+
*/
|
|
2102
|
+
async function conformDriver(factory) {
|
|
2103
|
+
for await (const finding of driverFindings(factory)) throw new DatabaseError("CONFORMANCE", finding.message, {
|
|
2104
|
+
check: finding.check,
|
|
2105
|
+
...finding.context
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Run the FULL driver-conformance battery and collect every violation — the
|
|
2110
|
+
* audit entry point for a driver author who wants a complete report rather
|
|
2111
|
+
* than a single fail-fast throw.
|
|
2112
|
+
*
|
|
2113
|
+
* @remarks
|
|
2114
|
+
* Drains {@link driverFindings} to completion: every phase runs regardless
|
|
2115
|
+
* of earlier violations, so a driver breaking two independent invariants
|
|
2116
|
+
* reports both. An empty array means the driver is fully conformant.
|
|
2117
|
+
*
|
|
2118
|
+
* @param factory - Mints a fresh, unopened driver instance (called once per phase)
|
|
2119
|
+
* @returns Every violated invariant found, in phase order (empty when fully conformant)
|
|
2120
|
+
*
|
|
2121
|
+
* @example
|
|
2122
|
+
* ```ts
|
|
2123
|
+
* import { auditDriver, createMemoryDriver } from '@orkestrel/database'
|
|
2124
|
+
*
|
|
2125
|
+
* const findings = await auditDriver(() => createMemoryDriver())
|
|
2126
|
+
* for (const finding of findings) console.log(`${finding.check}: ${finding.message}`)
|
|
2127
|
+
* ```
|
|
2128
|
+
*/
|
|
2129
|
+
async function auditDriver(factory) {
|
|
2130
|
+
const findings = [];
|
|
2131
|
+
for await (const finding of driverFindings(factory)) findings.push(finding);
|
|
2132
|
+
return findings;
|
|
2133
|
+
}
|
|
2134
|
+
//#endregion
|
|
2135
|
+
//#region node_modules/@orkestrel/emitter/dist/src/core/index.js
|
|
2136
|
+
/**
|
|
2137
|
+
* Extract the own enumerable keys of a mapped object, typed as its key union.
|
|
2138
|
+
*
|
|
2139
|
+
* @remarks
|
|
2140
|
+
* `Object.keys` widens its result to `string[]`, which breaks the key↔value
|
|
2141
|
+
* correlation a mapped type (like `EmitterHooks<TMap>`) otherwise guarantees.
|
|
2142
|
+
* A `for…in` push into a `keyof`-typed array narrows the result back,
|
|
2143
|
+
* type-safely and with no assertion.
|
|
2144
|
+
*
|
|
2145
|
+
* @typeParam T - The object shape whose keys are extracted.
|
|
2146
|
+
* @param object - The object to read keys from.
|
|
2147
|
+
* @returns The object's own enumerable keys, typed as `(keyof T)[]`.
|
|
2148
|
+
*
|
|
2149
|
+
* @example
|
|
2150
|
+
* ```ts
|
|
2151
|
+
* import { extractKeys } from '@src/core'
|
|
2152
|
+
*
|
|
2153
|
+
* const hooks = { tick: () => {}, done: () => {} }
|
|
2154
|
+
* extractKeys(hooks) // ['tick', 'done']
|
|
2155
|
+
* extractKeys({}) // []
|
|
2156
|
+
* ```
|
|
2157
|
+
*/
|
|
2158
|
+
function extractKeys(object) {
|
|
2159
|
+
const collected = [];
|
|
2160
|
+
for (const key in object) collected.push(key);
|
|
2161
|
+
return collected;
|
|
2162
|
+
}
|
|
2163
|
+
Object.freeze([
|
|
2164
|
+
"null",
|
|
2165
|
+
"boolean",
|
|
2166
|
+
"object",
|
|
2167
|
+
"array",
|
|
2168
|
+
"number",
|
|
2169
|
+
"integer",
|
|
2170
|
+
"string"
|
|
2171
|
+
]);
|
|
2172
|
+
/** Determine whether a value is callable. */
|
|
2173
|
+
function isFunction(value) {
|
|
2174
|
+
return typeof value === "function";
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2177
|
+
* A typed synchronous event emitter — the foundational observable primitive of
|
|
2178
|
+
* the codebase (AGENTS §13). Stateful entities OWN one as a `#emitter` field and
|
|
2179
|
+
* expose it through `readonly emitter`; they never inherit from it.
|
|
2180
|
+
*
|
|
2181
|
+
* @typeParam TMap - The event map: each event name to the argument tuple its
|
|
2182
|
+
* listeners receive.
|
|
2183
|
+
*
|
|
2184
|
+
* @remarks
|
|
2185
|
+
* - **Synchronous.** `emit` invokes listeners in registration order, in the
|
|
2186
|
+
* current tick.
|
|
2187
|
+
* - **Listener isolation.** A throwing listener never stops its siblings: every
|
|
2188
|
+
* listener runs, and a throw is routed to the `error` handler
|
|
2189
|
+
* ({@link EmitterOptions.error}) — never rethrown. Every throwing listener
|
|
2190
|
+
* surfaces (not just the first), and with no `error` handler a throw is swallowed
|
|
2191
|
+
* silently. The `error` handler runs inside its own try/catch, so a throwing
|
|
2192
|
+
* error-handler is swallowed too (anti-recursion — it cannot escape or re-enter).
|
|
2193
|
+
* - **Per-event storage.** Listeners live in a per-event `Set`, so every public
|
|
2194
|
+
* method is precisely typed with no assertions.
|
|
2195
|
+
* - **Destroyed → no-op.** After `destroy()`, `on` / `once` / `emit` do nothing
|
|
2196
|
+
* and `destroyed` is `true`.
|
|
2197
|
+
*
|
|
2198
|
+
* @example
|
|
2199
|
+
* ```ts
|
|
2200
|
+
* type CounterEventMap = {
|
|
2201
|
+
* tick: readonly [count: number]
|
|
2202
|
+
* done: readonly []
|
|
2203
|
+
* }
|
|
2204
|
+
*
|
|
2205
|
+
* const emitter = new Emitter<CounterEventMap>({
|
|
2206
|
+
* on: { done: () => stop() },
|
|
2207
|
+
* error: (error, event) => log(`listener for ${event} threw`, error),
|
|
2208
|
+
* })
|
|
2209
|
+
* emitter.on('tick', (count) => render(count))
|
|
2210
|
+
* emitter.emit('tick', 1)
|
|
2211
|
+
* ```
|
|
2212
|
+
*/
|
|
2213
|
+
var Emitter = class {
|
|
2214
|
+
#destroyed = false;
|
|
2215
|
+
#listeners = {};
|
|
2216
|
+
#wrappers = {};
|
|
2217
|
+
#error;
|
|
2218
|
+
constructor(options) {
|
|
2219
|
+
const error = options?.error;
|
|
2220
|
+
this.#error = isFunction(error) ? error : void 0;
|
|
2221
|
+
const hooks = options?.on;
|
|
2222
|
+
if (hooks !== void 0) this.#wire(hooks);
|
|
2223
|
+
}
|
|
2224
|
+
get destroyed() {
|
|
2225
|
+
return this.#destroyed;
|
|
2226
|
+
}
|
|
2227
|
+
on(event, handler) {
|
|
2228
|
+
if (this.#destroyed) return;
|
|
2229
|
+
(this.#listeners[event] ??= /* @__PURE__ */ new Set()).add(handler);
|
|
2230
|
+
}
|
|
2231
|
+
once(event, handler) {
|
|
2232
|
+
if (this.#destroyed) return;
|
|
2233
|
+
const pending = this.#wrappers[event] ??= /* @__PURE__ */ new Map();
|
|
2234
|
+
const wrapper = (...args) => {
|
|
2235
|
+
this.#listeners[event]?.delete(wrapper);
|
|
2236
|
+
const wrappers = pending.get(handler);
|
|
2237
|
+
wrappers?.delete(wrapper);
|
|
2238
|
+
if (wrappers !== void 0 && wrappers.size === 0) pending.delete(handler);
|
|
2239
|
+
handler(...args);
|
|
2240
|
+
};
|
|
2241
|
+
const wrappers = pending.get(handler) ?? /* @__PURE__ */ new Set();
|
|
2242
|
+
wrappers.add(wrapper);
|
|
2243
|
+
pending.set(handler, wrappers);
|
|
2244
|
+
this.on(event, wrapper);
|
|
2245
|
+
}
|
|
2246
|
+
off(event, handler) {
|
|
2247
|
+
const listeners = this.#listeners[event];
|
|
2248
|
+
const wrappers = this.#wrappers[event];
|
|
2249
|
+
const pending = wrappers?.get(handler);
|
|
2250
|
+
if (pending !== void 0) {
|
|
2251
|
+
for (const wrapper of pending) listeners?.delete(wrapper);
|
|
2252
|
+
wrappers?.delete(handler);
|
|
2253
|
+
}
|
|
2254
|
+
listeners?.delete(handler);
|
|
2255
|
+
}
|
|
2256
|
+
emit(event, ...args) {
|
|
2257
|
+
if (this.#destroyed) return;
|
|
2258
|
+
const listeners = this.#listeners[event];
|
|
2259
|
+
if (listeners === void 0) return;
|
|
2260
|
+
for (const handler of [...listeners]) try {
|
|
2261
|
+
handler(...args);
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
this.#surface(error, event);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
count(event) {
|
|
2267
|
+
if (event !== void 0) return this.#listeners[event]?.size ?? 0;
|
|
2268
|
+
let total = 0;
|
|
2269
|
+
for (const set of Object.values(this.#listeners)) total += set?.size ?? 0;
|
|
2270
|
+
return total;
|
|
2271
|
+
}
|
|
2272
|
+
clear(event) {
|
|
2273
|
+
if (event !== void 0) {
|
|
2274
|
+
delete this.#listeners[event];
|
|
2275
|
+
delete this.#wrappers[event];
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
this.#listeners = {};
|
|
2279
|
+
this.#wrappers = {};
|
|
2280
|
+
}
|
|
2281
|
+
destroy() {
|
|
2282
|
+
this.#listeners = {};
|
|
2283
|
+
this.#wrappers = {};
|
|
2284
|
+
this.#error = void 0;
|
|
2285
|
+
this.#destroyed = true;
|
|
2286
|
+
}
|
|
2287
|
+
#surface(error, event) {
|
|
2288
|
+
const handler = this.#error;
|
|
2289
|
+
if (handler === void 0) return;
|
|
2290
|
+
try {
|
|
2291
|
+
handler(error, String(event));
|
|
2292
|
+
} catch {}
|
|
2293
|
+
}
|
|
2294
|
+
#wire(hooks) {
|
|
2295
|
+
for (const event of extractKeys(hooks)) {
|
|
2296
|
+
const handler = hooks[event];
|
|
2297
|
+
if (isFunction(handler)) this.on(event, handler);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
//#endregion
|
|
2302
|
+
//#region src/core/Cursor.ts
|
|
2303
|
+
/**
|
|
2304
|
+
* A forward row cursor for bulk in-place mutation.
|
|
2305
|
+
*
|
|
2306
|
+
* @remarks
|
|
2307
|
+
* Iterates a snapshot of the table's keys captured when the cursor was opened,
|
|
2308
|
+
* reading each row lazily through the owning table — so a mutation made during
|
|
2309
|
+
* iteration cannot corrupt the walk, and a key removed mid-iteration is simply
|
|
2310
|
+
* skipped. `update` and `remove` act on the row at the current position.
|
|
2311
|
+
*/
|
|
2312
|
+
var Cursor = class {
|
|
2313
|
+
#table;
|
|
2314
|
+
#keys;
|
|
2315
|
+
#index = -1;
|
|
2316
|
+
#value;
|
|
2317
|
+
#closed = false;
|
|
2318
|
+
constructor(table, keys) {
|
|
2319
|
+
this.#table = table;
|
|
2320
|
+
this.#keys = keys;
|
|
2321
|
+
}
|
|
2322
|
+
get value() {
|
|
2323
|
+
return this.#value;
|
|
2324
|
+
}
|
|
2325
|
+
get index() {
|
|
2326
|
+
return this.#index;
|
|
2327
|
+
}
|
|
2328
|
+
get done() {
|
|
2329
|
+
return this.#closed || this.#index >= this.#keys.length;
|
|
2330
|
+
}
|
|
2331
|
+
async next() {
|
|
2332
|
+
if (this.#closed) return;
|
|
2333
|
+
this.#index += 1;
|
|
2334
|
+
while (this.#index < this.#keys.length) {
|
|
2335
|
+
const row = await this.#table.get(this.#keys[this.#index]);
|
|
2336
|
+
if (row !== void 0) {
|
|
2337
|
+
this.#value = row;
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
this.#index += 1;
|
|
2341
|
+
}
|
|
2342
|
+
this.#value = void 0;
|
|
2343
|
+
}
|
|
2344
|
+
async update(changes) {
|
|
2345
|
+
if (this.#closed || this.#value === void 0) return;
|
|
2346
|
+
const key = this.#keys[this.#index];
|
|
2347
|
+
await this.#table.update(key, changes);
|
|
2348
|
+
this.#value = await this.#table.get(key);
|
|
2349
|
+
}
|
|
2350
|
+
async remove() {
|
|
2351
|
+
if (this.#closed || this.#value === void 0) return;
|
|
2352
|
+
await this.#table.remove(this.#keys[this.#index]);
|
|
2353
|
+
this.#value = void 0;
|
|
2354
|
+
}
|
|
2355
|
+
close() {
|
|
2356
|
+
this.#closed = true;
|
|
2357
|
+
this.#value = void 0;
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
//#endregion
|
|
2361
|
+
//#region src/core/Clause.ts
|
|
2362
|
+
/**
|
|
2363
|
+
* A pending condition opened by a query's `where` / `and` / `or`.
|
|
2364
|
+
*
|
|
2365
|
+
* @remarks
|
|
2366
|
+
* Holds the column, the connector that will join this condition to the ones
|
|
2367
|
+
* before it, and a recorder the owning query supplies. Each operator builds the
|
|
2368
|
+
* {@link Condition}, hands it to the recorder, and returns the query — so the
|
|
2369
|
+
* fluent chain flows straight back into the builder without exposing a mutator.
|
|
2370
|
+
*/
|
|
2371
|
+
var Clause = class {
|
|
2372
|
+
#record;
|
|
2373
|
+
#column;
|
|
2374
|
+
#connector;
|
|
2375
|
+
constructor(record, column, connector) {
|
|
2376
|
+
this.#record = record;
|
|
2377
|
+
this.#column = column;
|
|
2378
|
+
this.#connector = connector;
|
|
2379
|
+
}
|
|
2380
|
+
equals(value) {
|
|
2381
|
+
return this.#apply("equals", [value]);
|
|
2382
|
+
}
|
|
2383
|
+
not(value) {
|
|
2384
|
+
return this.#apply("not", [value]);
|
|
2385
|
+
}
|
|
2386
|
+
above(value) {
|
|
2387
|
+
return this.#apply("above", [value]);
|
|
2388
|
+
}
|
|
2389
|
+
below(value) {
|
|
2390
|
+
return this.#apply("below", [value]);
|
|
2391
|
+
}
|
|
2392
|
+
from(value) {
|
|
2393
|
+
return this.#apply("from", [value]);
|
|
2394
|
+
}
|
|
2395
|
+
to(value) {
|
|
2396
|
+
return this.#apply("to", [value]);
|
|
2397
|
+
}
|
|
2398
|
+
between(lower, upper) {
|
|
2399
|
+
return this.#apply("between", [lower, upper]);
|
|
2400
|
+
}
|
|
2401
|
+
like(pattern) {
|
|
2402
|
+
return this.#apply("like", [pattern]);
|
|
2403
|
+
}
|
|
2404
|
+
glob(pattern) {
|
|
2405
|
+
return this.#apply("glob", [pattern]);
|
|
2406
|
+
}
|
|
2407
|
+
starts(prefix) {
|
|
2408
|
+
return this.#apply("starts", [prefix]);
|
|
2409
|
+
}
|
|
2410
|
+
ends(suffix) {
|
|
2411
|
+
return this.#apply("ends", [suffix]);
|
|
2412
|
+
}
|
|
2413
|
+
any(values) {
|
|
2414
|
+
return this.#apply("any", values);
|
|
2415
|
+
}
|
|
2416
|
+
none(values) {
|
|
2417
|
+
return this.#apply("none", values);
|
|
2418
|
+
}
|
|
2419
|
+
absent() {
|
|
2420
|
+
return this.#apply("absent", []);
|
|
2421
|
+
}
|
|
2422
|
+
present() {
|
|
2423
|
+
return this.#apply("present", []);
|
|
2424
|
+
}
|
|
2425
|
+
#apply(operator, values) {
|
|
2426
|
+
return this.#record({
|
|
2427
|
+
column: this.#column,
|
|
2428
|
+
operator,
|
|
2429
|
+
values,
|
|
2430
|
+
connector: this.#connector
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
//#endregion
|
|
2435
|
+
//#region src/core/Query.ts
|
|
2436
|
+
/**
|
|
2437
|
+
* A fluent query builder bound to one table.
|
|
2438
|
+
*
|
|
2439
|
+
* @remarks
|
|
2440
|
+
* Accumulates conditions, ordering, JS filters, and a page; each builder method
|
|
2441
|
+
* mutates and returns the same instance, so a chain reads as one statement. The
|
|
2442
|
+
* portable parts (conditions, order, page) compile into a {@link Criteria} the
|
|
2443
|
+
* table resolves; a `filter` predicate is applied in memory after the read and
|
|
2444
|
+
* before paging, so it composes with the rest without a backend ever seeing a
|
|
2445
|
+
* JS callback.
|
|
2446
|
+
*/
|
|
2447
|
+
var Query = class {
|
|
2448
|
+
#table;
|
|
2449
|
+
#conditions = [];
|
|
2450
|
+
#orders = [];
|
|
2451
|
+
#filters = [];
|
|
2452
|
+
#limit;
|
|
2453
|
+
#offset;
|
|
2454
|
+
constructor(table) {
|
|
2455
|
+
this.#table = table;
|
|
2456
|
+
}
|
|
2457
|
+
where(column) {
|
|
2458
|
+
return this.#clause(column, "and");
|
|
2459
|
+
}
|
|
2460
|
+
and(column) {
|
|
2461
|
+
return this.#clause(column, "and");
|
|
2462
|
+
}
|
|
2463
|
+
or(column) {
|
|
2464
|
+
return this.#clause(column, "or");
|
|
2465
|
+
}
|
|
2466
|
+
filter(predicate) {
|
|
2467
|
+
this.#filters.push(predicate);
|
|
2468
|
+
return this;
|
|
2469
|
+
}
|
|
2470
|
+
ascending(column) {
|
|
2471
|
+
this.#orders.push({
|
|
2472
|
+
column,
|
|
2473
|
+
direction: "ascending"
|
|
2474
|
+
});
|
|
2475
|
+
return this;
|
|
2476
|
+
}
|
|
2477
|
+
descending(column) {
|
|
2478
|
+
this.#orders.push({
|
|
2479
|
+
column,
|
|
2480
|
+
direction: "descending"
|
|
2481
|
+
});
|
|
2482
|
+
return this;
|
|
2483
|
+
}
|
|
2484
|
+
limit(count) {
|
|
2485
|
+
this.#limit = count;
|
|
2486
|
+
return this;
|
|
2487
|
+
}
|
|
2488
|
+
offset(count) {
|
|
2489
|
+
this.#offset = count;
|
|
2490
|
+
return this;
|
|
2491
|
+
}
|
|
2492
|
+
async all() {
|
|
2493
|
+
if (this.#filters.length === 0) return this.#table.records({
|
|
2494
|
+
conditions: this.#conditions,
|
|
2495
|
+
order: this.#orders,
|
|
2496
|
+
limit: this.#limit,
|
|
2497
|
+
offset: this.#offset
|
|
2498
|
+
});
|
|
2499
|
+
const fetched = await this.#table.records({
|
|
2500
|
+
conditions: this.#conditions,
|
|
2501
|
+
order: this.#orders
|
|
2502
|
+
});
|
|
2503
|
+
return this.#page(this.#filtered(fetched));
|
|
2504
|
+
}
|
|
2505
|
+
async first() {
|
|
2506
|
+
return (await this.all())[0];
|
|
2507
|
+
}
|
|
2508
|
+
async count() {
|
|
2509
|
+
if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
|
|
2510
|
+
const fetched = await this.#table.records({ conditions: this.#conditions });
|
|
2511
|
+
return this.#filtered(fetched).length;
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2514
|
+
* Lazy per-row evaluation of this query's conditions / filters / offset /
|
|
2515
|
+
* limit.
|
|
2516
|
+
*
|
|
2517
|
+
* @remarks
|
|
2518
|
+
* `order` and its comparators are IGNORED (streaming yields unsorted, as rows
|
|
2519
|
+
* are evaluated one at a time). Same abort semantics as
|
|
2520
|
+
* `TableInterface.scan`: the signal (if any) is checked before each yield,
|
|
2521
|
+
* and breaking out early closes the underlying source.
|
|
2522
|
+
*
|
|
2523
|
+
* @param options - `signal` to cancel the iteration; checked before each yield
|
|
2524
|
+
* @returns An async iterable of matching rows
|
|
2525
|
+
*/
|
|
2526
|
+
async *stream(options) {
|
|
2527
|
+
if (this.#filters.length === 0) {
|
|
2528
|
+
yield* this.#table.scan({
|
|
2529
|
+
conditions: this.#conditions,
|
|
2530
|
+
limit: this.#limit,
|
|
2531
|
+
offset: this.#offset
|
|
2532
|
+
}, options);
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
const offset = this.#offset ?? 0;
|
|
2536
|
+
let matched = 0;
|
|
2537
|
+
let yielded = 0;
|
|
2538
|
+
for await (const row of this.#table.scan({ conditions: this.#conditions }, options)) {
|
|
2539
|
+
if (this.#limit !== void 0 && yielded >= this.#limit) break;
|
|
2540
|
+
let matches = true;
|
|
2541
|
+
for (const predicate of this.#filters) if (!predicate(row)) {
|
|
2542
|
+
matches = false;
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
if (!matches) continue;
|
|
2546
|
+
if (matched < offset) {
|
|
2547
|
+
matched += 1;
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
matched += 1;
|
|
2551
|
+
yielded += 1;
|
|
2552
|
+
yield row;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
aggregate(operation, column) {
|
|
2556
|
+
if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
|
|
2557
|
+
return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
|
|
2558
|
+
}
|
|
2559
|
+
sum(column) {
|
|
2560
|
+
return this.aggregate("sum", column);
|
|
2561
|
+
}
|
|
2562
|
+
average(column) {
|
|
2563
|
+
return this.aggregate("average", column);
|
|
2564
|
+
}
|
|
2565
|
+
minimum(column) {
|
|
2566
|
+
return this.aggregate("minimum", column);
|
|
2567
|
+
}
|
|
2568
|
+
maximum(column) {
|
|
2569
|
+
return this.aggregate("maximum", column);
|
|
2570
|
+
}
|
|
2571
|
+
#clause(column, connector) {
|
|
2572
|
+
return new Clause((condition) => {
|
|
2573
|
+
this.#conditions.push(condition);
|
|
2574
|
+
return this;
|
|
2575
|
+
}, column, connector);
|
|
2576
|
+
}
|
|
2577
|
+
#filtered(rows) {
|
|
2578
|
+
let result = rows;
|
|
2579
|
+
for (const predicate of this.#filters) result = result.filter(predicate);
|
|
2580
|
+
return result;
|
|
2581
|
+
}
|
|
2582
|
+
#page(rows) {
|
|
2583
|
+
const offset = this.#offset ?? 0;
|
|
2584
|
+
if (offset === 0 && this.#limit === void 0) return rows;
|
|
2585
|
+
return rows.slice(offset, this.#limit === void 0 ? void 0 : offset + this.#limit);
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
//#endregion
|
|
2589
|
+
//#region src/core/Table.ts
|
|
2590
|
+
/**
|
|
2591
|
+
* A table — typed keyed CRUD plus fluent query and cursor access over a driver.
|
|
2592
|
+
*
|
|
2593
|
+
* @remarks
|
|
2594
|
+
* The table's contract is the load-bearing piece: writes go through `parse`
|
|
2595
|
+
* (coercing inputs and rejecting rows that don't fit with a `VALIDATION` throw),
|
|
2596
|
+
* reads come back through the contract guard (narrowing a stored {@link Row} to
|
|
2597
|
+
* the table's type — no assertion, AGENTS §1), and `contract` is exposed for
|
|
2598
|
+
* introspection and seeding. The driver only stores and scans; all querying is
|
|
2599
|
+
* the shared core engine in `helpers.ts`.
|
|
2600
|
+
*
|
|
2601
|
+
* @remarks
|
|
2602
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link TableEventMap}) carries the
|
|
2603
|
+
* per-row mutation moments — `write` (set / add / update), `remove`, `clear` — for
|
|
2604
|
+
* fire-and-forget observers (cache invalidation, sync, an audit log), ALONGSIDE the
|
|
2605
|
+
* database-level lifecycle. Events carry the affected KEY only (no value payload, to
|
|
2606
|
+
* keep fan-out lean); reads / queries / counts are not emitted. Every event is emitted
|
|
2607
|
+
* directly, strictly AFTER the driver write / delete / clear completes; the emitter
|
|
2608
|
+
* isolates a listener throw and routes it to its `error` handler (the `error` option),
|
|
2609
|
+
* so a buggy observer can never corrupt a write or perturb a transaction.
|
|
2610
|
+
*/
|
|
2611
|
+
var Table = class {
|
|
2612
|
+
#ready;
|
|
2613
|
+
#driver;
|
|
2614
|
+
#name;
|
|
2615
|
+
#key;
|
|
2616
|
+
#contract;
|
|
2617
|
+
#guard;
|
|
2618
|
+
#generate;
|
|
2619
|
+
#emitter;
|
|
2620
|
+
constructor(ready, driver, name, key, contract, generate, on, error) {
|
|
2621
|
+
this.#ready = ready;
|
|
2622
|
+
this.#driver = driver;
|
|
2623
|
+
this.#name = name;
|
|
2624
|
+
this.#key = key;
|
|
2625
|
+
this.#contract = contract;
|
|
2626
|
+
this.#guard = contract.is;
|
|
2627
|
+
this.#generate = generate;
|
|
2628
|
+
this.#emitter = new Emitter({
|
|
2629
|
+
on,
|
|
2630
|
+
error
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
get emitter() {
|
|
2634
|
+
return this.#emitter;
|
|
2635
|
+
}
|
|
2636
|
+
get name() {
|
|
2637
|
+
return this.#name;
|
|
2638
|
+
}
|
|
2639
|
+
get primary() {
|
|
2640
|
+
return this.#key;
|
|
2641
|
+
}
|
|
2642
|
+
get contract() {
|
|
2643
|
+
return this.#contract;
|
|
2644
|
+
}
|
|
2645
|
+
async get(keys) {
|
|
2646
|
+
await this.#ready();
|
|
2647
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#read(key));
|
|
2648
|
+
return this.#read(keys);
|
|
2649
|
+
}
|
|
2650
|
+
async resolve(keys) {
|
|
2651
|
+
await this.#ready();
|
|
2652
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
|
|
2653
|
+
return this.#resolveOne(keys);
|
|
2654
|
+
}
|
|
2655
|
+
async has(keys) {
|
|
2656
|
+
await this.#ready();
|
|
2657
|
+
if (isArray(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
|
|
2658
|
+
return await this.#read(keys) !== void 0;
|
|
2659
|
+
}
|
|
2660
|
+
async keys() {
|
|
2661
|
+
await this.#ready();
|
|
2662
|
+
return this.#driver.keys(this.#name);
|
|
2663
|
+
}
|
|
2664
|
+
async records(criteria, options) {
|
|
2665
|
+
checkAbort(options?.signal);
|
|
2666
|
+
await this.#ready();
|
|
2667
|
+
const source = await this.#driver.records?.(this.#name, criteria ?? {}) ?? applyCriteria(await this.#collect(), criteria);
|
|
2668
|
+
const rows = [];
|
|
2669
|
+
for (const row of source) if (this.#guard(row)) rows.push(row);
|
|
2670
|
+
return rows;
|
|
2671
|
+
}
|
|
2672
|
+
async count(criteria, options) {
|
|
2673
|
+
checkAbort(options?.signal);
|
|
2674
|
+
await this.#ready();
|
|
2675
|
+
const conditions = criteria?.conditions;
|
|
2676
|
+
const native = await this.#driver.count?.(this.#name, conditions ? { conditions } : {});
|
|
2677
|
+
if (native !== void 0) return native;
|
|
2678
|
+
return filterRows(await this.#collect(), criteria?.conditions ?? []).length;
|
|
2679
|
+
}
|
|
2680
|
+
async aggregate(operation, column, criteria, options) {
|
|
2681
|
+
checkAbort(options?.signal);
|
|
2682
|
+
await this.#ready();
|
|
2683
|
+
const conditions = criteria?.conditions;
|
|
2684
|
+
const filter = conditions ? { conditions } : {};
|
|
2685
|
+
const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
|
|
2686
|
+
if (native !== void 0) return native;
|
|
2687
|
+
return computeAggregate(await this.#driver.records?.(this.#name, filter) ?? filterRows(await this.#collect(), criteria?.conditions ?? []), operation, column);
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Stream the table's rows matching `criteria`, applying offset/limit paging.
|
|
2691
|
+
*
|
|
2692
|
+
* @remarks
|
|
2693
|
+
* `criteria.limit` counts rows that pass BOTH the criteria conditions AND the
|
|
2694
|
+
* table's contract guard (a stored row that fails the guard is skipped and
|
|
2695
|
+
* does not count toward `limit`) — this can differ from {@link records}'s
|
|
2696
|
+
* `limit`, which a driver's optional native `records` hook applies BEFORE
|
|
2697
|
+
* the contract guard runs, when storage holds rows that no longer conform
|
|
2698
|
+
* to the table's contract.
|
|
2699
|
+
*
|
|
2700
|
+
* @param criteria - Optional conditions plus offset/limit paging
|
|
2701
|
+
* @param options - `{ signal }` to abort mid-stream
|
|
2702
|
+
* @returns An async generator of matching, guard-conforming rows
|
|
2703
|
+
*/
|
|
2704
|
+
async *scan(criteria, options) {
|
|
2705
|
+
checkAbort(options?.signal);
|
|
2706
|
+
await this.#ready();
|
|
2707
|
+
if (this.#driver.stream !== void 0) {
|
|
2708
|
+
for await (const row of this.#driver.stream(this.#name, criteria ?? {})) {
|
|
2709
|
+
checkAbort(options?.signal);
|
|
2710
|
+
const narrowed = this.#cast(row);
|
|
2711
|
+
if (narrowed !== void 0) yield narrowed;
|
|
2712
|
+
}
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const conditions = criteria?.conditions;
|
|
2716
|
+
const offset = criteria?.offset ?? 0;
|
|
2717
|
+
const limit = criteria?.limit;
|
|
2718
|
+
let matched = 0;
|
|
2719
|
+
let yielded = 0;
|
|
2720
|
+
for await (const row of this.#driver.scan(this.#name)) {
|
|
2721
|
+
checkAbort(options?.signal);
|
|
2722
|
+
if (limit !== void 0 && yielded >= limit) break;
|
|
2723
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
|
|
2724
|
+
if (matched < offset) {
|
|
2725
|
+
matched += 1;
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
matched += 1;
|
|
2729
|
+
const narrowed = this.#cast(row);
|
|
2730
|
+
if (narrowed !== void 0) {
|
|
2731
|
+
yielded += 1;
|
|
2732
|
+
yield narrowed;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
async set(rows, options) {
|
|
2737
|
+
checkAbort(options?.signal);
|
|
2738
|
+
await this.#ready();
|
|
2739
|
+
if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
|
|
2740
|
+
return this.#put(rows, false);
|
|
2741
|
+
}
|
|
2742
|
+
async add(rows, options) {
|
|
2743
|
+
checkAbort(options?.signal);
|
|
2744
|
+
await this.#ready();
|
|
2745
|
+
if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, true), options?.signal);
|
|
2746
|
+
return this.#put(rows, true);
|
|
2747
|
+
}
|
|
2748
|
+
async update(keys, changes, options) {
|
|
2749
|
+
checkAbort(options?.signal);
|
|
2750
|
+
await this.#ready();
|
|
2751
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes), options?.signal);
|
|
2752
|
+
return this.#updateOne(keys, changes);
|
|
2753
|
+
}
|
|
2754
|
+
async remove(keys, options) {
|
|
2755
|
+
checkAbort(options?.signal);
|
|
2756
|
+
await this.#ready();
|
|
2757
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#delete(key), options?.signal);
|
|
2758
|
+
return this.#delete(keys);
|
|
2759
|
+
}
|
|
2760
|
+
async clear() {
|
|
2761
|
+
await this.#ready();
|
|
2762
|
+
await this.#driver.clear(this.#name);
|
|
2763
|
+
this.#emitter.emit("clear");
|
|
2764
|
+
}
|
|
2765
|
+
query() {
|
|
2766
|
+
return new Query(this);
|
|
2767
|
+
}
|
|
2768
|
+
async cursor() {
|
|
2769
|
+
await this.#ready();
|
|
2770
|
+
const cursor = new Cursor(this, await this.#driver.keys(this.#name));
|
|
2771
|
+
await cursor.next();
|
|
2772
|
+
return cursor;
|
|
2773
|
+
}
|
|
2774
|
+
async #each(items, operation, signal) {
|
|
2775
|
+
const results = [];
|
|
2776
|
+
for (const item of items) {
|
|
2777
|
+
checkAbort(signal);
|
|
2778
|
+
results.push(await operation(item));
|
|
2779
|
+
}
|
|
2780
|
+
return results;
|
|
2781
|
+
}
|
|
2782
|
+
async #read(key) {
|
|
2783
|
+
return this.#cast(await this.#driver.read(this.#name, key));
|
|
2784
|
+
}
|
|
2785
|
+
async #resolveOne(key) {
|
|
2786
|
+
const row = await this.#read(key);
|
|
2787
|
+
if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
|
|
2788
|
+
table: this.#name,
|
|
2789
|
+
key
|
|
2790
|
+
});
|
|
2791
|
+
return row;
|
|
2792
|
+
}
|
|
2793
|
+
async #put(row, exclusive) {
|
|
2794
|
+
const validated = this.#validate(this.#prepare(row));
|
|
2795
|
+
const key = this.#resolveKey(validated);
|
|
2796
|
+
if (exclusive && await this.#driver.read(this.#name, key) !== void 0) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${this.#name}'`, {
|
|
2797
|
+
table: this.#name,
|
|
2798
|
+
key
|
|
2799
|
+
});
|
|
2800
|
+
await this.#driver.write(this.#name, key, validated);
|
|
2801
|
+
this.#emitter.emit("write", key);
|
|
2802
|
+
return key;
|
|
2803
|
+
}
|
|
2804
|
+
async #updateOne(key, changes) {
|
|
2805
|
+
const existing = await this.#driver.read(this.#name, key);
|
|
2806
|
+
if (existing === void 0) return false;
|
|
2807
|
+
await this.#driver.write(this.#name, key, this.#validate(Object.assign({}, existing, changes)));
|
|
2808
|
+
this.#emitter.emit("write", key);
|
|
2809
|
+
return true;
|
|
2810
|
+
}
|
|
2811
|
+
async #delete(key) {
|
|
2812
|
+
const removed = await this.#driver.delete(this.#name, key);
|
|
2813
|
+
if (removed) this.#emitter.emit("remove", key);
|
|
2814
|
+
return removed;
|
|
2815
|
+
}
|
|
2816
|
+
async #collect() {
|
|
2817
|
+
const rows = [];
|
|
2818
|
+
for await (const row of this.#driver.scan(this.#name)) rows.push(row);
|
|
2819
|
+
return rows;
|
|
2820
|
+
}
|
|
2821
|
+
#prepare(row) {
|
|
2822
|
+
if (!isRecord(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
|
|
2823
|
+
const prepared = { ...row };
|
|
2824
|
+
if (prepared[this.#key] === void 0) {
|
|
2825
|
+
if (this.#generate === void 0) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is missing its key column '${this.#key}' and no key factory was provided`, {
|
|
2826
|
+
table: this.#name,
|
|
2827
|
+
column: this.#key
|
|
2828
|
+
});
|
|
2829
|
+
prepared[this.#key] = this.#generate();
|
|
2830
|
+
}
|
|
2831
|
+
return prepared;
|
|
2832
|
+
}
|
|
2833
|
+
#validate(row) {
|
|
2834
|
+
const parsed = this.#contract.parse(row);
|
|
2835
|
+
if (parsed === void 0 || !isRecord(parsed)) throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
|
|
2836
|
+
table: this.#name,
|
|
2837
|
+
row
|
|
2838
|
+
});
|
|
2839
|
+
return parsed;
|
|
2840
|
+
}
|
|
2841
|
+
#resolveKey(row) {
|
|
2842
|
+
const key = extractKey(row, this.#key);
|
|
2843
|
+
if (key === void 0) throw new DatabaseError("VALIDATION", `Row has no usable key in column '${this.#key}'`, {
|
|
2844
|
+
table: this.#name,
|
|
2845
|
+
column: this.#key
|
|
2846
|
+
});
|
|
2847
|
+
return key;
|
|
2848
|
+
}
|
|
2849
|
+
#cast(row) {
|
|
2850
|
+
return row !== void 0 && this.#guard(row) ? row : void 0;
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
//#endregion
|
|
2854
|
+
//#region src/core/Database.ts
|
|
2855
|
+
/**
|
|
2856
|
+
* A database — the ergonomic entry point over a {@link DriverInterface}.
|
|
2857
|
+
*
|
|
2858
|
+
* @remarks
|
|
2859
|
+
* Owns the driver and a `tables` shape map, connecting the driver lazily on first
|
|
2860
|
+
* use so a freshly created database is immediately usable. `table(name)` returns
|
|
2861
|
+
* a table typed by that table's shape `Infer`. `import` registers more tables and
|
|
2862
|
+
* returns a database re-typed with them over the **same** driver and storage;
|
|
2863
|
+
* `export` emits a portable {@link TableExport} per table. `transaction` snapshots
|
|
2864
|
+
* the driver, runs the scope, and rolls every table back if it throws — an
|
|
2865
|
+
* optimistic model that works uniformly across backends rather than reconciling
|
|
2866
|
+
* SQL's and IndexedDB's incompatible native transactions.
|
|
2867
|
+
*
|
|
2868
|
+
* @remarks
|
|
2869
|
+
* - **Versioned (optional).** When {@link DatabaseOptions.version} is set and the driver
|
|
2870
|
+
* implements both {@link DriverInterface.meta} and {@link DriverInterface.stamp},
|
|
2871
|
+
* `open()` reconciles the driver's persisted {@link DriverMeta} against the declared
|
|
2872
|
+
* version INSIDE the same lazy-connect chain, AFTER the `open` event fires — see
|
|
2873
|
+
* {@link DatabaseOptions.version} for the full reconciliation contract.
|
|
2874
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
|
|
2875
|
+
* connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
|
|
2876
|
+
* `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
|
|
2877
|
+
* event is emitted directly, strictly AFTER the relevant transition: `commit` only after
|
|
2878
|
+
* the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
|
|
2879
|
+
* OBSERVES the propagated error — it never swallows it (the original throw propagates
|
|
2880
|
+
* exactly as before). The emitter isolates a listener throw and routes it to its `error`
|
|
2881
|
+
* handler (the `error` option), so observation can never reorder, throw into, or corrupt
|
|
2882
|
+
* the snapshot / commit / rollback flow.
|
|
2883
|
+
*/
|
|
2884
|
+
var Database = class Database {
|
|
2885
|
+
#driver;
|
|
2886
|
+
#tables;
|
|
2887
|
+
#keys;
|
|
2888
|
+
#indexes;
|
|
2889
|
+
#name;
|
|
2890
|
+
#generate;
|
|
2891
|
+
#version;
|
|
2892
|
+
#emitter;
|
|
2893
|
+
#status = "idle";
|
|
2894
|
+
#ready;
|
|
2895
|
+
constructor(options) {
|
|
2896
|
+
this.#driver = options.driver;
|
|
2897
|
+
this.#tables = options.tables;
|
|
2898
|
+
this.#keys = options.keys ?? {};
|
|
2899
|
+
this.#indexes = options.indexes ?? {};
|
|
2900
|
+
this.#name = options.name ?? "database";
|
|
2901
|
+
this.#generate = options.key;
|
|
2902
|
+
this.#version = options.version;
|
|
2903
|
+
this.#emitter = new Emitter({
|
|
2904
|
+
on: options.on,
|
|
2905
|
+
error: options.error
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
get emitter() {
|
|
2909
|
+
return this.#emitter;
|
|
2910
|
+
}
|
|
2911
|
+
get name() {
|
|
2912
|
+
return this.#name;
|
|
2913
|
+
}
|
|
2914
|
+
get status() {
|
|
2915
|
+
return this.#status;
|
|
2916
|
+
}
|
|
2917
|
+
table(name) {
|
|
2918
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2919
|
+
return this.#build(name, this.#key(name), createContract(objectShape(this.#tables[name])));
|
|
2920
|
+
}
|
|
2921
|
+
import(tables, keys) {
|
|
2922
|
+
return this.#spawn(tables, {
|
|
2923
|
+
...this.#keys,
|
|
2924
|
+
...keys
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
export() {
|
|
2928
|
+
const result = {};
|
|
2929
|
+
for (const name of Object.keys(this.#tables)) {
|
|
2930
|
+
const columns = this.#tables[name];
|
|
2931
|
+
result[name] = {
|
|
2932
|
+
key: this.#key(name),
|
|
2933
|
+
columns,
|
|
2934
|
+
schema: compileSchema(objectShape(columns))
|
|
2935
|
+
};
|
|
2936
|
+
}
|
|
2937
|
+
return result;
|
|
2938
|
+
}
|
|
2939
|
+
async open() {
|
|
2940
|
+
await this.#connect();
|
|
2941
|
+
}
|
|
2942
|
+
async close() {
|
|
2943
|
+
this.#status = "closed";
|
|
2944
|
+
this.#ready = void 0;
|
|
2945
|
+
await this.#driver.close();
|
|
2946
|
+
this.#emitter.emit("close");
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Run `scope` transactionally: commit its writes on success, roll every table
|
|
2950
|
+
* back if it throws.
|
|
2951
|
+
*
|
|
2952
|
+
* @remarks
|
|
2953
|
+
* When the driver implements the optional native {@link DriverInterface.transaction}
|
|
2954
|
+
* hook, that native `commit` / `rollback` handle drives the transaction; otherwise
|
|
2955
|
+
* the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
|
|
2956
|
+
* emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
|
|
2957
|
+
* `options.signal` is checked ONCE at entry, before connecting or starting any
|
|
2958
|
+
* transactional work — an already-aborted signal throws `ABORTED` and neither the
|
|
2959
|
+
* native hook nor the snapshot floor is invoked. Nesting is unguarded and
|
|
2960
|
+
* unsupported exactly as before: this is a single-writer model, not reentrant.
|
|
2961
|
+
* On the native path, a `scope` throw rolls back via the native handle; a
|
|
2962
|
+
* native `commit` failure propagates as-is with no rollback attempt — the
|
|
2963
|
+
* engine owns transaction state after a failed COMMIT.
|
|
2964
|
+
*
|
|
2965
|
+
* @param scope - The transactional work to run
|
|
2966
|
+
* @param options - `{ signal }` to abort before the transaction starts
|
|
2967
|
+
* @returns The scope's resolved value
|
|
2968
|
+
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
|
|
2969
|
+
*/
|
|
2970
|
+
async transaction(scope, options) {
|
|
2971
|
+
checkAbort(options?.signal);
|
|
2972
|
+
await this.#connect();
|
|
2973
|
+
const native = await this.#driver.transaction?.();
|
|
2974
|
+
if (native !== void 0) {
|
|
2975
|
+
this.#emitter.emit("transaction");
|
|
2976
|
+
let value;
|
|
2977
|
+
try {
|
|
2978
|
+
value = await scope();
|
|
2979
|
+
} catch (error) {
|
|
2980
|
+
await native.rollback();
|
|
2981
|
+
this.#emitter.emit("rollback", error);
|
|
2982
|
+
throw error;
|
|
2983
|
+
}
|
|
2984
|
+
await native.commit();
|
|
2985
|
+
this.#emitter.emit("commit");
|
|
2986
|
+
return value;
|
|
2987
|
+
}
|
|
2988
|
+
const rollback = await this.#driver.snapshot();
|
|
2989
|
+
this.#emitter.emit("transaction");
|
|
2990
|
+
try {
|
|
2991
|
+
const value = await scope();
|
|
2992
|
+
this.#emitter.emit("commit");
|
|
2993
|
+
return value;
|
|
2994
|
+
} catch (error) {
|
|
2995
|
+
await rollback();
|
|
2996
|
+
this.#emitter.emit("rollback", error);
|
|
2997
|
+
throw error;
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Diff `deployed` against this database's declared schema and apply the
|
|
3002
|
+
* resulting plan through the driver's optional `migrate` hook.
|
|
3003
|
+
*
|
|
3004
|
+
* @param deployed - The schema currently deployed, as {@link TableSchema}s
|
|
3005
|
+
* @param options - `{ signal }` to abort before the migration starts
|
|
3006
|
+
* @returns The applied {@link Migration} plan
|
|
3007
|
+
* @throws A `MIGRATION` {@link DatabaseError} when the driver does not
|
|
3008
|
+
* implement `migrate`, or when a step references an unknown table
|
|
3009
|
+
* (propagated from the driver)
|
|
3010
|
+
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
|
|
3011
|
+
* already fired at entry
|
|
3012
|
+
*/
|
|
3013
|
+
async migrate(deployed, options) {
|
|
3014
|
+
checkAbort(options?.signal);
|
|
3015
|
+
await this.#connect();
|
|
3016
|
+
const plan = planMigration(deployed, this.#schema());
|
|
3017
|
+
if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
|
|
3018
|
+
await this.#apply(plan);
|
|
3019
|
+
return plan;
|
|
3020
|
+
}
|
|
3021
|
+
#build(name, key, contract) {
|
|
3022
|
+
return new Table(() => this.#connect(), this.#driver, name, key, contract, this.#generate);
|
|
3023
|
+
}
|
|
3024
|
+
#spawn(tables, keys) {
|
|
3025
|
+
return new Database({
|
|
3026
|
+
driver: this.#driver,
|
|
3027
|
+
tables,
|
|
3028
|
+
keys,
|
|
3029
|
+
name: this.#name,
|
|
3030
|
+
...this.#generate === void 0 ? {} : { key: this.#generate }
|
|
3031
|
+
});
|
|
3032
|
+
}
|
|
3033
|
+
#key(name) {
|
|
3034
|
+
return this.#keys[name] ?? "id";
|
|
3035
|
+
}
|
|
3036
|
+
#schema() {
|
|
3037
|
+
return Object.keys(this.#tables).map((name) => {
|
|
3038
|
+
const columns = this.#tables[name];
|
|
3039
|
+
return {
|
|
3040
|
+
name,
|
|
3041
|
+
primary: this.#key(name),
|
|
3042
|
+
columns: Object.keys(columns).map((column) => {
|
|
3043
|
+
const shape = columns[column];
|
|
3044
|
+
return {
|
|
3045
|
+
name: column,
|
|
3046
|
+
type: shapeToColumnType(shape),
|
|
3047
|
+
nullable: shape.type === "optional" || shape.type === "nullable"
|
|
3048
|
+
};
|
|
3049
|
+
}),
|
|
3050
|
+
indexes: this.#indexes[name] ?? []
|
|
3051
|
+
};
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
#connect() {
|
|
3055
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
3056
|
+
if (this.#ready === void 0) this.#ready = this.#driver.open(this.#schema()).then(async () => {
|
|
3057
|
+
if (this.#status === "idle") this.#status = "open";
|
|
3058
|
+
this.#emitter.emit("open");
|
|
3059
|
+
await this.#reconcile();
|
|
3060
|
+
});
|
|
3061
|
+
return this.#ready;
|
|
3062
|
+
}
|
|
3063
|
+
async #reconcile() {
|
|
3064
|
+
if (this.#version === void 0 || this.#driver.meta === void 0) return;
|
|
3065
|
+
const declared = this.#schema();
|
|
3066
|
+
const meta = await this.#driver.meta();
|
|
3067
|
+
if (meta === void 0) {
|
|
3068
|
+
await this.#stamp();
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
if (meta.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${meta.version} is newer than declared version ${this.#version}`, {
|
|
3072
|
+
name: this.#name,
|
|
3073
|
+
stored: meta.version,
|
|
3074
|
+
declared: this.#version
|
|
3075
|
+
});
|
|
3076
|
+
if (meta.version < this.#version) {
|
|
3077
|
+
const plan = planMigration(meta.schema, declared, meta.version, this.#version);
|
|
3078
|
+
if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
|
|
3079
|
+
name: this.#name,
|
|
3080
|
+
stored: meta.version,
|
|
3081
|
+
declared: this.#version
|
|
3082
|
+
});
|
|
3083
|
+
await this.#apply(plan);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
async #apply(plan) {
|
|
3087
|
+
const native = await this.#driver.transaction?.();
|
|
3088
|
+
if (native !== void 0) {
|
|
3089
|
+
try {
|
|
3090
|
+
await this.#driver.migrate?.(plan);
|
|
3091
|
+
await this.#stamp();
|
|
3092
|
+
} catch (error) {
|
|
3093
|
+
await native.rollback();
|
|
3094
|
+
throw error;
|
|
3095
|
+
}
|
|
3096
|
+
await native.commit();
|
|
3097
|
+
this.#emitter.emit("migrate", plan);
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
await this.#driver.migrate?.(plan);
|
|
3101
|
+
await this.#stamp();
|
|
3102
|
+
this.#emitter.emit("migrate", plan);
|
|
3103
|
+
}
|
|
3104
|
+
async #stamp() {
|
|
3105
|
+
if (this.#version === void 0 || this.#driver.stamp === void 0) return;
|
|
3106
|
+
const meta = {
|
|
3107
|
+
version: this.#version,
|
|
3108
|
+
schema: this.#schema()
|
|
3109
|
+
};
|
|
3110
|
+
await this.#driver.stamp(meta);
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
3113
|
+
//#endregion
|
|
3114
|
+
//#region src/core/drivers/MemoryDriver.ts
|
|
3115
|
+
/**
|
|
3116
|
+
* The reference {@link DriverInterface} — nested maps, no I/O.
|
|
3117
|
+
*
|
|
3118
|
+
* @remarks
|
|
3119
|
+
* The in-between made concrete: it runs identically in a browser or on a server,
|
|
3120
|
+
* so it is the storage behind tests, ephemeral caches, and any code that wants
|
|
3121
|
+
* the database API without a persistent backend. Rows are copied in and out so a
|
|
3122
|
+
* caller can never mutate stored state by reference (AGENTS §11), and `snapshot`
|
|
3123
|
+
* clones every table to give transactions an exact rollback point. `scan` and
|
|
3124
|
+
* `keys` yield in key order — sorted by the core {@link compareValues} total
|
|
3125
|
+
* order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
|
|
3126
|
+
* reads) backends honor, so an unordered read agrees across every backend rather
|
|
3127
|
+
* than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
|
|
3128
|
+
* implements the same nine methods over real storage.
|
|
3129
|
+
*/
|
|
3130
|
+
var MemoryDriver = class {
|
|
3131
|
+
#tables = /* @__PURE__ */ new Map();
|
|
3132
|
+
#meta;
|
|
3133
|
+
async open(schema) {
|
|
3134
|
+
for (const table of schema) if (!this.#tables.has(table.name)) this.#tables.set(table.name, /* @__PURE__ */ new Map());
|
|
3135
|
+
}
|
|
3136
|
+
async close() {}
|
|
3137
|
+
async read(table, key) {
|
|
3138
|
+
const row = this.#store(table).get(key);
|
|
3139
|
+
return row === void 0 ? void 0 : { ...row };
|
|
3140
|
+
}
|
|
3141
|
+
async write(table, key, row) {
|
|
3142
|
+
this.#store(table).set(key, { ...row });
|
|
3143
|
+
}
|
|
3144
|
+
async delete(table, key) {
|
|
3145
|
+
return this.#store(table).delete(key);
|
|
3146
|
+
}
|
|
3147
|
+
async keys(table) {
|
|
3148
|
+
return this.#ordered(table);
|
|
3149
|
+
}
|
|
3150
|
+
async *scan(table) {
|
|
3151
|
+
const store = this.#store(table);
|
|
3152
|
+
for (const key of this.#ordered(table)) {
|
|
3153
|
+
const row = store.get(key);
|
|
3154
|
+
if (row !== void 0) yield { ...row };
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Natively filtered lazy iteration — the {@link DriverInterface.stream} hook.
|
|
3159
|
+
*
|
|
3160
|
+
* @remarks
|
|
3161
|
+
* Iterates the table's keys in the same key order `scan` and `keys` yield
|
|
3162
|
+
* (sorted by {@link compareValues}), testing each row against
|
|
3163
|
+
* `criteria.conditions` (via {@link matchesCriteria}) before counting it
|
|
3164
|
+
* toward `offset` / `limit`. Both are applied lazily as matches are found —
|
|
3165
|
+
* `offset` matches are skipped without being yielded, and iteration stops the
|
|
3166
|
+
* instant `limit` yields have been produced, so a large table is never fully
|
|
3167
|
+
* walked for a small page. `criteria.order` is IGNORED (the same contract as
|
|
3168
|
+
* `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
|
|
3169
|
+
* order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
|
|
3170
|
+
* §11), and an unknown table mirrors `scan`'s empty-yield behavior.
|
|
3171
|
+
*
|
|
3172
|
+
* @param table - The table to stream
|
|
3173
|
+
* @param criteria - The filter / offset / limit to apply lazily
|
|
3174
|
+
*
|
|
3175
|
+
* @example
|
|
3176
|
+
* ```ts
|
|
3177
|
+
* for await (const row of driver.stream('users', { conditions, limit: 10 })) {
|
|
3178
|
+
* // one matched row at a time, in key order
|
|
3179
|
+
* }
|
|
3180
|
+
* ```
|
|
3181
|
+
*/
|
|
3182
|
+
async *stream(table, criteria) {
|
|
3183
|
+
const store = this.#store(table);
|
|
3184
|
+
const conditions = criteria.conditions;
|
|
3185
|
+
const offset = criteria.offset ?? 0;
|
|
3186
|
+
const limit = criteria.limit;
|
|
3187
|
+
let skipped = 0;
|
|
3188
|
+
let yielded = 0;
|
|
3189
|
+
for (const key of this.#ordered(table)) {
|
|
3190
|
+
if (limit !== void 0 && yielded >= limit) return;
|
|
3191
|
+
const row = store.get(key);
|
|
3192
|
+
if (row === void 0) continue;
|
|
3193
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
|
|
3194
|
+
if (skipped < offset) {
|
|
3195
|
+
skipped += 1;
|
|
3196
|
+
continue;
|
|
3197
|
+
}
|
|
3198
|
+
yield { ...row };
|
|
3199
|
+
yielded += 1;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
async clear(table) {
|
|
3203
|
+
this.#store(table).clear();
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* Capture the current state and return a thunk that rolls back to it.
|
|
3207
|
+
*
|
|
3208
|
+
* @remarks
|
|
3209
|
+
* `tables` omitted clones and restores the WHOLE store, byte-identical to the
|
|
3210
|
+
* prior whole-store behavior. `tables` provided clones ONLY the named tables,
|
|
3211
|
+
* and the returned thunk restores ONLY those — every other table keeps
|
|
3212
|
+
* whatever it was mutated to after the snapshot was taken.
|
|
3213
|
+
*
|
|
3214
|
+
* @param tables - The table names to scope the snapshot to; omitted captures every table
|
|
3215
|
+
* @returns A thunk that restores the captured tables
|
|
3216
|
+
*/
|
|
3217
|
+
async snapshot(tables) {
|
|
3218
|
+
if (tables === void 0) {
|
|
3219
|
+
const copy = /* @__PURE__ */ new Map();
|
|
3220
|
+
for (const [name, store] of this.#tables) {
|
|
3221
|
+
const cloned = /* @__PURE__ */ new Map();
|
|
3222
|
+
for (const [key, row] of store) cloned.set(key, { ...row });
|
|
3223
|
+
copy.set(name, cloned);
|
|
3224
|
+
}
|
|
3225
|
+
return async () => {
|
|
3226
|
+
this.#tables.clear();
|
|
3227
|
+
for (const [name, store] of copy) this.#tables.set(name, store);
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
const copy = /* @__PURE__ */ new Map();
|
|
3231
|
+
for (const name of tables) {
|
|
3232
|
+
const store = this.#tables.get(name);
|
|
3233
|
+
if (store === void 0) continue;
|
|
3234
|
+
const cloned = /* @__PURE__ */ new Map();
|
|
3235
|
+
for (const [key, row] of store) cloned.set(key, { ...row });
|
|
3236
|
+
copy.set(name, cloned);
|
|
3237
|
+
}
|
|
3238
|
+
return async () => {
|
|
3239
|
+
for (const [name, store] of copy) this.#tables.set(name, store);
|
|
3240
|
+
};
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Return the persisted {@link DriverMeta}, or `undefined` when the store has
|
|
3244
|
+
* never been stamped.
|
|
3245
|
+
*
|
|
3246
|
+
* @remarks
|
|
3247
|
+
* In-process only — the metadata lives in this instance's memory, exactly
|
|
3248
|
+
* like the rest of this driver's storage. A driver-conformance-valid
|
|
3249
|
+
* implementation of the optional `meta` / `stamp` pair.
|
|
3250
|
+
*
|
|
3251
|
+
* @returns The last-stamped {@link DriverMeta}, or `undefined`
|
|
3252
|
+
*/
|
|
3253
|
+
async meta() {
|
|
3254
|
+
return this.#meta;
|
|
3255
|
+
}
|
|
3256
|
+
/**
|
|
3257
|
+
* Persist `meta` verbatim for a later `meta()` to return.
|
|
3258
|
+
*
|
|
3259
|
+
* @param meta - The {@link DriverMeta} to persist
|
|
3260
|
+
*/
|
|
3261
|
+
async stamp(meta) {
|
|
3262
|
+
this.#meta = meta;
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* Apply a {@link Migration} plan's steps against the in-memory store.
|
|
3266
|
+
*
|
|
3267
|
+
* @remarks
|
|
3268
|
+
* A multi-step plan applies its steps sequentially and is NOT atomic — a
|
|
3269
|
+
* failure partway through a plan leaves the earlier steps already applied.
|
|
3270
|
+
*
|
|
3271
|
+
* @param plan - The migration plan to apply
|
|
3272
|
+
*/
|
|
3273
|
+
async migrate(plan) {
|
|
3274
|
+
for (const step of plan.steps) switch (step.operation) {
|
|
3275
|
+
case "table.add":
|
|
3276
|
+
if (!this.#tables.has(step.table.name)) this.#tables.set(step.table.name, /* @__PURE__ */ new Map());
|
|
3277
|
+
break;
|
|
3278
|
+
case "table.remove":
|
|
3279
|
+
this.#require(step.table);
|
|
3280
|
+
this.#tables.delete(step.table);
|
|
3281
|
+
break;
|
|
3282
|
+
case "column.add":
|
|
3283
|
+
case "column.remove": {
|
|
3284
|
+
const store = this.#require(step.table);
|
|
3285
|
+
const rows = [...store.entries()];
|
|
3286
|
+
const migrated = migrateRows(rows.map(([, row]) => row), [step]);
|
|
3287
|
+
rows.forEach(([key], index) => store.set(key, migrated[index]));
|
|
3288
|
+
break;
|
|
3289
|
+
}
|
|
3290
|
+
case "index.add":
|
|
3291
|
+
case "index.remove":
|
|
3292
|
+
this.#require(step.table);
|
|
3293
|
+
break;
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
#ordered(table) {
|
|
3297
|
+
return [...this.#store(table).keys()].sort(compareValues);
|
|
3298
|
+
}
|
|
3299
|
+
#require(table) {
|
|
3300
|
+
const store = this.#tables.get(table);
|
|
3301
|
+
if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
|
|
3302
|
+
return store;
|
|
3303
|
+
}
|
|
3304
|
+
#store(table) {
|
|
3305
|
+
let store = this.#tables.get(table);
|
|
3306
|
+
if (store === void 0) {
|
|
3307
|
+
store = /* @__PURE__ */ new Map();
|
|
3308
|
+
this.#tables.set(table, store);
|
|
3309
|
+
}
|
|
3310
|
+
return store;
|
|
3311
|
+
}
|
|
3312
|
+
};
|
|
3313
|
+
//#endregion
|
|
3314
|
+
//#region src/core/factories.ts
|
|
3315
|
+
/**
|
|
3316
|
+
* Create a database over a driver and a declared `tables` schema.
|
|
3317
|
+
*
|
|
3318
|
+
* @remarks
|
|
3319
|
+
* `tables` maps each name to its columns (a `column → shape` map); the database
|
|
3320
|
+
* wraps each in an `objectShape`, so you never write `objectShape` at the table
|
|
3321
|
+
* level. The `const` type parameter captures the literal names and columns, so
|
|
3322
|
+
* `db.table('users')` is checked against the schema and typed by `Infer` of its
|
|
3323
|
+
* columns — no annotations. Name a non-`id` primary-key column per table via the
|
|
3324
|
+
* optional `keys` map.
|
|
3325
|
+
*
|
|
3326
|
+
* @param options - The driver, the `tables` column map, optional `keys`, and an
|
|
3327
|
+
* optional `name`
|
|
3328
|
+
* @returns A typed {@link DatabaseInterface}
|
|
3329
|
+
*
|
|
3330
|
+
* @example
|
|
3331
|
+
* ```ts
|
|
3332
|
+
* import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
3333
|
+
* import { integerShape, stringShape } from '@orkestrel/contract'
|
|
3334
|
+
*
|
|
3335
|
+
* const db = createDatabase({
|
|
3336
|
+
* driver: createMemoryDriver(),
|
|
3337
|
+
* tables: {
|
|
3338
|
+
* users: { id: stringShape(), age: integerShape() },
|
|
3339
|
+
* posts: { slug: stringShape(), title: stringShape() },
|
|
3340
|
+
* },
|
|
3341
|
+
* keys: { posts: 'slug' },
|
|
3342
|
+
* })
|
|
3343
|
+
* await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
|
|
3344
|
+
* ```
|
|
3345
|
+
*/
|
|
3346
|
+
function createDatabase(options) {
|
|
3347
|
+
return new Database(options);
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* Create the in-memory reference {@link DriverInterface}.
|
|
3351
|
+
*
|
|
3352
|
+
* @remarks
|
|
3353
|
+
* Backed by nested maps with no I/O — the same driver runs in a browser or on a
|
|
3354
|
+
* server, making it the natural choice for tests and ephemeral storage.
|
|
3355
|
+
*
|
|
3356
|
+
* @returns A fresh in-memory driver
|
|
3357
|
+
*/
|
|
3358
|
+
function createMemoryDriver() {
|
|
3359
|
+
return new MemoryDriver();
|
|
3360
|
+
}
|
|
3361
|
+
//#endregion
|
|
3362
|
+
export { Clause, Cursor, DEFAULT_PRIMARY, Database, DatabaseError, MAX_PATTERN_LENGTH, MemoryDriver, Query, Table, applyCriteria, auditDriver, checkAbort, compareValues, computeAggregate, conformDriver, createDatabase, createMemoryDriver, deepEqual, driverFindings, extractKey, filterRows, globMatch, isDatabaseError, likeMatch, matchesCondition, matchesCriteria, migrateRows, planMigration, shapeToColumnType, sortRows, wildcardMatch };
|
|
3363
|
+
|
|
3364
|
+
//# sourceMappingURL=index.js.map
|