@orkestrel/database 0.0.1 → 0.0.3
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/README.md +43 -10
- package/dist/src/browser/index.d.ts +342 -0
- package/dist/src/browser/index.js +686 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/core/index.cjs +2494 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1766 -0
- package/dist/src/core/index.d.ts +1766 -11
- package/dist/src/core/index.js +237 -1142
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +836 -153
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +1054 -0
- package/dist/src/server/index.d.ts +1054 -5
- package/dist/src/server/index.js +1663 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +40 -16
- package/dist/src/core/Clause.d.ts +0 -30
- package/dist/src/core/Cursor.d.ts +0 -21
- package/dist/src/core/Database.d.ts +0 -80
- package/dist/src/core/Query.d.ts +0 -47
- package/dist/src/core/Table.d.ts +0 -69
- package/dist/src/core/constants.d.ts +0 -22
- package/dist/src/core/drivers/MemoryDriver.d.ts +0 -94
- package/dist/src/core/errors.d.ts +0 -38
- package/dist/src/core/factories.d.ts +0 -43
- package/dist/src/core/helpers.d.ts +0 -383
- package/dist/src/core/types.d.ts +0 -739
- package/dist/src/server/compilers.d.ts +0 -169
- package/dist/src/server/drivers/JSONDriver.d.ts +0 -106
- package/dist/src/server/factories.d.ts +0 -31
- package/dist/src/server/helpers.d.ts +0 -222
- package/dist/src/server/types.d.ts +0 -36
package/dist/src/core/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { compileSchema, createContract, isArray, isBoolean, isFiniteNumber, isRecord, isString, objectShape, parseNumber, resolveField } from "@orkestrel/contract";
|
|
2
|
+
import { Emitter } from "@orkestrel/emitter";
|
|
1
3
|
//#region src/core/constants.ts
|
|
2
4
|
/**
|
|
3
5
|
* The primary-key column assumed when {@link TableKeys} does not name one.
|
|
@@ -21,6 +23,10 @@ var DEFAULT_PRIMARY = "id";
|
|
|
21
23
|
* `VALIDATION` {@link DatabaseError}; the cap is generous for any legitimate search.
|
|
22
24
|
*/
|
|
23
25
|
var MAX_PATTERN_LENGTH = 1024;
|
|
26
|
+
/** The number of bytes encoded by an RFC 4122 UUID. */
|
|
27
|
+
var UUID_BYTE_COUNT = 16;
|
|
28
|
+
/** The number of distinct values one UUID byte may hold. */
|
|
29
|
+
var UUID_BYTE_RANGE = 256;
|
|
24
30
|
//#endregion
|
|
25
31
|
//#region src/core/errors.ts
|
|
26
32
|
/**
|
|
@@ -67,946 +73,6 @@ var DatabaseError = class extends Error {
|
|
|
67
73
|
function isDatabaseError(value) {
|
|
68
74
|
return value instanceof DatabaseError;
|
|
69
75
|
}
|
|
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
76
|
//#endregion
|
|
1011
77
|
//#region src/core/helpers.ts
|
|
1012
78
|
/**
|
|
@@ -1153,9 +219,16 @@ function globMatch(value, pattern) {
|
|
|
1153
219
|
* @remarks
|
|
1154
220
|
* Reads the condition's column — a `FieldPath`, resolved with `resolveField` (a
|
|
1155
221
|
* string is one column; an array descends a nested value) — and applies the
|
|
1156
|
-
* operator. Range operators
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
222
|
+
* operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
|
|
223
|
+
* {@link compareValues}, the total order; the equality family (`equals` / `not`
|
|
224
|
+
* / `any` / `none`) uses {@link deepEqual} — STRUCTURAL equality, not the total
|
|
225
|
+
* order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
|
|
226
|
+
* operand only matches a structurally-equal value, never every row holding any
|
|
227
|
+
* object. This is a semantics change from ranking: `deepEqual` is SameValueZero
|
|
228
|
+
* on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
|
|
229
|
+
* anything under the old rank-based comparison). `like` / `glob` / `starts` /
|
|
230
|
+
* `ends` match only strings; `absent` / `present` test nullishness. Total — a
|
|
231
|
+
* type mismatch is simply a non-match.
|
|
1159
232
|
*
|
|
1160
233
|
* @param row - The row to test
|
|
1161
234
|
* @param condition - The condition to apply
|
|
@@ -1166,8 +239,8 @@ function matchesCondition(row, condition) {
|
|
|
1166
239
|
const first = condition.values[0];
|
|
1167
240
|
const second = condition.values[1];
|
|
1168
241
|
switch (condition.operator) {
|
|
1169
|
-
case "equals": return
|
|
1170
|
-
case "not": return
|
|
242
|
+
case "equals": return deepEqual(value, first);
|
|
243
|
+
case "not": return !deepEqual(value, first);
|
|
1171
244
|
case "above": return compareValues(value, first) > 0;
|
|
1172
245
|
case "below": return compareValues(value, first) < 0;
|
|
1173
246
|
case "from": return compareValues(value, first) >= 0;
|
|
@@ -1177,8 +250,8 @@ function matchesCondition(row, condition) {
|
|
|
1177
250
|
case "glob": return isString(value) && isString(first) && globMatch(value, first);
|
|
1178
251
|
case "starts": return isString(value) && isString(first) && value.startsWith(first);
|
|
1179
252
|
case "ends": return isString(value) && isString(first) && value.endsWith(first);
|
|
1180
|
-
case "any": return condition.values.some((candidate) =>
|
|
1181
|
-
case "none": return !condition.values.some((candidate) =>
|
|
253
|
+
case "any": return condition.values.some((candidate) => deepEqual(value, candidate));
|
|
254
|
+
case "none": return !condition.values.some((candidate) => deepEqual(value, candidate));
|
|
1182
255
|
case "absent": return value === void 0 || value === null;
|
|
1183
256
|
case "present": return value !== void 0 && value !== null;
|
|
1184
257
|
}
|
|
@@ -1363,6 +436,44 @@ function shapeToColumnType(shape) {
|
|
|
1363
436
|
}
|
|
1364
437
|
}
|
|
1365
438
|
/**
|
|
439
|
+
* Whether a value is a well-formed {@link DriverMeta} — the boundary guard a
|
|
440
|
+
* versioning driver's `meta()` narrows a stored (structured-clone or
|
|
441
|
+
* `JSON.parse`d) value through before trusting it, replacing the per-driver
|
|
442
|
+
* duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
|
|
443
|
+
*
|
|
444
|
+
* @remarks
|
|
445
|
+
* Total and total-recursive over the whole shape: a finite `version`, and a
|
|
446
|
+
* `schema` array of well-formed {@link TableSchema} entries — each a `name` /
|
|
447
|
+
* `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
|
|
448
|
+
* entries (a `name` string, a {@link ColumnType} literal, a `nullable`
|
|
449
|
+
* boolean), and an `indexes` array of string arrays. Anything off-shape
|
|
450
|
+
* (including a non-record) returns `false` rather than throwing.
|
|
451
|
+
*
|
|
452
|
+
* @param value - The value to test
|
|
453
|
+
* @returns `true` when `value` is a well-formed `DriverMeta`
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* ```ts
|
|
457
|
+
* isDriverMeta({ version: 1, schema: [] }) // true
|
|
458
|
+
* isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
|
|
459
|
+
* ```
|
|
460
|
+
*/
|
|
461
|
+
function isDriverMeta(value) {
|
|
462
|
+
const COLUMN_TYPES = [
|
|
463
|
+
"text",
|
|
464
|
+
"integer",
|
|
465
|
+
"real",
|
|
466
|
+
"boolean",
|
|
467
|
+
"json",
|
|
468
|
+
"blob"
|
|
469
|
+
];
|
|
470
|
+
const isColumnType = (candidate) => isString(candidate) && COLUMN_TYPES.some((type) => type === candidate);
|
|
471
|
+
const isColumnSchema = (candidate) => isRecord(candidate) && isString(candidate.name) && isColumnType(candidate.type) && isBoolean(candidate.nullable);
|
|
472
|
+
const isIndexGroup = (candidate) => isArray(candidate) && candidate.every((entry) => isString(entry));
|
|
473
|
+
const isTableSchema = (candidate) => isRecord(candidate) && isString(candidate.name) && isString(candidate.primary) && isArray(candidate.columns) && candidate.columns.every(isColumnSchema) && isArray(candidate.indexes) && candidate.indexes.every(isIndexGroup);
|
|
474
|
+
return isRecord(value) && isFiniteNumber(value.version) && isArray(value.schema) && value.schema.every(isTableSchema);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
1366
477
|
* Throw when an {@link ReadOptions.signal | AbortSignal} has fired — the shared
|
|
1367
478
|
* cancellation gate checked at operation boundaries and between streamed rows.
|
|
1368
479
|
*
|
|
@@ -1406,11 +517,22 @@ function checkAbort(signal) {
|
|
|
1406
517
|
* plan labels only — version tracking itself is deferred to persistent
|
|
1407
518
|
* backends.
|
|
1408
519
|
*
|
|
520
|
+
* A column present in BOTH schemas under the same name but with a different
|
|
521
|
+
* `type` or `nullable` throws a `MIGRATION` {@link DatabaseError} naming the
|
|
522
|
+
* table, the column, and the from→to difference — a name-only diff would
|
|
523
|
+
* otherwise silently produce NO step for the drift, and versioned
|
|
524
|
+
* reconciliation would stamp over it. There is no automatic in-place
|
|
525
|
+
* type-change step: the manual path is to add a new column, copy/convert the
|
|
526
|
+
* data at the application layer, then remove the old column — two separate
|
|
527
|
+
* plans, never a single implicit "alter" step.
|
|
528
|
+
*
|
|
1409
529
|
* @param deployed - The table schemas currently applied
|
|
1410
530
|
* @param declared - The table schemas the caller wants applied
|
|
1411
531
|
* @param from - The plan's source version label (defaults to `0`)
|
|
1412
532
|
* @param to - The plan's target version label (defaults to `1`)
|
|
1413
533
|
* @returns The migration plan moving `deployed` toward `declared`
|
|
534
|
+
* @throws A `MIGRATION` {@link DatabaseError} when a shared column's `type` or
|
|
535
|
+
* `nullable` differs between `deployed` and `declared`
|
|
1414
536
|
*
|
|
1415
537
|
* @example
|
|
1416
538
|
* ```ts
|
|
@@ -1443,11 +565,29 @@ function planMigration(deployed, declared, from = 0, to = 1) {
|
|
|
1443
565
|
table: table.name,
|
|
1444
566
|
column: column.name
|
|
1445
567
|
});
|
|
1446
|
-
for (const column of table.columns)
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
568
|
+
for (const column of table.columns) {
|
|
569
|
+
const previous = beforeColumns.get(column.name);
|
|
570
|
+
if (previous === void 0) {
|
|
571
|
+
steps.push({
|
|
572
|
+
operation: "column.add",
|
|
573
|
+
table: table.name,
|
|
574
|
+
column
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (previous.type !== column.type || previous.nullable !== column.nullable) throw new DatabaseError("MIGRATION", `planMigration: column '${column.name}' on table '${table.name}' changed shape (type ${previous.type}→${column.type}, nullable ${previous.nullable}→${column.nullable}) — in-place type/nullability changes are not auto-migrated; add a new column, copy/convert the data, then remove the old column`, {
|
|
579
|
+
table: table.name,
|
|
580
|
+
column: column.name,
|
|
581
|
+
from: {
|
|
582
|
+
type: previous.type,
|
|
583
|
+
nullable: previous.nullable
|
|
584
|
+
},
|
|
585
|
+
to: {
|
|
586
|
+
type: column.type,
|
|
587
|
+
nullable: column.nullable
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
1451
591
|
const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
|
|
1452
592
|
for (const index of before.indexes) if (!table.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
1453
593
|
operation: "index.remove",
|
|
@@ -1510,12 +650,14 @@ function migrateRows(rows, steps) {
|
|
|
1510
650
|
* default `id`, `posts` keyed by a non-id `slug`) and, calling `factory()`
|
|
1511
651
|
* fresh for each phase so failures stay isolated, verifies: `open`/`close`;
|
|
1512
652
|
* `read` of a missing key returns `undefined`; `write`/`read` round-trip with
|
|
1513
|
-
* copy-in/copy-out isolation (mutating the caller's row
|
|
1514
|
-
* row `read` returns, never perturbs
|
|
1515
|
-
* `delete` returns `true` then `false`;
|
|
1516
|
-
* order; `clear` empties only its target
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
653
|
+
* DEEP copy-in/copy-out isolation (mutating the caller's row — including a
|
|
654
|
+
* NESTED field — after `write`, or a row `read` returns, never perturbs
|
|
655
|
+
* stored state) and upsert-overwrite; `delete` returns `true` then `false`;
|
|
656
|
+
* `keys`/`scan` yield in ascending key order; `clear` empties only its target
|
|
657
|
+
* table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
|
|
658
|
+
* NESTED field mutated in place on a read-back row between capture and
|
|
659
|
+
* restore; a scoped `snapshot(['users'])` rolls back only the named table,
|
|
660
|
+
* leaving a concurrent mutation to another table intact; a
|
|
1519
661
|
* non-`id` primary key (`posts.slug`) round-trips; a nested-object row
|
|
1520
662
|
* round-trips structurally (via {@link deepEqual}). The optional surface is
|
|
1521
663
|
* presence-gated: when `migrate` exists, a `column.remove` plan strips the
|
|
@@ -1568,11 +710,16 @@ async function* driverFindings(factory) {
|
|
|
1568
710
|
name: "age",
|
|
1569
711
|
type: "integer",
|
|
1570
712
|
nullable: true
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
name: "meta",
|
|
716
|
+
type: "json",
|
|
717
|
+
nullable: true
|
|
1571
718
|
}
|
|
1572
719
|
],
|
|
1573
720
|
indexes: []
|
|
1574
721
|
};
|
|
1575
|
-
const
|
|
722
|
+
const CONFORMANCE_POSTS_SCHEMA = {
|
|
1576
723
|
name: "posts",
|
|
1577
724
|
primary: "slug",
|
|
1578
725
|
columns: [{
|
|
@@ -1585,7 +732,8 @@ async function* driverFindings(factory) {
|
|
|
1585
732
|
nullable: false
|
|
1586
733
|
}],
|
|
1587
734
|
indexes: []
|
|
1588
|
-
}
|
|
735
|
+
};
|
|
736
|
+
const CONFORMANCE_SCHEMA = [CONFORMANCE_USERS_SCHEMA, CONFORMANCE_POSTS_SCHEMA];
|
|
1589
737
|
const findingOf = (check, message, context) => ({
|
|
1590
738
|
check,
|
|
1591
739
|
message,
|
|
@@ -1622,29 +770,33 @@ async function* driverFindings(factory) {
|
|
|
1622
770
|
const input = {
|
|
1623
771
|
id: "u1",
|
|
1624
772
|
name: "Ada",
|
|
1625
|
-
age: 30
|
|
773
|
+
age: 30,
|
|
774
|
+
meta: { tags: ["a"] }
|
|
1626
775
|
};
|
|
1627
776
|
await driver.write("users", "u1", input);
|
|
1628
777
|
input.name = "Mutated after write";
|
|
778
|
+
if (isRecord(input.meta) && Array.isArray(input.meta.tags)) input.meta.tags.push("mutated");
|
|
1629
779
|
const stored = await driver.read("users", "u1");
|
|
1630
780
|
const original = {
|
|
1631
781
|
id: "u1",
|
|
1632
782
|
name: "Ada",
|
|
1633
|
-
age: 30
|
|
783
|
+
age: 30,
|
|
784
|
+
meta: { tags: ["a"] }
|
|
1634
785
|
};
|
|
1635
786
|
if (stored === void 0 || !deepEqual(stored, original)) {
|
|
1636
787
|
await driver.close();
|
|
1637
|
-
return findingOf("copy-in", "write must copy the input row rather than store it by reference", {
|
|
788
|
+
return findingOf("copy-in", "write must deep-copy the input row (including nested fields) rather than store it by reference", {
|
|
1638
789
|
table: "users",
|
|
1639
790
|
expected: original,
|
|
1640
791
|
actual: stored
|
|
1641
792
|
});
|
|
1642
793
|
}
|
|
1643
794
|
stored.name = "Mutated after read";
|
|
795
|
+
if (isRecord(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
|
|
1644
796
|
const reread = await driver.read("users", "u1");
|
|
1645
797
|
if (reread === void 0 || !deepEqual(reread, original)) {
|
|
1646
798
|
await driver.close();
|
|
1647
|
-
return findingOf("copy-out", "read must copy the stored row rather than return it by reference", {
|
|
799
|
+
return findingOf("copy-out", "read must deep-copy the stored row (including nested fields) rather than return it by reference", {
|
|
1648
800
|
table: "users",
|
|
1649
801
|
expected: original,
|
|
1650
802
|
actual: reread
|
|
@@ -1807,6 +959,37 @@ async function* driverFindings(factory) {
|
|
|
1807
959
|
});
|
|
1808
960
|
}
|
|
1809
961
|
},
|
|
962
|
+
{
|
|
963
|
+
check: "snapshot-nested",
|
|
964
|
+
run: async () => {
|
|
965
|
+
const driver = factory();
|
|
966
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
967
|
+
const original = {
|
|
968
|
+
id: "u3",
|
|
969
|
+
name: "Nested",
|
|
970
|
+
age: 20,
|
|
971
|
+
meta: { tags: ["a"] }
|
|
972
|
+
};
|
|
973
|
+
await driver.write("users", "u3", original);
|
|
974
|
+
const rollback = await driver.snapshot();
|
|
975
|
+
const before = await driver.read("users", "u3");
|
|
976
|
+
if (isRecord(before) && isRecord(before.meta) && Array.isArray(before.meta.tags)) before.meta.tags.push("mutated-before-restore");
|
|
977
|
+
await driver.write("users", "u3", {
|
|
978
|
+
id: "u3",
|
|
979
|
+
name: "Nested",
|
|
980
|
+
age: 20,
|
|
981
|
+
meta: { tags: ["a", "mutated-after-write"] }
|
|
982
|
+
});
|
|
983
|
+
await rollback();
|
|
984
|
+
const restored = await driver.read("users", "u3");
|
|
985
|
+
await driver.close();
|
|
986
|
+
if (restored === void 0 || !deepEqual(restored, original)) return findingOf("snapshot-nested", "snapshot rollback must restore pre-snapshot nested field values, unaffected by a later in-place mutation of a read-back row", {
|
|
987
|
+
table: "users",
|
|
988
|
+
expected: original,
|
|
989
|
+
actual: restored
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
},
|
|
1810
993
|
{
|
|
1811
994
|
check: "non-id-primary",
|
|
1812
995
|
run: async () => {
|
|
@@ -1855,21 +1038,22 @@ async function* driverFindings(factory) {
|
|
|
1855
1038
|
run: async () => {
|
|
1856
1039
|
const driver = factory();
|
|
1857
1040
|
if (driver.migrate === void 0) return void 0;
|
|
1858
|
-
|
|
1041
|
+
const deployedUsers = {
|
|
1042
|
+
...CONFORMANCE_USERS_SCHEMA,
|
|
1043
|
+
columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
|
|
1044
|
+
name: "legacy",
|
|
1045
|
+
type: "boolean",
|
|
1046
|
+
nullable: true
|
|
1047
|
+
}]
|
|
1048
|
+
};
|
|
1049
|
+
await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
|
|
1859
1050
|
await driver.write("users", "u1", {
|
|
1860
1051
|
id: "u1",
|
|
1861
1052
|
name: "Ada",
|
|
1862
1053
|
age: 30,
|
|
1863
1054
|
legacy: true
|
|
1864
1055
|
});
|
|
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]);
|
|
1056
|
+
const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
|
|
1873
1057
|
await driver.migrate(removePlan);
|
|
1874
1058
|
const migrated = await driver.read("users", "u1");
|
|
1875
1059
|
if (migrated === void 0 || "legacy" in migrated) {
|
|
@@ -2131,173 +1315,40 @@ async function auditDriver(factory) {
|
|
|
2131
1315
|
for await (const finding of driverFindings(factory)) findings.push(finding);
|
|
2132
1316
|
return findings;
|
|
2133
1317
|
}
|
|
2134
|
-
//#endregion
|
|
2135
|
-
//#region node_modules/@orkestrel/emitter/dist/src/core/index.js
|
|
2136
1318
|
/**
|
|
2137
|
-
*
|
|
1319
|
+
* Generate an RFC 4122 version 4 UUID from a number source — no host crypto global.
|
|
2138
1320
|
*
|
|
2139
1321
|
* @remarks
|
|
2140
|
-
*
|
|
2141
|
-
*
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2144
|
-
*
|
|
2145
|
-
*
|
|
2146
|
-
*
|
|
2147
|
-
*
|
|
1322
|
+
* Draws exactly {@link UUID_BYTE_COUNT} values from `random`, one per byte, then
|
|
1323
|
+
* forces the version (`4`) and variant (`10xx`) bits. The default source is
|
|
1324
|
+
* `Math.random` — a pure-ECMAScript intrinsic, so generation works on every host;
|
|
1325
|
+
* pass a seeded source (`seededRandom` from `@orkestrel/contract`) and reuse it
|
|
1326
|
+
* across calls for reproducible sequences in tests and fixtures — production
|
|
1327
|
+
* identifiers should keep the default source, whose engine entropy is far larger
|
|
1328
|
+
* than a 32-bit seed. Each byte is floored and masked, so a source straying
|
|
1329
|
+
* outside `[0, 1)` (negative, `>= 1`, `NaN`, `Infinity`) can never yield a
|
|
1330
|
+
* malformed UUID. Suitable as a collision-resistant record identifier — not a
|
|
1331
|
+
* cryptographic token; never use one as a secret.
|
|
1332
|
+
*
|
|
1333
|
+
* @param random - A number source returning values in the half-open range `[0, 1)` (defaults to `Math.random`)
|
|
1334
|
+
* @returns A lowercase RFC 4122 version 4 UUID
|
|
2148
1335
|
*
|
|
2149
1336
|
* @example
|
|
2150
1337
|
* ```ts
|
|
2151
|
-
* import {
|
|
1338
|
+
* import { generateUUID } from '@orkestrel/database'
|
|
1339
|
+
* import { seededRandom } from '@orkestrel/contract'
|
|
2152
1340
|
*
|
|
2153
|
-
*
|
|
2154
|
-
*
|
|
2155
|
-
* extractKeys({}) // []
|
|
1341
|
+
* generateUUID() // e.g. '9b2f7c1e-3d4a-4f6b-8e2d-5a1c0b9f8e7d'
|
|
1342
|
+
* generateUUID(seededRandom(42)) // the same UUID on every run
|
|
2156
1343
|
* ```
|
|
2157
1344
|
*/
|
|
2158
|
-
function
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
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";
|
|
1345
|
+
function generateUUID(random = Math.random) {
|
|
1346
|
+
const bytes = Array.from({ length: 16 }, () => Math.floor(random() * 256) & 255);
|
|
1347
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1348
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1349
|
+
const hex = bytes.map((byte) => byte.toString(16).padStart(2, "0"));
|
|
1350
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
|
2175
1351
|
}
|
|
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
1352
|
//#endregion
|
|
2302
1353
|
//#region src/core/Cursor.ts
|
|
2303
1354
|
/**
|
|
@@ -2669,6 +1720,21 @@ var Table = class {
|
|
|
2669
1720
|
for (const row of source) if (this.#guard(row)) rows.push(row);
|
|
2670
1721
|
return rows;
|
|
2671
1722
|
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Count rows matching `criteria`'s conditions.
|
|
1725
|
+
*
|
|
1726
|
+
* @remarks
|
|
1727
|
+
* Unlike {@link records}, which narrows every row through the table's
|
|
1728
|
+
* contract guard before returning it, `count` operates on STORED rows
|
|
1729
|
+
* WITHOUT that guard (both the native `driver.count` hook and the
|
|
1730
|
+
* `filterRows`-over-`#collect()` fallback count raw storage) — so it can
|
|
1731
|
+
* exceed `(await records(criteria)).length` when storage holds rows that
|
|
1732
|
+
* no longer conform to the table's contract (legacy or migrated data).
|
|
1733
|
+
*
|
|
1734
|
+
* @param criteria - Optional conditions to filter by (paging is ignored)
|
|
1735
|
+
* @param options - `{ signal }` to abort
|
|
1736
|
+
* @returns The count of matching stored rows
|
|
1737
|
+
*/
|
|
2672
1738
|
async count(criteria, options) {
|
|
2673
1739
|
checkAbort(options?.signal);
|
|
2674
1740
|
await this.#ready();
|
|
@@ -2677,6 +1743,23 @@ var Table = class {
|
|
|
2677
1743
|
if (native !== void 0) return native;
|
|
2678
1744
|
return filterRows(await this.#collect(), criteria?.conditions ?? []).length;
|
|
2679
1745
|
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Compute an aggregate over `column` across rows matching `criteria`'s
|
|
1748
|
+
* conditions.
|
|
1749
|
+
*
|
|
1750
|
+
* @remarks
|
|
1751
|
+
* Like {@link count}, `aggregate` operates on STORED rows WITHOUT the
|
|
1752
|
+
* contract guard {@link records} / {@link scan} apply — a non-conforming
|
|
1753
|
+
* stored row still contributes to the computed aggregate when it matches
|
|
1754
|
+
* the conditions, even though it would never appear in `records()`'s
|
|
1755
|
+
* output.
|
|
1756
|
+
*
|
|
1757
|
+
* @param operation - The aggregate to compute
|
|
1758
|
+
* @param column - The column to aggregate
|
|
1759
|
+
* @param criteria - Optional conditions to filter by (paging is ignored)
|
|
1760
|
+
* @param options - `{ signal }` to abort
|
|
1761
|
+
* @returns The aggregate value, or `undefined` when undefined for the inputs
|
|
1762
|
+
*/
|
|
2680
1763
|
async aggregate(operation, column, criteria, options) {
|
|
2681
1764
|
checkAbort(options?.signal);
|
|
2682
1765
|
await this.#ready();
|
|
@@ -2699,7 +1782,7 @@ var Table = class {
|
|
|
2699
1782
|
*
|
|
2700
1783
|
* @param criteria - Optional conditions plus offset/limit paging
|
|
2701
1784
|
* @param options - `{ signal }` to abort mid-stream
|
|
2702
|
-
* @returns An async
|
|
1785
|
+
* @returns An async iterable of matching, guard-conforming rows
|
|
2703
1786
|
*/
|
|
2704
1787
|
async *scan(criteria, options) {
|
|
2705
1788
|
checkAbort(options?.signal);
|
|
@@ -3118,8 +2201,12 @@ var Database = class Database {
|
|
|
3118
2201
|
* @remarks
|
|
3119
2202
|
* The in-between made concrete: it runs identically in a browser or on a server,
|
|
3120
2203
|
* 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
|
|
3122
|
-
*
|
|
2204
|
+
* the database API without a persistent backend. Rows are DEEP-copied (via
|
|
2205
|
+
* `structuredClone`) in and out — at `write`, `read`, `scan`, `stream`, and both
|
|
2206
|
+
* snapshot capture and restore — so a caller mutating a nested field of an input
|
|
2207
|
+
* row, a returned row, or a row mutated in place between snapshot and rollback
|
|
2208
|
+
* can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
|
|
2209
|
+
* would still share nested object/array references. `snapshot`
|
|
3123
2210
|
* clones every table to give transactions an exact rollback point. `scan` and
|
|
3124
2211
|
* `keys` yield in key order — sorted by the core {@link compareValues} total
|
|
3125
2212
|
* order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
|
|
@@ -3136,10 +2223,10 @@ var MemoryDriver = class {
|
|
|
3136
2223
|
async close() {}
|
|
3137
2224
|
async read(table, key) {
|
|
3138
2225
|
const row = this.#store(table).get(key);
|
|
3139
|
-
return row === void 0 ? void 0 :
|
|
2226
|
+
return row === void 0 ? void 0 : structuredClone(row);
|
|
3140
2227
|
}
|
|
3141
2228
|
async write(table, key, row) {
|
|
3142
|
-
this.#store(table).set(key,
|
|
2229
|
+
this.#store(table).set(key, structuredClone(row));
|
|
3143
2230
|
}
|
|
3144
2231
|
async delete(table, key) {
|
|
3145
2232
|
return this.#store(table).delete(key);
|
|
@@ -3151,7 +2238,7 @@ var MemoryDriver = class {
|
|
|
3151
2238
|
const store = this.#store(table);
|
|
3152
2239
|
for (const key of this.#ordered(table)) {
|
|
3153
2240
|
const row = store.get(key);
|
|
3154
|
-
if (row !== void 0) yield
|
|
2241
|
+
if (row !== void 0) yield structuredClone(row);
|
|
3155
2242
|
}
|
|
3156
2243
|
}
|
|
3157
2244
|
/**
|
|
@@ -3195,7 +2282,7 @@ var MemoryDriver = class {
|
|
|
3195
2282
|
skipped += 1;
|
|
3196
2283
|
continue;
|
|
3197
2284
|
}
|
|
3198
|
-
yield
|
|
2285
|
+
yield structuredClone(row);
|
|
3199
2286
|
yielded += 1;
|
|
3200
2287
|
}
|
|
3201
2288
|
}
|
|
@@ -3219,12 +2306,16 @@ var MemoryDriver = class {
|
|
|
3219
2306
|
const copy = /* @__PURE__ */ new Map();
|
|
3220
2307
|
for (const [name, store] of this.#tables) {
|
|
3221
2308
|
const cloned = /* @__PURE__ */ new Map();
|
|
3222
|
-
for (const [key, row] of store) cloned.set(key,
|
|
2309
|
+
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
3223
2310
|
copy.set(name, cloned);
|
|
3224
2311
|
}
|
|
3225
2312
|
return async () => {
|
|
3226
2313
|
this.#tables.clear();
|
|
3227
|
-
for (const [name, store] of copy)
|
|
2314
|
+
for (const [name, store] of copy) {
|
|
2315
|
+
const restored = /* @__PURE__ */ new Map();
|
|
2316
|
+
for (const [key, row] of store) restored.set(key, structuredClone(row));
|
|
2317
|
+
this.#tables.set(name, restored);
|
|
2318
|
+
}
|
|
3228
2319
|
};
|
|
3229
2320
|
}
|
|
3230
2321
|
const copy = /* @__PURE__ */ new Map();
|
|
@@ -3232,11 +2323,15 @@ var MemoryDriver = class {
|
|
|
3232
2323
|
const store = this.#tables.get(name);
|
|
3233
2324
|
if (store === void 0) continue;
|
|
3234
2325
|
const cloned = /* @__PURE__ */ new Map();
|
|
3235
|
-
for (const [key, row] of store) cloned.set(key,
|
|
2326
|
+
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
3236
2327
|
copy.set(name, cloned);
|
|
3237
2328
|
}
|
|
3238
2329
|
return async () => {
|
|
3239
|
-
for (const [name, store] of copy)
|
|
2330
|
+
for (const [name, store] of copy) {
|
|
2331
|
+
const restored = /* @__PURE__ */ new Map();
|
|
2332
|
+
for (const [key, row] of store) restored.set(key, structuredClone(row));
|
|
2333
|
+
this.#tables.set(name, restored);
|
|
2334
|
+
}
|
|
3240
2335
|
};
|
|
3241
2336
|
}
|
|
3242
2337
|
/**
|
|
@@ -3359,6 +2454,6 @@ function createMemoryDriver() {
|
|
|
3359
2454
|
return new MemoryDriver();
|
|
3360
2455
|
}
|
|
3361
2456
|
//#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 };
|
|
2457
|
+
export { Clause, Cursor, DEFAULT_PRIMARY, Database, DatabaseError, MAX_PATTERN_LENGTH, MemoryDriver, Query, Table, UUID_BYTE_COUNT, UUID_BYTE_RANGE, applyCriteria, auditDriver, checkAbort, compareValues, computeAggregate, conformDriver, createDatabase, createMemoryDriver, deepEqual, driverFindings, extractKey, filterRows, generateUUID, globMatch, isDatabaseError, isDriverMeta, likeMatch, matchesCondition, matchesCriteria, migrateRows, planMigration, shapeToColumnType, sortRows, wildcardMatch };
|
|
3363
2458
|
|
|
3364
2459
|
//# sourceMappingURL=index.js.map
|