@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
|
@@ -0,0 +1,2494 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
3
|
+
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
4
|
+
//#region src/core/constants.ts
|
|
5
|
+
/**
|
|
6
|
+
* The primary-key column assumed when {@link TableKeys} does not name one.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* `id` is the convention IndexedDB (`keyPath: 'id'`) and SQL (`id` / rowid) both
|
|
10
|
+
* lean on, so a table that omits `key` keys its rows by `id`.
|
|
11
|
+
*/
|
|
12
|
+
var DEFAULT_PRIMARY = "id";
|
|
13
|
+
/**
|
|
14
|
+
* The longest `LIKE` / `GLOB` pattern the wildcard matcher accepts before rejecting it.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* A ReDoS bound (AGENTS §6.5): the SA1–SA4 migration lets a model supply `list`
|
|
18
|
+
* criteria over the wire, so `likeMatch` / `globMatch` run attacker-controlled
|
|
19
|
+
* patterns. The matcher is the LINEAR greedy two-pointer wildcard match — never a
|
|
20
|
+
* backtracking regex (`.*`-segments-separated-by-literals against a long input is the
|
|
21
|
+
* catastrophic shape JS cannot bound without atomic groups), so it is O(value ×
|
|
22
|
+
* pattern). Capping the pattern length bounds that pattern factor, leaving a match
|
|
23
|
+
* linear in the value length whatever the pattern. A longer pattern throws a
|
|
24
|
+
* `VALIDATION` {@link DatabaseError}; the cap is generous for any legitimate search.
|
|
25
|
+
*/
|
|
26
|
+
var MAX_PATTERN_LENGTH = 1024;
|
|
27
|
+
/** The number of bytes encoded by an RFC 4122 UUID. */
|
|
28
|
+
var UUID_BYTE_COUNT = 16;
|
|
29
|
+
/** The number of distinct values one UUID byte may hold. */
|
|
30
|
+
var UUID_BYTE_RANGE = 256;
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/core/errors.ts
|
|
33
|
+
/**
|
|
34
|
+
* An error thrown by the database layer.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Carries a {@link DatabaseErrorCode} and an optional `context` bag naming the
|
|
38
|
+
* offending table / key. Thrown for: operating on a closed database (`CLOSED`), a
|
|
39
|
+
* `resolve` miss (`NOT_FOUND`), an `add` onto an existing key (`CONFLICT`), a
|
|
40
|
+
* row that fails its table's contract (`VALIDATION`), a cancelled operation whose
|
|
41
|
+
* {@link ReadOptions.signal} aborted (`ABORTED`, carrying `signal.reason` in
|
|
42
|
+
* `context`), an inapplicable {@link Migration} plan (`MIGRATION`), a
|
|
43
|
+
* driver that violates a {@link DriverInterface} invariant, thrown by the
|
|
44
|
+
* `conformDriver` helper (`CONFORMANCE`), and an unexpected infrastructure
|
|
45
|
+
* fault surfaced by a driver seam — e.g. a filesystem failure while
|
|
46
|
+
* persisting (`DRIVER`) — as opposed to expected domain conditions, which
|
|
47
|
+
* keep their specific codes.
|
|
48
|
+
*/
|
|
49
|
+
var DatabaseError = class extends Error {
|
|
50
|
+
code;
|
|
51
|
+
context;
|
|
52
|
+
constructor(code, message, context) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "DatabaseError";
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.context = context;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Narrow an unknown caught value to a {@link DatabaseError}.
|
|
61
|
+
*
|
|
62
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
63
|
+
* @returns `true` when `value` is a {@link DatabaseError}
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* try {
|
|
68
|
+
* await users.add(row)
|
|
69
|
+
* } catch (error) {
|
|
70
|
+
* if (isDatabaseError(error) && error.code === 'CONFLICT') await users.set(row)
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
function isDatabaseError(value) {
|
|
75
|
+
return value instanceof DatabaseError;
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/core/helpers.ts
|
|
79
|
+
/**
|
|
80
|
+
* A total ordering over arbitrary values — the comparator behind sorting and the
|
|
81
|
+
* range operators.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* Values of different types order by a fixed type rank (`undefined` < `null` <
|
|
85
|
+
* boolean < number < string < other); same-typed values compare naturally.
|
|
86
|
+
* `NaN` sorts after every other number and equal to itself, so the comparator
|
|
87
|
+
* is total and never returns `NaN`.
|
|
88
|
+
*
|
|
89
|
+
* @param left - The left value
|
|
90
|
+
* @param right - The right value
|
|
91
|
+
* @returns `-1`, `0`, or `1`
|
|
92
|
+
*/
|
|
93
|
+
function compareValues(left, right) {
|
|
94
|
+
const rankOf = (value) => {
|
|
95
|
+
if (value === void 0) return 0;
|
|
96
|
+
if (value === null) return 1;
|
|
97
|
+
if (typeof value === "boolean") return 2;
|
|
98
|
+
if (typeof value === "number") return 3;
|
|
99
|
+
if (typeof value === "string") return 4;
|
|
100
|
+
return 5;
|
|
101
|
+
};
|
|
102
|
+
const leftRank = rankOf(left);
|
|
103
|
+
const rightRank = rankOf(right);
|
|
104
|
+
if (leftRank !== rightRank) return leftRank < rightRank ? -1 : 1;
|
|
105
|
+
if (typeof left === "number" && typeof right === "number") {
|
|
106
|
+
if (Number.isNaN(left) || Number.isNaN(right)) return Number.isNaN(left) ? Number.isNaN(right) ? 0 : 1 : -1;
|
|
107
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
if (typeof left === "string" && typeof right === "string") return left < right ? -1 : left > right ? 1 : 0;
|
|
110
|
+
if (typeof left === "boolean" && typeof right === "boolean") return left === right ? 0 : left ? 1 : -1;
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Structural equality by SameValueZero leaves — the comparator behind conformance
|
|
115
|
+
* checks and any test/fixture that needs "same data", not "same reference".
|
|
116
|
+
*
|
|
117
|
+
* @remarks
|
|
118
|
+
* Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
|
|
119
|
+
* Arrays compare by index (same length, every element `deepEqual`). Plain
|
|
120
|
+
* records (via `isRecord`) compare by their OWN enumerable keys: same key
|
|
121
|
+
* COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
|
|
122
|
+
* with a `deepEqual` value — so a key present with value `undefined` is NOT
|
|
123
|
+
* equal to that key being absent (both differ in `Object.keys` membership).
|
|
124
|
+
* Anything else (functions, class instances, mismatched shapes) falls through
|
|
125
|
+
* to `false`. There is no cycle detection — a cyclic input recurses forever;
|
|
126
|
+
* callers pass acyclic data (rows, plans, config).
|
|
127
|
+
*
|
|
128
|
+
* @param left - The left value
|
|
129
|
+
* @param right - The right value
|
|
130
|
+
* @returns Whether `left` and `right` are structurally equal
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* deepEqual(Number.NaN, Number.NaN) // true
|
|
135
|
+
* deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
|
|
136
|
+
* deepEqual({ a: undefined }, {}) // false — present-undefined ≠ absent
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
function deepEqual(left, right) {
|
|
140
|
+
if (typeof left === "number" && typeof right === "number") return Number.isNaN(left) && Number.isNaN(right) || left === right;
|
|
141
|
+
if (left === right) return true;
|
|
142
|
+
if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
|
|
143
|
+
if ((0, _orkestrel_contract.isRecord)(left) && (0, _orkestrel_contract.isRecord)(right)) {
|
|
144
|
+
const leftKeys = Object.keys(left);
|
|
145
|
+
const rightKeys = Object.keys(right);
|
|
146
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
147
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqual(left[key], right[key]));
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
|
|
153
|
+
* engine behind {@link likeMatch} and {@link globMatch}.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
|
|
157
|
+
* `.*` segments separated by literals, matched against a long non-matching input, blow
|
|
158
|
+
* up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
|
|
159
|
+
* (AGENTS §6.5, now that the authed server runs model-supplied `list` criteria over the
|
|
160
|
+
* wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
|
|
161
|
+
* the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
|
|
162
|
+
* that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
|
|
163
|
+
* never the exponential / polynomial backtracking a regex would do. The pattern length
|
|
164
|
+
* is capped at {@link MAX_PATTERN_LENGTH} (a `VALIDATION` {@link DatabaseError} over it),
|
|
165
|
+
* bounding the pattern factor so a match stays linear in the value length whatever the
|
|
166
|
+
* pattern.
|
|
167
|
+
*
|
|
168
|
+
* The `any` wildcard matches any run (including empty); `single` matches exactly one
|
|
169
|
+
* char; every other pattern char matches itself LITERALLY (a pattern `.` / `(` / `\` is
|
|
170
|
+
* a literal — the regex-metacharacter hazard is gone with the regex). `any` is tested
|
|
171
|
+
* BEFORE a literal match, so a value that literally contains the wildcard char never
|
|
172
|
+
* shadows the wildcard. Case folding is applied to BOTH sides when `fold` is set.
|
|
173
|
+
*
|
|
174
|
+
* @param value - The value to test
|
|
175
|
+
* @param pattern - The wildcard pattern
|
|
176
|
+
* @param any - The any-run wildcard char (`%` for `LIKE`, `*` for `GLOB`)
|
|
177
|
+
* @param single - The single-char wildcard char (`_` for `LIKE`, `?` for `GLOB`)
|
|
178
|
+
* @param fold - Whether to match case-INSENSITIVELY (`LIKE` folds; `GLOB` does not)
|
|
179
|
+
* @returns Whether `value` matches `pattern`
|
|
180
|
+
* @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
|
|
181
|
+
*/
|
|
182
|
+
function wildcardMatch(value, pattern, any, single, fold) {
|
|
183
|
+
if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
|
|
184
|
+
length: pattern.length,
|
|
185
|
+
limit: MAX_PATTERN_LENGTH
|
|
186
|
+
});
|
|
187
|
+
const haystack = fold ? value.toLowerCase() : value;
|
|
188
|
+
const needle = fold ? pattern.toLowerCase() : pattern;
|
|
189
|
+
let vi = 0;
|
|
190
|
+
let pi = 0;
|
|
191
|
+
let star = -1;
|
|
192
|
+
let mark = 0;
|
|
193
|
+
while (vi < haystack.length) {
|
|
194
|
+
const pc = pi < needle.length ? needle[pi] : void 0;
|
|
195
|
+
if (pc === any) {
|
|
196
|
+
star = pi;
|
|
197
|
+
mark = vi;
|
|
198
|
+
pi += 1;
|
|
199
|
+
} else if (pc !== void 0 && (pc === single || pc === haystack[vi])) {
|
|
200
|
+
vi += 1;
|
|
201
|
+
pi += 1;
|
|
202
|
+
} else if (star !== -1) {
|
|
203
|
+
pi = star + 1;
|
|
204
|
+
mark += 1;
|
|
205
|
+
vi = mark;
|
|
206
|
+
} else return false;
|
|
207
|
+
}
|
|
208
|
+
while (pi < needle.length && needle[pi] === any) pi += 1;
|
|
209
|
+
return pi === needle.length;
|
|
210
|
+
}
|
|
211
|
+
function likeMatch(value, pattern) {
|
|
212
|
+
return wildcardMatch(value, pattern, "%", "_", true);
|
|
213
|
+
}
|
|
214
|
+
function globMatch(value, pattern) {
|
|
215
|
+
return wildcardMatch(value, pattern, "*", "?", false);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Evaluate one {@link Condition} against a row — the per-operator predicate.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* Reads the condition's column — a `FieldPath`, resolved with `resolveField` (a
|
|
222
|
+
* string is one column; an array descends a nested value) — and applies the
|
|
223
|
+
* operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
|
|
224
|
+
* {@link compareValues}, the total order; the equality family (`equals` / `not`
|
|
225
|
+
* / `any` / `none`) uses {@link deepEqual} — STRUCTURAL equality, not the total
|
|
226
|
+
* order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
|
|
227
|
+
* operand only matches a structurally-equal value, never every row holding any
|
|
228
|
+
* object. This is a semantics change from ranking: `deepEqual` is SameValueZero
|
|
229
|
+
* on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
|
|
230
|
+
* anything under the old rank-based comparison). `like` / `glob` / `starts` /
|
|
231
|
+
* `ends` match only strings; `absent` / `present` test nullishness. Total — a
|
|
232
|
+
* type mismatch is simply a non-match.
|
|
233
|
+
*
|
|
234
|
+
* @param row - The row to test
|
|
235
|
+
* @param condition - The condition to apply
|
|
236
|
+
* @returns Whether the row satisfies the condition
|
|
237
|
+
*/
|
|
238
|
+
function matchesCondition(row, condition) {
|
|
239
|
+
const value = (0, _orkestrel_contract.resolveField)(row, condition.column);
|
|
240
|
+
const first = condition.values[0];
|
|
241
|
+
const second = condition.values[1];
|
|
242
|
+
switch (condition.operator) {
|
|
243
|
+
case "equals": return deepEqual(value, first);
|
|
244
|
+
case "not": return !deepEqual(value, first);
|
|
245
|
+
case "above": return compareValues(value, first) > 0;
|
|
246
|
+
case "below": return compareValues(value, first) < 0;
|
|
247
|
+
case "from": return compareValues(value, first) >= 0;
|
|
248
|
+
case "to": return compareValues(value, first) <= 0;
|
|
249
|
+
case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
|
|
250
|
+
case "like": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && likeMatch(value, first);
|
|
251
|
+
case "glob": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && globMatch(value, first);
|
|
252
|
+
case "starts": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && value.startsWith(first);
|
|
253
|
+
case "ends": return (0, _orkestrel_contract.isString)(value) && (0, _orkestrel_contract.isString)(first) && value.endsWith(first);
|
|
254
|
+
case "any": return condition.values.some((candidate) => deepEqual(value, candidate));
|
|
255
|
+
case "none": return !condition.values.some((candidate) => deepEqual(value, candidate));
|
|
256
|
+
case "absent": return value === void 0 || value === null;
|
|
257
|
+
case "present": return value !== void 0 && value !== null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Fold a row through a list of conditions, joining each by its connector.
|
|
262
|
+
*
|
|
263
|
+
* @remarks
|
|
264
|
+
* Evaluated left-to-right: the first condition seeds the result, and each later
|
|
265
|
+
* condition combines with `&&` (`and`) or `||` (`or`). An empty list matches
|
|
266
|
+
* every row. There is no operator precedence — conditions combine in the order
|
|
267
|
+
* the query builder recorded them.
|
|
268
|
+
*
|
|
269
|
+
* @param row - The row to test
|
|
270
|
+
* @param conditions - The conditions to fold
|
|
271
|
+
* @returns Whether the row satisfies the combined conditions
|
|
272
|
+
*/
|
|
273
|
+
function matchesCriteria(row, conditions) {
|
|
274
|
+
let result = true;
|
|
275
|
+
let seeded = false;
|
|
276
|
+
for (const condition of conditions) {
|
|
277
|
+
const match = matchesCondition(row, condition);
|
|
278
|
+
if (!seeded) {
|
|
279
|
+
result = match;
|
|
280
|
+
seeded = true;
|
|
281
|
+
} else result = condition.connector === "or" ? result || match : result && match;
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Filter rows by a list of conditions — the shared basis for a table's count
|
|
287
|
+
* and aggregate paths (no sort/page, unlike {@link applyCriteria}).
|
|
288
|
+
*
|
|
289
|
+
* @remarks
|
|
290
|
+
* An empty condition list matches every row (returned as-is, no copy). Folds
|
|
291
|
+
* each row through {@link matchesCriteria}.
|
|
292
|
+
*
|
|
293
|
+
* @param rows - The rows to filter
|
|
294
|
+
* @param conditions - The conditions to apply (empty matches everything)
|
|
295
|
+
* @returns The matching rows
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* filterRows(
|
|
300
|
+
* [{ age: 30 }, { age: 12 }],
|
|
301
|
+
* [{ column: 'age', operator: 'above', values: [18], connector: 'and' }],
|
|
302
|
+
* ) // => [{ age: 30 }]
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
function filterRows(rows, conditions) {
|
|
306
|
+
if (conditions.length === 0) return rows;
|
|
307
|
+
return rows.filter((row) => matchesCriteria(row, conditions));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Sort rows by an ordering specification, leaving the input untouched.
|
|
311
|
+
*
|
|
312
|
+
* @remarks
|
|
313
|
+
* Applies the terms in priority order — the first term that distinguishes two
|
|
314
|
+
* rows decides — using {@link compareValues}, reversing for `descending`.
|
|
315
|
+
*
|
|
316
|
+
* @param rows - The rows to sort
|
|
317
|
+
* @param order - The ordering terms in priority order
|
|
318
|
+
* @returns A new, sorted array
|
|
319
|
+
*/
|
|
320
|
+
function sortRows(rows, order) {
|
|
321
|
+
const sorted = [...rows];
|
|
322
|
+
sorted.sort((left, right) => {
|
|
323
|
+
for (const term of order) {
|
|
324
|
+
const comparison = compareValues((0, _orkestrel_contract.resolveField)(left, term.column), (0, _orkestrel_contract.resolveField)(right, term.column));
|
|
325
|
+
if (comparison !== 0) return term.direction === "descending" ? -comparison : comparison;
|
|
326
|
+
}
|
|
327
|
+
return 0;
|
|
328
|
+
});
|
|
329
|
+
return sorted;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Apply a {@link Criteria} to rows — filter, then sort, then page.
|
|
333
|
+
*
|
|
334
|
+
* @remarks
|
|
335
|
+
* The whole portable read pipeline in one place: conditions filter, `order`
|
|
336
|
+
* sorts, and `offset` / `limit` window the result. Each step is skipped when its
|
|
337
|
+
* part of the criteria is absent. The reference {@link DriverInterface} backends
|
|
338
|
+
* lean on this rather than each re-deriving it.
|
|
339
|
+
*
|
|
340
|
+
* @param rows - The rows to process (typically a table's full `scan`)
|
|
341
|
+
* @param criteria - The read specification, or `undefined` for all rows as-is
|
|
342
|
+
* @returns The filtered, sorted, paged rows
|
|
343
|
+
*/
|
|
344
|
+
function applyCriteria(rows, criteria) {
|
|
345
|
+
let result = rows;
|
|
346
|
+
const conditions = criteria?.conditions;
|
|
347
|
+
if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesCriteria(row, conditions));
|
|
348
|
+
const order = criteria?.order;
|
|
349
|
+
if (order !== void 0 && order.length > 0) result = sortRows(result, order);
|
|
350
|
+
const offset = criteria?.offset ?? 0;
|
|
351
|
+
const limit = criteria?.limit;
|
|
352
|
+
if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Compute an aggregate over a column across rows.
|
|
357
|
+
*
|
|
358
|
+
* @remarks
|
|
359
|
+
* `count` returns the row count. The numeric aggregates coerce each cell with
|
|
360
|
+
* the contracts `parseNumber` (so `'42'` counts) and ignore non-numeric cells;
|
|
361
|
+
* over zero numeric values they return `undefined` — the SQL `NULL` of an empty
|
|
362
|
+
* aggregate.
|
|
363
|
+
*
|
|
364
|
+
* @param rows - The rows to aggregate (non-record entries are ignored)
|
|
365
|
+
* @param operation - The aggregate to compute
|
|
366
|
+
* @param column - The column to aggregate
|
|
367
|
+
* @returns The aggregate value, or `undefined` when undefined for the inputs
|
|
368
|
+
*/
|
|
369
|
+
function computeAggregate(rows, operation, column) {
|
|
370
|
+
if (operation === "count") return rows.length;
|
|
371
|
+
const numbers = [];
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
if (!(0, _orkestrel_contract.isRecord)(row)) continue;
|
|
374
|
+
const value = (0, _orkestrel_contract.parseNumber)((0, _orkestrel_contract.resolveField)(row, column));
|
|
375
|
+
if (value !== void 0) numbers.push(value);
|
|
376
|
+
}
|
|
377
|
+
if (numbers.length === 0) return void 0;
|
|
378
|
+
if (operation === "sum" || operation === "average") {
|
|
379
|
+
const total = numbers.reduce((sum, value) => sum + value, 0);
|
|
380
|
+
return operation === "average" ? total / numbers.length : total;
|
|
381
|
+
}
|
|
382
|
+
return operation === "minimum" ? Math.min(...numbers) : Math.max(...numbers);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Read a row's primary key from a column, when it is a usable {@link Key}.
|
|
386
|
+
*
|
|
387
|
+
* @param row - The row to read
|
|
388
|
+
* @param column - The primary-key column name
|
|
389
|
+
* @returns The key (a string or finite number), or `undefined`
|
|
390
|
+
*/
|
|
391
|
+
function extractKey(row, column) {
|
|
392
|
+
const value = row[column];
|
|
393
|
+
if ((0, _orkestrel_contract.isString)(value)) return value;
|
|
394
|
+
if ((0, _orkestrel_contract.isFiniteNumber)(value)) return value;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Map a column's {@link ContractShape} to its portable {@link ColumnType} — the
|
|
398
|
+
* value a `TableSchema` carries so a native backend can declare a real column.
|
|
399
|
+
*
|
|
400
|
+
* @remarks
|
|
401
|
+
* `string` → `text`; `number` → `integer` when the shape is integer-only, else
|
|
402
|
+
* `real`; `boolean` → `boolean`. A `literal` takes the type of its values
|
|
403
|
+
* (all-boolean → `boolean`, all-integer → `integer`, mixed/fractional numbers →
|
|
404
|
+
* `real`, anything else → `text`). `optional` / `nullable` unwrap to their inner
|
|
405
|
+
* type (nullability is tracked separately). `null` / `object` / `array` / `union` /
|
|
406
|
+
* `json` / `raw` → `json`: a backend stores them as JSON text and can `json_extract`
|
|
407
|
+
* for nested `FieldPath` queries. A scan-only backend ignores the result.
|
|
408
|
+
*
|
|
409
|
+
* @param shape - The column's contract shape
|
|
410
|
+
* @returns The portable column type
|
|
411
|
+
*
|
|
412
|
+
* @example
|
|
413
|
+
* ```ts
|
|
414
|
+
* shapeToColumnType(stringShape()) // 'text'
|
|
415
|
+
* shapeToColumnType(integerShape()) // 'integer'
|
|
416
|
+
* shapeToColumnType(optionalShape(integerShape())) // 'integer'
|
|
417
|
+
* shapeToColumnType(objectShape({ a: stringShape() })) // 'json'
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
function shapeToColumnType(shape) {
|
|
421
|
+
switch (shape.type) {
|
|
422
|
+
case "string": return "text";
|
|
423
|
+
case "number": return shape.integer === true ? "integer" : "real";
|
|
424
|
+
case "boolean": return "boolean";
|
|
425
|
+
case "literal":
|
|
426
|
+
if (shape.values.every((value) => typeof value === "boolean")) return "boolean";
|
|
427
|
+
if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
|
|
428
|
+
return "text";
|
|
429
|
+
case "optional":
|
|
430
|
+
case "nullable": return shapeToColumnType(shape.inner);
|
|
431
|
+
case "null":
|
|
432
|
+
case "object":
|
|
433
|
+
case "array":
|
|
434
|
+
case "union":
|
|
435
|
+
case "json":
|
|
436
|
+
case "raw": return "json";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Whether a value is a well-formed {@link DriverMeta} — the boundary guard a
|
|
441
|
+
* versioning driver's `meta()` narrows a stored (structured-clone or
|
|
442
|
+
* `JSON.parse`d) value through before trusting it, replacing the per-driver
|
|
443
|
+
* duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
|
|
444
|
+
*
|
|
445
|
+
* @remarks
|
|
446
|
+
* Total and total-recursive over the whole shape: a finite `version`, and a
|
|
447
|
+
* `schema` array of well-formed {@link TableSchema} entries — each a `name` /
|
|
448
|
+
* `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
|
|
449
|
+
* entries (a `name` string, a {@link ColumnType} literal, a `nullable`
|
|
450
|
+
* boolean), and an `indexes` array of string arrays. Anything off-shape
|
|
451
|
+
* (including a non-record) returns `false` rather than throwing.
|
|
452
|
+
*
|
|
453
|
+
* @param value - The value to test
|
|
454
|
+
* @returns `true` when `value` is a well-formed `DriverMeta`
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* ```ts
|
|
458
|
+
* isDriverMeta({ version: 1, schema: [] }) // true
|
|
459
|
+
* isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
function isDriverMeta(value) {
|
|
463
|
+
const COLUMN_TYPES = [
|
|
464
|
+
"text",
|
|
465
|
+
"integer",
|
|
466
|
+
"real",
|
|
467
|
+
"boolean",
|
|
468
|
+
"json",
|
|
469
|
+
"blob"
|
|
470
|
+
];
|
|
471
|
+
const isColumnType = (candidate) => (0, _orkestrel_contract.isString)(candidate) && COLUMN_TYPES.some((type) => type === candidate);
|
|
472
|
+
const isColumnSchema = (candidate) => (0, _orkestrel_contract.isRecord)(candidate) && (0, _orkestrel_contract.isString)(candidate.name) && isColumnType(candidate.type) && (0, _orkestrel_contract.isBoolean)(candidate.nullable);
|
|
473
|
+
const isIndexGroup = (candidate) => (0, _orkestrel_contract.isArray)(candidate) && candidate.every((entry) => (0, _orkestrel_contract.isString)(entry));
|
|
474
|
+
const isTableSchema = (candidate) => (0, _orkestrel_contract.isRecord)(candidate) && (0, _orkestrel_contract.isString)(candidate.name) && (0, _orkestrel_contract.isString)(candidate.primary) && (0, _orkestrel_contract.isArray)(candidate.columns) && candidate.columns.every(isColumnSchema) && (0, _orkestrel_contract.isArray)(candidate.indexes) && candidate.indexes.every(isIndexGroup);
|
|
475
|
+
return (0, _orkestrel_contract.isRecord)(value) && (0, _orkestrel_contract.isFiniteNumber)(value.version) && (0, _orkestrel_contract.isArray)(value.schema) && value.schema.every(isTableSchema);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Throw when an {@link ReadOptions.signal | AbortSignal} has fired — the shared
|
|
479
|
+
* cancellation gate checked at operation boundaries and between streamed rows.
|
|
480
|
+
*
|
|
481
|
+
* @remarks
|
|
482
|
+
* A no-op for `undefined` or a live signal, so callers thread `options?.signal`
|
|
483
|
+
* straight through. When the signal has aborted, throws an `ABORTED`
|
|
484
|
+
* {@link DatabaseError} carrying the signal's `reason` in its context — callers
|
|
485
|
+
* mint signals with whatever tool they like (`AbortSignal.timeout(ms)`,
|
|
486
|
+
* `new AbortController()`, `@orkestrel/abort`).
|
|
487
|
+
*
|
|
488
|
+
* @param signal - The signal to check, if any
|
|
489
|
+
* @returns Nothing — returns normally while the signal is live
|
|
490
|
+
* @throws An `ABORTED` {@link DatabaseError} when the signal has aborted
|
|
491
|
+
*
|
|
492
|
+
* @example
|
|
493
|
+
* ```ts
|
|
494
|
+
* import { checkAbort } from '@orkestrel/database'
|
|
495
|
+
*
|
|
496
|
+
* const controller = new AbortController()
|
|
497
|
+
* checkAbort(controller.signal) // returns
|
|
498
|
+
* controller.abort('too slow')
|
|
499
|
+
* checkAbort(controller.signal) // throws DatabaseError('ABORTED', …)
|
|
500
|
+
* ```
|
|
501
|
+
*/
|
|
502
|
+
function checkAbort(signal) {
|
|
503
|
+
if (signal?.aborted) throw new DatabaseError("ABORTED", "Operation aborted", { reason: signal.reason });
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Structurally diff a deployed and a declared table set into a {@link Migration}
|
|
507
|
+
* plan.
|
|
508
|
+
*
|
|
509
|
+
* @remarks
|
|
510
|
+
* Tables present in `declared` but not `deployed` become `table.add` steps
|
|
511
|
+
* (carrying the full declared {@link TableSchema}); tables present in
|
|
512
|
+
* `deployed` but not `declared` become `table.remove` steps. Tables present in
|
|
513
|
+
* both are diffed column-by-column (by name) and index-group-by-index-group
|
|
514
|
+
* (by deep equality of the column-name array), each producing `column.add` /
|
|
515
|
+
* `column.remove` / `index.add` / `index.remove` steps. Step order is
|
|
516
|
+
* deterministic: every `table.remove`, then every `table.add`, then each
|
|
517
|
+
* shared table's column/index changes in `declared` order. `from` / `to` are
|
|
518
|
+
* plan labels only — version tracking itself is deferred to persistent
|
|
519
|
+
* backends.
|
|
520
|
+
*
|
|
521
|
+
* A column present in BOTH schemas under the same name but with a different
|
|
522
|
+
* `type` or `nullable` throws a `MIGRATION` {@link DatabaseError} naming the
|
|
523
|
+
* table, the column, and the from→to difference — a name-only diff would
|
|
524
|
+
* otherwise silently produce NO step for the drift, and versioned
|
|
525
|
+
* reconciliation would stamp over it. There is no automatic in-place
|
|
526
|
+
* type-change step: the manual path is to add a new column, copy/convert the
|
|
527
|
+
* data at the application layer, then remove the old column — two separate
|
|
528
|
+
* plans, never a single implicit "alter" step.
|
|
529
|
+
*
|
|
530
|
+
* @param deployed - The table schemas currently applied
|
|
531
|
+
* @param declared - The table schemas the caller wants applied
|
|
532
|
+
* @param from - The plan's source version label (defaults to `0`)
|
|
533
|
+
* @param to - The plan's target version label (defaults to `1`)
|
|
534
|
+
* @returns The migration plan moving `deployed` toward `declared`
|
|
535
|
+
* @throws A `MIGRATION` {@link DatabaseError} when a shared column's `type` or
|
|
536
|
+
* `nullable` differs between `deployed` and `declared`
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* ```ts
|
|
540
|
+
* const plan = planMigration(
|
|
541
|
+
* [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
|
|
542
|
+
* [{ name: 'users', primary: 'id', columns: [{ name: 'age', type: 'integer', nullable: false }], indexes: [] }],
|
|
543
|
+
* )
|
|
544
|
+
* // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
|
|
545
|
+
* ```
|
|
546
|
+
*/
|
|
547
|
+
function planMigration(deployed, declared, from = 0, to = 1) {
|
|
548
|
+
const deployedByName = new Map(deployed.map((table) => [table.name, table]));
|
|
549
|
+
const declaredByName = new Map(declared.map((table) => [table.name, table]));
|
|
550
|
+
const steps = [];
|
|
551
|
+
for (const table of deployed) if (!declaredByName.has(table.name)) steps.push({
|
|
552
|
+
operation: "table.remove",
|
|
553
|
+
table: table.name
|
|
554
|
+
});
|
|
555
|
+
for (const table of declared) if (!deployedByName.has(table.name)) steps.push({
|
|
556
|
+
operation: "table.add",
|
|
557
|
+
table
|
|
558
|
+
});
|
|
559
|
+
for (const table of declared) {
|
|
560
|
+
const before = deployedByName.get(table.name);
|
|
561
|
+
if (before === void 0) continue;
|
|
562
|
+
const beforeColumns = new Map(before.columns.map((column) => [column.name, column]));
|
|
563
|
+
const afterColumns = new Map(table.columns.map((column) => [column.name, column]));
|
|
564
|
+
for (const column of before.columns) if (!afterColumns.has(column.name)) steps.push({
|
|
565
|
+
operation: "column.remove",
|
|
566
|
+
table: table.name,
|
|
567
|
+
column: column.name
|
|
568
|
+
});
|
|
569
|
+
for (const column of table.columns) {
|
|
570
|
+
const previous = beforeColumns.get(column.name);
|
|
571
|
+
if (previous === void 0) {
|
|
572
|
+
steps.push({
|
|
573
|
+
operation: "column.add",
|
|
574
|
+
table: table.name,
|
|
575
|
+
column
|
|
576
|
+
});
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
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`, {
|
|
580
|
+
table: table.name,
|
|
581
|
+
column: column.name,
|
|
582
|
+
from: {
|
|
583
|
+
type: previous.type,
|
|
584
|
+
nullable: previous.nullable
|
|
585
|
+
},
|
|
586
|
+
to: {
|
|
587
|
+
type: column.type,
|
|
588
|
+
nullable: column.nullable
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
|
|
593
|
+
for (const index of before.indexes) if (!table.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
594
|
+
operation: "index.remove",
|
|
595
|
+
table: table.name,
|
|
596
|
+
index
|
|
597
|
+
});
|
|
598
|
+
for (const index of table.indexes) if (!before.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
599
|
+
operation: "index.add",
|
|
600
|
+
table: table.name,
|
|
601
|
+
index
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
from,
|
|
606
|
+
to,
|
|
607
|
+
steps
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
|
|
612
|
+
*
|
|
613
|
+
* @remarks
|
|
614
|
+
* `column.remove` drops that field from every row (a fresh copy — inputs are
|
|
615
|
+
* never mutated, AGENTS §11); `column.add` leaves rows as-is (an absent field
|
|
616
|
+
* reads as `undefined`, backfill is application policy). `table.add` /
|
|
617
|
+
* `table.remove` / `index.add` / `index.remove` are no-ops here (they operate
|
|
618
|
+
* on storage shape, not row shape). Steps for tables other than the one
|
|
619
|
+
* `rows` belongs to are ignored — pass only the steps relevant to this table.
|
|
620
|
+
*
|
|
621
|
+
* @param rows - The table's current rows
|
|
622
|
+
* @param steps - The migration steps to apply (typically one table's slice of a {@link Migration})
|
|
623
|
+
* @returns A new array of transformed rows; `rows` is never mutated
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* ```ts
|
|
627
|
+
* const rows = [{ id: 'a', name: 'Ada', legacy: true }]
|
|
628
|
+
* migrateRows(rows, [{ operation: 'column.remove', table: 'users', column: 'legacy' }])
|
|
629
|
+
* // => [{ id: 'a', name: 'Ada' }]
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
function migrateRows(rows, steps) {
|
|
633
|
+
const removed = steps.filter((step) => step.operation === "column.remove").map((step) => step.column);
|
|
634
|
+
if (removed.length === 0) return rows.map((row) => ({ ...row }));
|
|
635
|
+
return rows.map((row) => {
|
|
636
|
+
const next = {};
|
|
637
|
+
for (const key of Object.keys(row)) if (!removed.includes(key)) next[key] = row[key];
|
|
638
|
+
return next;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Run the driver-conformance battery against a fresh {@link DriverInterface}
|
|
643
|
+
* per phase, yielding one {@link ConformanceFinding} per violated invariant —
|
|
644
|
+
* the shared invariant suite every backend (in-memory, SQLite, IndexedDB)
|
|
645
|
+
* must uphold to be a drop-in {@link DriverInterface}.
|
|
646
|
+
*
|
|
647
|
+
* @remarks
|
|
648
|
+
* Framework-agnostic: no test-runner or Node imports, only sibling core
|
|
649
|
+
* modules — so it runs equally from a unit test, a smoke script, or a new
|
|
650
|
+
* driver's own README. Opens a fixed two-table schema (`users` keyed by the
|
|
651
|
+
* default `id`, `posts` keyed by a non-id `slug`) and, calling `factory()`
|
|
652
|
+
* fresh for each phase so failures stay isolated, verifies: `open`/`close`;
|
|
653
|
+
* `read` of a missing key returns `undefined`; `write`/`read` round-trip with
|
|
654
|
+
* DEEP copy-in/copy-out isolation (mutating the caller's row — including a
|
|
655
|
+
* NESTED field — after `write`, or a row `read` returns, never perturbs
|
|
656
|
+
* stored state) and upsert-overwrite; `delete` returns `true` then `false`;
|
|
657
|
+
* `keys`/`scan` yield in ascending key order; `clear` empties only its target
|
|
658
|
+
* table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
|
|
659
|
+
* NESTED field mutated in place on a read-back row between capture and
|
|
660
|
+
* restore; a scoped `snapshot(['users'])` rolls back only the named table,
|
|
661
|
+
* leaving a concurrent mutation to another table intact; a
|
|
662
|
+
* non-`id` primary key (`posts.slug`) round-trips; a nested-object row
|
|
663
|
+
* round-trips structurally (via {@link deepEqual}). The optional surface is
|
|
664
|
+
* presence-gated: when `migrate` exists, a `column.remove` plan strips the
|
|
665
|
+
* column from stored rows and a plan referencing an unknown table throws
|
|
666
|
+
* `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
|
|
667
|
+
* condition-matching rows and honors `offset`/`limit`; when `transaction`
|
|
668
|
+
* exists, `commit` persists and `rollback` restores; when both `meta` and
|
|
669
|
+
* `stamp` exist, a fresh store's `meta()` is `undefined`, and after
|
|
670
|
+
* `stamp({ version, schema })`, `meta()` returns the exact stamped value.
|
|
671
|
+
*
|
|
672
|
+
* Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
|
|
673
|
+
* finding built from the assertion, while an UNEXPECTED throw (a driver
|
|
674
|
+
* crash mid-phase) is caught and yielded as a finding too, naming the phase
|
|
675
|
+
* as `check` and carrying the caught error in `context.error` — a broken
|
|
676
|
+
* driver can never escape the battery as an unhandled rejection. Within a
|
|
677
|
+
* phase, the FIRST violated assertion yields and the phase stops (matching
|
|
678
|
+
* the historical fail-fast shape at phase granularity); the generator then
|
|
679
|
+
* moves on to the next phase regardless. Because this is a **generator**,
|
|
680
|
+
* consuming only the first yielded value reproduces true fail-fast (later
|
|
681
|
+
* phases never run) — that is exactly what {@link conformDriver} does.
|
|
682
|
+
*
|
|
683
|
+
* @param factory - Mints a fresh, unopened driver instance (called once per phase)
|
|
684
|
+
* @yields One {@link ConformanceFinding} per violated invariant, in phase order
|
|
685
|
+
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```ts
|
|
688
|
+
* import { createMemoryDriver, driverFindings } from '@orkestrel/database'
|
|
689
|
+
*
|
|
690
|
+
* for await (const finding of driverFindings(() => createMemoryDriver())) {
|
|
691
|
+
* console.log(finding.check, finding.message)
|
|
692
|
+
* }
|
|
693
|
+
* ```
|
|
694
|
+
*/
|
|
695
|
+
async function* driverFindings(factory) {
|
|
696
|
+
const CONFORMANCE_USERS_SCHEMA = {
|
|
697
|
+
name: "users",
|
|
698
|
+
primary: "id",
|
|
699
|
+
columns: [
|
|
700
|
+
{
|
|
701
|
+
name: "id",
|
|
702
|
+
type: "text",
|
|
703
|
+
nullable: false
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: "name",
|
|
707
|
+
type: "text",
|
|
708
|
+
nullable: false
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
name: "age",
|
|
712
|
+
type: "integer",
|
|
713
|
+
nullable: true
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: "meta",
|
|
717
|
+
type: "json",
|
|
718
|
+
nullable: true
|
|
719
|
+
}
|
|
720
|
+
],
|
|
721
|
+
indexes: []
|
|
722
|
+
};
|
|
723
|
+
const CONFORMANCE_POSTS_SCHEMA = {
|
|
724
|
+
name: "posts",
|
|
725
|
+
primary: "slug",
|
|
726
|
+
columns: [{
|
|
727
|
+
name: "slug",
|
|
728
|
+
type: "text",
|
|
729
|
+
nullable: false
|
|
730
|
+
}, {
|
|
731
|
+
name: "title",
|
|
732
|
+
type: "text",
|
|
733
|
+
nullable: false
|
|
734
|
+
}],
|
|
735
|
+
indexes: []
|
|
736
|
+
};
|
|
737
|
+
const CONFORMANCE_SCHEMA = [CONFORMANCE_USERS_SCHEMA, CONFORMANCE_POSTS_SCHEMA];
|
|
738
|
+
const findingOf = (check, message, context) => ({
|
|
739
|
+
check,
|
|
740
|
+
message,
|
|
741
|
+
context
|
|
742
|
+
});
|
|
743
|
+
const phases = [
|
|
744
|
+
{
|
|
745
|
+
check: "open-close",
|
|
746
|
+
run: async () => {
|
|
747
|
+
const driver = factory();
|
|
748
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
749
|
+
await driver.close();
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
check: "read-missing",
|
|
754
|
+
run: async () => {
|
|
755
|
+
const driver = factory();
|
|
756
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
757
|
+
const missing = await driver.read("users", "nope");
|
|
758
|
+
await driver.close();
|
|
759
|
+
if (missing !== void 0) return findingOf("read-missing", "read of a missing key must return undefined", {
|
|
760
|
+
table: "users",
|
|
761
|
+
expected: void 0,
|
|
762
|
+
actual: missing
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
check: "write-read",
|
|
768
|
+
run: async () => {
|
|
769
|
+
const driver = factory();
|
|
770
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
771
|
+
const input = {
|
|
772
|
+
id: "u1",
|
|
773
|
+
name: "Ada",
|
|
774
|
+
age: 30,
|
|
775
|
+
meta: { tags: ["a"] }
|
|
776
|
+
};
|
|
777
|
+
await driver.write("users", "u1", input);
|
|
778
|
+
input.name = "Mutated after write";
|
|
779
|
+
if ((0, _orkestrel_contract.isRecord)(input.meta) && Array.isArray(input.meta.tags)) input.meta.tags.push("mutated");
|
|
780
|
+
const stored = await driver.read("users", "u1");
|
|
781
|
+
const original = {
|
|
782
|
+
id: "u1",
|
|
783
|
+
name: "Ada",
|
|
784
|
+
age: 30,
|
|
785
|
+
meta: { tags: ["a"] }
|
|
786
|
+
};
|
|
787
|
+
if (stored === void 0 || !deepEqual(stored, original)) {
|
|
788
|
+
await driver.close();
|
|
789
|
+
return findingOf("copy-in", "write must deep-copy the input row (including nested fields) rather than store it by reference", {
|
|
790
|
+
table: "users",
|
|
791
|
+
expected: original,
|
|
792
|
+
actual: stored
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
stored.name = "Mutated after read";
|
|
796
|
+
if ((0, _orkestrel_contract.isRecord)(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
|
|
797
|
+
const reread = await driver.read("users", "u1");
|
|
798
|
+
if (reread === void 0 || !deepEqual(reread, original)) {
|
|
799
|
+
await driver.close();
|
|
800
|
+
return findingOf("copy-out", "read must deep-copy the stored row (including nested fields) rather than return it by reference", {
|
|
801
|
+
table: "users",
|
|
802
|
+
expected: original,
|
|
803
|
+
actual: reread
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
const overwrite = {
|
|
807
|
+
id: "u1",
|
|
808
|
+
name: "Ada Overwritten",
|
|
809
|
+
age: 31
|
|
810
|
+
};
|
|
811
|
+
await driver.write("users", "u1", overwrite);
|
|
812
|
+
const overwritten = await driver.read("users", "u1");
|
|
813
|
+
await driver.close();
|
|
814
|
+
if (overwritten === void 0 || !deepEqual(overwritten, overwrite)) return findingOf("upsert", "write must upsert-overwrite an existing key", {
|
|
815
|
+
table: "users",
|
|
816
|
+
expected: overwrite,
|
|
817
|
+
actual: overwritten
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
check: "delete",
|
|
823
|
+
run: async () => {
|
|
824
|
+
const driver = factory();
|
|
825
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
826
|
+
await driver.write("users", "u1", {
|
|
827
|
+
id: "u1",
|
|
828
|
+
name: "Ada",
|
|
829
|
+
age: 30
|
|
830
|
+
});
|
|
831
|
+
const first = await driver.delete("users", "u1");
|
|
832
|
+
if (first !== true) {
|
|
833
|
+
await driver.close();
|
|
834
|
+
return findingOf("delete-true", "delete of an existing key must return true", {
|
|
835
|
+
table: "users",
|
|
836
|
+
expected: true,
|
|
837
|
+
actual: first
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const second = await driver.delete("users", "u1");
|
|
841
|
+
await driver.close();
|
|
842
|
+
if (second !== false) return findingOf("delete-false", "delete of an already-removed key must return false", {
|
|
843
|
+
table: "users",
|
|
844
|
+
expected: false,
|
|
845
|
+
actual: second
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
check: "order",
|
|
851
|
+
run: async () => {
|
|
852
|
+
const driver = factory();
|
|
853
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
854
|
+
for (const row of [
|
|
855
|
+
{
|
|
856
|
+
id: "c",
|
|
857
|
+
name: "C",
|
|
858
|
+
age: 3
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
id: "a",
|
|
862
|
+
name: "A",
|
|
863
|
+
age: 1
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
id: "b",
|
|
867
|
+
name: "B",
|
|
868
|
+
age: 2
|
|
869
|
+
}
|
|
870
|
+
]) await driver.write("users", row.id, row);
|
|
871
|
+
const expected = [
|
|
872
|
+
"a",
|
|
873
|
+
"b",
|
|
874
|
+
"c"
|
|
875
|
+
];
|
|
876
|
+
const keys = [...await driver.keys("users")];
|
|
877
|
+
if (!deepEqual(keys, expected)) {
|
|
878
|
+
await driver.close();
|
|
879
|
+
return findingOf("keys-order", "keys must be returned in ascending key order", {
|
|
880
|
+
table: "users",
|
|
881
|
+
expected,
|
|
882
|
+
actual: keys
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
const scanned = [];
|
|
886
|
+
for await (const row of driver.scan("users")) scanned.push(row);
|
|
887
|
+
const scannedIds = scanned.map((row) => row.id);
|
|
888
|
+
await driver.close();
|
|
889
|
+
if (!deepEqual(scannedIds, expected)) return findingOf("scan-order", "scan must yield rows in ascending key order", {
|
|
890
|
+
table: "users",
|
|
891
|
+
expected,
|
|
892
|
+
actual: scannedIds
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
check: "clear",
|
|
898
|
+
run: async () => {
|
|
899
|
+
const driver = factory();
|
|
900
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
901
|
+
await driver.write("users", "u1", {
|
|
902
|
+
id: "u1",
|
|
903
|
+
name: "Ada",
|
|
904
|
+
age: 30
|
|
905
|
+
});
|
|
906
|
+
await driver.write("posts", "p1", {
|
|
907
|
+
slug: "p1",
|
|
908
|
+
title: "Post"
|
|
909
|
+
});
|
|
910
|
+
await driver.clear("users");
|
|
911
|
+
const usersKeys = await driver.keys("users");
|
|
912
|
+
const postsKeys = await driver.keys("posts");
|
|
913
|
+
await driver.close();
|
|
914
|
+
if (usersKeys.length !== 0) return findingOf("clear-target", "clear must empty the targeted table", {
|
|
915
|
+
table: "users",
|
|
916
|
+
expected: [],
|
|
917
|
+
actual: usersKeys
|
|
918
|
+
});
|
|
919
|
+
if (postsKeys.length !== 1) return findingOf("clear-other", "clear must not affect other tables", {
|
|
920
|
+
table: "posts",
|
|
921
|
+
expected: 1,
|
|
922
|
+
actual: postsKeys.length
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
check: "snapshot",
|
|
928
|
+
run: async () => {
|
|
929
|
+
const driver = factory();
|
|
930
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
931
|
+
const original = {
|
|
932
|
+
id: "u1",
|
|
933
|
+
name: "Ada",
|
|
934
|
+
age: 30
|
|
935
|
+
};
|
|
936
|
+
await driver.write("users", "u1", original);
|
|
937
|
+
const rollback = await driver.snapshot();
|
|
938
|
+
await driver.write("users", "u2", {
|
|
939
|
+
id: "u2",
|
|
940
|
+
name: "Grace",
|
|
941
|
+
age: 40
|
|
942
|
+
});
|
|
943
|
+
await driver.delete("users", "u1");
|
|
944
|
+
await rollback();
|
|
945
|
+
const keys = [...await driver.keys("users")];
|
|
946
|
+
if (!deepEqual(keys, ["u1"])) {
|
|
947
|
+
await driver.close();
|
|
948
|
+
return findingOf("snapshot-rollback", "snapshot rollback must restore the pre-snapshot key set", {
|
|
949
|
+
table: "users",
|
|
950
|
+
expected: ["u1"],
|
|
951
|
+
actual: keys
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
const restored = await driver.read("users", "u1");
|
|
955
|
+
await driver.close();
|
|
956
|
+
if (restored === void 0 || !deepEqual(restored, original)) return findingOf("snapshot-rollback-value", "snapshot rollback must restore pre-snapshot row values", {
|
|
957
|
+
table: "users",
|
|
958
|
+
expected: original,
|
|
959
|
+
actual: restored
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
check: "snapshot-nested",
|
|
965
|
+
run: async () => {
|
|
966
|
+
const driver = factory();
|
|
967
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
968
|
+
const original = {
|
|
969
|
+
id: "u3",
|
|
970
|
+
name: "Nested",
|
|
971
|
+
age: 20,
|
|
972
|
+
meta: { tags: ["a"] }
|
|
973
|
+
};
|
|
974
|
+
await driver.write("users", "u3", original);
|
|
975
|
+
const rollback = await driver.snapshot();
|
|
976
|
+
const before = await driver.read("users", "u3");
|
|
977
|
+
if ((0, _orkestrel_contract.isRecord)(before) && (0, _orkestrel_contract.isRecord)(before.meta) && Array.isArray(before.meta.tags)) before.meta.tags.push("mutated-before-restore");
|
|
978
|
+
await driver.write("users", "u3", {
|
|
979
|
+
id: "u3",
|
|
980
|
+
name: "Nested",
|
|
981
|
+
age: 20,
|
|
982
|
+
meta: { tags: ["a", "mutated-after-write"] }
|
|
983
|
+
});
|
|
984
|
+
await rollback();
|
|
985
|
+
const restored = await driver.read("users", "u3");
|
|
986
|
+
await driver.close();
|
|
987
|
+
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", {
|
|
988
|
+
table: "users",
|
|
989
|
+
expected: original,
|
|
990
|
+
actual: restored
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
check: "non-id-primary",
|
|
996
|
+
run: async () => {
|
|
997
|
+
const driver = factory();
|
|
998
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
999
|
+
await driver.write("posts", "hello-world", {
|
|
1000
|
+
slug: "hello-world",
|
|
1001
|
+
title: "Hello"
|
|
1002
|
+
});
|
|
1003
|
+
const post = await driver.read("posts", "hello-world");
|
|
1004
|
+
const key = post === void 0 ? void 0 : extractKey(post, "slug");
|
|
1005
|
+
await driver.close();
|
|
1006
|
+
if (key !== "hello-world") return findingOf("non-id-primary", "a non-id primary key column must round-trip through the store", {
|
|
1007
|
+
table: "posts",
|
|
1008
|
+
expected: "hello-world",
|
|
1009
|
+
actual: key
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
check: "nested-roundtrip",
|
|
1015
|
+
run: async () => {
|
|
1016
|
+
const driver = factory();
|
|
1017
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1018
|
+
const nested = {
|
|
1019
|
+
id: "u3",
|
|
1020
|
+
name: "Nested",
|
|
1021
|
+
age: 20,
|
|
1022
|
+
meta: {
|
|
1023
|
+
tags: ["a", "b"],
|
|
1024
|
+
deep: { flag: true }
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
await driver.write("users", "u3", nested);
|
|
1028
|
+
const readBack = await driver.read("users", "u3");
|
|
1029
|
+
await driver.close();
|
|
1030
|
+
if (readBack === void 0 || !deepEqual(readBack, nested)) return findingOf("nested-roundtrip", "a nested-object row must round-trip structurally", {
|
|
1031
|
+
table: "users",
|
|
1032
|
+
expected: nested,
|
|
1033
|
+
actual: readBack
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
check: "migrate",
|
|
1039
|
+
run: async () => {
|
|
1040
|
+
const driver = factory();
|
|
1041
|
+
if (driver.migrate === void 0) return void 0;
|
|
1042
|
+
const deployedUsers = {
|
|
1043
|
+
...CONFORMANCE_USERS_SCHEMA,
|
|
1044
|
+
columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
|
|
1045
|
+
name: "legacy",
|
|
1046
|
+
type: "boolean",
|
|
1047
|
+
nullable: true
|
|
1048
|
+
}]
|
|
1049
|
+
};
|
|
1050
|
+
await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
|
|
1051
|
+
await driver.write("users", "u1", {
|
|
1052
|
+
id: "u1",
|
|
1053
|
+
name: "Ada",
|
|
1054
|
+
age: 30,
|
|
1055
|
+
legacy: true
|
|
1056
|
+
});
|
|
1057
|
+
const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
|
|
1058
|
+
await driver.migrate(removePlan);
|
|
1059
|
+
const migrated = await driver.read("users", "u1");
|
|
1060
|
+
if (migrated === void 0 || "legacy" in migrated) {
|
|
1061
|
+
await driver.close();
|
|
1062
|
+
return findingOf("migrate-column-remove", "a column.remove migration must strip the column from stored rows", {
|
|
1063
|
+
table: "users",
|
|
1064
|
+
expected: void 0,
|
|
1065
|
+
actual: migrated === void 0 ? void 0 : migrated.legacy
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
let caught;
|
|
1069
|
+
try {
|
|
1070
|
+
await driver.migrate({
|
|
1071
|
+
from: 0,
|
|
1072
|
+
to: 1,
|
|
1073
|
+
steps: [{
|
|
1074
|
+
operation: "table.remove",
|
|
1075
|
+
table: "ghost"
|
|
1076
|
+
}]
|
|
1077
|
+
});
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
caught = error;
|
|
1080
|
+
}
|
|
1081
|
+
await driver.close();
|
|
1082
|
+
if (!isDatabaseError(caught) || caught.code !== "MIGRATION") return findingOf("migrate-unknown-table", "a migration step referencing an unknown table must throw a MIGRATION DatabaseError", {
|
|
1083
|
+
table: "ghost",
|
|
1084
|
+
expected: "MIGRATION",
|
|
1085
|
+
actual: isDatabaseError(caught) ? caught.code : caught
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
check: "stream",
|
|
1091
|
+
run: async () => {
|
|
1092
|
+
const driver = factory();
|
|
1093
|
+
if (driver.stream === void 0) return void 0;
|
|
1094
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1095
|
+
for (const row of [
|
|
1096
|
+
{
|
|
1097
|
+
id: "a",
|
|
1098
|
+
name: "A",
|
|
1099
|
+
age: 10
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
id: "b",
|
|
1103
|
+
name: "B",
|
|
1104
|
+
age: 20
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
id: "c",
|
|
1108
|
+
name: "C",
|
|
1109
|
+
age: 30
|
|
1110
|
+
}
|
|
1111
|
+
]) await driver.write("users", row.id, row);
|
|
1112
|
+
const criteria = { conditions: [{
|
|
1113
|
+
column: "age",
|
|
1114
|
+
operator: "above",
|
|
1115
|
+
values: [10],
|
|
1116
|
+
connector: "and"
|
|
1117
|
+
}] };
|
|
1118
|
+
const matched = [];
|
|
1119
|
+
for await (const row of driver.stream("users", criteria)) matched.push(row);
|
|
1120
|
+
const matchedIds = matched.map((row) => row.id).sort();
|
|
1121
|
+
if (!deepEqual(matchedIds, ["b", "c"])) {
|
|
1122
|
+
await driver.close();
|
|
1123
|
+
return findingOf("stream-match", "stream must yield only condition-matching rows", {
|
|
1124
|
+
table: "users",
|
|
1125
|
+
expected: ["b", "c"],
|
|
1126
|
+
actual: matchedIds
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
const paged = [];
|
|
1130
|
+
for await (const row of driver.stream("users", {
|
|
1131
|
+
offset: 1,
|
|
1132
|
+
limit: 1
|
|
1133
|
+
})) paged.push(row);
|
|
1134
|
+
await driver.close();
|
|
1135
|
+
if (paged.length !== 1) return findingOf("stream-page", "stream must honor offset and limit", {
|
|
1136
|
+
table: "users",
|
|
1137
|
+
expected: 1,
|
|
1138
|
+
actual: paged.length
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
check: "transaction",
|
|
1144
|
+
run: async () => {
|
|
1145
|
+
const driver = factory();
|
|
1146
|
+
if (driver.transaction === void 0) return void 0;
|
|
1147
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1148
|
+
await driver.write("users", "u1", {
|
|
1149
|
+
id: "u1",
|
|
1150
|
+
name: "Ada",
|
|
1151
|
+
age: 30
|
|
1152
|
+
});
|
|
1153
|
+
const committing = await driver.transaction();
|
|
1154
|
+
await driver.write("users", "u2", {
|
|
1155
|
+
id: "u2",
|
|
1156
|
+
name: "Grace",
|
|
1157
|
+
age: 40
|
|
1158
|
+
});
|
|
1159
|
+
await committing.commit();
|
|
1160
|
+
const afterCommit = [...await driver.keys("users")].sort();
|
|
1161
|
+
if (!deepEqual(afterCommit, ["u1", "u2"])) {
|
|
1162
|
+
await driver.close();
|
|
1163
|
+
return findingOf("transaction-commit", "transaction commit must persist writes made during the scope", {
|
|
1164
|
+
table: "users",
|
|
1165
|
+
expected: ["u1", "u2"],
|
|
1166
|
+
actual: afterCommit
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
const rollingBack = await driver.transaction();
|
|
1170
|
+
await driver.write("users", "u3", {
|
|
1171
|
+
id: "u3",
|
|
1172
|
+
name: "Marie",
|
|
1173
|
+
age: 50
|
|
1174
|
+
});
|
|
1175
|
+
await rollingBack.rollback();
|
|
1176
|
+
const afterRollback = [...await driver.keys("users")].sort();
|
|
1177
|
+
await driver.close();
|
|
1178
|
+
if (!deepEqual(afterRollback, ["u1", "u2"])) return findingOf("transaction-rollback", "transaction rollback must restore pre-transaction state", {
|
|
1179
|
+
table: "users",
|
|
1180
|
+
expected: ["u1", "u2"],
|
|
1181
|
+
actual: afterRollback
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
check: "meta-stamp",
|
|
1187
|
+
run: async () => {
|
|
1188
|
+
const driver = factory();
|
|
1189
|
+
if (driver.meta === void 0 || driver.stamp === void 0) return void 0;
|
|
1190
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1191
|
+
const fresh = await driver.meta();
|
|
1192
|
+
if (fresh !== void 0) {
|
|
1193
|
+
await driver.close();
|
|
1194
|
+
return findingOf("meta-fresh", "a fresh store must report undefined meta", {
|
|
1195
|
+
expected: void 0,
|
|
1196
|
+
actual: fresh
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
const stamped = {
|
|
1200
|
+
version: 1,
|
|
1201
|
+
schema: CONFORMANCE_SCHEMA
|
|
1202
|
+
};
|
|
1203
|
+
await driver.stamp(stamped);
|
|
1204
|
+
const read = await driver.meta();
|
|
1205
|
+
await driver.close();
|
|
1206
|
+
if (read === void 0 || !deepEqual(read, stamped)) return findingOf("meta-stamp", "meta() must return exactly the last-stamped value", {
|
|
1207
|
+
expected: stamped,
|
|
1208
|
+
actual: read
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
check: "snapshot-scoped",
|
|
1214
|
+
run: async () => {
|
|
1215
|
+
const driver = factory();
|
|
1216
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1217
|
+
await driver.write("users", "u1", {
|
|
1218
|
+
id: "u1",
|
|
1219
|
+
name: "Ada",
|
|
1220
|
+
age: 30
|
|
1221
|
+
});
|
|
1222
|
+
await driver.write("posts", "p1", {
|
|
1223
|
+
slug: "p1",
|
|
1224
|
+
title: "Post"
|
|
1225
|
+
});
|
|
1226
|
+
const rollback = await driver.snapshot(["users"]);
|
|
1227
|
+
await driver.write("users", "u2", {
|
|
1228
|
+
id: "u2",
|
|
1229
|
+
name: "Grace",
|
|
1230
|
+
age: 40
|
|
1231
|
+
});
|
|
1232
|
+
await driver.write("posts", "p2", {
|
|
1233
|
+
slug: "p2",
|
|
1234
|
+
title: "Another post"
|
|
1235
|
+
});
|
|
1236
|
+
await rollback();
|
|
1237
|
+
const usersKeys = [...await driver.keys("users")];
|
|
1238
|
+
if (!deepEqual(usersKeys, ["u1"])) {
|
|
1239
|
+
await driver.close();
|
|
1240
|
+
return findingOf("snapshot-scoped-users", "a scoped snapshot must roll back only the named table", {
|
|
1241
|
+
table: "users",
|
|
1242
|
+
expected: ["u1"],
|
|
1243
|
+
actual: usersKeys
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
const postsKeys = [...await driver.keys("posts")].sort();
|
|
1247
|
+
await driver.close();
|
|
1248
|
+
if (!deepEqual(postsKeys, ["p1", "p2"])) return findingOf("snapshot-scoped-posts", "a scoped snapshot must leave an unnamed table's mutations intact", {
|
|
1249
|
+
table: "posts",
|
|
1250
|
+
expected: ["p1", "p2"],
|
|
1251
|
+
actual: postsKeys
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
];
|
|
1256
|
+
for (const phase of phases) try {
|
|
1257
|
+
const finding = await phase.run();
|
|
1258
|
+
if (finding !== void 0) yield finding;
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
yield findingOf(phase.check, error instanceof Error ? error.message : String(error), { error });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Run the driver-conformance battery, throwing on the first violated
|
|
1265
|
+
* invariant — the fail-fast entry point most callers (test setup, CI smoke
|
|
1266
|
+
* checks) want.
|
|
1267
|
+
*
|
|
1268
|
+
* @remarks
|
|
1269
|
+
* A thin driver over {@link driverFindings}: because that generator is
|
|
1270
|
+
* lazy, consuming only its first yielded value means every LATER phase
|
|
1271
|
+
* never runs — true fail-fast, not merely "report only the first". The
|
|
1272
|
+
* thrown error is byte-compatible with the historical shape: a
|
|
1273
|
+
* `CONFORMANCE` {@link DatabaseError} whose `message` is the finding's
|
|
1274
|
+
* `message` and whose `context` is `{ check, ...finding.context }`.
|
|
1275
|
+
*
|
|
1276
|
+
* @param factory - Mints a fresh, unopened driver instance (called once per phase)
|
|
1277
|
+
* @returns Nothing — resolves once every phase has passed
|
|
1278
|
+
* @throws A `CONFORMANCE` {@link DatabaseError} on the first violated invariant
|
|
1279
|
+
*
|
|
1280
|
+
* @example
|
|
1281
|
+
* ```ts
|
|
1282
|
+
* import { conformDriver, createMemoryDriver } from '@orkestrel/database'
|
|
1283
|
+
*
|
|
1284
|
+
* await conformDriver(() => createMemoryDriver()) // resolves when every invariant holds
|
|
1285
|
+
* ```
|
|
1286
|
+
*/
|
|
1287
|
+
async function conformDriver(factory) {
|
|
1288
|
+
for await (const finding of driverFindings(factory)) throw new DatabaseError("CONFORMANCE", finding.message, {
|
|
1289
|
+
check: finding.check,
|
|
1290
|
+
...finding.context
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Run the FULL driver-conformance battery and collect every violation — the
|
|
1295
|
+
* audit entry point for a driver author who wants a complete report rather
|
|
1296
|
+
* than a single fail-fast throw.
|
|
1297
|
+
*
|
|
1298
|
+
* @remarks
|
|
1299
|
+
* Drains {@link driverFindings} to completion: every phase runs regardless
|
|
1300
|
+
* of earlier violations, so a driver breaking two independent invariants
|
|
1301
|
+
* reports both. An empty array means the driver is fully conformant.
|
|
1302
|
+
*
|
|
1303
|
+
* @param factory - Mints a fresh, unopened driver instance (called once per phase)
|
|
1304
|
+
* @returns Every violated invariant found, in phase order (empty when fully conformant)
|
|
1305
|
+
*
|
|
1306
|
+
* @example
|
|
1307
|
+
* ```ts
|
|
1308
|
+
* import { auditDriver, createMemoryDriver } from '@orkestrel/database'
|
|
1309
|
+
*
|
|
1310
|
+
* const findings = await auditDriver(() => createMemoryDriver())
|
|
1311
|
+
* for (const finding of findings) console.log(`${finding.check}: ${finding.message}`)
|
|
1312
|
+
* ```
|
|
1313
|
+
*/
|
|
1314
|
+
async function auditDriver(factory) {
|
|
1315
|
+
const findings = [];
|
|
1316
|
+
for await (const finding of driverFindings(factory)) findings.push(finding);
|
|
1317
|
+
return findings;
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Generate an RFC 4122 version 4 UUID from a number source — no host crypto global.
|
|
1321
|
+
*
|
|
1322
|
+
* @remarks
|
|
1323
|
+
* Draws exactly {@link UUID_BYTE_COUNT} values from `random`, one per byte, then
|
|
1324
|
+
* forces the version (`4`) and variant (`10xx`) bits. The default source is
|
|
1325
|
+
* `Math.random` — a pure-ECMAScript intrinsic, so generation works on every host;
|
|
1326
|
+
* pass a seeded source (`seededRandom` from `@orkestrel/contract`) and reuse it
|
|
1327
|
+
* across calls for reproducible sequences in tests and fixtures — production
|
|
1328
|
+
* identifiers should keep the default source, whose engine entropy is far larger
|
|
1329
|
+
* than a 32-bit seed. Each byte is floored and masked, so a source straying
|
|
1330
|
+
* outside `[0, 1)` (negative, `>= 1`, `NaN`, `Infinity`) can never yield a
|
|
1331
|
+
* malformed UUID. Suitable as a collision-resistant record identifier — not a
|
|
1332
|
+
* cryptographic token; never use one as a secret.
|
|
1333
|
+
*
|
|
1334
|
+
* @param random - A number source returning values in the half-open range `[0, 1)` (defaults to `Math.random`)
|
|
1335
|
+
* @returns A lowercase RFC 4122 version 4 UUID
|
|
1336
|
+
*
|
|
1337
|
+
* @example
|
|
1338
|
+
* ```ts
|
|
1339
|
+
* import { generateUUID } from '@orkestrel/database'
|
|
1340
|
+
* import { seededRandom } from '@orkestrel/contract'
|
|
1341
|
+
*
|
|
1342
|
+
* generateUUID() // e.g. '9b2f7c1e-3d4a-4f6b-8e2d-5a1c0b9f8e7d'
|
|
1343
|
+
* generateUUID(seededRandom(42)) // the same UUID on every run
|
|
1344
|
+
* ```
|
|
1345
|
+
*/
|
|
1346
|
+
function generateUUID(random = Math.random) {
|
|
1347
|
+
const bytes = Array.from({ length: 16 }, () => Math.floor(random() * 256) & 255);
|
|
1348
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
1349
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
1350
|
+
const hex = bytes.map((byte) => byte.toString(16).padStart(2, "0"));
|
|
1351
|
+
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("")}`;
|
|
1352
|
+
}
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/core/Cursor.ts
|
|
1355
|
+
/**
|
|
1356
|
+
* A forward row cursor for bulk in-place mutation.
|
|
1357
|
+
*
|
|
1358
|
+
* @remarks
|
|
1359
|
+
* Iterates a snapshot of the table's keys captured when the cursor was opened,
|
|
1360
|
+
* reading each row lazily through the owning table — so a mutation made during
|
|
1361
|
+
* iteration cannot corrupt the walk, and a key removed mid-iteration is simply
|
|
1362
|
+
* skipped. `update` and `remove` act on the row at the current position.
|
|
1363
|
+
*/
|
|
1364
|
+
var Cursor = class {
|
|
1365
|
+
#table;
|
|
1366
|
+
#keys;
|
|
1367
|
+
#index = -1;
|
|
1368
|
+
#value;
|
|
1369
|
+
#closed = false;
|
|
1370
|
+
constructor(table, keys) {
|
|
1371
|
+
this.#table = table;
|
|
1372
|
+
this.#keys = keys;
|
|
1373
|
+
}
|
|
1374
|
+
get value() {
|
|
1375
|
+
return this.#value;
|
|
1376
|
+
}
|
|
1377
|
+
get index() {
|
|
1378
|
+
return this.#index;
|
|
1379
|
+
}
|
|
1380
|
+
get done() {
|
|
1381
|
+
return this.#closed || this.#index >= this.#keys.length;
|
|
1382
|
+
}
|
|
1383
|
+
async next() {
|
|
1384
|
+
if (this.#closed) return;
|
|
1385
|
+
this.#index += 1;
|
|
1386
|
+
while (this.#index < this.#keys.length) {
|
|
1387
|
+
const row = await this.#table.get(this.#keys[this.#index]);
|
|
1388
|
+
if (row !== void 0) {
|
|
1389
|
+
this.#value = row;
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
this.#index += 1;
|
|
1393
|
+
}
|
|
1394
|
+
this.#value = void 0;
|
|
1395
|
+
}
|
|
1396
|
+
async update(changes) {
|
|
1397
|
+
if (this.#closed || this.#value === void 0) return;
|
|
1398
|
+
const key = this.#keys[this.#index];
|
|
1399
|
+
await this.#table.update(key, changes);
|
|
1400
|
+
this.#value = await this.#table.get(key);
|
|
1401
|
+
}
|
|
1402
|
+
async remove() {
|
|
1403
|
+
if (this.#closed || this.#value === void 0) return;
|
|
1404
|
+
await this.#table.remove(this.#keys[this.#index]);
|
|
1405
|
+
this.#value = void 0;
|
|
1406
|
+
}
|
|
1407
|
+
close() {
|
|
1408
|
+
this.#closed = true;
|
|
1409
|
+
this.#value = void 0;
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
//#endregion
|
|
1413
|
+
//#region src/core/Clause.ts
|
|
1414
|
+
/**
|
|
1415
|
+
* A pending condition opened by a query's `where` / `and` / `or`.
|
|
1416
|
+
*
|
|
1417
|
+
* @remarks
|
|
1418
|
+
* Holds the column, the connector that will join this condition to the ones
|
|
1419
|
+
* before it, and a recorder the owning query supplies. Each operator builds the
|
|
1420
|
+
* {@link Condition}, hands it to the recorder, and returns the query — so the
|
|
1421
|
+
* fluent chain flows straight back into the builder without exposing a mutator.
|
|
1422
|
+
*/
|
|
1423
|
+
var Clause = class {
|
|
1424
|
+
#record;
|
|
1425
|
+
#column;
|
|
1426
|
+
#connector;
|
|
1427
|
+
constructor(record, column, connector) {
|
|
1428
|
+
this.#record = record;
|
|
1429
|
+
this.#column = column;
|
|
1430
|
+
this.#connector = connector;
|
|
1431
|
+
}
|
|
1432
|
+
equals(value) {
|
|
1433
|
+
return this.#apply("equals", [value]);
|
|
1434
|
+
}
|
|
1435
|
+
not(value) {
|
|
1436
|
+
return this.#apply("not", [value]);
|
|
1437
|
+
}
|
|
1438
|
+
above(value) {
|
|
1439
|
+
return this.#apply("above", [value]);
|
|
1440
|
+
}
|
|
1441
|
+
below(value) {
|
|
1442
|
+
return this.#apply("below", [value]);
|
|
1443
|
+
}
|
|
1444
|
+
from(value) {
|
|
1445
|
+
return this.#apply("from", [value]);
|
|
1446
|
+
}
|
|
1447
|
+
to(value) {
|
|
1448
|
+
return this.#apply("to", [value]);
|
|
1449
|
+
}
|
|
1450
|
+
between(lower, upper) {
|
|
1451
|
+
return this.#apply("between", [lower, upper]);
|
|
1452
|
+
}
|
|
1453
|
+
like(pattern) {
|
|
1454
|
+
return this.#apply("like", [pattern]);
|
|
1455
|
+
}
|
|
1456
|
+
glob(pattern) {
|
|
1457
|
+
return this.#apply("glob", [pattern]);
|
|
1458
|
+
}
|
|
1459
|
+
starts(prefix) {
|
|
1460
|
+
return this.#apply("starts", [prefix]);
|
|
1461
|
+
}
|
|
1462
|
+
ends(suffix) {
|
|
1463
|
+
return this.#apply("ends", [suffix]);
|
|
1464
|
+
}
|
|
1465
|
+
any(values) {
|
|
1466
|
+
return this.#apply("any", values);
|
|
1467
|
+
}
|
|
1468
|
+
none(values) {
|
|
1469
|
+
return this.#apply("none", values);
|
|
1470
|
+
}
|
|
1471
|
+
absent() {
|
|
1472
|
+
return this.#apply("absent", []);
|
|
1473
|
+
}
|
|
1474
|
+
present() {
|
|
1475
|
+
return this.#apply("present", []);
|
|
1476
|
+
}
|
|
1477
|
+
#apply(operator, values) {
|
|
1478
|
+
return this.#record({
|
|
1479
|
+
column: this.#column,
|
|
1480
|
+
operator,
|
|
1481
|
+
values,
|
|
1482
|
+
connector: this.#connector
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region src/core/Query.ts
|
|
1488
|
+
/**
|
|
1489
|
+
* A fluent query builder bound to one table.
|
|
1490
|
+
*
|
|
1491
|
+
* @remarks
|
|
1492
|
+
* Accumulates conditions, ordering, JS filters, and a page; each builder method
|
|
1493
|
+
* mutates and returns the same instance, so a chain reads as one statement. The
|
|
1494
|
+
* portable parts (conditions, order, page) compile into a {@link Criteria} the
|
|
1495
|
+
* table resolves; a `filter` predicate is applied in memory after the read and
|
|
1496
|
+
* before paging, so it composes with the rest without a backend ever seeing a
|
|
1497
|
+
* JS callback.
|
|
1498
|
+
*/
|
|
1499
|
+
var Query = class {
|
|
1500
|
+
#table;
|
|
1501
|
+
#conditions = [];
|
|
1502
|
+
#orders = [];
|
|
1503
|
+
#filters = [];
|
|
1504
|
+
#limit;
|
|
1505
|
+
#offset;
|
|
1506
|
+
constructor(table) {
|
|
1507
|
+
this.#table = table;
|
|
1508
|
+
}
|
|
1509
|
+
where(column) {
|
|
1510
|
+
return this.#clause(column, "and");
|
|
1511
|
+
}
|
|
1512
|
+
and(column) {
|
|
1513
|
+
return this.#clause(column, "and");
|
|
1514
|
+
}
|
|
1515
|
+
or(column) {
|
|
1516
|
+
return this.#clause(column, "or");
|
|
1517
|
+
}
|
|
1518
|
+
filter(predicate) {
|
|
1519
|
+
this.#filters.push(predicate);
|
|
1520
|
+
return this;
|
|
1521
|
+
}
|
|
1522
|
+
ascending(column) {
|
|
1523
|
+
this.#orders.push({
|
|
1524
|
+
column,
|
|
1525
|
+
direction: "ascending"
|
|
1526
|
+
});
|
|
1527
|
+
return this;
|
|
1528
|
+
}
|
|
1529
|
+
descending(column) {
|
|
1530
|
+
this.#orders.push({
|
|
1531
|
+
column,
|
|
1532
|
+
direction: "descending"
|
|
1533
|
+
});
|
|
1534
|
+
return this;
|
|
1535
|
+
}
|
|
1536
|
+
limit(count) {
|
|
1537
|
+
this.#limit = count;
|
|
1538
|
+
return this;
|
|
1539
|
+
}
|
|
1540
|
+
offset(count) {
|
|
1541
|
+
this.#offset = count;
|
|
1542
|
+
return this;
|
|
1543
|
+
}
|
|
1544
|
+
async all() {
|
|
1545
|
+
if (this.#filters.length === 0) return this.#table.records({
|
|
1546
|
+
conditions: this.#conditions,
|
|
1547
|
+
order: this.#orders,
|
|
1548
|
+
limit: this.#limit,
|
|
1549
|
+
offset: this.#offset
|
|
1550
|
+
});
|
|
1551
|
+
const fetched = await this.#table.records({
|
|
1552
|
+
conditions: this.#conditions,
|
|
1553
|
+
order: this.#orders
|
|
1554
|
+
});
|
|
1555
|
+
return this.#page(this.#filtered(fetched));
|
|
1556
|
+
}
|
|
1557
|
+
async first() {
|
|
1558
|
+
return (await this.all())[0];
|
|
1559
|
+
}
|
|
1560
|
+
async count() {
|
|
1561
|
+
if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
|
|
1562
|
+
const fetched = await this.#table.records({ conditions: this.#conditions });
|
|
1563
|
+
return this.#filtered(fetched).length;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Lazy per-row evaluation of this query's conditions / filters / offset /
|
|
1567
|
+
* limit.
|
|
1568
|
+
*
|
|
1569
|
+
* @remarks
|
|
1570
|
+
* `order` and its comparators are IGNORED (streaming yields unsorted, as rows
|
|
1571
|
+
* are evaluated one at a time). Same abort semantics as
|
|
1572
|
+
* `TableInterface.scan`: the signal (if any) is checked before each yield,
|
|
1573
|
+
* and breaking out early closes the underlying source.
|
|
1574
|
+
*
|
|
1575
|
+
* @param options - `signal` to cancel the iteration; checked before each yield
|
|
1576
|
+
* @returns An async iterable of matching rows
|
|
1577
|
+
*/
|
|
1578
|
+
async *stream(options) {
|
|
1579
|
+
if (this.#filters.length === 0) {
|
|
1580
|
+
yield* this.#table.scan({
|
|
1581
|
+
conditions: this.#conditions,
|
|
1582
|
+
limit: this.#limit,
|
|
1583
|
+
offset: this.#offset
|
|
1584
|
+
}, options);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const offset = this.#offset ?? 0;
|
|
1588
|
+
let matched = 0;
|
|
1589
|
+
let yielded = 0;
|
|
1590
|
+
for await (const row of this.#table.scan({ conditions: this.#conditions }, options)) {
|
|
1591
|
+
if (this.#limit !== void 0 && yielded >= this.#limit) break;
|
|
1592
|
+
let matches = true;
|
|
1593
|
+
for (const predicate of this.#filters) if (!predicate(row)) {
|
|
1594
|
+
matches = false;
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
if (!matches) continue;
|
|
1598
|
+
if (matched < offset) {
|
|
1599
|
+
matched += 1;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
matched += 1;
|
|
1603
|
+
yielded += 1;
|
|
1604
|
+
yield row;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
aggregate(operation, column) {
|
|
1608
|
+
if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
|
|
1609
|
+
return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
|
|
1610
|
+
}
|
|
1611
|
+
sum(column) {
|
|
1612
|
+
return this.aggregate("sum", column);
|
|
1613
|
+
}
|
|
1614
|
+
average(column) {
|
|
1615
|
+
return this.aggregate("average", column);
|
|
1616
|
+
}
|
|
1617
|
+
minimum(column) {
|
|
1618
|
+
return this.aggregate("minimum", column);
|
|
1619
|
+
}
|
|
1620
|
+
maximum(column) {
|
|
1621
|
+
return this.aggregate("maximum", column);
|
|
1622
|
+
}
|
|
1623
|
+
#clause(column, connector) {
|
|
1624
|
+
return new Clause((condition) => {
|
|
1625
|
+
this.#conditions.push(condition);
|
|
1626
|
+
return this;
|
|
1627
|
+
}, column, connector);
|
|
1628
|
+
}
|
|
1629
|
+
#filtered(rows) {
|
|
1630
|
+
let result = rows;
|
|
1631
|
+
for (const predicate of this.#filters) result = result.filter(predicate);
|
|
1632
|
+
return result;
|
|
1633
|
+
}
|
|
1634
|
+
#page(rows) {
|
|
1635
|
+
const offset = this.#offset ?? 0;
|
|
1636
|
+
if (offset === 0 && this.#limit === void 0) return rows;
|
|
1637
|
+
return rows.slice(offset, this.#limit === void 0 ? void 0 : offset + this.#limit);
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/core/Table.ts
|
|
1642
|
+
/**
|
|
1643
|
+
* A table — typed keyed CRUD plus fluent query and cursor access over a driver.
|
|
1644
|
+
*
|
|
1645
|
+
* @remarks
|
|
1646
|
+
* The table's contract is the load-bearing piece: writes go through `parse`
|
|
1647
|
+
* (coercing inputs and rejecting rows that don't fit with a `VALIDATION` throw),
|
|
1648
|
+
* reads come back through the contract guard (narrowing a stored {@link Row} to
|
|
1649
|
+
* the table's type — no assertion, AGENTS §1), and `contract` is exposed for
|
|
1650
|
+
* introspection and seeding. The driver only stores and scans; all querying is
|
|
1651
|
+
* the shared core engine in `helpers.ts`.
|
|
1652
|
+
*
|
|
1653
|
+
* @remarks
|
|
1654
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link TableEventMap}) carries the
|
|
1655
|
+
* per-row mutation moments — `write` (set / add / update), `remove`, `clear` — for
|
|
1656
|
+
* fire-and-forget observers (cache invalidation, sync, an audit log), ALONGSIDE the
|
|
1657
|
+
* database-level lifecycle. Events carry the affected KEY only (no value payload, to
|
|
1658
|
+
* keep fan-out lean); reads / queries / counts are not emitted. Every event is emitted
|
|
1659
|
+
* directly, strictly AFTER the driver write / delete / clear completes; the emitter
|
|
1660
|
+
* isolates a listener throw and routes it to its `error` handler (the `error` option),
|
|
1661
|
+
* so a buggy observer can never corrupt a write or perturb a transaction.
|
|
1662
|
+
*/
|
|
1663
|
+
var Table = class {
|
|
1664
|
+
#ready;
|
|
1665
|
+
#driver;
|
|
1666
|
+
#name;
|
|
1667
|
+
#key;
|
|
1668
|
+
#contract;
|
|
1669
|
+
#guard;
|
|
1670
|
+
#generate;
|
|
1671
|
+
#emitter;
|
|
1672
|
+
constructor(ready, driver, name, key, contract, generate, on, error) {
|
|
1673
|
+
this.#ready = ready;
|
|
1674
|
+
this.#driver = driver;
|
|
1675
|
+
this.#name = name;
|
|
1676
|
+
this.#key = key;
|
|
1677
|
+
this.#contract = contract;
|
|
1678
|
+
this.#guard = contract.is;
|
|
1679
|
+
this.#generate = generate;
|
|
1680
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1681
|
+
on,
|
|
1682
|
+
error
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
get emitter() {
|
|
1686
|
+
return this.#emitter;
|
|
1687
|
+
}
|
|
1688
|
+
get name() {
|
|
1689
|
+
return this.#name;
|
|
1690
|
+
}
|
|
1691
|
+
get primary() {
|
|
1692
|
+
return this.#key;
|
|
1693
|
+
}
|
|
1694
|
+
get contract() {
|
|
1695
|
+
return this.#contract;
|
|
1696
|
+
}
|
|
1697
|
+
async get(keys) {
|
|
1698
|
+
await this.#ready();
|
|
1699
|
+
if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#read(key));
|
|
1700
|
+
return this.#read(keys);
|
|
1701
|
+
}
|
|
1702
|
+
async resolve(keys) {
|
|
1703
|
+
await this.#ready();
|
|
1704
|
+
if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
|
|
1705
|
+
return this.#resolveOne(keys);
|
|
1706
|
+
}
|
|
1707
|
+
async has(keys) {
|
|
1708
|
+
await this.#ready();
|
|
1709
|
+
if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
|
|
1710
|
+
return await this.#read(keys) !== void 0;
|
|
1711
|
+
}
|
|
1712
|
+
async keys() {
|
|
1713
|
+
await this.#ready();
|
|
1714
|
+
return this.#driver.keys(this.#name);
|
|
1715
|
+
}
|
|
1716
|
+
async records(criteria, options) {
|
|
1717
|
+
checkAbort(options?.signal);
|
|
1718
|
+
await this.#ready();
|
|
1719
|
+
const source = await this.#driver.records?.(this.#name, criteria ?? {}) ?? applyCriteria(await this.#collect(), criteria);
|
|
1720
|
+
const rows = [];
|
|
1721
|
+
for (const row of source) if (this.#guard(row)) rows.push(row);
|
|
1722
|
+
return rows;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Count rows matching `criteria`'s conditions.
|
|
1726
|
+
*
|
|
1727
|
+
* @remarks
|
|
1728
|
+
* Unlike {@link records}, which narrows every row through the table's
|
|
1729
|
+
* contract guard before returning it, `count` operates on STORED rows
|
|
1730
|
+
* WITHOUT that guard (both the native `driver.count` hook and the
|
|
1731
|
+
* `filterRows`-over-`#collect()` fallback count raw storage) — so it can
|
|
1732
|
+
* exceed `(await records(criteria)).length` when storage holds rows that
|
|
1733
|
+
* no longer conform to the table's contract (legacy or migrated data).
|
|
1734
|
+
*
|
|
1735
|
+
* @param criteria - Optional conditions to filter by (paging is ignored)
|
|
1736
|
+
* @param options - `{ signal }` to abort
|
|
1737
|
+
* @returns The count of matching stored rows
|
|
1738
|
+
*/
|
|
1739
|
+
async count(criteria, options) {
|
|
1740
|
+
checkAbort(options?.signal);
|
|
1741
|
+
await this.#ready();
|
|
1742
|
+
const conditions = criteria?.conditions;
|
|
1743
|
+
const native = await this.#driver.count?.(this.#name, conditions ? { conditions } : {});
|
|
1744
|
+
if (native !== void 0) return native;
|
|
1745
|
+
return filterRows(await this.#collect(), criteria?.conditions ?? []).length;
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Compute an aggregate over `column` across rows matching `criteria`'s
|
|
1749
|
+
* conditions.
|
|
1750
|
+
*
|
|
1751
|
+
* @remarks
|
|
1752
|
+
* Like {@link count}, `aggregate` operates on STORED rows WITHOUT the
|
|
1753
|
+
* contract guard {@link records} / {@link scan} apply — a non-conforming
|
|
1754
|
+
* stored row still contributes to the computed aggregate when it matches
|
|
1755
|
+
* the conditions, even though it would never appear in `records()`'s
|
|
1756
|
+
* output.
|
|
1757
|
+
*
|
|
1758
|
+
* @param operation - The aggregate to compute
|
|
1759
|
+
* @param column - The column to aggregate
|
|
1760
|
+
* @param criteria - Optional conditions to filter by (paging is ignored)
|
|
1761
|
+
* @param options - `{ signal }` to abort
|
|
1762
|
+
* @returns The aggregate value, or `undefined` when undefined for the inputs
|
|
1763
|
+
*/
|
|
1764
|
+
async aggregate(operation, column, criteria, options) {
|
|
1765
|
+
checkAbort(options?.signal);
|
|
1766
|
+
await this.#ready();
|
|
1767
|
+
const conditions = criteria?.conditions;
|
|
1768
|
+
const filter = conditions ? { conditions } : {};
|
|
1769
|
+
const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
|
|
1770
|
+
if (native !== void 0) return native;
|
|
1771
|
+
return computeAggregate(await this.#driver.records?.(this.#name, filter) ?? filterRows(await this.#collect(), criteria?.conditions ?? []), operation, column);
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Stream the table's rows matching `criteria`, applying offset/limit paging.
|
|
1775
|
+
*
|
|
1776
|
+
* @remarks
|
|
1777
|
+
* `criteria.limit` counts rows that pass BOTH the criteria conditions AND the
|
|
1778
|
+
* table's contract guard (a stored row that fails the guard is skipped and
|
|
1779
|
+
* does not count toward `limit`) — this can differ from {@link records}'s
|
|
1780
|
+
* `limit`, which a driver's optional native `records` hook applies BEFORE
|
|
1781
|
+
* the contract guard runs, when storage holds rows that no longer conform
|
|
1782
|
+
* to the table's contract.
|
|
1783
|
+
*
|
|
1784
|
+
* @param criteria - Optional conditions plus offset/limit paging
|
|
1785
|
+
* @param options - `{ signal }` to abort mid-stream
|
|
1786
|
+
* @returns An async iterable of matching, guard-conforming rows
|
|
1787
|
+
*/
|
|
1788
|
+
async *scan(criteria, options) {
|
|
1789
|
+
checkAbort(options?.signal);
|
|
1790
|
+
await this.#ready();
|
|
1791
|
+
if (this.#driver.stream !== void 0) {
|
|
1792
|
+
for await (const row of this.#driver.stream(this.#name, criteria ?? {})) {
|
|
1793
|
+
checkAbort(options?.signal);
|
|
1794
|
+
const narrowed = this.#cast(row);
|
|
1795
|
+
if (narrowed !== void 0) yield narrowed;
|
|
1796
|
+
}
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const conditions = criteria?.conditions;
|
|
1800
|
+
const offset = criteria?.offset ?? 0;
|
|
1801
|
+
const limit = criteria?.limit;
|
|
1802
|
+
let matched = 0;
|
|
1803
|
+
let yielded = 0;
|
|
1804
|
+
for await (const row of this.#driver.scan(this.#name)) {
|
|
1805
|
+
checkAbort(options?.signal);
|
|
1806
|
+
if (limit !== void 0 && yielded >= limit) break;
|
|
1807
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
|
|
1808
|
+
if (matched < offset) {
|
|
1809
|
+
matched += 1;
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
matched += 1;
|
|
1813
|
+
const narrowed = this.#cast(row);
|
|
1814
|
+
if (narrowed !== void 0) {
|
|
1815
|
+
yielded += 1;
|
|
1816
|
+
yield narrowed;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
async set(rows, options) {
|
|
1821
|
+
checkAbort(options?.signal);
|
|
1822
|
+
await this.#ready();
|
|
1823
|
+
if ((0, _orkestrel_contract.isArray)(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
|
|
1824
|
+
return this.#put(rows, false);
|
|
1825
|
+
}
|
|
1826
|
+
async add(rows, options) {
|
|
1827
|
+
checkAbort(options?.signal);
|
|
1828
|
+
await this.#ready();
|
|
1829
|
+
if ((0, _orkestrel_contract.isArray)(rows)) return this.#each(rows, (row) => this.#put(row, true), options?.signal);
|
|
1830
|
+
return this.#put(rows, true);
|
|
1831
|
+
}
|
|
1832
|
+
async update(keys, changes, options) {
|
|
1833
|
+
checkAbort(options?.signal);
|
|
1834
|
+
await this.#ready();
|
|
1835
|
+
if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes), options?.signal);
|
|
1836
|
+
return this.#updateOne(keys, changes);
|
|
1837
|
+
}
|
|
1838
|
+
async remove(keys, options) {
|
|
1839
|
+
checkAbort(options?.signal);
|
|
1840
|
+
await this.#ready();
|
|
1841
|
+
if ((0, _orkestrel_contract.isArray)(keys)) return this.#each(keys, (key) => this.#delete(key), options?.signal);
|
|
1842
|
+
return this.#delete(keys);
|
|
1843
|
+
}
|
|
1844
|
+
async clear() {
|
|
1845
|
+
await this.#ready();
|
|
1846
|
+
await this.#driver.clear(this.#name);
|
|
1847
|
+
this.#emitter.emit("clear");
|
|
1848
|
+
}
|
|
1849
|
+
query() {
|
|
1850
|
+
return new Query(this);
|
|
1851
|
+
}
|
|
1852
|
+
async cursor() {
|
|
1853
|
+
await this.#ready();
|
|
1854
|
+
const cursor = new Cursor(this, await this.#driver.keys(this.#name));
|
|
1855
|
+
await cursor.next();
|
|
1856
|
+
return cursor;
|
|
1857
|
+
}
|
|
1858
|
+
async #each(items, operation, signal) {
|
|
1859
|
+
const results = [];
|
|
1860
|
+
for (const item of items) {
|
|
1861
|
+
checkAbort(signal);
|
|
1862
|
+
results.push(await operation(item));
|
|
1863
|
+
}
|
|
1864
|
+
return results;
|
|
1865
|
+
}
|
|
1866
|
+
async #read(key) {
|
|
1867
|
+
return this.#cast(await this.#driver.read(this.#name, key));
|
|
1868
|
+
}
|
|
1869
|
+
async #resolveOne(key) {
|
|
1870
|
+
const row = await this.#read(key);
|
|
1871
|
+
if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
|
|
1872
|
+
table: this.#name,
|
|
1873
|
+
key
|
|
1874
|
+
});
|
|
1875
|
+
return row;
|
|
1876
|
+
}
|
|
1877
|
+
async #put(row, exclusive) {
|
|
1878
|
+
const validated = this.#validate(this.#prepare(row));
|
|
1879
|
+
const key = this.#resolveKey(validated);
|
|
1880
|
+
if (exclusive && await this.#driver.read(this.#name, key) !== void 0) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${this.#name}'`, {
|
|
1881
|
+
table: this.#name,
|
|
1882
|
+
key
|
|
1883
|
+
});
|
|
1884
|
+
await this.#driver.write(this.#name, key, validated);
|
|
1885
|
+
this.#emitter.emit("write", key);
|
|
1886
|
+
return key;
|
|
1887
|
+
}
|
|
1888
|
+
async #updateOne(key, changes) {
|
|
1889
|
+
const existing = await this.#driver.read(this.#name, key);
|
|
1890
|
+
if (existing === void 0) return false;
|
|
1891
|
+
await this.#driver.write(this.#name, key, this.#validate(Object.assign({}, existing, changes)));
|
|
1892
|
+
this.#emitter.emit("write", key);
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
async #delete(key) {
|
|
1896
|
+
const removed = await this.#driver.delete(this.#name, key);
|
|
1897
|
+
if (removed) this.#emitter.emit("remove", key);
|
|
1898
|
+
return removed;
|
|
1899
|
+
}
|
|
1900
|
+
async #collect() {
|
|
1901
|
+
const rows = [];
|
|
1902
|
+
for await (const row of this.#driver.scan(this.#name)) rows.push(row);
|
|
1903
|
+
return rows;
|
|
1904
|
+
}
|
|
1905
|
+
#prepare(row) {
|
|
1906
|
+
if (!(0, _orkestrel_contract.isRecord)(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
|
|
1907
|
+
const prepared = { ...row };
|
|
1908
|
+
if (prepared[this.#key] === void 0) {
|
|
1909
|
+
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`, {
|
|
1910
|
+
table: this.#name,
|
|
1911
|
+
column: this.#key
|
|
1912
|
+
});
|
|
1913
|
+
prepared[this.#key] = this.#generate();
|
|
1914
|
+
}
|
|
1915
|
+
return prepared;
|
|
1916
|
+
}
|
|
1917
|
+
#validate(row) {
|
|
1918
|
+
const parsed = this.#contract.parse(row);
|
|
1919
|
+
if (parsed === void 0 || !(0, _orkestrel_contract.isRecord)(parsed)) throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
|
|
1920
|
+
table: this.#name,
|
|
1921
|
+
row
|
|
1922
|
+
});
|
|
1923
|
+
return parsed;
|
|
1924
|
+
}
|
|
1925
|
+
#resolveKey(row) {
|
|
1926
|
+
const key = extractKey(row, this.#key);
|
|
1927
|
+
if (key === void 0) throw new DatabaseError("VALIDATION", `Row has no usable key in column '${this.#key}'`, {
|
|
1928
|
+
table: this.#name,
|
|
1929
|
+
column: this.#key
|
|
1930
|
+
});
|
|
1931
|
+
return key;
|
|
1932
|
+
}
|
|
1933
|
+
#cast(row) {
|
|
1934
|
+
return row !== void 0 && this.#guard(row) ? row : void 0;
|
|
1935
|
+
}
|
|
1936
|
+
};
|
|
1937
|
+
//#endregion
|
|
1938
|
+
//#region src/core/Database.ts
|
|
1939
|
+
/**
|
|
1940
|
+
* A database — the ergonomic entry point over a {@link DriverInterface}.
|
|
1941
|
+
*
|
|
1942
|
+
* @remarks
|
|
1943
|
+
* Owns the driver and a `tables` shape map, connecting the driver lazily on first
|
|
1944
|
+
* use so a freshly created database is immediately usable. `table(name)` returns
|
|
1945
|
+
* a table typed by that table's shape `Infer`. `import` registers more tables and
|
|
1946
|
+
* returns a database re-typed with them over the **same** driver and storage;
|
|
1947
|
+
* `export` emits a portable {@link TableExport} per table. `transaction` snapshots
|
|
1948
|
+
* the driver, runs the scope, and rolls every table back if it throws — an
|
|
1949
|
+
* optimistic model that works uniformly across backends rather than reconciling
|
|
1950
|
+
* SQL's and IndexedDB's incompatible native transactions.
|
|
1951
|
+
*
|
|
1952
|
+
* @remarks
|
|
1953
|
+
* - **Versioned (optional).** When {@link DatabaseOptions.version} is set and the driver
|
|
1954
|
+
* implements both {@link DriverInterface.meta} and {@link DriverInterface.stamp},
|
|
1955
|
+
* `open()` reconciles the driver's persisted {@link DriverMeta} against the declared
|
|
1956
|
+
* version INSIDE the same lazy-connect chain, AFTER the `open` event fires — see
|
|
1957
|
+
* {@link DatabaseOptions.version} for the full reconciliation contract.
|
|
1958
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
|
|
1959
|
+
* connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
|
|
1960
|
+
* `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
|
|
1961
|
+
* event is emitted directly, strictly AFTER the relevant transition: `commit` only after
|
|
1962
|
+
* the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
|
|
1963
|
+
* OBSERVES the propagated error — it never swallows it (the original throw propagates
|
|
1964
|
+
* exactly as before). The emitter isolates a listener throw and routes it to its `error`
|
|
1965
|
+
* handler (the `error` option), so observation can never reorder, throw into, or corrupt
|
|
1966
|
+
* the snapshot / commit / rollback flow.
|
|
1967
|
+
*/
|
|
1968
|
+
var Database = class Database {
|
|
1969
|
+
#driver;
|
|
1970
|
+
#tables;
|
|
1971
|
+
#keys;
|
|
1972
|
+
#indexes;
|
|
1973
|
+
#name;
|
|
1974
|
+
#generate;
|
|
1975
|
+
#version;
|
|
1976
|
+
#emitter;
|
|
1977
|
+
#status = "idle";
|
|
1978
|
+
#ready;
|
|
1979
|
+
constructor(options) {
|
|
1980
|
+
this.#driver = options.driver;
|
|
1981
|
+
this.#tables = options.tables;
|
|
1982
|
+
this.#keys = options.keys ?? {};
|
|
1983
|
+
this.#indexes = options.indexes ?? {};
|
|
1984
|
+
this.#name = options.name ?? "database";
|
|
1985
|
+
this.#generate = options.key;
|
|
1986
|
+
this.#version = options.version;
|
|
1987
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1988
|
+
on: options.on,
|
|
1989
|
+
error: options.error
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
get emitter() {
|
|
1993
|
+
return this.#emitter;
|
|
1994
|
+
}
|
|
1995
|
+
get name() {
|
|
1996
|
+
return this.#name;
|
|
1997
|
+
}
|
|
1998
|
+
get status() {
|
|
1999
|
+
return this.#status;
|
|
2000
|
+
}
|
|
2001
|
+
table(name) {
|
|
2002
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2003
|
+
return this.#build(name, this.#key(name), (0, _orkestrel_contract.createContract)((0, _orkestrel_contract.objectShape)(this.#tables[name])));
|
|
2004
|
+
}
|
|
2005
|
+
import(tables, keys) {
|
|
2006
|
+
return this.#spawn(tables, {
|
|
2007
|
+
...this.#keys,
|
|
2008
|
+
...keys
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
export() {
|
|
2012
|
+
const result = {};
|
|
2013
|
+
for (const name of Object.keys(this.#tables)) {
|
|
2014
|
+
const columns = this.#tables[name];
|
|
2015
|
+
result[name] = {
|
|
2016
|
+
key: this.#key(name),
|
|
2017
|
+
columns,
|
|
2018
|
+
schema: (0, _orkestrel_contract.compileSchema)((0, _orkestrel_contract.objectShape)(columns))
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
return result;
|
|
2022
|
+
}
|
|
2023
|
+
async open() {
|
|
2024
|
+
await this.#connect();
|
|
2025
|
+
}
|
|
2026
|
+
async close() {
|
|
2027
|
+
this.#status = "closed";
|
|
2028
|
+
this.#ready = void 0;
|
|
2029
|
+
await this.#driver.close();
|
|
2030
|
+
this.#emitter.emit("close");
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Run `scope` transactionally: commit its writes on success, roll every table
|
|
2034
|
+
* back if it throws.
|
|
2035
|
+
*
|
|
2036
|
+
* @remarks
|
|
2037
|
+
* When the driver implements the optional native {@link DriverInterface.transaction}
|
|
2038
|
+
* hook, that native `commit` / `rollback` handle drives the transaction; otherwise
|
|
2039
|
+
* the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
|
|
2040
|
+
* emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
|
|
2041
|
+
* `options.signal` is checked ONCE at entry, before connecting or starting any
|
|
2042
|
+
* transactional work — an already-aborted signal throws `ABORTED` and neither the
|
|
2043
|
+
* native hook nor the snapshot floor is invoked. Nesting is unguarded and
|
|
2044
|
+
* unsupported exactly as before: this is a single-writer model, not reentrant.
|
|
2045
|
+
* On the native path, a `scope` throw rolls back via the native handle; a
|
|
2046
|
+
* native `commit` failure propagates as-is with no rollback attempt — the
|
|
2047
|
+
* engine owns transaction state after a failed COMMIT.
|
|
2048
|
+
*
|
|
2049
|
+
* @param scope - The transactional work to run
|
|
2050
|
+
* @param options - `{ signal }` to abort before the transaction starts
|
|
2051
|
+
* @returns The scope's resolved value
|
|
2052
|
+
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
|
|
2053
|
+
*/
|
|
2054
|
+
async transaction(scope, options) {
|
|
2055
|
+
checkAbort(options?.signal);
|
|
2056
|
+
await this.#connect();
|
|
2057
|
+
const native = await this.#driver.transaction?.();
|
|
2058
|
+
if (native !== void 0) {
|
|
2059
|
+
this.#emitter.emit("transaction");
|
|
2060
|
+
let value;
|
|
2061
|
+
try {
|
|
2062
|
+
value = await scope();
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
await native.rollback();
|
|
2065
|
+
this.#emitter.emit("rollback", error);
|
|
2066
|
+
throw error;
|
|
2067
|
+
}
|
|
2068
|
+
await native.commit();
|
|
2069
|
+
this.#emitter.emit("commit");
|
|
2070
|
+
return value;
|
|
2071
|
+
}
|
|
2072
|
+
const rollback = await this.#driver.snapshot();
|
|
2073
|
+
this.#emitter.emit("transaction");
|
|
2074
|
+
try {
|
|
2075
|
+
const value = await scope();
|
|
2076
|
+
this.#emitter.emit("commit");
|
|
2077
|
+
return value;
|
|
2078
|
+
} catch (error) {
|
|
2079
|
+
await rollback();
|
|
2080
|
+
this.#emitter.emit("rollback", error);
|
|
2081
|
+
throw error;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Diff `deployed` against this database's declared schema and apply the
|
|
2086
|
+
* resulting plan through the driver's optional `migrate` hook.
|
|
2087
|
+
*
|
|
2088
|
+
* @param deployed - The schema currently deployed, as {@link TableSchema}s
|
|
2089
|
+
* @param options - `{ signal }` to abort before the migration starts
|
|
2090
|
+
* @returns The applied {@link Migration} plan
|
|
2091
|
+
* @throws A `MIGRATION` {@link DatabaseError} when the driver does not
|
|
2092
|
+
* implement `migrate`, or when a step references an unknown table
|
|
2093
|
+
* (propagated from the driver)
|
|
2094
|
+
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
|
|
2095
|
+
* already fired at entry
|
|
2096
|
+
*/
|
|
2097
|
+
async migrate(deployed, options) {
|
|
2098
|
+
checkAbort(options?.signal);
|
|
2099
|
+
await this.#connect();
|
|
2100
|
+
const plan = planMigration(deployed, this.#schema());
|
|
2101
|
+
if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
|
|
2102
|
+
await this.#apply(plan);
|
|
2103
|
+
return plan;
|
|
2104
|
+
}
|
|
2105
|
+
#build(name, key, contract) {
|
|
2106
|
+
return new Table(() => this.#connect(), this.#driver, name, key, contract, this.#generate);
|
|
2107
|
+
}
|
|
2108
|
+
#spawn(tables, keys) {
|
|
2109
|
+
return new Database({
|
|
2110
|
+
driver: this.#driver,
|
|
2111
|
+
tables,
|
|
2112
|
+
keys,
|
|
2113
|
+
name: this.#name,
|
|
2114
|
+
...this.#generate === void 0 ? {} : { key: this.#generate }
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
#key(name) {
|
|
2118
|
+
return this.#keys[name] ?? "id";
|
|
2119
|
+
}
|
|
2120
|
+
#schema() {
|
|
2121
|
+
return Object.keys(this.#tables).map((name) => {
|
|
2122
|
+
const columns = this.#tables[name];
|
|
2123
|
+
return {
|
|
2124
|
+
name,
|
|
2125
|
+
primary: this.#key(name),
|
|
2126
|
+
columns: Object.keys(columns).map((column) => {
|
|
2127
|
+
const shape = columns[column];
|
|
2128
|
+
return {
|
|
2129
|
+
name: column,
|
|
2130
|
+
type: shapeToColumnType(shape),
|
|
2131
|
+
nullable: shape.type === "optional" || shape.type === "nullable"
|
|
2132
|
+
};
|
|
2133
|
+
}),
|
|
2134
|
+
indexes: this.#indexes[name] ?? []
|
|
2135
|
+
};
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
#connect() {
|
|
2139
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2140
|
+
if (this.#ready === void 0) this.#ready = this.#driver.open(this.#schema()).then(async () => {
|
|
2141
|
+
if (this.#status === "idle") this.#status = "open";
|
|
2142
|
+
this.#emitter.emit("open");
|
|
2143
|
+
await this.#reconcile();
|
|
2144
|
+
});
|
|
2145
|
+
return this.#ready;
|
|
2146
|
+
}
|
|
2147
|
+
async #reconcile() {
|
|
2148
|
+
if (this.#version === void 0 || this.#driver.meta === void 0) return;
|
|
2149
|
+
const declared = this.#schema();
|
|
2150
|
+
const meta = await this.#driver.meta();
|
|
2151
|
+
if (meta === void 0) {
|
|
2152
|
+
await this.#stamp();
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (meta.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${meta.version} is newer than declared version ${this.#version}`, {
|
|
2156
|
+
name: this.#name,
|
|
2157
|
+
stored: meta.version,
|
|
2158
|
+
declared: this.#version
|
|
2159
|
+
});
|
|
2160
|
+
if (meta.version < this.#version) {
|
|
2161
|
+
const plan = planMigration(meta.schema, declared, meta.version, this.#version);
|
|
2162
|
+
if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
|
|
2163
|
+
name: this.#name,
|
|
2164
|
+
stored: meta.version,
|
|
2165
|
+
declared: this.#version
|
|
2166
|
+
});
|
|
2167
|
+
await this.#apply(plan);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
async #apply(plan) {
|
|
2171
|
+
const native = await this.#driver.transaction?.();
|
|
2172
|
+
if (native !== void 0) {
|
|
2173
|
+
try {
|
|
2174
|
+
await this.#driver.migrate?.(plan);
|
|
2175
|
+
await this.#stamp();
|
|
2176
|
+
} catch (error) {
|
|
2177
|
+
await native.rollback();
|
|
2178
|
+
throw error;
|
|
2179
|
+
}
|
|
2180
|
+
await native.commit();
|
|
2181
|
+
this.#emitter.emit("migrate", plan);
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
await this.#driver.migrate?.(plan);
|
|
2185
|
+
await this.#stamp();
|
|
2186
|
+
this.#emitter.emit("migrate", plan);
|
|
2187
|
+
}
|
|
2188
|
+
async #stamp() {
|
|
2189
|
+
if (this.#version === void 0 || this.#driver.stamp === void 0) return;
|
|
2190
|
+
const meta = {
|
|
2191
|
+
version: this.#version,
|
|
2192
|
+
schema: this.#schema()
|
|
2193
|
+
};
|
|
2194
|
+
await this.#driver.stamp(meta);
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
//#endregion
|
|
2198
|
+
//#region src/core/drivers/MemoryDriver.ts
|
|
2199
|
+
/**
|
|
2200
|
+
* The reference {@link DriverInterface} — nested maps, no I/O.
|
|
2201
|
+
*
|
|
2202
|
+
* @remarks
|
|
2203
|
+
* The in-between made concrete: it runs identically in a browser or on a server,
|
|
2204
|
+
* so it is the storage behind tests, ephemeral caches, and any code that wants
|
|
2205
|
+
* the database API without a persistent backend. Rows are DEEP-copied (via
|
|
2206
|
+
* `structuredClone`) in and out — at `write`, `read`, `scan`, `stream`, and both
|
|
2207
|
+
* snapshot capture and restore — so a caller mutating a nested field of an input
|
|
2208
|
+
* row, a returned row, or a row mutated in place between snapshot and rollback
|
|
2209
|
+
* can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
|
|
2210
|
+
* would still share nested object/array references. `snapshot`
|
|
2211
|
+
* clones every table to give transactions an exact rollback point. `scan` and
|
|
2212
|
+
* `keys` yield in key order — sorted by the core {@link compareValues} total
|
|
2213
|
+
* order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
|
|
2214
|
+
* reads) backends honor, so an unordered read agrees across every backend rather
|
|
2215
|
+
* than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
|
|
2216
|
+
* implements the same nine methods over real storage.
|
|
2217
|
+
*/
|
|
2218
|
+
var MemoryDriver = class {
|
|
2219
|
+
#tables = /* @__PURE__ */ new Map();
|
|
2220
|
+
#meta;
|
|
2221
|
+
async open(schema) {
|
|
2222
|
+
for (const table of schema) if (!this.#tables.has(table.name)) this.#tables.set(table.name, /* @__PURE__ */ new Map());
|
|
2223
|
+
}
|
|
2224
|
+
async close() {}
|
|
2225
|
+
async read(table, key) {
|
|
2226
|
+
const row = this.#store(table).get(key);
|
|
2227
|
+
return row === void 0 ? void 0 : structuredClone(row);
|
|
2228
|
+
}
|
|
2229
|
+
async write(table, key, row) {
|
|
2230
|
+
this.#store(table).set(key, structuredClone(row));
|
|
2231
|
+
}
|
|
2232
|
+
async delete(table, key) {
|
|
2233
|
+
return this.#store(table).delete(key);
|
|
2234
|
+
}
|
|
2235
|
+
async keys(table) {
|
|
2236
|
+
return this.#ordered(table);
|
|
2237
|
+
}
|
|
2238
|
+
async *scan(table) {
|
|
2239
|
+
const store = this.#store(table);
|
|
2240
|
+
for (const key of this.#ordered(table)) {
|
|
2241
|
+
const row = store.get(key);
|
|
2242
|
+
if (row !== void 0) yield structuredClone(row);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Natively filtered lazy iteration — the {@link DriverInterface.stream} hook.
|
|
2247
|
+
*
|
|
2248
|
+
* @remarks
|
|
2249
|
+
* Iterates the table's keys in the same key order `scan` and `keys` yield
|
|
2250
|
+
* (sorted by {@link compareValues}), testing each row against
|
|
2251
|
+
* `criteria.conditions` (via {@link matchesCriteria}) before counting it
|
|
2252
|
+
* toward `offset` / `limit`. Both are applied lazily as matches are found —
|
|
2253
|
+
* `offset` matches are skipped without being yielded, and iteration stops the
|
|
2254
|
+
* instant `limit` yields have been produced, so a large table is never fully
|
|
2255
|
+
* walked for a small page. `criteria.order` is IGNORED (the same contract as
|
|
2256
|
+
* `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
|
|
2257
|
+
* order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
|
|
2258
|
+
* §11), and an unknown table mirrors `scan`'s empty-yield behavior.
|
|
2259
|
+
*
|
|
2260
|
+
* @param table - The table to stream
|
|
2261
|
+
* @param criteria - The filter / offset / limit to apply lazily
|
|
2262
|
+
*
|
|
2263
|
+
* @example
|
|
2264
|
+
* ```ts
|
|
2265
|
+
* for await (const row of driver.stream('users', { conditions, limit: 10 })) {
|
|
2266
|
+
* // one matched row at a time, in key order
|
|
2267
|
+
* }
|
|
2268
|
+
* ```
|
|
2269
|
+
*/
|
|
2270
|
+
async *stream(table, criteria) {
|
|
2271
|
+
const store = this.#store(table);
|
|
2272
|
+
const conditions = criteria.conditions;
|
|
2273
|
+
const offset = criteria.offset ?? 0;
|
|
2274
|
+
const limit = criteria.limit;
|
|
2275
|
+
let skipped = 0;
|
|
2276
|
+
let yielded = 0;
|
|
2277
|
+
for (const key of this.#ordered(table)) {
|
|
2278
|
+
if (limit !== void 0 && yielded >= limit) return;
|
|
2279
|
+
const row = store.get(key);
|
|
2280
|
+
if (row === void 0) continue;
|
|
2281
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
|
|
2282
|
+
if (skipped < offset) {
|
|
2283
|
+
skipped += 1;
|
|
2284
|
+
continue;
|
|
2285
|
+
}
|
|
2286
|
+
yield structuredClone(row);
|
|
2287
|
+
yielded += 1;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
async clear(table) {
|
|
2291
|
+
this.#store(table).clear();
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Capture the current state and return a thunk that rolls back to it.
|
|
2295
|
+
*
|
|
2296
|
+
* @remarks
|
|
2297
|
+
* `tables` omitted clones and restores the WHOLE store, byte-identical to the
|
|
2298
|
+
* prior whole-store behavior. `tables` provided clones ONLY the named tables,
|
|
2299
|
+
* and the returned thunk restores ONLY those — every other table keeps
|
|
2300
|
+
* whatever it was mutated to after the snapshot was taken.
|
|
2301
|
+
*
|
|
2302
|
+
* @param tables - The table names to scope the snapshot to; omitted captures every table
|
|
2303
|
+
* @returns A thunk that restores the captured tables
|
|
2304
|
+
*/
|
|
2305
|
+
async snapshot(tables) {
|
|
2306
|
+
if (tables === void 0) {
|
|
2307
|
+
const copy = /* @__PURE__ */ new Map();
|
|
2308
|
+
for (const [name, store] of this.#tables) {
|
|
2309
|
+
const cloned = /* @__PURE__ */ new Map();
|
|
2310
|
+
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
2311
|
+
copy.set(name, cloned);
|
|
2312
|
+
}
|
|
2313
|
+
return async () => {
|
|
2314
|
+
this.#tables.clear();
|
|
2315
|
+
for (const [name, store] of copy) {
|
|
2316
|
+
const restored = /* @__PURE__ */ new Map();
|
|
2317
|
+
for (const [key, row] of store) restored.set(key, structuredClone(row));
|
|
2318
|
+
this.#tables.set(name, restored);
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
const copy = /* @__PURE__ */ new Map();
|
|
2323
|
+
for (const name of tables) {
|
|
2324
|
+
const store = this.#tables.get(name);
|
|
2325
|
+
if (store === void 0) continue;
|
|
2326
|
+
const cloned = /* @__PURE__ */ new Map();
|
|
2327
|
+
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
2328
|
+
copy.set(name, cloned);
|
|
2329
|
+
}
|
|
2330
|
+
return async () => {
|
|
2331
|
+
for (const [name, store] of copy) {
|
|
2332
|
+
const restored = /* @__PURE__ */ new Map();
|
|
2333
|
+
for (const [key, row] of store) restored.set(key, structuredClone(row));
|
|
2334
|
+
this.#tables.set(name, restored);
|
|
2335
|
+
}
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Return the persisted {@link DriverMeta}, or `undefined` when the store has
|
|
2340
|
+
* never been stamped.
|
|
2341
|
+
*
|
|
2342
|
+
* @remarks
|
|
2343
|
+
* In-process only — the metadata lives in this instance's memory, exactly
|
|
2344
|
+
* like the rest of this driver's storage. A driver-conformance-valid
|
|
2345
|
+
* implementation of the optional `meta` / `stamp` pair.
|
|
2346
|
+
*
|
|
2347
|
+
* @returns The last-stamped {@link DriverMeta}, or `undefined`
|
|
2348
|
+
*/
|
|
2349
|
+
async meta() {
|
|
2350
|
+
return this.#meta;
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Persist `meta` verbatim for a later `meta()` to return.
|
|
2354
|
+
*
|
|
2355
|
+
* @param meta - The {@link DriverMeta} to persist
|
|
2356
|
+
*/
|
|
2357
|
+
async stamp(meta) {
|
|
2358
|
+
this.#meta = meta;
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Apply a {@link Migration} plan's steps against the in-memory store.
|
|
2362
|
+
*
|
|
2363
|
+
* @remarks
|
|
2364
|
+
* A multi-step plan applies its steps sequentially and is NOT atomic — a
|
|
2365
|
+
* failure partway through a plan leaves the earlier steps already applied.
|
|
2366
|
+
*
|
|
2367
|
+
* @param plan - The migration plan to apply
|
|
2368
|
+
*/
|
|
2369
|
+
async migrate(plan) {
|
|
2370
|
+
for (const step of plan.steps) switch (step.operation) {
|
|
2371
|
+
case "table.add":
|
|
2372
|
+
if (!this.#tables.has(step.table.name)) this.#tables.set(step.table.name, /* @__PURE__ */ new Map());
|
|
2373
|
+
break;
|
|
2374
|
+
case "table.remove":
|
|
2375
|
+
this.#require(step.table);
|
|
2376
|
+
this.#tables.delete(step.table);
|
|
2377
|
+
break;
|
|
2378
|
+
case "column.add":
|
|
2379
|
+
case "column.remove": {
|
|
2380
|
+
const store = this.#require(step.table);
|
|
2381
|
+
const rows = [...store.entries()];
|
|
2382
|
+
const migrated = migrateRows(rows.map(([, row]) => row), [step]);
|
|
2383
|
+
rows.forEach(([key], index) => store.set(key, migrated[index]));
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
case "index.add":
|
|
2387
|
+
case "index.remove":
|
|
2388
|
+
this.#require(step.table);
|
|
2389
|
+
break;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
#ordered(table) {
|
|
2393
|
+
return [...this.#store(table).keys()].sort(compareValues);
|
|
2394
|
+
}
|
|
2395
|
+
#require(table) {
|
|
2396
|
+
const store = this.#tables.get(table);
|
|
2397
|
+
if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
|
|
2398
|
+
return store;
|
|
2399
|
+
}
|
|
2400
|
+
#store(table) {
|
|
2401
|
+
let store = this.#tables.get(table);
|
|
2402
|
+
if (store === void 0) {
|
|
2403
|
+
store = /* @__PURE__ */ new Map();
|
|
2404
|
+
this.#tables.set(table, store);
|
|
2405
|
+
}
|
|
2406
|
+
return store;
|
|
2407
|
+
}
|
|
2408
|
+
};
|
|
2409
|
+
//#endregion
|
|
2410
|
+
//#region src/core/factories.ts
|
|
2411
|
+
/**
|
|
2412
|
+
* Create a database over a driver and a declared `tables` schema.
|
|
2413
|
+
*
|
|
2414
|
+
* @remarks
|
|
2415
|
+
* `tables` maps each name to its columns (a `column → shape` map); the database
|
|
2416
|
+
* wraps each in an `objectShape`, so you never write `objectShape` at the table
|
|
2417
|
+
* level. The `const` type parameter captures the literal names and columns, so
|
|
2418
|
+
* `db.table('users')` is checked against the schema and typed by `Infer` of its
|
|
2419
|
+
* columns — no annotations. Name a non-`id` primary-key column per table via the
|
|
2420
|
+
* optional `keys` map.
|
|
2421
|
+
*
|
|
2422
|
+
* @param options - The driver, the `tables` column map, optional `keys`, and an
|
|
2423
|
+
* optional `name`
|
|
2424
|
+
* @returns A typed {@link DatabaseInterface}
|
|
2425
|
+
*
|
|
2426
|
+
* @example
|
|
2427
|
+
* ```ts
|
|
2428
|
+
* import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
2429
|
+
* import { integerShape, stringShape } from '@orkestrel/contract'
|
|
2430
|
+
*
|
|
2431
|
+
* const db = createDatabase({
|
|
2432
|
+
* driver: createMemoryDriver(),
|
|
2433
|
+
* tables: {
|
|
2434
|
+
* users: { id: stringShape(), age: integerShape() },
|
|
2435
|
+
* posts: { slug: stringShape(), title: stringShape() },
|
|
2436
|
+
* },
|
|
2437
|
+
* keys: { posts: 'slug' },
|
|
2438
|
+
* })
|
|
2439
|
+
* await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
|
|
2440
|
+
* ```
|
|
2441
|
+
*/
|
|
2442
|
+
function createDatabase(options) {
|
|
2443
|
+
return new Database(options);
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Create the in-memory reference {@link DriverInterface}.
|
|
2447
|
+
*
|
|
2448
|
+
* @remarks
|
|
2449
|
+
* Backed by nested maps with no I/O — the same driver runs in a browser or on a
|
|
2450
|
+
* server, making it the natural choice for tests and ephemeral storage.
|
|
2451
|
+
*
|
|
2452
|
+
* @returns A fresh in-memory driver
|
|
2453
|
+
*/
|
|
2454
|
+
function createMemoryDriver() {
|
|
2455
|
+
return new MemoryDriver();
|
|
2456
|
+
}
|
|
2457
|
+
//#endregion
|
|
2458
|
+
exports.Clause = Clause;
|
|
2459
|
+
exports.Cursor = Cursor;
|
|
2460
|
+
exports.DEFAULT_PRIMARY = DEFAULT_PRIMARY;
|
|
2461
|
+
exports.Database = Database;
|
|
2462
|
+
exports.DatabaseError = DatabaseError;
|
|
2463
|
+
exports.MAX_PATTERN_LENGTH = MAX_PATTERN_LENGTH;
|
|
2464
|
+
exports.MemoryDriver = MemoryDriver;
|
|
2465
|
+
exports.Query = Query;
|
|
2466
|
+
exports.Table = Table;
|
|
2467
|
+
exports.UUID_BYTE_COUNT = UUID_BYTE_COUNT;
|
|
2468
|
+
exports.UUID_BYTE_RANGE = UUID_BYTE_RANGE;
|
|
2469
|
+
exports.applyCriteria = applyCriteria;
|
|
2470
|
+
exports.auditDriver = auditDriver;
|
|
2471
|
+
exports.checkAbort = checkAbort;
|
|
2472
|
+
exports.compareValues = compareValues;
|
|
2473
|
+
exports.computeAggregate = computeAggregate;
|
|
2474
|
+
exports.conformDriver = conformDriver;
|
|
2475
|
+
exports.createDatabase = createDatabase;
|
|
2476
|
+
exports.createMemoryDriver = createMemoryDriver;
|
|
2477
|
+
exports.deepEqual = deepEqual;
|
|
2478
|
+
exports.driverFindings = driverFindings;
|
|
2479
|
+
exports.extractKey = extractKey;
|
|
2480
|
+
exports.filterRows = filterRows;
|
|
2481
|
+
exports.generateUUID = generateUUID;
|
|
2482
|
+
exports.globMatch = globMatch;
|
|
2483
|
+
exports.isDatabaseError = isDatabaseError;
|
|
2484
|
+
exports.isDriverMeta = isDriverMeta;
|
|
2485
|
+
exports.likeMatch = likeMatch;
|
|
2486
|
+
exports.matchesCondition = matchesCondition;
|
|
2487
|
+
exports.matchesCriteria = matchesCriteria;
|
|
2488
|
+
exports.migrateRows = migrateRows;
|
|
2489
|
+
exports.planMigration = planMigration;
|
|
2490
|
+
exports.shapeToColumnType = shapeToColumnType;
|
|
2491
|
+
exports.sortRows = sortRows;
|
|
2492
|
+
exports.wildcardMatch = wildcardMatch;
|
|
2493
|
+
|
|
2494
|
+
//# sourceMappingURL=index.cjs.map
|