@orkestrel/database 0.0.6 → 0.0.8
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 +13 -35
- package/dist/src/browser/index.d.ts +92 -79
- package/dist/src/browser/index.js +357 -220
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +1796 -803
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +545 -706
- package/dist/src/core/index.d.ts +545 -706
- package/dist/src/core/index.js +1775 -790
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1532 -747
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +471 -566
- package/dist/src/server/index.d.ts +471 -566
- package/dist/src/server/index.js +1515 -731
- package/dist/src/server/index.js.map +1 -1
- package/package.json +9 -10
package/dist/src/core/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { cloneJSONRecord, cloneJSONValue, compileGuard, compileSchema, createContract, isArray, isRecord, isString, objectShape, parseNumber, resolveField } from "@orkestrel/contract";
|
|
2
2
|
import { Emitter } from "@orkestrel/emitter";
|
|
3
3
|
//#region src/core/constants.ts
|
|
4
4
|
/**
|
|
5
|
-
* The primary-key column assumed when {@link
|
|
5
|
+
* The primary-key column assumed when {@link PrimaryMap} does not name one.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
8
|
* `id` is the convention IndexedDB (`keyPath: 'id'`) and SQL (`id` / rowid) both
|
|
9
|
-
* lean on, so a table
|
|
9
|
+
* lean on, so a table without a `primary` override keys its rows by `id`.
|
|
10
10
|
*/
|
|
11
11
|
var DEFAULT_PRIMARY = "id";
|
|
12
12
|
/**
|
|
@@ -14,7 +14,7 @@ var DEFAULT_PRIMARY = "id";
|
|
|
14
14
|
*
|
|
15
15
|
* @remarks
|
|
16
16
|
* A ReDoS bound (AGENTS §6.5): the SA1–SA4 migration lets a model supply `list`
|
|
17
|
-
*
|
|
17
|
+
* input over the wire, so `matchesLikePattern` / `matchesGlobPattern` run attacker-controlled
|
|
18
18
|
* patterns. The matcher is the LINEAR greedy two-pointer wildcard match — never a
|
|
19
19
|
* backtracking regex (`.*`-segments-separated-by-literals against a long input is the
|
|
20
20
|
* catastrophic shape JS cannot bound without atomic groups), so it is O(value ×
|
|
@@ -23,10 +23,6 @@ var DEFAULT_PRIMARY = "id";
|
|
|
23
23
|
* `VALIDATION` {@link DatabaseError}; the cap is generous for any legitimate search.
|
|
24
24
|
*/
|
|
25
25
|
var MAX_PATTERN_LENGTH = 1024;
|
|
26
|
-
/** The number of bytes encoded by an RFC 4122 UUID. */
|
|
27
|
-
var UUID_BYTE_COUNT = 16;
|
|
28
|
-
/** The number of distinct values one UUID byte may hold. */
|
|
29
|
-
var UUID_BYTE_RANGE = 256;
|
|
30
26
|
//#endregion
|
|
31
27
|
//#region src/core/errors.ts
|
|
32
28
|
/**
|
|
@@ -36,8 +32,8 @@ var UUID_BYTE_RANGE = 256;
|
|
|
36
32
|
* Carries a {@link DatabaseErrorCode} and an optional `context` bag naming the
|
|
37
33
|
* offending table / key. Thrown for: operating on a closed database (`CLOSED`), a
|
|
38
34
|
* `resolve` miss (`NOT_FOUND`), an `add` onto an existing key (`CONFLICT`), a
|
|
39
|
-
* row that fails its table's contract (`VALIDATION`),
|
|
40
|
-
* {@link
|
|
35
|
+
* row that fails its table's contract (`VALIDATION`), an aborted operation whose
|
|
36
|
+
* {@link OperationOptions.signal} aborted (`ABORTED`, carrying `signal.reason` in
|
|
41
37
|
* `context`), an inapplicable {@link Migration} plan (`MIGRATION`), a
|
|
42
38
|
* driver that violates a {@link DriverInterface} invariant, thrown by the
|
|
43
39
|
* `conformDriver` helper (`CONFORMANCE`), and an unexpected infrastructure
|
|
@@ -74,6 +70,219 @@ function isDatabaseError(value) {
|
|
|
74
70
|
return value instanceof DatabaseError;
|
|
75
71
|
}
|
|
76
72
|
//#endregion
|
|
73
|
+
//#region src/core/validators.ts
|
|
74
|
+
/**
|
|
75
|
+
* Validate the paging fields of a portable query.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* A present `limit` or `offset` must be a finite nonnegative integer; zero is
|
|
79
|
+
* valid. Validation is deterministic (`limit` before `offset`). Non-finite
|
|
80
|
+
* values are rendered as strings in error context so JSON serialization cannot
|
|
81
|
+
* collapse `NaN` or infinity to `null`.
|
|
82
|
+
*
|
|
83
|
+
* @param input - The portable query whose paging fields to validate
|
|
84
|
+
* @throws {@link DatabaseError} `VALIDATION` when a paging field is invalid
|
|
85
|
+
*/
|
|
86
|
+
function validatePage(input) {
|
|
87
|
+
const limit = input?.limit;
|
|
88
|
+
if (limit !== void 0 && (!Number.isInteger(limit) || limit < 0)) throw new DatabaseError("VALIDATION", "Query limit must be a nonnegative integer", {
|
|
89
|
+
field: "limit",
|
|
90
|
+
value: Number.isFinite(limit) ? limit : String(limit)
|
|
91
|
+
});
|
|
92
|
+
const offset = input?.offset;
|
|
93
|
+
if (offset !== void 0 && (!Number.isInteger(offset) || offset < 0)) throw new DatabaseError("VALIDATION", "Query offset must be a nonnegative integer", {
|
|
94
|
+
field: "offset",
|
|
95
|
+
value: Number.isFinite(offset) ? offset : String(offset)
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Test whether a value is a usable database key.
|
|
100
|
+
*
|
|
101
|
+
* @param value - The value to test
|
|
102
|
+
* @returns Whether `value` is a string or finite number
|
|
103
|
+
*/
|
|
104
|
+
function isKey(value) {
|
|
105
|
+
return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Test whether a value is a portable column schema.
|
|
109
|
+
*
|
|
110
|
+
* @param value - The value to test
|
|
111
|
+
* @returns Whether `value` is a complete {@link ColumnSchema}
|
|
112
|
+
*/
|
|
113
|
+
function isColumnSchema(value) {
|
|
114
|
+
try {
|
|
115
|
+
const column = cloneJSONRecord(value);
|
|
116
|
+
const keys = Object.keys(column);
|
|
117
|
+
return keys.length === 4 && keys.includes("name") && keys.includes("storage") && keys.includes("optional") && keys.includes("nullable") && typeof column.name === "string" && column.name.length > 0 && (column.storage === "text" || column.storage === "integer" || column.storage === "real" || column.storage === "boolean" || column.storage === "json" || column.storage === "blob") && typeof column.optional === "boolean" && typeof column.nullable === "boolean";
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Test whether a value is a portable table schema.
|
|
124
|
+
*
|
|
125
|
+
* @param value - The value to test
|
|
126
|
+
* @returns Whether `value` is a complete {@link TableSchema}
|
|
127
|
+
*/
|
|
128
|
+
function isTableSchema(value) {
|
|
129
|
+
try {
|
|
130
|
+
const table = cloneJSONRecord(value);
|
|
131
|
+
const keys = Object.keys(table);
|
|
132
|
+
if (keys.length !== 4 || !keys.includes("name") || !keys.includes("primary") || !keys.includes("columns") || !keys.includes("indexes") || typeof table.name !== "string" || table.name.length === 0 || typeof table.primary !== "string" || table.primary.length === 0 || !Array.isArray(table.columns) || !Array.isArray(table.indexes) || !table.columns.every(isColumnSchema)) return false;
|
|
133
|
+
const names = table.columns.map((column) => column.name);
|
|
134
|
+
if (new Set(names).size !== names.length || !names.includes(table.primary) || !table.indexes.every((index) => Array.isArray(index) && index.length > 0 && index.every((column) => typeof column === "string" && names.includes(column)))) return false;
|
|
135
|
+
const indexes = table.indexes.map((index) => JSON.stringify(index));
|
|
136
|
+
return new Set(indexes).size === indexes.length;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Test whether a value is a complete portable driver schema.
|
|
143
|
+
*
|
|
144
|
+
* @param value - The value to test
|
|
145
|
+
* @returns Whether `value` is a table-schema collection with unique table names
|
|
146
|
+
*/
|
|
147
|
+
function isDriverSchema(value) {
|
|
148
|
+
try {
|
|
149
|
+
const schema = cloneJSONValue(value);
|
|
150
|
+
if (!Array.isArray(schema) || !schema.every(isTableSchema)) return false;
|
|
151
|
+
const names = schema.map((table) => table.name);
|
|
152
|
+
return new Set(names).size === names.length;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Test whether a value is one ordered migration step.
|
|
159
|
+
*
|
|
160
|
+
* @param value - The value to test
|
|
161
|
+
* @returns Whether `value` is a complete {@link MigrationStep}
|
|
162
|
+
*/
|
|
163
|
+
function isMigrationStep(value) {
|
|
164
|
+
try {
|
|
165
|
+
const step = cloneJSONRecord(value);
|
|
166
|
+
if (typeof step.operation !== "string") return false;
|
|
167
|
+
const keys = Object.keys(step);
|
|
168
|
+
switch (step.operation) {
|
|
169
|
+
case "table.add": return keys.length === 2 && keys.includes("operation") && keys.includes("table") && isTableSchema(step.table);
|
|
170
|
+
case "table.remove": return keys.length === 2 && keys.includes("operation") && keys.includes("table") && typeof step.table === "string" && step.table.length > 0;
|
|
171
|
+
case "column.add": return keys.length === 3 && keys.includes("operation") && keys.includes("table") && keys.includes("column") && typeof step.table === "string" && step.table.length > 0 && isColumnSchema(step.column);
|
|
172
|
+
case "column.remove": return keys.length === 3 && keys.includes("operation") && keys.includes("table") && keys.includes("column") && typeof step.table === "string" && step.table.length > 0 && typeof step.column === "string" && step.column.length > 0;
|
|
173
|
+
case "index.add":
|
|
174
|
+
case "index.remove": return keys.length === 3 && keys.includes("operation") && keys.includes("table") && keys.includes("index") && typeof step.table === "string" && step.table.length > 0 && Array.isArray(step.index) && step.index.length > 0 && step.index.every((column) => typeof column === "string" && column.length > 0);
|
|
175
|
+
default: return false;
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Test whether a value is an ordered migration plan.
|
|
183
|
+
*
|
|
184
|
+
* @param value - The value to test
|
|
185
|
+
* @returns Whether `value` is a complete {@link Migration}
|
|
186
|
+
*/
|
|
187
|
+
function isMigration(value) {
|
|
188
|
+
try {
|
|
189
|
+
const migration = cloneJSONRecord(value);
|
|
190
|
+
const keys = Object.keys(migration);
|
|
191
|
+
return keys.length === 3 && keys.includes("from") && keys.includes("to") && keys.includes("steps") && typeof migration.from === "number" && Number.isFinite(migration.from) && typeof migration.to === "number" && Number.isFinite(migration.to) && Array.isArray(migration.steps) && migration.steps.every(isMigrationStep);
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Test whether a value is persisted driver metadata.
|
|
198
|
+
*
|
|
199
|
+
* @param value - The value to test
|
|
200
|
+
* @returns Whether `value` is complete {@link DriverMetadata}
|
|
201
|
+
*/
|
|
202
|
+
function isDriverMetadata(value) {
|
|
203
|
+
try {
|
|
204
|
+
const metadata = cloneJSONRecord(value);
|
|
205
|
+
const keys = Object.keys(metadata);
|
|
206
|
+
return keys.length === 2 && keys.includes("version") && keys.includes("schema") && typeof metadata.version === "number" && Number.isFinite(metadata.version) && isDriverSchema(metadata.schema);
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Test whether a value is one atomic migration request.
|
|
213
|
+
*
|
|
214
|
+
* @param value - The value to test
|
|
215
|
+
* @returns Whether `value` is a complete {@link MigrationInput}
|
|
216
|
+
*/
|
|
217
|
+
function isMigrationInput(value) {
|
|
218
|
+
try {
|
|
219
|
+
const input = cloneJSONRecord(value);
|
|
220
|
+
const keys = Object.keys(input);
|
|
221
|
+
return (keys.length === 1 || keys.length === 2) && keys.includes("plan") && (keys.length === 1 || keys.includes("metadata")) && isMigration(input.plan) && (input.metadata === void 0 || isDriverMetadata(input.metadata));
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/core/cloners.ts
|
|
228
|
+
/**
|
|
229
|
+
* Clone unknown driver metadata into a distinct deeply frozen snapshot.
|
|
230
|
+
*
|
|
231
|
+
* @param value - Unknown metadata
|
|
232
|
+
* @returns Owned driver metadata
|
|
233
|
+
*/
|
|
234
|
+
function cloneDriverMetadata(value) {
|
|
235
|
+
try {
|
|
236
|
+
const metadata = cloneJSONRecord(value);
|
|
237
|
+
if (isDriverMetadata(metadata)) return metadata;
|
|
238
|
+
throw new DatabaseError("VALIDATION", "Driver metadata is invalid", { path: "metadata" });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof DatabaseError) throw error;
|
|
241
|
+
throw new DatabaseError("VALIDATION", "Driver metadata is invalid", {
|
|
242
|
+
path: "metadata",
|
|
243
|
+
cause: error
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Clone unknown driver schema into a distinct deeply frozen snapshot.
|
|
249
|
+
*
|
|
250
|
+
* @param value - Unknown table schema collection
|
|
251
|
+
* @returns Owned driver schema
|
|
252
|
+
*/
|
|
253
|
+
function cloneDriverSchema(value) {
|
|
254
|
+
try {
|
|
255
|
+
const schema = cloneJSONValue(value);
|
|
256
|
+
if (isDriverSchema(schema)) return schema;
|
|
257
|
+
throw new DatabaseError("VALIDATION", "Driver schema is invalid", { path: "schema" });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof DatabaseError) throw error;
|
|
260
|
+
throw new DatabaseError("VALIDATION", "Driver schema is invalid", {
|
|
261
|
+
path: "schema",
|
|
262
|
+
cause: error
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Clone unknown migration input into a distinct deeply frozen snapshot.
|
|
268
|
+
*
|
|
269
|
+
* @param value - Unknown migration input
|
|
270
|
+
* @returns Owned migration input
|
|
271
|
+
*/
|
|
272
|
+
function cloneMigrationInput(value) {
|
|
273
|
+
try {
|
|
274
|
+
const input = cloneJSONRecord(value);
|
|
275
|
+
if (isMigrationInput(input)) return input;
|
|
276
|
+
throw new DatabaseError("VALIDATION", "Migration input is invalid", { path: "migration" });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error instanceof DatabaseError) throw error;
|
|
279
|
+
throw new DatabaseError("VALIDATION", "Migration input is invalid", {
|
|
280
|
+
path: "migration",
|
|
281
|
+
cause: error
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
77
286
|
//#region src/core/helpers.ts
|
|
78
287
|
/**
|
|
79
288
|
* A total ordering over arbitrary values — the comparator behind sorting and the
|
|
@@ -106,14 +315,15 @@ function compareValues(left, right) {
|
|
|
106
315
|
*
|
|
107
316
|
* @remarks
|
|
108
317
|
* Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
|
|
109
|
-
* Arrays compare by index (same length, every element `
|
|
318
|
+
* Arrays compare by index (same length, every element `equalsValue`). Plain
|
|
110
319
|
* records (via `isRecord`) compare by their OWN enumerable keys: same key
|
|
111
320
|
* COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
|
|
112
|
-
* with a `
|
|
321
|
+
* with a `equalsValue` value — so a key present with value `undefined` is NOT
|
|
113
322
|
* equal to that key being absent (both differ in `Object.keys` membership).
|
|
114
323
|
* Anything else (functions, class instances, mismatched shapes) falls through
|
|
115
|
-
* to `false`.
|
|
116
|
-
*
|
|
324
|
+
* to `false`. Container pairs are tracked iteratively, so self-referential and
|
|
325
|
+
* mutually cyclic arrays/records terminate without consuming the call stack.
|
|
326
|
+
* Hostile proxy traps and accessors are contained as a non-match.
|
|
117
327
|
*
|
|
118
328
|
* @param left - The left value
|
|
119
329
|
* @param right - The right value
|
|
@@ -121,32 +331,67 @@ function compareValues(left, right) {
|
|
|
121
331
|
*
|
|
122
332
|
* @example
|
|
123
333
|
* ```ts
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
334
|
+
* equalsValue(Number.NaN, Number.NaN) // true
|
|
335
|
+
* equalsValue({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true
|
|
336
|
+
* equalsValue({ a: undefined }, {}) // false — present-undefined ≠ absent
|
|
127
337
|
* ```
|
|
128
338
|
*/
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
339
|
+
function equalsValue(left, right) {
|
|
340
|
+
const pending = [[left, right]];
|
|
341
|
+
const compared = /* @__PURE__ */ new WeakMap();
|
|
342
|
+
try {
|
|
343
|
+
while (pending.length > 0) {
|
|
344
|
+
const pair = pending.pop();
|
|
345
|
+
if (pair === void 0) continue;
|
|
346
|
+
const [currentLeft, currentRight] = pair;
|
|
347
|
+
if (typeof currentLeft === "number" && typeof currentRight === "number") {
|
|
348
|
+
if (Number.isNaN(currentLeft) && Number.isNaN(currentRight) || currentLeft === currentRight) continue;
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
if (currentLeft === currentRight) continue;
|
|
352
|
+
const leftArray = Array.isArray(currentLeft);
|
|
353
|
+
const rightArray = Array.isArray(currentRight);
|
|
354
|
+
const leftRecord = isRecord(currentLeft);
|
|
355
|
+
const rightRecord = isRecord(currentRight);
|
|
356
|
+
if (leftArray !== rightArray || leftRecord !== rightRecord) return false;
|
|
357
|
+
if (!leftArray && !leftRecord || !rightArray && !rightRecord) return false;
|
|
358
|
+
const prior = compared.get(currentLeft);
|
|
359
|
+
if (prior?.has(currentRight)) continue;
|
|
360
|
+
if (prior === void 0) compared.set(currentLeft, new WeakSet([currentRight]));
|
|
361
|
+
else prior.add(currentRight);
|
|
362
|
+
if (leftArray && rightArray) {
|
|
363
|
+
if (currentLeft.length !== currentRight.length) return false;
|
|
364
|
+
for (let index = 0; index < currentLeft.length; index += 1) {
|
|
365
|
+
const leftOwn = Object.hasOwn(currentLeft, index);
|
|
366
|
+
if (leftOwn !== Object.hasOwn(currentRight, index)) return false;
|
|
367
|
+
if (leftOwn) pending.push([currentLeft[index], currentRight[index]]);
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (leftRecord && rightRecord) {
|
|
372
|
+
const leftKeys = Object.keys(currentLeft);
|
|
373
|
+
const rightKeys = Object.keys(currentRight);
|
|
374
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
375
|
+
for (const key of leftKeys) {
|
|
376
|
+
if (!Object.hasOwn(currentRight, key)) return false;
|
|
377
|
+
pending.push([currentLeft[key], currentRight[key]]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
} catch {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
140
385
|
}
|
|
141
386
|
/**
|
|
142
387
|
* Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
|
|
143
|
-
* engine behind {@link
|
|
388
|
+
* engine behind {@link matchesLikePattern} and {@link matchesGlobPattern}.
|
|
144
389
|
*
|
|
145
390
|
* @remarks
|
|
146
391
|
* A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
|
|
147
392
|
* `.*` segments separated by literals, matched against a long non-matching input, blow
|
|
148
393
|
* up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
|
|
149
|
-
* (AGENTS §6.5, now that the authed server runs model-supplied `list`
|
|
394
|
+
* (AGENTS §6.5, now that the authed server runs model-supplied `list` input over the
|
|
150
395
|
* wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
|
|
151
396
|
* the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
|
|
152
397
|
* that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
|
|
@@ -169,7 +414,7 @@ function deepEqual(left, right) {
|
|
|
169
414
|
* @returns Whether `value` matches `pattern`
|
|
170
415
|
* @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
|
|
171
416
|
*/
|
|
172
|
-
function
|
|
417
|
+
function matchesWildcardPattern(value, pattern, any, single, fold) {
|
|
173
418
|
if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
|
|
174
419
|
length: pattern.length,
|
|
175
420
|
limit: MAX_PATTERN_LENGTH
|
|
@@ -198,11 +443,11 @@ function wildcardMatch(value, pattern, any, single, fold) {
|
|
|
198
443
|
while (pi < needle.length && needle[pi] === any) pi += 1;
|
|
199
444
|
return pi === needle.length;
|
|
200
445
|
}
|
|
201
|
-
function
|
|
202
|
-
return
|
|
446
|
+
function matchesLikePattern(value, pattern) {
|
|
447
|
+
return matchesWildcardPattern(value, pattern, "%", "_", true);
|
|
203
448
|
}
|
|
204
|
-
function
|
|
205
|
-
return
|
|
449
|
+
function matchesGlobPattern(value, pattern) {
|
|
450
|
+
return matchesWildcardPattern(value, pattern, "*", "?", false);
|
|
206
451
|
}
|
|
207
452
|
/**
|
|
208
453
|
* Evaluate one {@link Condition} against a row — the per-operator predicate.
|
|
@@ -212,10 +457,10 @@ function globMatch(value, pattern) {
|
|
|
212
457
|
* string is one column; an array descends a nested value) — and applies the
|
|
213
458
|
* operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
|
|
214
459
|
* {@link compareValues}, the total order; the equality family (`equals` / `not`
|
|
215
|
-
* / `any` / `none`) uses {@link
|
|
460
|
+
* / `any` / `none`) uses {@link equalsValue} — STRUCTURAL equality, not the total
|
|
216
461
|
* order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
|
|
217
462
|
* operand only matches a structurally-equal value, never every row holding any
|
|
218
|
-
* object. This is a semantics change from ranking: `
|
|
463
|
+
* object. This is a semantics change from ranking: `equalsValue` is SameValueZero
|
|
219
464
|
* on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
|
|
220
465
|
* anything under the old rank-based comparison). `like` / `glob` / `starts` /
|
|
221
466
|
* `ends` match only strings; `absent` / `present` test nullishness. Total — a
|
|
@@ -230,19 +475,19 @@ function matchesCondition(row, condition) {
|
|
|
230
475
|
const first = condition.values[0];
|
|
231
476
|
const second = condition.values[1];
|
|
232
477
|
switch (condition.operator) {
|
|
233
|
-
case "equals": return
|
|
234
|
-
case "not": return !
|
|
478
|
+
case "equals": return equalsValue(value, first);
|
|
479
|
+
case "not": return !equalsValue(value, first);
|
|
235
480
|
case "above": return compareValues(value, first) > 0;
|
|
236
481
|
case "below": return compareValues(value, first) < 0;
|
|
237
482
|
case "from": return compareValues(value, first) >= 0;
|
|
238
483
|
case "to": return compareValues(value, first) <= 0;
|
|
239
484
|
case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
|
|
240
|
-
case "like": return isString(value) && isString(first) &&
|
|
241
|
-
case "glob": return isString(value) && isString(first) &&
|
|
485
|
+
case "like": return isString(value) && isString(first) && matchesLikePattern(value, first);
|
|
486
|
+
case "glob": return isString(value) && isString(first) && matchesGlobPattern(value, first);
|
|
242
487
|
case "starts": return isString(value) && isString(first) && value.startsWith(first);
|
|
243
488
|
case "ends": return isString(value) && isString(first) && value.endsWith(first);
|
|
244
|
-
case "any": return condition.values.some((candidate) =>
|
|
245
|
-
case "none": return !condition.values.some((candidate) =>
|
|
489
|
+
case "any": return condition.values.some((candidate) => equalsValue(value, candidate));
|
|
490
|
+
case "none": return !condition.values.some((candidate) => equalsValue(value, candidate));
|
|
246
491
|
case "absent": return value === void 0 || value === null;
|
|
247
492
|
case "present": return value !== void 0 && value !== null;
|
|
248
493
|
}
|
|
@@ -260,7 +505,7 @@ function matchesCondition(row, condition) {
|
|
|
260
505
|
* @param conditions - The conditions to fold
|
|
261
506
|
* @returns Whether the row satisfies the combined conditions
|
|
262
507
|
*/
|
|
263
|
-
function
|
|
508
|
+
function matchesQuery(row, conditions) {
|
|
264
509
|
let result = true;
|
|
265
510
|
let seeded = false;
|
|
266
511
|
for (const condition of conditions) {
|
|
@@ -274,11 +519,11 @@ function matchesCriteria(row, conditions) {
|
|
|
274
519
|
}
|
|
275
520
|
/**
|
|
276
521
|
* Filter rows by a list of conditions — the shared basis for a table's count
|
|
277
|
-
* and aggregate paths (no sort/page, unlike {@link
|
|
522
|
+
* and aggregate paths (no sort/page, unlike {@link applyQuery}).
|
|
278
523
|
*
|
|
279
524
|
* @remarks
|
|
280
525
|
* An empty condition list matches every row (returned as-is, no copy). Folds
|
|
281
|
-
* each row through {@link
|
|
526
|
+
* each row through {@link matchesQuery}.
|
|
282
527
|
*
|
|
283
528
|
* @param rows - The rows to filter
|
|
284
529
|
* @param conditions - The conditions to apply (empty matches everything)
|
|
@@ -294,7 +539,7 @@ function matchesCriteria(row, conditions) {
|
|
|
294
539
|
*/
|
|
295
540
|
function filterRows(rows, conditions) {
|
|
296
541
|
if (conditions.length === 0) return rows;
|
|
297
|
-
return rows.filter((row) =>
|
|
542
|
+
return rows.filter((row) => matchesQuery(row, conditions));
|
|
298
543
|
}
|
|
299
544
|
/**
|
|
300
545
|
* Sort rows by an ordering specification, leaving the input untouched.
|
|
@@ -319,26 +564,27 @@ function sortRows(rows, order) {
|
|
|
319
564
|
return sorted;
|
|
320
565
|
}
|
|
321
566
|
/**
|
|
322
|
-
* Apply a {@link
|
|
567
|
+
* Apply a {@link QueryInput} to rows — filter, then sort, then page.
|
|
323
568
|
*
|
|
324
569
|
* @remarks
|
|
325
570
|
* The whole portable read pipeline in one place: conditions filter, `order`
|
|
326
571
|
* sorts, and `offset` / `limit` window the result. Each step is skipped when its
|
|
327
|
-
* part of the
|
|
572
|
+
* part of the input is absent. The reference {@link DriverInterface} backends
|
|
328
573
|
* lean on this rather than each re-deriving it.
|
|
329
574
|
*
|
|
330
575
|
* @param rows - The rows to process (typically a table's full `scan`)
|
|
331
|
-
* @param
|
|
576
|
+
* @param input - The read specification, or `undefined` for all rows as-is
|
|
332
577
|
* @returns The filtered, sorted, paged rows
|
|
333
578
|
*/
|
|
334
|
-
function
|
|
579
|
+
function applyQuery(rows, input) {
|
|
580
|
+
validatePage(input);
|
|
335
581
|
let result = rows;
|
|
336
|
-
const conditions =
|
|
337
|
-
if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) =>
|
|
338
|
-
const order =
|
|
582
|
+
const conditions = input?.conditions;
|
|
583
|
+
if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) => matchesQuery(row, conditions));
|
|
584
|
+
const order = input?.order;
|
|
339
585
|
if (order !== void 0 && order.length > 0) result = sortRows(result, order);
|
|
340
|
-
const offset =
|
|
341
|
-
const limit =
|
|
586
|
+
const offset = input?.offset ?? 0;
|
|
587
|
+
const limit = input?.limit;
|
|
342
588
|
if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
|
|
343
589
|
return result;
|
|
344
590
|
}
|
|
@@ -380,11 +626,24 @@ function computeAggregate(rows, operation, column) {
|
|
|
380
626
|
*/
|
|
381
627
|
function extractKey(row, column) {
|
|
382
628
|
const value = row[column];
|
|
383
|
-
|
|
384
|
-
|
|
629
|
+
return isKey(value) ? value : void 0;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Return a fresh row whose primary column is authoritatively bound to its storage key.
|
|
633
|
+
*
|
|
634
|
+
* @param row - The caller row
|
|
635
|
+
* @param primary - The primary column
|
|
636
|
+
* @param key - The authoritative storage key
|
|
637
|
+
* @returns A fresh row with the bound primary
|
|
638
|
+
*/
|
|
639
|
+
function bindRowKey(row, primary, key) {
|
|
640
|
+
return {
|
|
641
|
+
...row,
|
|
642
|
+
[primary]: key
|
|
643
|
+
};
|
|
385
644
|
}
|
|
386
645
|
/**
|
|
387
|
-
* Map a column's {@link ContractShape} to its portable {@link
|
|
646
|
+
* Map a column's {@link ContractShape} to its portable {@link ColumnStorage} — the
|
|
388
647
|
* value a `TableSchema` carries so a native backend can declare a real column.
|
|
389
648
|
*
|
|
390
649
|
* @remarks
|
|
@@ -401,13 +660,13 @@ function extractKey(row, column) {
|
|
|
401
660
|
*
|
|
402
661
|
* @example
|
|
403
662
|
* ```ts
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
663
|
+
* shapeToColumnStorage(stringShape()) // 'text'
|
|
664
|
+
* shapeToColumnStorage(integerShape()) // 'integer'
|
|
665
|
+
* shapeToColumnStorage(optionalShape(integerShape())) // 'integer'
|
|
666
|
+
* shapeToColumnStorage(objectShape({ a: stringShape() })) // 'json'
|
|
408
667
|
* ```
|
|
409
668
|
*/
|
|
410
|
-
function
|
|
669
|
+
function shapeToColumnStorage(shape) {
|
|
411
670
|
switch (shape.type) {
|
|
412
671
|
case "string": return "text";
|
|
413
672
|
case "number": return shape.integer === true ? "integer" : "real";
|
|
@@ -417,7 +676,7 @@ function shapeToColumnType(shape) {
|
|
|
417
676
|
if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
|
|
418
677
|
return "text";
|
|
419
678
|
case "optional":
|
|
420
|
-
case "nullable": return
|
|
679
|
+
case "nullable": return shapeToColumnStorage(shape.inner);
|
|
421
680
|
case "null":
|
|
422
681
|
case "object":
|
|
423
682
|
case "array":
|
|
@@ -427,48 +686,31 @@ function shapeToColumnType(shape) {
|
|
|
427
686
|
}
|
|
428
687
|
}
|
|
429
688
|
/**
|
|
430
|
-
*
|
|
431
|
-
* versioning driver's `meta()` narrows a stored (structured-clone or
|
|
432
|
-
* `JSON.parse`d) value through before trusting it, replacing the per-driver
|
|
433
|
-
* duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
|
|
434
|
-
*
|
|
435
|
-
* @remarks
|
|
436
|
-
* Total and total-recursive over the whole shape: a finite `version`, and a
|
|
437
|
-
* `schema` array of well-formed {@link TableSchema} entries — each a `name` /
|
|
438
|
-
* `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
|
|
439
|
-
* entries (a `name` string, a {@link ColumnType} literal, a `nullable`
|
|
440
|
-
* boolean), and an `indexes` array of string arrays. Anything off-shape
|
|
441
|
-
* (including a non-record) returns `false` rather than throwing.
|
|
689
|
+
* Project one contract shape into a portable column schema.
|
|
442
690
|
*
|
|
443
|
-
* @param
|
|
444
|
-
* @
|
|
445
|
-
*
|
|
446
|
-
* @example
|
|
447
|
-
* ```ts
|
|
448
|
-
* isDriverMeta({ version: 1, schema: [] }) // true
|
|
449
|
-
* isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
|
|
450
|
-
* ```
|
|
691
|
+
* @param name - The column name
|
|
692
|
+
* @param shape - The column contract shape
|
|
693
|
+
* @returns The portable storage and independent absence/null acceptance
|
|
451
694
|
*/
|
|
452
|
-
function
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
].some((type) => type === column.type) && isBoolean(column.nullable)) && isArray(table.indexes) && table.indexes.every((index) => isArray(index) && index.every((column) => isString(column))));
|
|
695
|
+
function shapeToColumnSchema(name, shape) {
|
|
696
|
+
const isColumn = compileGuard(objectShape({ value: shape }));
|
|
697
|
+
return {
|
|
698
|
+
name,
|
|
699
|
+
storage: shapeToColumnStorage(shape),
|
|
700
|
+
optional: isColumn({}),
|
|
701
|
+
nullable: isColumn({ value: null })
|
|
702
|
+
};
|
|
461
703
|
}
|
|
462
704
|
/**
|
|
463
|
-
* Throw when an {@link
|
|
464
|
-
*
|
|
705
|
+
* Throw when an {@link OperationOptions.signal | AbortSignal} has fired — the shared
|
|
706
|
+
* abort gate checked at operation boundaries and between streamed rows.
|
|
465
707
|
*
|
|
466
708
|
* @remarks
|
|
467
709
|
* A no-op for `undefined` or a live signal, so callers thread `options?.signal`
|
|
468
710
|
* straight through. When the signal has aborted, throws an `ABORTED`
|
|
469
711
|
* {@link DatabaseError} carrying the signal's `reason` in its context — callers
|
|
470
|
-
* mint signals with
|
|
471
|
-
* `new AbortController()
|
|
712
|
+
* mint signals with native APIs such as `AbortSignal.timeout(ms)` or
|
|
713
|
+
* `new AbortController()`.
|
|
472
714
|
*
|
|
473
715
|
* @param signal - The signal to check, if any
|
|
474
716
|
* @returns Nothing — returns normally while the signal is live
|
|
@@ -500,11 +742,12 @@ function checkAbort(signal) {
|
|
|
500
742
|
* `column.remove` / `index.add` / `index.remove` steps. Step order is
|
|
501
743
|
* deterministic: every `table.remove`, then every `table.add`, then each
|
|
502
744
|
* shared table's column/index changes in `declared` order. `from` / `to` are
|
|
503
|
-
* plan labels only
|
|
504
|
-
*
|
|
745
|
+
* plan labels only; versioning drivers persist and reconcile them through
|
|
746
|
+
* {@link DriverMetadata}.
|
|
505
747
|
*
|
|
506
748
|
* A column present in BOTH schemas under the same name but with a different
|
|
507
|
-
* `
|
|
749
|
+
* `storage`, `optional`, or `nullable` value throws a `MIGRATION`
|
|
750
|
+
* {@link DatabaseError} naming the
|
|
508
751
|
* table, the column, and the from→to difference — a name-only diff would
|
|
509
752
|
* otherwise silently produce NO step for the drift, and versioned
|
|
510
753
|
* reconciliation would stamp over it. There is no automatic in-place
|
|
@@ -517,42 +760,66 @@ function checkAbort(signal) {
|
|
|
517
760
|
* @param from - The plan's source version label (defaults to `0`)
|
|
518
761
|
* @param to - The plan's target version label (defaults to `1`)
|
|
519
762
|
* @returns The migration plan moving `deployed` toward `declared`
|
|
520
|
-
* @throws A `MIGRATION` {@link DatabaseError} when a shared
|
|
521
|
-
*
|
|
763
|
+
* @throws A `MIGRATION` {@link DatabaseError} when a shared table's primary or
|
|
764
|
+
* a shared column's `storage`, `optional`, or `nullable` differs, or when a
|
|
765
|
+
* required non-null column would be added to an existing table without a
|
|
766
|
+
* portable backfill
|
|
522
767
|
*
|
|
523
768
|
* @example
|
|
524
769
|
* ```ts
|
|
525
770
|
* const plan = planMigration(
|
|
526
|
-
* [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
|
|
527
|
-
* [{ name: 'users', primary: 'id', columns: [{ name: 'age',
|
|
771
|
+
* [{ name: 'users', primary: 'id', columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }], indexes: [] }],
|
|
772
|
+
* [{ name: 'users', primary: 'id', columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }, { name: 'age', storage: 'integer', optional: true, nullable: false }], indexes: [] }],
|
|
528
773
|
* )
|
|
529
774
|
* // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
|
|
530
775
|
* ```
|
|
531
776
|
*/
|
|
532
777
|
function planMigration(deployed, declared, from = 0, to = 1) {
|
|
533
|
-
|
|
534
|
-
|
|
778
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) throw new DatabaseError("MIGRATION", "Migration versions must be finite", {
|
|
779
|
+
from,
|
|
780
|
+
to
|
|
781
|
+
});
|
|
782
|
+
let beforeSchema;
|
|
783
|
+
let targetSchema;
|
|
784
|
+
try {
|
|
785
|
+
beforeSchema = normalizeDriverSchema(deployed);
|
|
786
|
+
targetSchema = normalizeDriverSchema(declared);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
throw new DatabaseError("MIGRATION", "Migration schema is invalid", { cause: error });
|
|
789
|
+
}
|
|
790
|
+
const deployedByName = new Map(beforeSchema.map((table) => [table.name, table]));
|
|
791
|
+
const declaredByName = new Map(targetSchema.map((table) => [table.name, table]));
|
|
535
792
|
const steps = [];
|
|
536
|
-
for (const table of
|
|
793
|
+
for (const table of beforeSchema) if (!declaredByName.has(table.name)) steps.push({
|
|
537
794
|
operation: "table.remove",
|
|
538
795
|
table: table.name
|
|
539
796
|
});
|
|
540
|
-
for (const table of
|
|
797
|
+
for (const table of targetSchema) if (!deployedByName.has(table.name)) steps.push({
|
|
541
798
|
operation: "table.add",
|
|
542
799
|
table
|
|
543
800
|
});
|
|
544
|
-
for (const table of
|
|
801
|
+
for (const table of targetSchema) {
|
|
545
802
|
const before = deployedByName.get(table.name);
|
|
546
803
|
if (before === void 0) continue;
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
804
|
+
if (before.primary !== table.primary) throw new DatabaseError("MIGRATION", `planMigration: primary column on table '${table.name}' changed from '${before.primary}' to '${table.primary}'`, {
|
|
805
|
+
table: table.name,
|
|
806
|
+
from: before.primary,
|
|
807
|
+
to: table.primary
|
|
808
|
+
});
|
|
809
|
+
const beforeColumnMap = new Map(before.columns.map((column) => [column.name, column]));
|
|
810
|
+
const afterColumnMap = new Map(table.columns.map((column) => [column.name, column]));
|
|
811
|
+
for (const index of before.indexes) if (!table.indexes.some((candidate) => equalsValue(candidate, index))) steps.push({
|
|
812
|
+
operation: "index.remove",
|
|
813
|
+
table: table.name,
|
|
814
|
+
index
|
|
815
|
+
});
|
|
816
|
+
for (const column of before.columns) if (!afterColumnMap.has(column.name)) steps.push({
|
|
550
817
|
operation: "column.remove",
|
|
551
818
|
table: table.name,
|
|
552
819
|
column: column.name
|
|
553
820
|
});
|
|
554
821
|
for (const column of table.columns) {
|
|
555
|
-
const previous =
|
|
822
|
+
const previous = beforeColumnMap.get(column.name);
|
|
556
823
|
if (previous === void 0) {
|
|
557
824
|
steps.push({
|
|
558
825
|
operation: "column.add",
|
|
@@ -561,35 +828,158 @@ function planMigration(deployed, declared, from = 0, to = 1) {
|
|
|
561
828
|
});
|
|
562
829
|
continue;
|
|
563
830
|
}
|
|
564
|
-
if (previous.
|
|
831
|
+
if (previous.storage !== column.storage || previous.optional !== column.optional || previous.nullable !== column.nullable) throw new DatabaseError("MIGRATION", `planMigration: column '${column.name}' on table '${table.name}' changed shape (storage ${previous.storage}→${column.storage}, optional ${previous.optional}→${column.optional}, nullable ${previous.nullable}→${column.nullable}) — in-place storage/optionality/nullability changes are not auto-migrated; add a new column, copy/convert the data, then remove the old column`, {
|
|
565
832
|
table: table.name,
|
|
566
833
|
column: column.name,
|
|
567
834
|
from: {
|
|
568
|
-
|
|
835
|
+
storage: previous.storage,
|
|
836
|
+
optional: previous.optional,
|
|
569
837
|
nullable: previous.nullable
|
|
570
838
|
},
|
|
571
839
|
to: {
|
|
572
|
-
|
|
840
|
+
storage: column.storage,
|
|
841
|
+
optional: column.optional,
|
|
573
842
|
nullable: column.nullable
|
|
574
843
|
}
|
|
575
844
|
});
|
|
576
845
|
}
|
|
577
|
-
for (const index of
|
|
578
|
-
operation: "index.remove",
|
|
579
|
-
table: table.name,
|
|
580
|
-
index
|
|
581
|
-
});
|
|
582
|
-
for (const index of table.indexes) if (!before.indexes.some((candidate) => deepEqual(candidate, index))) steps.push({
|
|
846
|
+
for (const index of table.indexes) if (!before.indexes.some((candidate) => equalsValue(candidate, index))) steps.push({
|
|
583
847
|
operation: "index.add",
|
|
584
848
|
table: table.name,
|
|
585
849
|
index
|
|
586
850
|
});
|
|
587
851
|
}
|
|
588
|
-
|
|
852
|
+
const projected = projectMigrationSchema(beforeSchema, steps);
|
|
853
|
+
if (!equalsValue(projected, targetSchema)) throw new DatabaseError("MIGRATION", "Migration plan does not project to the declared schema", {
|
|
854
|
+
projected,
|
|
855
|
+
declared: targetSchema
|
|
856
|
+
});
|
|
857
|
+
return cloneMigrationInput({ plan: {
|
|
589
858
|
from,
|
|
590
859
|
to,
|
|
591
860
|
steps
|
|
592
|
-
};
|
|
861
|
+
} }).plan;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Sequentially project migration steps over a canonical validated owned schema.
|
|
865
|
+
* Adding a required non-null column to an existing table rejects with
|
|
866
|
+
* `MIGRATION`; optional-only and nullable-only additions remain portable.
|
|
867
|
+
*
|
|
868
|
+
* @param schema - The initial deployed schema
|
|
869
|
+
* @param steps - The ordered migration steps
|
|
870
|
+
* @returns A fresh owned final schema
|
|
871
|
+
*/
|
|
872
|
+
function projectMigrationSchema(schema, steps) {
|
|
873
|
+
let owned;
|
|
874
|
+
let projectedSteps;
|
|
875
|
+
try {
|
|
876
|
+
owned = normalizeDriverSchema(schema);
|
|
877
|
+
projectedSteps = cloneMigrationInput({ plan: {
|
|
878
|
+
from: 0,
|
|
879
|
+
to: 1,
|
|
880
|
+
steps
|
|
881
|
+
} }).plan.steps;
|
|
882
|
+
} catch (error) {
|
|
883
|
+
throw new DatabaseError("MIGRATION", "Migration input is invalid", { cause: error });
|
|
884
|
+
}
|
|
885
|
+
const tables = new Map(owned.map((table) => [table.name, table]));
|
|
886
|
+
for (const step of projectedSteps) {
|
|
887
|
+
if (step.operation === "table.add") {
|
|
888
|
+
if (tables.has(step.table.name)) throw new DatabaseError("MIGRATION", `migrate: table '${step.table.name}' already exists`, { table: step.table.name });
|
|
889
|
+
tables.set(step.table.name, step.table);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const table = tables.get(step.table);
|
|
893
|
+
if (table === void 0) throw new DatabaseError("MIGRATION", `migrate: table '${step.table}' does not exist`, { table: step.table });
|
|
894
|
+
if (step.operation === "table.remove") {
|
|
895
|
+
tables.delete(step.table);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (step.operation === "column.add") {
|
|
899
|
+
if (table.columns.some((column) => column.name === step.column.name)) throw new DatabaseError("MIGRATION", `migrate: column '${step.column.name}' already exists`, {
|
|
900
|
+
table: step.table,
|
|
901
|
+
column: step.column.name
|
|
902
|
+
});
|
|
903
|
+
if (!step.column.optional && !step.column.nullable) throw new DatabaseError("MIGRATION", `migrate: required non-null column '${step.column.name}' cannot be added automatically to existing table '${step.table}'`, {
|
|
904
|
+
table: step.table,
|
|
905
|
+
column: step.column.name
|
|
906
|
+
});
|
|
907
|
+
tables.set(step.table, {
|
|
908
|
+
...table,
|
|
909
|
+
columns: [...table.columns, step.column]
|
|
910
|
+
});
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (step.operation === "column.remove") {
|
|
914
|
+
if (!table.columns.some((column) => column.name === step.column)) throw new DatabaseError("MIGRATION", `migrate: column '${step.column}' does not exist`, {
|
|
915
|
+
table: step.table,
|
|
916
|
+
column: step.column
|
|
917
|
+
});
|
|
918
|
+
if (table.primary === step.column) throw new DatabaseError("MIGRATION", "migrate: cannot remove the primary column", {
|
|
919
|
+
table: step.table,
|
|
920
|
+
column: step.column
|
|
921
|
+
});
|
|
922
|
+
if (table.indexes.some((index) => index.includes(step.column))) throw new DatabaseError("MIGRATION", "migrate: cannot remove an indexed column", {
|
|
923
|
+
table: step.table,
|
|
924
|
+
column: step.column
|
|
925
|
+
});
|
|
926
|
+
tables.set(step.table, {
|
|
927
|
+
...table,
|
|
928
|
+
columns: table.columns.filter((column) => column.name !== step.column)
|
|
929
|
+
});
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (step.operation === "index.add") {
|
|
933
|
+
if (step.index.length === 0 || step.index.some((name) => !table.columns.some((column) => column.name === name))) throw new DatabaseError("MIGRATION", "migrate: index references a missing column", {
|
|
934
|
+
table: step.table,
|
|
935
|
+
index: step.index
|
|
936
|
+
});
|
|
937
|
+
if (table.indexes.some((index) => equalsValue(index, step.index))) throw new DatabaseError("MIGRATION", "migrate: index already exists", {
|
|
938
|
+
table: step.table,
|
|
939
|
+
index: step.index
|
|
940
|
+
});
|
|
941
|
+
tables.set(step.table, {
|
|
942
|
+
...table,
|
|
943
|
+
indexes: [...table.indexes, step.index]
|
|
944
|
+
});
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (!table.indexes.some((index) => equalsValue(index, step.index))) throw new DatabaseError("MIGRATION", "migrate: index does not exist", {
|
|
948
|
+
table: step.table,
|
|
949
|
+
index: step.index
|
|
950
|
+
});
|
|
951
|
+
tables.set(step.table, {
|
|
952
|
+
...table,
|
|
953
|
+
indexes: table.indexes.filter((index) => !equalsValue(index, step.index))
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
return normalizeDriverSchema([...tables.values()]);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
throw new DatabaseError("MIGRATION", "Projected migration schema is invalid", { cause: error });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Canonicalize an unknown driver schema into a distinct deeply frozen snapshot.
|
|
964
|
+
*
|
|
965
|
+
* @remarks
|
|
966
|
+
* Table and column lists are sorted by name. The index list is sorted by the
|
|
967
|
+
* complete serialized tuple while column order inside each compound index is
|
|
968
|
+
* preserved because it carries index semantics. Validation and ownership flow
|
|
969
|
+
* through {@link cloneDriverSchema} before and after projection.
|
|
970
|
+
*
|
|
971
|
+
* @param value - Unknown driver schema
|
|
972
|
+
* @returns A validated, owned canonical schema
|
|
973
|
+
*/
|
|
974
|
+
function normalizeDriverSchema(value) {
|
|
975
|
+
const tables = cloneDriverSchema(value).map((table) => ({
|
|
976
|
+
name: table.name,
|
|
977
|
+
primary: table.primary,
|
|
978
|
+
columns: [...table.columns].sort((left, right) => compareValues(left.name, right.name)),
|
|
979
|
+
indexes: [...table.indexes].sort((left, right) => compareValues(JSON.stringify(left), JSON.stringify(right)))
|
|
980
|
+
}));
|
|
981
|
+
tables.sort((left, right) => compareValues(left.name, right.name));
|
|
982
|
+
return cloneDriverSchema(tables);
|
|
593
983
|
}
|
|
594
984
|
/**
|
|
595
985
|
* Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
|
|
@@ -637,21 +1027,24 @@ function migrateRows(rows, steps) {
|
|
|
637
1027
|
* `read` of a missing key returns `undefined`; `write`/`read` round-trip with
|
|
638
1028
|
* DEEP copy-in/copy-out isolation (mutating the caller's row — including a
|
|
639
1029
|
* NESTED field — after `write`, or a row `read` returns, never perturbs
|
|
640
|
-
* stored state) and upsert-overwrite;
|
|
1030
|
+
* stored state) and upsert-overwrite; simultaneous same-key `insert` calls
|
|
1031
|
+
* produce exactly one commit and one `CONFLICT`; pre-aborted `write`,
|
|
1032
|
+
* `insert`, and `delete` calls leave storage unchanged; `delete` returns
|
|
1033
|
+
* `true` then `false`;
|
|
641
1034
|
* `keys`/`scan` yield in ascending key order; `clear` empties only its target
|
|
642
1035
|
* table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
|
|
643
1036
|
* NESTED field mutated in place on a read-back row between capture and
|
|
644
1037
|
* restore; a scoped `snapshot(['users'])` rolls back only the named table,
|
|
645
1038
|
* leaving a concurrent mutation to another table intact; a
|
|
646
1039
|
* non-`id` primary key (`posts.slug`) round-trips; a nested-object row
|
|
647
|
-
* round-trips structurally (via {@link
|
|
1040
|
+
* round-trips structurally (via {@link equalsValue}). The optional surface is
|
|
648
1041
|
* presence-gated: when `migrate` exists, a `column.remove` plan strips the
|
|
649
1042
|
* column from stored rows and a plan referencing an unknown table throws
|
|
650
1043
|
* `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
|
|
651
1044
|
* condition-matching rows and honors `offset`/`limit`; when `transaction`
|
|
652
|
-
* exists, `commit` persists and `rollback` restores; when both `
|
|
653
|
-
* `stamp` exist, a fresh store's `
|
|
654
|
-
* `stamp({ version, schema })`, `
|
|
1045
|
+
* exists, `commit` persists and `rollback` restores; when both `metadata` and
|
|
1046
|
+
* `stamp` exist, a fresh store's `metadata()` is `undefined`, and after
|
|
1047
|
+
* `stamp({ version, schema })`, `metadata()` returns the exact stamped value.
|
|
655
1048
|
*
|
|
656
1049
|
* Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
|
|
657
1050
|
* finding built from the assertion, while an UNEXPECTED throw (a driver
|
|
@@ -683,23 +1076,27 @@ async function* driverFindings(factory) {
|
|
|
683
1076
|
columns: [
|
|
684
1077
|
{
|
|
685
1078
|
name: "id",
|
|
686
|
-
|
|
1079
|
+
storage: "text",
|
|
1080
|
+
optional: false,
|
|
687
1081
|
nullable: false
|
|
688
1082
|
},
|
|
689
1083
|
{
|
|
690
1084
|
name: "name",
|
|
691
|
-
|
|
1085
|
+
storage: "text",
|
|
1086
|
+
optional: false,
|
|
692
1087
|
nullable: false
|
|
693
1088
|
},
|
|
694
1089
|
{
|
|
695
1090
|
name: "age",
|
|
696
|
-
|
|
697
|
-
|
|
1091
|
+
storage: "integer",
|
|
1092
|
+
optional: true,
|
|
1093
|
+
nullable: false
|
|
698
1094
|
},
|
|
699
1095
|
{
|
|
700
1096
|
name: "meta",
|
|
701
|
-
|
|
702
|
-
|
|
1097
|
+
storage: "json",
|
|
1098
|
+
optional: true,
|
|
1099
|
+
nullable: false
|
|
703
1100
|
}
|
|
704
1101
|
],
|
|
705
1102
|
indexes: []
|
|
@@ -709,11 +1106,13 @@ async function* driverFindings(factory) {
|
|
|
709
1106
|
primary: "slug",
|
|
710
1107
|
columns: [{
|
|
711
1108
|
name: "slug",
|
|
712
|
-
|
|
1109
|
+
storage: "text",
|
|
1110
|
+
optional: false,
|
|
713
1111
|
nullable: false
|
|
714
1112
|
}, {
|
|
715
1113
|
name: "title",
|
|
716
|
-
|
|
1114
|
+
storage: "text",
|
|
1115
|
+
optional: false,
|
|
717
1116
|
nullable: false
|
|
718
1117
|
}],
|
|
719
1118
|
indexes: []
|
|
@@ -755,7 +1154,7 @@ async function* driverFindings(factory) {
|
|
|
755
1154
|
const driver = factory();
|
|
756
1155
|
await driver.open(CONFORMANCE_SCHEMA);
|
|
757
1156
|
const input = {
|
|
758
|
-
id: "
|
|
1157
|
+
id: "caller",
|
|
759
1158
|
name: "Ada",
|
|
760
1159
|
age: 30,
|
|
761
1160
|
meta: { tags: ["a"] }
|
|
@@ -770,7 +1169,7 @@ async function* driverFindings(factory) {
|
|
|
770
1169
|
age: 30,
|
|
771
1170
|
meta: { tags: ["a"] }
|
|
772
1171
|
};
|
|
773
|
-
if (stored === void 0 || !
|
|
1172
|
+
if (stored === void 0 || !equalsValue(stored, original)) {
|
|
774
1173
|
await driver.close();
|
|
775
1174
|
yield {
|
|
776
1175
|
check: "copy-in",
|
|
@@ -786,7 +1185,7 @@ async function* driverFindings(factory) {
|
|
|
786
1185
|
stored.name = "Mutated after read";
|
|
787
1186
|
if (isRecord(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
|
|
788
1187
|
const reread = await driver.read("users", "u1");
|
|
789
|
-
if (reread === void 0 || !
|
|
1188
|
+
if (reread === void 0 || !equalsValue(reread, original)) {
|
|
790
1189
|
await driver.close();
|
|
791
1190
|
yield {
|
|
792
1191
|
check: "copy-out",
|
|
@@ -800,19 +1199,23 @@ async function* driverFindings(factory) {
|
|
|
800
1199
|
break writeRead;
|
|
801
1200
|
}
|
|
802
1201
|
const overwrite = {
|
|
803
|
-
id: "
|
|
1202
|
+
id: "caller",
|
|
804
1203
|
name: "Ada Overwritten",
|
|
805
1204
|
age: 31
|
|
806
1205
|
};
|
|
807
1206
|
await driver.write("users", "u1", overwrite);
|
|
808
1207
|
const overwritten = await driver.read("users", "u1");
|
|
809
1208
|
await driver.close();
|
|
810
|
-
|
|
1209
|
+
const expectedOverwrite = {
|
|
1210
|
+
...overwrite,
|
|
1211
|
+
id: "u1"
|
|
1212
|
+
};
|
|
1213
|
+
if (overwritten === void 0 || !equalsValue(overwritten, expectedOverwrite)) yield {
|
|
811
1214
|
check: "upsert",
|
|
812
1215
|
message: "write must upsert-overwrite an existing key",
|
|
813
1216
|
context: {
|
|
814
1217
|
table: "users",
|
|
815
|
-
expected:
|
|
1218
|
+
expected: expectedOverwrite,
|
|
816
1219
|
actual: overwritten
|
|
817
1220
|
}
|
|
818
1221
|
};
|
|
@@ -823,6 +1226,47 @@ async function* driverFindings(factory) {
|
|
|
823
1226
|
context: { error }
|
|
824
1227
|
};
|
|
825
1228
|
}
|
|
1229
|
+
try {
|
|
1230
|
+
const driver = factory();
|
|
1231
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1232
|
+
const outcomes = await Promise.allSettled([driver.insert("users", "u1", {
|
|
1233
|
+
id: "u1",
|
|
1234
|
+
name: "Ada",
|
|
1235
|
+
age: 30
|
|
1236
|
+
}), driver.insert("users", "u1", {
|
|
1237
|
+
id: "u1",
|
|
1238
|
+
name: "Grace",
|
|
1239
|
+
age: 40
|
|
1240
|
+
})]);
|
|
1241
|
+
let fulfilled = 0;
|
|
1242
|
+
let conflicted = 0;
|
|
1243
|
+
for (const outcome of outcomes) if (outcome.status === "fulfilled") fulfilled += 1;
|
|
1244
|
+
else if (isDatabaseError(outcome.reason) && outcome.reason.code === "CONFLICT") conflicted += 1;
|
|
1245
|
+
const keys = await driver.keys("users");
|
|
1246
|
+
await driver.close();
|
|
1247
|
+
if (fulfilled !== 1 || conflicted !== 1 || !equalsValue(keys, ["u1"])) yield {
|
|
1248
|
+
check: "insert-atomic",
|
|
1249
|
+
message: "concurrent same-key inserts must produce one commit and one CONFLICT",
|
|
1250
|
+
context: {
|
|
1251
|
+
expected: {
|
|
1252
|
+
fulfilled: 1,
|
|
1253
|
+
conflicted: 1,
|
|
1254
|
+
keys: ["u1"]
|
|
1255
|
+
},
|
|
1256
|
+
actual: {
|
|
1257
|
+
fulfilled,
|
|
1258
|
+
conflicted,
|
|
1259
|
+
keys
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
yield {
|
|
1265
|
+
check: "insert-atomic",
|
|
1266
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1267
|
+
context: { error }
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
826
1270
|
deletePhase: try {
|
|
827
1271
|
const driver = factory();
|
|
828
1272
|
await driver.open(CONFORMANCE_SCHEMA);
|
|
@@ -863,6 +1307,69 @@ async function* driverFindings(factory) {
|
|
|
863
1307
|
context: { error }
|
|
864
1308
|
};
|
|
865
1309
|
}
|
|
1310
|
+
try {
|
|
1311
|
+
const driver = factory();
|
|
1312
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1313
|
+
await driver.write("users", "u1", {
|
|
1314
|
+
id: "u1",
|
|
1315
|
+
name: "Ada",
|
|
1316
|
+
age: 30
|
|
1317
|
+
});
|
|
1318
|
+
const controller = new AbortController();
|
|
1319
|
+
controller.abort("conformance abort");
|
|
1320
|
+
let writeError;
|
|
1321
|
+
let insertError;
|
|
1322
|
+
let deleteError;
|
|
1323
|
+
try {
|
|
1324
|
+
await driver.write("users", "u2", {
|
|
1325
|
+
id: "u2",
|
|
1326
|
+
name: "Grace",
|
|
1327
|
+
age: 40
|
|
1328
|
+
}, { signal: controller.signal });
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
writeError = error;
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
await driver.insert("users", "u2", {
|
|
1334
|
+
id: "u2",
|
|
1335
|
+
name: "Grace",
|
|
1336
|
+
age: 40
|
|
1337
|
+
}, { signal: controller.signal });
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
insertError = error;
|
|
1340
|
+
}
|
|
1341
|
+
try {
|
|
1342
|
+
await driver.delete("users", "u1", { signal: controller.signal });
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
deleteError = error;
|
|
1345
|
+
}
|
|
1346
|
+
const keys = await driver.keys("users");
|
|
1347
|
+
await driver.close();
|
|
1348
|
+
if (!isDatabaseError(writeError) || writeError.code !== "ABORTED" || !isDatabaseError(insertError) || insertError.code !== "ABORTED" || !isDatabaseError(deleteError) || deleteError.code !== "ABORTED" || !equalsValue(keys, ["u1"])) yield {
|
|
1349
|
+
check: "mutation-abort",
|
|
1350
|
+
message: "pre-aborted write/insert/delete must reject ABORTED without changing rows",
|
|
1351
|
+
context: {
|
|
1352
|
+
expected: {
|
|
1353
|
+
write: "ABORTED",
|
|
1354
|
+
insert: "ABORTED",
|
|
1355
|
+
delete: "ABORTED",
|
|
1356
|
+
keys: ["u1"]
|
|
1357
|
+
},
|
|
1358
|
+
actual: {
|
|
1359
|
+
write: isDatabaseError(writeError) ? writeError.code : writeError,
|
|
1360
|
+
insert: isDatabaseError(insertError) ? insertError.code : insertError,
|
|
1361
|
+
delete: isDatabaseError(deleteError) ? deleteError.code : deleteError,
|
|
1362
|
+
keys
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
yield {
|
|
1368
|
+
check: "mutation-abort",
|
|
1369
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1370
|
+
context: { error }
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
866
1373
|
orderPhase: try {
|
|
867
1374
|
const driver = factory();
|
|
868
1375
|
await driver.open(CONFORMANCE_SCHEMA);
|
|
@@ -889,7 +1396,7 @@ async function* driverFindings(factory) {
|
|
|
889
1396
|
"c"
|
|
890
1397
|
];
|
|
891
1398
|
const keys = [...await driver.keys("users")];
|
|
892
|
-
if (!
|
|
1399
|
+
if (!equalsValue(keys, expected)) {
|
|
893
1400
|
await driver.close();
|
|
894
1401
|
yield {
|
|
895
1402
|
check: "keys-order",
|
|
@@ -906,7 +1413,7 @@ async function* driverFindings(factory) {
|
|
|
906
1413
|
for await (const row of driver.scan("users")) scanned.push(row);
|
|
907
1414
|
const scannedIds = scanned.map((row) => row.id);
|
|
908
1415
|
await driver.close();
|
|
909
|
-
if (!
|
|
1416
|
+
if (!equalsValue(scannedIds, expected)) yield {
|
|
910
1417
|
check: "scan-order",
|
|
911
1418
|
message: "scan must yield rows in ascending key order",
|
|
912
1419
|
context: {
|
|
@@ -984,7 +1491,7 @@ async function* driverFindings(factory) {
|
|
|
984
1491
|
await driver.delete("users", "u1");
|
|
985
1492
|
await rollback();
|
|
986
1493
|
const keys = [...await driver.keys("users")];
|
|
987
|
-
if (!
|
|
1494
|
+
if (!equalsValue(keys, ["u1"])) {
|
|
988
1495
|
await driver.close();
|
|
989
1496
|
yield {
|
|
990
1497
|
check: "snapshot-rollback",
|
|
@@ -999,7 +1506,7 @@ async function* driverFindings(factory) {
|
|
|
999
1506
|
}
|
|
1000
1507
|
const restored = await driver.read("users", "u1");
|
|
1001
1508
|
await driver.close();
|
|
1002
|
-
if (restored === void 0 || !
|
|
1509
|
+
if (restored === void 0 || !equalsValue(restored, original)) yield {
|
|
1003
1510
|
check: "snapshot-rollback-value",
|
|
1004
1511
|
message: "snapshot rollback must restore pre-snapshot row values",
|
|
1005
1512
|
context: {
|
|
@@ -1037,7 +1544,7 @@ async function* driverFindings(factory) {
|
|
|
1037
1544
|
await rollback();
|
|
1038
1545
|
const restored = await driver.read("users", "u3");
|
|
1039
1546
|
await driver.close();
|
|
1040
|
-
if (restored === void 0 || !
|
|
1547
|
+
if (restored === void 0 || !equalsValue(restored, original)) yield {
|
|
1041
1548
|
check: "snapshot-nested",
|
|
1042
1549
|
message: "snapshot rollback must restore pre-snapshot nested field values, unaffected by a later in-place mutation of a read-back row",
|
|
1043
1550
|
context: {
|
|
@@ -1057,7 +1564,7 @@ async function* driverFindings(factory) {
|
|
|
1057
1564
|
const driver = factory();
|
|
1058
1565
|
await driver.open(CONFORMANCE_SCHEMA);
|
|
1059
1566
|
await driver.write("posts", "hello-world", {
|
|
1060
|
-
slug: "
|
|
1567
|
+
slug: "caller",
|
|
1061
1568
|
title: "Hello"
|
|
1062
1569
|
});
|
|
1063
1570
|
const post = await driver.read("posts", "hello-world");
|
|
@@ -1094,7 +1601,7 @@ async function* driverFindings(factory) {
|
|
|
1094
1601
|
await driver.write("users", "u3", nested);
|
|
1095
1602
|
const readBack = await driver.read("users", "u3");
|
|
1096
1603
|
await driver.close();
|
|
1097
|
-
if (readBack === void 0 || !
|
|
1604
|
+
if (readBack === void 0 || !equalsValue(readBack, nested)) yield {
|
|
1098
1605
|
check: "nested-roundtrip",
|
|
1099
1606
|
message: "a nested-object row must round-trip structurally",
|
|
1100
1607
|
context: {
|
|
@@ -1117,8 +1624,9 @@ async function* driverFindings(factory) {
|
|
|
1117
1624
|
...CONFORMANCE_USERS_SCHEMA,
|
|
1118
1625
|
columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
|
|
1119
1626
|
name: "legacy",
|
|
1120
|
-
|
|
1121
|
-
|
|
1627
|
+
storage: "boolean",
|
|
1628
|
+
optional: true,
|
|
1629
|
+
nullable: false
|
|
1122
1630
|
}]
|
|
1123
1631
|
};
|
|
1124
1632
|
await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
|
|
@@ -1129,7 +1637,7 @@ async function* driverFindings(factory) {
|
|
|
1129
1637
|
legacy: true
|
|
1130
1638
|
});
|
|
1131
1639
|
const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
|
|
1132
|
-
await driver.migrate(removePlan);
|
|
1640
|
+
await driver.migrate({ plan: removePlan });
|
|
1133
1641
|
const migrated = await driver.read("users", "u1");
|
|
1134
1642
|
if (migrated === void 0 || "legacy" in migrated) {
|
|
1135
1643
|
await driver.close();
|
|
@@ -1146,14 +1654,14 @@ async function* driverFindings(factory) {
|
|
|
1146
1654
|
}
|
|
1147
1655
|
let caught;
|
|
1148
1656
|
try {
|
|
1149
|
-
await driver.migrate({
|
|
1657
|
+
await driver.migrate({ plan: {
|
|
1150
1658
|
from: 0,
|
|
1151
1659
|
to: 1,
|
|
1152
1660
|
steps: [{
|
|
1153
1661
|
operation: "table.remove",
|
|
1154
1662
|
table: "ghost"
|
|
1155
1663
|
}]
|
|
1156
|
-
});
|
|
1664
|
+
} });
|
|
1157
1665
|
} catch (error) {
|
|
1158
1666
|
caught = error;
|
|
1159
1667
|
}
|
|
@@ -1195,16 +1703,16 @@ async function* driverFindings(factory) {
|
|
|
1195
1703
|
age: 30
|
|
1196
1704
|
}
|
|
1197
1705
|
]) await driver.write("users", row.id, row);
|
|
1198
|
-
const
|
|
1706
|
+
const input = { conditions: [{
|
|
1199
1707
|
column: "age",
|
|
1200
1708
|
operator: "above",
|
|
1201
1709
|
values: [10],
|
|
1202
1710
|
connector: "and"
|
|
1203
1711
|
}] };
|
|
1204
1712
|
const matched = [];
|
|
1205
|
-
for await (const row of driver.stream("users",
|
|
1713
|
+
for await (const row of driver.stream("users", input)) matched.push(row);
|
|
1206
1714
|
const matchedIds = matched.map((row) => row.id).sort();
|
|
1207
|
-
if (!
|
|
1715
|
+
if (!equalsValue(matchedIds, ["b", "c"])) {
|
|
1208
1716
|
await driver.close();
|
|
1209
1717
|
yield {
|
|
1210
1718
|
check: "stream-match",
|
|
@@ -1248,15 +1756,15 @@ async function* driverFindings(factory) {
|
|
|
1248
1756
|
name: "Ada",
|
|
1249
1757
|
age: 30
|
|
1250
1758
|
});
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1759
|
+
await driver.transaction(async (transaction) => {
|
|
1760
|
+
await transaction.write("users", "u2", {
|
|
1761
|
+
id: "u2",
|
|
1762
|
+
name: "Grace",
|
|
1763
|
+
age: 40
|
|
1764
|
+
});
|
|
1256
1765
|
});
|
|
1257
|
-
await committing.commit();
|
|
1258
1766
|
const afterCommit = [...await driver.keys("users")].sort();
|
|
1259
|
-
if (!
|
|
1767
|
+
if (!equalsValue(afterCommit, ["u1", "u2"])) {
|
|
1260
1768
|
await driver.close();
|
|
1261
1769
|
yield {
|
|
1262
1770
|
check: "transaction-commit",
|
|
@@ -1269,16 +1777,22 @@ async function* driverFindings(factory) {
|
|
|
1269
1777
|
};
|
|
1270
1778
|
break transactionPhase;
|
|
1271
1779
|
}
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1780
|
+
const reason = {};
|
|
1781
|
+
try {
|
|
1782
|
+
await driver.transaction(async (transaction) => {
|
|
1783
|
+
await transaction.write("users", "u3", {
|
|
1784
|
+
id: "u3",
|
|
1785
|
+
name: "Marie",
|
|
1786
|
+
age: 50
|
|
1787
|
+
});
|
|
1788
|
+
throw reason;
|
|
1789
|
+
});
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
if (error !== reason) throw error;
|
|
1792
|
+
}
|
|
1279
1793
|
const afterRollback = [...await driver.keys("users")].sort();
|
|
1280
1794
|
await driver.close();
|
|
1281
|
-
if (!
|
|
1795
|
+
if (!equalsValue(afterRollback, ["u1", "u2"])) yield {
|
|
1282
1796
|
check: "transaction-rollback",
|
|
1283
1797
|
message: "transaction rollback must restore pre-transaction state",
|
|
1284
1798
|
context: {
|
|
@@ -1294,33 +1808,33 @@ async function* driverFindings(factory) {
|
|
|
1294
1808
|
context: { error }
|
|
1295
1809
|
};
|
|
1296
1810
|
}
|
|
1297
|
-
|
|
1811
|
+
metadataPhase: try {
|
|
1298
1812
|
const driver = factory();
|
|
1299
|
-
if (driver.
|
|
1813
|
+
if (driver.metadata === void 0 || driver.stamp === void 0) break metadataPhase;
|
|
1300
1814
|
await driver.open(CONFORMANCE_SCHEMA);
|
|
1301
|
-
const fresh = await driver.
|
|
1815
|
+
const fresh = await driver.metadata();
|
|
1302
1816
|
if (fresh !== void 0) {
|
|
1303
1817
|
await driver.close();
|
|
1304
1818
|
yield {
|
|
1305
|
-
check: "
|
|
1306
|
-
message: "a fresh store must report undefined
|
|
1819
|
+
check: "metadata-fresh",
|
|
1820
|
+
message: "a fresh store must report undefined metadata",
|
|
1307
1821
|
context: {
|
|
1308
1822
|
expected: void 0,
|
|
1309
1823
|
actual: fresh
|
|
1310
1824
|
}
|
|
1311
1825
|
};
|
|
1312
|
-
break
|
|
1826
|
+
break metadataPhase;
|
|
1313
1827
|
}
|
|
1314
1828
|
const stamped = {
|
|
1315
1829
|
version: 1,
|
|
1316
1830
|
schema: CONFORMANCE_SCHEMA
|
|
1317
1831
|
};
|
|
1318
1832
|
await driver.stamp(stamped);
|
|
1319
|
-
const read = await driver.
|
|
1833
|
+
const read = await driver.metadata();
|
|
1320
1834
|
await driver.close();
|
|
1321
|
-
if (read === void 0 || !
|
|
1322
|
-
check: "
|
|
1323
|
-
message: "
|
|
1835
|
+
if (read === void 0 || !equalsValue(read, stamped)) yield {
|
|
1836
|
+
check: "metadata-stamp",
|
|
1837
|
+
message: "metadata() must return exactly the last-stamped value",
|
|
1324
1838
|
context: {
|
|
1325
1839
|
expected: stamped,
|
|
1326
1840
|
actual: read
|
|
@@ -1328,7 +1842,7 @@ async function* driverFindings(factory) {
|
|
|
1328
1842
|
};
|
|
1329
1843
|
} catch (error) {
|
|
1330
1844
|
yield {
|
|
1331
|
-
check: "
|
|
1845
|
+
check: "metadata-stamp",
|
|
1332
1846
|
message: error instanceof Error ? error.message : String(error),
|
|
1333
1847
|
context: { error }
|
|
1334
1848
|
};
|
|
@@ -1357,7 +1871,7 @@ async function* driverFindings(factory) {
|
|
|
1357
1871
|
});
|
|
1358
1872
|
await rollback();
|
|
1359
1873
|
const usersKeys = [...await driver.keys("users")];
|
|
1360
|
-
if (!
|
|
1874
|
+
if (!equalsValue(usersKeys, ["u1"])) {
|
|
1361
1875
|
await driver.close();
|
|
1362
1876
|
yield {
|
|
1363
1877
|
check: "snapshot-scoped-users",
|
|
@@ -1372,7 +1886,7 @@ async function* driverFindings(factory) {
|
|
|
1372
1886
|
}
|
|
1373
1887
|
const postsKeys = [...await driver.keys("posts")].sort();
|
|
1374
1888
|
await driver.close();
|
|
1375
|
-
if (!
|
|
1889
|
+
if (!equalsValue(postsKeys, ["p1", "p2"])) yield {
|
|
1376
1890
|
check: "snapshot-scoped-posts",
|
|
1377
1891
|
message: "a scoped snapshot must leave an unnamed table's mutations intact",
|
|
1378
1892
|
context: {
|
|
@@ -1445,62 +1959,451 @@ async function auditDriver(factory) {
|
|
|
1445
1959
|
for await (const finding of driverFindings(factory)) findings.push(finding);
|
|
1446
1960
|
return findings;
|
|
1447
1961
|
}
|
|
1448
|
-
/**
|
|
1449
|
-
* Generate an RFC 4122 version 4 UUID from a number source — no host crypto global.
|
|
1450
|
-
*
|
|
1451
|
-
* @remarks
|
|
1452
|
-
* Draws exactly {@link UUID_BYTE_COUNT} values from `random`, one per byte, then
|
|
1453
|
-
* forces the version (`4`) and variant (`10xx`) bits. The default source is
|
|
1454
|
-
* `Math.random` — a pure-ECMAScript intrinsic, so generation works on every host;
|
|
1455
|
-
* pass a seeded source (`seededRandom` from `@orkestrel/contract`) and reuse it
|
|
1456
|
-
* across calls for reproducible sequences in tests and fixtures — production
|
|
1457
|
-
* identifiers should keep the default source, whose engine entropy is far larger
|
|
1458
|
-
* than a 32-bit seed. Each byte is floored and masked, so a source straying
|
|
1459
|
-
* outside `[0, 1)` (negative, `>= 1`, `NaN`, `Infinity`) can never yield a
|
|
1460
|
-
* malformed UUID. Suitable as a collision-resistant record identifier — not a
|
|
1461
|
-
* cryptographic token; never use one as a secret.
|
|
1462
|
-
*
|
|
1463
|
-
* @param random - A number source returning values in the half-open range `[0, 1)` (defaults to `Math.random`)
|
|
1464
|
-
* @returns A lowercase RFC 4122 version 4 UUID
|
|
1465
|
-
*
|
|
1466
|
-
* @example
|
|
1467
|
-
* ```ts
|
|
1468
|
-
* import { generateUUID } from '@orkestrel/database'
|
|
1469
|
-
* import { seededRandom } from '@orkestrel/contract'
|
|
1470
|
-
*
|
|
1471
|
-
* generateUUID() // e.g. '9b2f7c1e-3d4a-4f6b-8e2d-5a1c0b9f8e7d'
|
|
1472
|
-
* generateUUID(seededRandom(42)) // the same UUID on every run
|
|
1473
|
-
* ```
|
|
1474
|
-
*/
|
|
1475
|
-
function generateUUID(random = Math.random) {
|
|
1476
|
-
const hex = Array.from({ length: 16 }, (_, index) => {
|
|
1477
|
-
const byte = Math.floor(random() * 256) & 255;
|
|
1478
|
-
if (index === 6) return byte & 15 | 64;
|
|
1479
|
-
if (index === 8) return byte & 63 | 128;
|
|
1480
|
-
return byte;
|
|
1481
|
-
}).map((byte) => byte.toString(16).padStart(2, "0"));
|
|
1482
|
-
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("")}`;
|
|
1483
|
-
}
|
|
1484
1962
|
//#endregion
|
|
1485
|
-
//#region src/core/
|
|
1963
|
+
//#region src/core/TransactionIterator.ts
|
|
1486
1964
|
/**
|
|
1487
|
-
*
|
|
1965
|
+
* The internal continuation boundary for one transaction-scoped async iterable.
|
|
1488
1966
|
*
|
|
1489
1967
|
* @remarks
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
* skipped. `update` and `remove` act on the row at the current position.
|
|
1968
|
+
* Each active continuation enters the owning transaction ledger independently,
|
|
1969
|
+
* so an idle iterator never pins settlement. A continuation requested after
|
|
1970
|
+
* admission closes rejects while still attempting source cleanup exactly once.
|
|
1494
1971
|
*/
|
|
1495
|
-
var
|
|
1496
|
-
#
|
|
1497
|
-
#
|
|
1498
|
-
#
|
|
1972
|
+
var TransactionIterator = class {
|
|
1973
|
+
#source;
|
|
1974
|
+
#scope;
|
|
1975
|
+
#cleaned = false;
|
|
1976
|
+
constructor(source, scope) {
|
|
1977
|
+
this.#source = source[Symbol.asyncIterator]();
|
|
1978
|
+
this.#scope = scope;
|
|
1979
|
+
}
|
|
1980
|
+
[Symbol.asyncIterator]() {
|
|
1981
|
+
return this;
|
|
1982
|
+
}
|
|
1983
|
+
next() {
|
|
1984
|
+
return this.#continue(() => this.#next());
|
|
1985
|
+
}
|
|
1986
|
+
return() {
|
|
1987
|
+
return this.#continue(() => this.#return());
|
|
1988
|
+
}
|
|
1989
|
+
throw(error) {
|
|
1990
|
+
return this.#continue(() => this.#throw(error));
|
|
1991
|
+
}
|
|
1992
|
+
async #next() {
|
|
1993
|
+
if (this.#cleaned) return {
|
|
1994
|
+
done: true,
|
|
1995
|
+
value: void 0
|
|
1996
|
+
};
|
|
1997
|
+
const result = await this.#source.next();
|
|
1998
|
+
if (result.done === true) this.#cleaned = true;
|
|
1999
|
+
return result;
|
|
2000
|
+
}
|
|
2001
|
+
async #return() {
|
|
2002
|
+
if (this.#cleaned || this.#source.return === void 0) {
|
|
2003
|
+
this.#cleaned = true;
|
|
2004
|
+
return {
|
|
2005
|
+
done: true,
|
|
2006
|
+
value: void 0
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
this.#cleaned = true;
|
|
2010
|
+
return this.#source.return();
|
|
2011
|
+
}
|
|
2012
|
+
async #throw(error) {
|
|
2013
|
+
if (this.#source.throw !== void 0) {
|
|
2014
|
+
const result = await this.#source.throw(error);
|
|
2015
|
+
if (result.done === true) this.#cleaned = true;
|
|
2016
|
+
return result;
|
|
2017
|
+
}
|
|
2018
|
+
try {
|
|
2019
|
+
await this.#return();
|
|
2020
|
+
} catch {}
|
|
2021
|
+
throw error;
|
|
2022
|
+
}
|
|
2023
|
+
#continue(operation) {
|
|
2024
|
+
if (!this.#scope.accepting) this.#cleanup();
|
|
2025
|
+
return this.#scope.track(operation);
|
|
2026
|
+
}
|
|
2027
|
+
#cleanup() {
|
|
2028
|
+
if (this.#cleaned) return;
|
|
2029
|
+
this.#cleaned = true;
|
|
2030
|
+
try {
|
|
2031
|
+
(this.#source.return?.())?.catch(() => {});
|
|
2032
|
+
} catch {}
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
//#endregion
|
|
2036
|
+
//#region src/core/TransactionScope.ts
|
|
2037
|
+
/**
|
|
2038
|
+
* The internal lifetime boundary for one database transaction callback.
|
|
2039
|
+
*
|
|
2040
|
+
* @remarks
|
|
2041
|
+
* Promise operations enter synchronously through {@link track}. Closing stops new
|
|
2042
|
+
* work while {@link drain} contains every operation already accepted, including
|
|
2043
|
+
* work the callback started without awaiting. {@link stream} applies the same
|
|
2044
|
+
* boundary to each iterator continuation without retaining an idle iterator.
|
|
2045
|
+
*/
|
|
2046
|
+
var TransactionScope = class {
|
|
2047
|
+
#operations = /* @__PURE__ */ new Set();
|
|
2048
|
+
#accepting = true;
|
|
2049
|
+
#failed = false;
|
|
2050
|
+
#error;
|
|
2051
|
+
get accepting() {
|
|
2052
|
+
return this.#accepting;
|
|
2053
|
+
}
|
|
2054
|
+
check() {
|
|
2055
|
+
if (!this.#accepting) throw new DatabaseError("CONFLICT", "Transaction scope has settled");
|
|
2056
|
+
}
|
|
2057
|
+
track(operation) {
|
|
2058
|
+
try {
|
|
2059
|
+
this.check();
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
return Promise.reject(error);
|
|
2062
|
+
}
|
|
2063
|
+
let promise;
|
|
2064
|
+
try {
|
|
2065
|
+
promise = operation();
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
promise = Promise.reject(error);
|
|
2068
|
+
}
|
|
2069
|
+
this.#operations.add(promise);
|
|
2070
|
+
promise.then(() => {
|
|
2071
|
+
this.#operations.delete(promise);
|
|
2072
|
+
}, (error) => {
|
|
2073
|
+
this.#operations.delete(promise);
|
|
2074
|
+
if (!this.#failed) {
|
|
2075
|
+
this.#failed = true;
|
|
2076
|
+
this.#error = error;
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
return promise;
|
|
2080
|
+
}
|
|
2081
|
+
stream(source) {
|
|
2082
|
+
return new TransactionIterator(source, this);
|
|
2083
|
+
}
|
|
2084
|
+
stop() {
|
|
2085
|
+
this.#accepting = false;
|
|
2086
|
+
}
|
|
2087
|
+
async drain() {
|
|
2088
|
+
while (this.#operations.size > 0) await Promise.allSettled(this.#operations);
|
|
2089
|
+
if (this.#failed) throw this.#error;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
//#endregion
|
|
2093
|
+
//#region src/core/DatabaseContext.ts
|
|
2094
|
+
/**
|
|
2095
|
+
* The internal shared owner behind every typed view of one database.
|
|
2096
|
+
*
|
|
2097
|
+
* @remarks
|
|
2098
|
+
* A context owns the driver, merged physical schema, lifecycle, observation,
|
|
2099
|
+
* migration, and single transaction admission. It is deliberately omitted from
|
|
2100
|
+
* the public barrel; {@link Database} is the consumer-facing typed view.
|
|
2101
|
+
*/
|
|
2102
|
+
var DatabaseContext = class {
|
|
2103
|
+
#driver;
|
|
2104
|
+
#name;
|
|
2105
|
+
#version;
|
|
2106
|
+
#error;
|
|
2107
|
+
#emitter;
|
|
2108
|
+
#operations = /* @__PURE__ */ new Set();
|
|
2109
|
+
#schema = [];
|
|
2110
|
+
#transaction;
|
|
2111
|
+
#status = "idle";
|
|
2112
|
+
#ready;
|
|
2113
|
+
#failure;
|
|
2114
|
+
constructor(options) {
|
|
2115
|
+
this.#driver = options.driver;
|
|
2116
|
+
this.#name = options.name ?? "database";
|
|
2117
|
+
this.#version = options.version;
|
|
2118
|
+
this.#error = options.error;
|
|
2119
|
+
this.#emitter = new Emitter({
|
|
2120
|
+
...options.on === void 0 ? {} : { on: options.on },
|
|
2121
|
+
...options.error === void 0 ? {} : { error: options.error }
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
get driver() {
|
|
2125
|
+
return this.#driver;
|
|
2126
|
+
}
|
|
2127
|
+
get emitter() {
|
|
2128
|
+
return this.#emitter;
|
|
2129
|
+
}
|
|
2130
|
+
get error() {
|
|
2131
|
+
return this.#error;
|
|
2132
|
+
}
|
|
2133
|
+
get name() {
|
|
2134
|
+
return this.#name;
|
|
2135
|
+
}
|
|
2136
|
+
get accepting() {
|
|
2137
|
+
return this.#status !== "closed" && this.#transaction === void 0;
|
|
2138
|
+
}
|
|
2139
|
+
get status() {
|
|
2140
|
+
return this.#status;
|
|
2141
|
+
}
|
|
2142
|
+
get version() {
|
|
2143
|
+
return this.#version;
|
|
2144
|
+
}
|
|
2145
|
+
register(schema) {
|
|
2146
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2147
|
+
if (this.#ready !== void 0 || this.#transaction !== void 0) throw new DatabaseError("CONFLICT", `Database '${this.#name}' cannot import tables after opening has started`, {
|
|
2148
|
+
name: this.#name,
|
|
2149
|
+
status: this.#status
|
|
2150
|
+
});
|
|
2151
|
+
const registered = normalizeDriverSchema(schema);
|
|
2152
|
+
const merged = [...this.#schema];
|
|
2153
|
+
for (const table of registered) {
|
|
2154
|
+
const existing = merged.find((candidate) => candidate.name === table.name);
|
|
2155
|
+
if (existing === void 0) {
|
|
2156
|
+
merged.push(table);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
if (!equalsValue(existing, table)) throw new DatabaseError("VALIDATION", `Table '${table.name}' conflicts with its registered schema`, { table: table.name });
|
|
2160
|
+
}
|
|
2161
|
+
this.#schema = normalizeDriverSchema(merged);
|
|
2162
|
+
}
|
|
2163
|
+
async open() {
|
|
2164
|
+
this.#outside();
|
|
2165
|
+
await this.connect();
|
|
2166
|
+
}
|
|
2167
|
+
async close() {
|
|
2168
|
+
this.#outside();
|
|
2169
|
+
if (this.#status === "closed") return;
|
|
2170
|
+
this.#status = "closed";
|
|
2171
|
+
await this.#drain();
|
|
2172
|
+
const ready = this.#ready;
|
|
2173
|
+
if (ready !== void 0) await ready.catch(() => {});
|
|
2174
|
+
this.#ready = void 0;
|
|
2175
|
+
await this.#driver.close();
|
|
2176
|
+
this.#emitter.emit("close");
|
|
2177
|
+
}
|
|
2178
|
+
connect() {
|
|
2179
|
+
return this.#connect();
|
|
2180
|
+
}
|
|
2181
|
+
track(operation) {
|
|
2182
|
+
try {
|
|
2183
|
+
this.#admit();
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
return Promise.reject(error);
|
|
2186
|
+
}
|
|
2187
|
+
let promise;
|
|
2188
|
+
try {
|
|
2189
|
+
promise = operation();
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
promise = Promise.reject(error);
|
|
2192
|
+
}
|
|
2193
|
+
this.#operations.add(promise);
|
|
2194
|
+
promise.then(() => {
|
|
2195
|
+
this.#operations.delete(promise);
|
|
2196
|
+
}, () => {
|
|
2197
|
+
this.#operations.delete(promise);
|
|
2198
|
+
});
|
|
2199
|
+
return promise;
|
|
2200
|
+
}
|
|
2201
|
+
async transaction(scope, options) {
|
|
2202
|
+
checkAbort(options?.signal);
|
|
2203
|
+
this.#admit();
|
|
2204
|
+
const token = {};
|
|
2205
|
+
this.#transaction = token;
|
|
2206
|
+
try {
|
|
2207
|
+
await this.#drain();
|
|
2208
|
+
await this.#connect();
|
|
2209
|
+
if (this.#driver.transaction !== void 0) {
|
|
2210
|
+
const rejection = {
|
|
2211
|
+
rejected: false,
|
|
2212
|
+
error: void 0,
|
|
2213
|
+
marker: {}
|
|
2214
|
+
};
|
|
2215
|
+
try {
|
|
2216
|
+
const value = await this.#driver.transaction(async (storage) => {
|
|
2217
|
+
this.#emitter.emit("transaction");
|
|
2218
|
+
const outcome = await scope(storage, new TransactionScope());
|
|
2219
|
+
if (!outcome.success) {
|
|
2220
|
+
rejection.rejected = true;
|
|
2221
|
+
rejection.error = outcome.error;
|
|
2222
|
+
throw rejection.marker;
|
|
2223
|
+
}
|
|
2224
|
+
return outcome.value;
|
|
2225
|
+
});
|
|
2226
|
+
this.#emitter.emit("commit");
|
|
2227
|
+
return value;
|
|
2228
|
+
} catch (error) {
|
|
2229
|
+
if (rejection.rejected) {
|
|
2230
|
+
if (Object.is(error, rejection.marker)) {
|
|
2231
|
+
this.#emitter.emit("rollback", rejection.error);
|
|
2232
|
+
throw rejection.error;
|
|
2233
|
+
}
|
|
2234
|
+
throw this.#rollbackError(rejection.error, error);
|
|
2235
|
+
}
|
|
2236
|
+
throw error;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const rollback = await this.#driver.snapshot();
|
|
2240
|
+
this.#emitter.emit("transaction");
|
|
2241
|
+
const outcome = await scope(this.#driver, new TransactionScope());
|
|
2242
|
+
if (outcome.success) {
|
|
2243
|
+
this.#emitter.emit("commit");
|
|
2244
|
+
return outcome.value;
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
await rollback();
|
|
2248
|
+
} catch (cause) {
|
|
2249
|
+
throw this.#rollbackError(outcome.error, cause);
|
|
2250
|
+
}
|
|
2251
|
+
this.#emitter.emit("rollback", outcome.error);
|
|
2252
|
+
throw outcome.error;
|
|
2253
|
+
} finally {
|
|
2254
|
+
if (this.#transaction === token) this.#transaction = void 0;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
async migrate(deployed, options) {
|
|
2258
|
+
checkAbort(options?.signal);
|
|
2259
|
+
this.#outside();
|
|
2260
|
+
if (this.#ready !== void 0 || this.#status !== "idle") throw new DatabaseError("CONFLICT", `Database '${this.#name}' cannot apply an explicit deployed schema after opening`, {
|
|
2261
|
+
name: this.#name,
|
|
2262
|
+
status: this.#status
|
|
2263
|
+
});
|
|
2264
|
+
if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
|
|
2265
|
+
const plan = planMigration(deployed, this.#schema);
|
|
2266
|
+
const readiness = this.#transition(deployed, plan).catch((error) => {
|
|
2267
|
+
if (this.#ready === readiness) this.#ready = void 0;
|
|
2268
|
+
this.#failure = { error };
|
|
2269
|
+
throw error;
|
|
2270
|
+
});
|
|
2271
|
+
this.#failure = void 0;
|
|
2272
|
+
this.#ready = readiness;
|
|
2273
|
+
await readiness;
|
|
2274
|
+
return plan;
|
|
2275
|
+
}
|
|
2276
|
+
#admit() {
|
|
2277
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2278
|
+
if (this.#transaction !== void 0) throw new DatabaseError("CONFLICT", `Database '${this.#name}' has an active transaction`, { name: this.#name });
|
|
2279
|
+
if (this.#failure !== void 0) throw this.#failure.error;
|
|
2280
|
+
}
|
|
2281
|
+
#outside() {
|
|
2282
|
+
if (this.#transaction !== void 0) throw new DatabaseError("CONFLICT", `Database '${this.#name}' has an active transaction`, { name: this.#name });
|
|
2283
|
+
}
|
|
2284
|
+
#connect() {
|
|
2285
|
+
if (this.#ready !== void 0) return this.#ready;
|
|
2286
|
+
if (this.#failure !== void 0) throw this.#failure.error;
|
|
2287
|
+
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2288
|
+
const readiness = this.#driver.open(this.#schema).then(async () => {
|
|
2289
|
+
if (this.#status === "idle") {
|
|
2290
|
+
this.#status = "open";
|
|
2291
|
+
this.#emitter.emit("open");
|
|
2292
|
+
}
|
|
2293
|
+
await this.#reconcile();
|
|
2294
|
+
}).catch((error) => {
|
|
2295
|
+
if (this.#ready === readiness) this.#ready = void 0;
|
|
2296
|
+
throw error;
|
|
2297
|
+
});
|
|
2298
|
+
this.#ready = readiness;
|
|
2299
|
+
return readiness;
|
|
2300
|
+
}
|
|
2301
|
+
async #drain() {
|
|
2302
|
+
if (this.#operations.size === 0) return;
|
|
2303
|
+
await Promise.allSettled(this.#operations);
|
|
2304
|
+
}
|
|
2305
|
+
async #reconcile() {
|
|
2306
|
+
if (this.#version === void 0 || this.#driver.metadata === void 0 || this.#driver.stamp === void 0) return;
|
|
2307
|
+
const metadata = await this.#driver.metadata();
|
|
2308
|
+
if (metadata === void 0) {
|
|
2309
|
+
await this.#stamp();
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
if (metadata.version > this.#version) throw new DatabaseError("MIGRATION", `Database '${this.#name}' store version ${metadata.version} is newer than declared version ${this.#version}`, {
|
|
2313
|
+
name: this.#name,
|
|
2314
|
+
stored: metadata.version,
|
|
2315
|
+
declared: this.#version
|
|
2316
|
+
});
|
|
2317
|
+
if (metadata.version === this.#version) {
|
|
2318
|
+
if (!equalsValue(normalizeDriverSchema(metadata.schema), this.#schema)) throw new DatabaseError("MIGRATION", `Database '${this.#name}' stored schema differs at version ${this.#version}`, {
|
|
2319
|
+
name: this.#name,
|
|
2320
|
+
version: this.#version
|
|
2321
|
+
});
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
const plan = planMigration(metadata.schema, this.#schema, metadata.version, this.#version);
|
|
2325
|
+
if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
|
|
2326
|
+
name: this.#name,
|
|
2327
|
+
stored: metadata.version,
|
|
2328
|
+
declared: this.#version
|
|
2329
|
+
});
|
|
2330
|
+
await this.#apply(plan);
|
|
2331
|
+
}
|
|
2332
|
+
async #apply(plan) {
|
|
2333
|
+
if (this.#driver.transaction !== void 0) {
|
|
2334
|
+
await this.#driver.transaction(async (storage) => {
|
|
2335
|
+
if (plan.steps.length > 0 && storage.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' transaction does not support migration`, { name: this.#name });
|
|
2336
|
+
if (storage.migrate === void 0) await this.#stamp(storage);
|
|
2337
|
+
else await storage.migrate(this.#migration(plan));
|
|
2338
|
+
});
|
|
2339
|
+
this.#emitter.emit("migrate", plan);
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
if (this.#driver.migrate === void 0) await this.#stamp();
|
|
2343
|
+
else await this.#driver.migrate(this.#migration(plan));
|
|
2344
|
+
this.#emitter.emit("migrate", plan);
|
|
2345
|
+
}
|
|
2346
|
+
async #transition(deployed, plan) {
|
|
2347
|
+
await this.#driver.open(deployed);
|
|
2348
|
+
await this.#apply(plan);
|
|
2349
|
+
if (this.#status === "idle") {
|
|
2350
|
+
this.#status = "open";
|
|
2351
|
+
this.#emitter.emit("open");
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
async #stamp(storage) {
|
|
2355
|
+
const target = storage ?? this.#driver;
|
|
2356
|
+
if (this.#version === void 0 || target.stamp === void 0) return;
|
|
2357
|
+
const metadata = {
|
|
2358
|
+
version: this.#version,
|
|
2359
|
+
schema: this.#schema
|
|
2360
|
+
};
|
|
2361
|
+
await target.stamp(metadata);
|
|
2362
|
+
}
|
|
2363
|
+
#migration(plan) {
|
|
2364
|
+
if (this.#version === void 0) return { plan };
|
|
2365
|
+
return {
|
|
2366
|
+
plan,
|
|
2367
|
+
metadata: {
|
|
2368
|
+
version: this.#version,
|
|
2369
|
+
schema: this.#schema
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
#rollbackError(transaction, cause) {
|
|
2374
|
+
return new DatabaseError("DRIVER", `Database '${this.#name}' rollback failed`, {
|
|
2375
|
+
cause,
|
|
2376
|
+
transaction
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
//#endregion
|
|
2381
|
+
//#region src/core/Cursor.ts
|
|
2382
|
+
/**
|
|
2383
|
+
* A forward row cursor for bulk in-place mutation.
|
|
2384
|
+
*
|
|
2385
|
+
* @remarks
|
|
2386
|
+
* Iterates a snapshot of the table's keys captured when the cursor was opened,
|
|
2387
|
+
* reading each row lazily through the owning table — so a mutation made during
|
|
2388
|
+
* iteration cannot corrupt the walk, and a key removed mid-iteration is simply
|
|
2389
|
+
* skipped. `update` and `remove` act on the row at the current position.
|
|
2390
|
+
*/
|
|
2391
|
+
var Cursor = class {
|
|
2392
|
+
#keys;
|
|
2393
|
+
#read;
|
|
2394
|
+
#update;
|
|
2395
|
+
#remove;
|
|
2396
|
+
#track;
|
|
2397
|
+
#tail = Promise.resolve();
|
|
2398
|
+
#index = -1;
|
|
1499
2399
|
#value;
|
|
1500
2400
|
#closed = false;
|
|
1501
|
-
constructor(
|
|
1502
|
-
this.#table = table;
|
|
2401
|
+
constructor(keys, read, update, remove, track) {
|
|
1503
2402
|
this.#keys = keys;
|
|
2403
|
+
this.#read = read;
|
|
2404
|
+
this.#update = update;
|
|
2405
|
+
this.#remove = remove;
|
|
2406
|
+
this.#track = track;
|
|
1504
2407
|
}
|
|
1505
2408
|
get value() {
|
|
1506
2409
|
return this.#value;
|
|
@@ -1511,16 +2414,31 @@ var Cursor = class {
|
|
|
1511
2414
|
get done() {
|
|
1512
2415
|
return this.#closed || this.#index >= this.#keys.length;
|
|
1513
2416
|
}
|
|
1514
|
-
|
|
2417
|
+
next() {
|
|
2418
|
+
return this.#track(() => this.#queue(() => this.#advance()));
|
|
2419
|
+
}
|
|
2420
|
+
update(changes) {
|
|
2421
|
+
return this.#track(() => this.#queue(() => this.#revise(changes)));
|
|
2422
|
+
}
|
|
2423
|
+
remove() {
|
|
2424
|
+
return this.#track(() => this.#queue(() => this.#delete()));
|
|
2425
|
+
}
|
|
2426
|
+
close() {
|
|
2427
|
+
this.#closed = true;
|
|
2428
|
+
this.#value = void 0;
|
|
2429
|
+
}
|
|
2430
|
+
async #advance() {
|
|
1515
2431
|
if (this.#closed) return;
|
|
1516
2432
|
this.#index += 1;
|
|
1517
2433
|
while (this.#index < this.#keys.length) {
|
|
2434
|
+
if (this.#closed) return;
|
|
1518
2435
|
const key = this.#keys[this.#index];
|
|
1519
2436
|
if (key === void 0) {
|
|
1520
2437
|
this.#index += 1;
|
|
1521
2438
|
continue;
|
|
1522
2439
|
}
|
|
1523
|
-
const row = await this.#
|
|
2440
|
+
const row = await this.#read(key);
|
|
2441
|
+
if (this.#closed) return;
|
|
1524
2442
|
if (row !== void 0) {
|
|
1525
2443
|
this.#value = row;
|
|
1526
2444
|
return;
|
|
@@ -1529,97 +2447,103 @@ var Cursor = class {
|
|
|
1529
2447
|
}
|
|
1530
2448
|
this.#value = void 0;
|
|
1531
2449
|
}
|
|
1532
|
-
async
|
|
2450
|
+
async #revise(changes) {
|
|
1533
2451
|
if (this.#closed || this.#value === void 0) return;
|
|
1534
2452
|
const key = this.#keys[this.#index];
|
|
1535
2453
|
if (key === void 0) return;
|
|
1536
|
-
await this.#
|
|
1537
|
-
|
|
2454
|
+
await this.#update(key, changes);
|
|
2455
|
+
if (this.#closed) return;
|
|
2456
|
+
const row = await this.#read(key);
|
|
2457
|
+
if (this.#closed) return;
|
|
2458
|
+
this.#value = row;
|
|
1538
2459
|
}
|
|
1539
|
-
async
|
|
2460
|
+
async #delete() {
|
|
1540
2461
|
if (this.#closed || this.#value === void 0) return;
|
|
1541
2462
|
const key = this.#keys[this.#index];
|
|
1542
2463
|
if (key === void 0) return;
|
|
1543
|
-
await this.#
|
|
2464
|
+
await this.#remove(key);
|
|
2465
|
+
if (this.#closed) return;
|
|
1544
2466
|
this.#value = void 0;
|
|
1545
2467
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
this.#
|
|
2468
|
+
#queue(operation) {
|
|
2469
|
+
const result = this.#tail.then(operation);
|
|
2470
|
+
this.#tail = result.then(() => void 0, () => void 0);
|
|
2471
|
+
return result;
|
|
1549
2472
|
}
|
|
1550
2473
|
};
|
|
1551
2474
|
//#endregion
|
|
1552
|
-
//#region src/core/
|
|
2475
|
+
//#region src/core/DatabaseIterator.ts
|
|
1553
2476
|
/**
|
|
1554
|
-
*
|
|
2477
|
+
* The internal continuation admission boundary for a root database stream.
|
|
1555
2478
|
*
|
|
1556
2479
|
* @remarks
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
2480
|
+
* Each continuation enters the shared root operation ledger independently, so
|
|
2481
|
+
* an idle iterator never delays a transaction or close. A continuation rejected
|
|
2482
|
+
* after transaction or close admission closes attempts source cleanup exactly
|
|
2483
|
+
* once and leaves the iterator terminal.
|
|
1561
2484
|
*/
|
|
1562
|
-
var
|
|
1563
|
-
#
|
|
1564
|
-
#
|
|
1565
|
-
#
|
|
1566
|
-
constructor(
|
|
1567
|
-
this.#
|
|
1568
|
-
this.#
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
return this.#apply("equals", [value]);
|
|
1573
|
-
}
|
|
1574
|
-
not(value) {
|
|
1575
|
-
return this.#apply("not", [value]);
|
|
1576
|
-
}
|
|
1577
|
-
above(value) {
|
|
1578
|
-
return this.#apply("above", [value]);
|
|
1579
|
-
}
|
|
1580
|
-
below(value) {
|
|
1581
|
-
return this.#apply("below", [value]);
|
|
1582
|
-
}
|
|
1583
|
-
from(value) {
|
|
1584
|
-
return this.#apply("from", [value]);
|
|
1585
|
-
}
|
|
1586
|
-
to(value) {
|
|
1587
|
-
return this.#apply("to", [value]);
|
|
1588
|
-
}
|
|
1589
|
-
between(lower, upper) {
|
|
1590
|
-
return this.#apply("between", [lower, upper]);
|
|
1591
|
-
}
|
|
1592
|
-
like(pattern) {
|
|
1593
|
-
return this.#apply("like", [pattern]);
|
|
1594
|
-
}
|
|
1595
|
-
glob(pattern) {
|
|
1596
|
-
return this.#apply("glob", [pattern]);
|
|
2485
|
+
var DatabaseIterator = class {
|
|
2486
|
+
#source;
|
|
2487
|
+
#context;
|
|
2488
|
+
#cleaned = false;
|
|
2489
|
+
constructor(source, context) {
|
|
2490
|
+
this.#source = source[Symbol.asyncIterator]();
|
|
2491
|
+
this.#context = context;
|
|
2492
|
+
}
|
|
2493
|
+
[Symbol.asyncIterator]() {
|
|
2494
|
+
return this;
|
|
1597
2495
|
}
|
|
1598
|
-
|
|
1599
|
-
return this.#
|
|
2496
|
+
next() {
|
|
2497
|
+
return this.#continue(() => this.#next());
|
|
1600
2498
|
}
|
|
1601
|
-
|
|
1602
|
-
return this.#
|
|
2499
|
+
return() {
|
|
2500
|
+
return this.#continue(() => this.#return());
|
|
1603
2501
|
}
|
|
1604
|
-
|
|
1605
|
-
return this.#
|
|
2502
|
+
throw(error) {
|
|
2503
|
+
return this.#continue(() => this.#throw(error));
|
|
1606
2504
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
2505
|
+
async #next() {
|
|
2506
|
+
if (this.#cleaned) return {
|
|
2507
|
+
done: true,
|
|
2508
|
+
value: void 0
|
|
2509
|
+
};
|
|
2510
|
+
await this.#context.connect();
|
|
2511
|
+
const result = await this.#source.next();
|
|
2512
|
+
if (result.done === true) this.#cleaned = true;
|
|
2513
|
+
return result;
|
|
1609
2514
|
}
|
|
1610
|
-
|
|
1611
|
-
|
|
2515
|
+
async #return() {
|
|
2516
|
+
if (this.#cleaned || this.#source.return === void 0) {
|
|
2517
|
+
this.#cleaned = true;
|
|
2518
|
+
return {
|
|
2519
|
+
done: true,
|
|
2520
|
+
value: void 0
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
this.#cleaned = true;
|
|
2524
|
+
return this.#source.return();
|
|
2525
|
+
}
|
|
2526
|
+
async #throw(error) {
|
|
2527
|
+
if (this.#source.throw !== void 0) {
|
|
2528
|
+
const result = await this.#source.throw(error);
|
|
2529
|
+
if (result.done === true) this.#cleaned = true;
|
|
2530
|
+
return result;
|
|
2531
|
+
}
|
|
2532
|
+
try {
|
|
2533
|
+
await this.#return();
|
|
2534
|
+
} catch {}
|
|
2535
|
+
throw error;
|
|
1612
2536
|
}
|
|
1613
|
-
|
|
1614
|
-
|
|
2537
|
+
#continue(operation) {
|
|
2538
|
+
if (!this.#context.accepting) this.#cleanup();
|
|
2539
|
+
return this.#context.track(operation);
|
|
1615
2540
|
}
|
|
1616
|
-
#
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
});
|
|
2541
|
+
#cleanup() {
|
|
2542
|
+
if (this.#cleaned) return;
|
|
2543
|
+
this.#cleaned = true;
|
|
2544
|
+
try {
|
|
2545
|
+
(this.#source.return?.())?.catch(() => {});
|
|
2546
|
+
} catch {}
|
|
1623
2547
|
}
|
|
1624
2548
|
};
|
|
1625
2549
|
//#endregion
|
|
@@ -1628,12 +2552,9 @@ var Clause = class {
|
|
|
1628
2552
|
* A fluent query builder bound to one table.
|
|
1629
2553
|
*
|
|
1630
2554
|
* @remarks
|
|
1631
|
-
* Accumulates conditions, ordering, JS filters, and a page
|
|
1632
|
-
* mutates and returns the same instance
|
|
1633
|
-
*
|
|
1634
|
-
* table resolves; a `filter` predicate is applied in memory after the read and
|
|
1635
|
-
* before paging, so it composes with the rest without a backend ever seeing a
|
|
1636
|
-
* JS callback.
|
|
2555
|
+
* Accumulates typed conditions, ordering, JS filters, and a page. Each builder
|
|
2556
|
+
* method mutates and returns the same instance. Portable inputs flow to the
|
|
2557
|
+
* table, while predicates remain an in-memory refinement.
|
|
1637
2558
|
*/
|
|
1638
2559
|
var Query = class {
|
|
1639
2560
|
#table;
|
|
@@ -1645,42 +2566,29 @@ var Query = class {
|
|
|
1645
2566
|
constructor(table) {
|
|
1646
2567
|
this.#table = table;
|
|
1647
2568
|
}
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
}
|
|
1651
|
-
and(column) {
|
|
1652
|
-
return this.#clause(column, "and");
|
|
1653
|
-
}
|
|
1654
|
-
or(column) {
|
|
1655
|
-
return this.#clause(column, "or");
|
|
1656
|
-
}
|
|
1657
|
-
filter(predicate) {
|
|
1658
|
-
this.#filters.push(predicate);
|
|
2569
|
+
condition(input) {
|
|
2570
|
+
this.#conditions.push(input);
|
|
1659
2571
|
return this;
|
|
1660
2572
|
}
|
|
1661
|
-
|
|
1662
|
-
this.#orders.push(
|
|
1663
|
-
column,
|
|
1664
|
-
direction: "ascending"
|
|
1665
|
-
});
|
|
2573
|
+
order(input) {
|
|
2574
|
+
this.#orders.push(input);
|
|
1666
2575
|
return this;
|
|
1667
2576
|
}
|
|
1668
|
-
|
|
1669
|
-
this.#
|
|
1670
|
-
column,
|
|
1671
|
-
direction: "descending"
|
|
1672
|
-
});
|
|
2577
|
+
filter(predicate) {
|
|
2578
|
+
this.#filters.push(predicate);
|
|
1673
2579
|
return this;
|
|
1674
2580
|
}
|
|
1675
2581
|
limit(count) {
|
|
2582
|
+
validatePage({ limit: count });
|
|
1676
2583
|
this.#limit = count;
|
|
1677
2584
|
return this;
|
|
1678
2585
|
}
|
|
1679
2586
|
offset(count) {
|
|
2587
|
+
validatePage({ offset: count });
|
|
1680
2588
|
this.#offset = count;
|
|
1681
2589
|
return this;
|
|
1682
2590
|
}
|
|
1683
|
-
async
|
|
2591
|
+
async collect() {
|
|
1684
2592
|
if (this.#filters.length === 0) return this.#table.records({
|
|
1685
2593
|
conditions: this.#conditions,
|
|
1686
2594
|
order: this.#orders,
|
|
@@ -1693,8 +2601,8 @@ var Query = class {
|
|
|
1693
2601
|
});
|
|
1694
2602
|
return this.#page(this.#filtered(fetched));
|
|
1695
2603
|
}
|
|
1696
|
-
async
|
|
1697
|
-
return (await this.
|
|
2604
|
+
async find() {
|
|
2605
|
+
return (await this.collect())[0];
|
|
1698
2606
|
}
|
|
1699
2607
|
async count() {
|
|
1700
2608
|
if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
|
|
@@ -1702,17 +2610,10 @@ var Query = class {
|
|
|
1702
2610
|
return this.#filtered(fetched).length;
|
|
1703
2611
|
}
|
|
1704
2612
|
/**
|
|
1705
|
-
*
|
|
1706
|
-
* limit.
|
|
1707
|
-
*
|
|
1708
|
-
* @remarks
|
|
1709
|
-
* `order` and its comparators are IGNORED (streaming yields unsorted, as rows
|
|
1710
|
-
* are evaluated one at a time). Same abort semantics as
|
|
1711
|
-
* `TableInterface.scan`: the signal (if any) is checked before each yield,
|
|
1712
|
-
* and breaking out early closes the underlying source.
|
|
2613
|
+
* Lazily evaluate conditions, filters, offset, and limit.
|
|
1713
2614
|
*
|
|
1714
|
-
* @param options -
|
|
1715
|
-
* @returns
|
|
2615
|
+
* @param options - Optional abort options
|
|
2616
|
+
* @returns Matching rows in storage order
|
|
1716
2617
|
*/
|
|
1717
2618
|
async *stream(options) {
|
|
1718
2619
|
if (this.#filters.length === 0) {
|
|
@@ -1747,24 +2648,6 @@ var Query = class {
|
|
|
1747
2648
|
if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
|
|
1748
2649
|
return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
|
|
1749
2650
|
}
|
|
1750
|
-
sum(column) {
|
|
1751
|
-
return this.aggregate("sum", column);
|
|
1752
|
-
}
|
|
1753
|
-
average(column) {
|
|
1754
|
-
return this.aggregate("average", column);
|
|
1755
|
-
}
|
|
1756
|
-
minimum(column) {
|
|
1757
|
-
return this.aggregate("minimum", column);
|
|
1758
|
-
}
|
|
1759
|
-
maximum(column) {
|
|
1760
|
-
return this.aggregate("maximum", column);
|
|
1761
|
-
}
|
|
1762
|
-
#clause(column, connector) {
|
|
1763
|
-
return new Clause((condition) => {
|
|
1764
|
-
this.#conditions.push(condition);
|
|
1765
|
-
return this;
|
|
1766
|
-
}, column, connector);
|
|
1767
|
-
}
|
|
1768
2651
|
#filtered(rows) {
|
|
1769
2652
|
let result = rows;
|
|
1770
2653
|
for (const predicate of this.#filters) result = result.filter(predicate);
|
|
@@ -1807,8 +2690,10 @@ var Table = class {
|
|
|
1807
2690
|
#contract;
|
|
1808
2691
|
#guard;
|
|
1809
2692
|
#generate;
|
|
2693
|
+
#context;
|
|
2694
|
+
#scope;
|
|
1810
2695
|
#emitter;
|
|
1811
|
-
constructor(ready, driver, name, key, contract, generate,
|
|
2696
|
+
constructor(ready, driver, name, key, contract, generate, error, context, scope) {
|
|
1812
2697
|
this.#ready = ready;
|
|
1813
2698
|
this.#driver = driver;
|
|
1814
2699
|
this.#name = name;
|
|
@@ -1816,10 +2701,9 @@ var Table = class {
|
|
|
1816
2701
|
this.#contract = contract;
|
|
1817
2702
|
this.#guard = contract.is;
|
|
1818
2703
|
this.#generate = generate;
|
|
1819
|
-
this.#
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
});
|
|
2704
|
+
this.#context = context;
|
|
2705
|
+
this.#scope = scope;
|
|
2706
|
+
this.#emitter = new Emitter({ ...error !== void 0 ? { error } : {} });
|
|
1823
2707
|
}
|
|
1824
2708
|
get emitter() {
|
|
1825
2709
|
return this.#emitter;
|
|
@@ -1833,58 +2717,77 @@ var Table = class {
|
|
|
1833
2717
|
get contract() {
|
|
1834
2718
|
return this.#contract;
|
|
1835
2719
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
2720
|
+
get(keys) {
|
|
2721
|
+
return this.#track(async () => {
|
|
2722
|
+
await this.#ready();
|
|
2723
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#read(key));
|
|
2724
|
+
return this.#read(keys);
|
|
2725
|
+
});
|
|
1840
2726
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2727
|
+
resolve(keys) {
|
|
2728
|
+
return this.#track(async () => {
|
|
2729
|
+
await this.#ready();
|
|
2730
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#resolveOne(key));
|
|
2731
|
+
return this.#resolveOne(keys);
|
|
2732
|
+
});
|
|
1845
2733
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2734
|
+
has(keys) {
|
|
2735
|
+
return this.#track(async () => {
|
|
2736
|
+
await this.#ready();
|
|
2737
|
+
if (isArray(keys)) return this.#each(keys, async (key) => await this.#read(key) !== void 0);
|
|
2738
|
+
return await this.#read(keys) !== void 0;
|
|
2739
|
+
});
|
|
1850
2740
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2741
|
+
keys() {
|
|
2742
|
+
return this.#track(async () => {
|
|
2743
|
+
await this.#ready();
|
|
2744
|
+
return this.#driver.keys(this.#name);
|
|
2745
|
+
});
|
|
1854
2746
|
}
|
|
1855
|
-
async records(
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2747
|
+
async records(input, options) {
|
|
2748
|
+
validatePage(input);
|
|
2749
|
+
return this.#track(async () => {
|
|
2750
|
+
checkAbort(options?.signal);
|
|
2751
|
+
await this.#ready();
|
|
2752
|
+
const candidate = {
|
|
2753
|
+
...input?.conditions === void 0 ? {} : { conditions: input.conditions },
|
|
2754
|
+
...input?.order === void 0 ? {} : { order: input.order }
|
|
2755
|
+
};
|
|
2756
|
+
const source = await this.#driver.records?.(this.#name, candidate) ?? applyQuery(await this.#collect(), candidate);
|
|
2757
|
+
const rows = [];
|
|
2758
|
+
for (const row of source) if (this.#guard(row)) rows.push(row);
|
|
2759
|
+
const offset = input?.offset ?? 0;
|
|
2760
|
+
const limit = input?.limit;
|
|
2761
|
+
return rows.slice(offset, limit === void 0 ? void 0 : offset + limit);
|
|
2762
|
+
});
|
|
1862
2763
|
}
|
|
1863
2764
|
/**
|
|
1864
|
-
* Count rows matching `
|
|
2765
|
+
* Count contract-valid rows matching `input`'s conditions.
|
|
1865
2766
|
*
|
|
1866
2767
|
* @remarks
|
|
1867
|
-
*
|
|
1868
|
-
* contract guard
|
|
1869
|
-
*
|
|
1870
|
-
* `filterRows`-over-`#collect()` fallback count raw storage) — so it can
|
|
1871
|
-
* exceed `(await records(criteria)).length` when storage holds rows that
|
|
1872
|
-
* no longer conform to the table's contract (legacy or migrated data).
|
|
2768
|
+
* Paging is ignored. Candidate rows use the driver's native `records` hook
|
|
2769
|
+
* when present, then the table contract guard determines the count so legacy
|
|
2770
|
+
* invalid rows cannot make `count()` disagree with `records()`.
|
|
1873
2771
|
*
|
|
1874
|
-
* @param
|
|
2772
|
+
* @param input - Optional conditions to filter by (paging is ignored)
|
|
1875
2773
|
* @param options - `{ signal }` to abort
|
|
1876
|
-
* @returns The count of matching
|
|
2774
|
+
* @returns The count of matching contract-valid rows
|
|
1877
2775
|
*/
|
|
1878
|
-
async count(
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
2776
|
+
async count(input, options) {
|
|
2777
|
+
validatePage(input);
|
|
2778
|
+
return this.#track(async () => {
|
|
2779
|
+
checkAbort(options?.signal);
|
|
2780
|
+
await this.#ready();
|
|
2781
|
+
const conditions = input?.conditions;
|
|
2782
|
+
const candidate = conditions === void 0 ? {} : { conditions };
|
|
2783
|
+
const rows = await this.#driver.records?.(this.#name, candidate) ?? filterRows(await this.#collect(), conditions ?? []);
|
|
2784
|
+
let count = 0;
|
|
2785
|
+
for (const row of rows) if (this.#guard(row)) count += 1;
|
|
2786
|
+
return count;
|
|
2787
|
+
});
|
|
1885
2788
|
}
|
|
1886
2789
|
/**
|
|
1887
|
-
* Compute an aggregate over `column` across rows matching `
|
|
2790
|
+
* Compute an aggregate over `column` across rows matching `input`'s
|
|
1888
2791
|
* conditions.
|
|
1889
2792
|
*
|
|
1890
2793
|
* @remarks
|
|
@@ -1896,115 +2799,138 @@ var Table = class {
|
|
|
1896
2799
|
*
|
|
1897
2800
|
* @param operation - The aggregate to compute
|
|
1898
2801
|
* @param column - The column to aggregate
|
|
1899
|
-
* @param
|
|
2802
|
+
* @param input - Optional conditions to filter by (paging is ignored)
|
|
1900
2803
|
* @param options - `{ signal }` to abort
|
|
1901
2804
|
* @returns The aggregate value, or `undefined` when undefined for the inputs
|
|
1902
2805
|
*/
|
|
1903
|
-
async aggregate(operation, column,
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2806
|
+
async aggregate(operation, column, input, options) {
|
|
2807
|
+
validatePage(input);
|
|
2808
|
+
return this.#track(async () => {
|
|
2809
|
+
checkAbort(options?.signal);
|
|
2810
|
+
await this.#ready();
|
|
2811
|
+
const conditions = input?.conditions;
|
|
2812
|
+
const filter = conditions ? { conditions } : {};
|
|
2813
|
+
const native = this.#driver.aggregate?.(this.#name, operation, column, filter);
|
|
2814
|
+
if (native !== void 0) return native;
|
|
2815
|
+
return computeAggregate(await this.#driver.records?.(this.#name, filter) ?? filterRows(await this.#collect(), input?.conditions ?? []), operation, column);
|
|
2816
|
+
});
|
|
1911
2817
|
}
|
|
1912
2818
|
/**
|
|
1913
|
-
* Stream the table's rows matching `
|
|
2819
|
+
* Stream the table's rows matching `input`, applying offset/limit paging.
|
|
1914
2820
|
*
|
|
1915
2821
|
* @remarks
|
|
1916
|
-
* `
|
|
1917
|
-
* table's contract guard
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
* the contract guard runs, when storage holds rows that no longer conform
|
|
1921
|
-
* to the table's contract.
|
|
2822
|
+
* `input.limit` counts rows that pass both the input conditions and the
|
|
2823
|
+
* table's contract guard. Native streams receive only the conditions; this
|
|
2824
|
+
* table rechecks them, narrows each candidate, and applies offset/limit last,
|
|
2825
|
+
* matching {@link records} when storage contains legacy invalid rows.
|
|
1922
2826
|
*
|
|
1923
|
-
* @param
|
|
2827
|
+
* @param input - Optional conditions plus offset/limit paging
|
|
1924
2828
|
* @param options - `{ signal }` to abort mid-stream
|
|
1925
2829
|
* @returns An async iterable of matching, guard-conforming rows
|
|
1926
2830
|
*/
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
if (this.#
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
return;
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
const offset = criteria?.offset ?? 0;
|
|
1940
|
-
const limit = criteria?.limit;
|
|
1941
|
-
let matched = 0;
|
|
1942
|
-
let yielded = 0;
|
|
1943
|
-
for await (const row of this.#driver.scan(this.#name)) {
|
|
1944
|
-
checkAbort(options?.signal);
|
|
1945
|
-
if (limit !== void 0 && yielded >= limit) break;
|
|
1946
|
-
if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
|
|
1947
|
-
if (matched < offset) {
|
|
1948
|
-
matched += 1;
|
|
1949
|
-
continue;
|
|
1950
|
-
}
|
|
1951
|
-
matched += 1;
|
|
1952
|
-
const narrowed = this.#cast(row);
|
|
1953
|
-
if (narrowed !== void 0) {
|
|
1954
|
-
yielded += 1;
|
|
1955
|
-
yield narrowed;
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
async set(rows, options) {
|
|
1960
|
-
checkAbort(options?.signal);
|
|
1961
|
-
await this.#ready();
|
|
1962
|
-
if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
|
|
1963
|
-
return this.#put(rows, false);
|
|
2831
|
+
scan(input, options) {
|
|
2832
|
+
validatePage(input);
|
|
2833
|
+
const source = this.#scan(input, options);
|
|
2834
|
+
if (this.#context !== void 0) return new DatabaseIterator(source, this.#context);
|
|
2835
|
+
return this.#scope === void 0 ? source : this.#scope.stream(source);
|
|
2836
|
+
}
|
|
2837
|
+
set(rows, options) {
|
|
2838
|
+
return this.#track(async () => {
|
|
2839
|
+
await this.#wait(options?.signal);
|
|
2840
|
+
if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false, options), options?.signal);
|
|
2841
|
+
return this.#put(rows, false, options);
|
|
2842
|
+
});
|
|
1964
2843
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2844
|
+
add(rows, options) {
|
|
2845
|
+
return this.#track(async () => {
|
|
2846
|
+
await this.#wait(options?.signal);
|
|
2847
|
+
if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, true, options), options?.signal);
|
|
2848
|
+
return this.#put(rows, true, options);
|
|
2849
|
+
});
|
|
1970
2850
|
}
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2851
|
+
update(keys, changes, options) {
|
|
2852
|
+
return this.#track(async () => {
|
|
2853
|
+
await this.#wait(options?.signal);
|
|
2854
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#updateOne(key, changes, options), options?.signal);
|
|
2855
|
+
return this.#updateOne(keys, changes, options);
|
|
2856
|
+
});
|
|
1976
2857
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
2858
|
+
remove(keys, options) {
|
|
2859
|
+
return this.#track(async () => {
|
|
2860
|
+
await this.#wait(options?.signal);
|
|
2861
|
+
if (isArray(keys)) return this.#each(keys, (key) => this.#delete(key, options), options?.signal);
|
|
2862
|
+
return this.#delete(keys, options);
|
|
2863
|
+
});
|
|
1982
2864
|
}
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2865
|
+
clear() {
|
|
2866
|
+
return this.#track(async () => {
|
|
2867
|
+
await this.#ready();
|
|
2868
|
+
await this.#driver.clear(this.#name);
|
|
2869
|
+
this.#emitter.emit("clear");
|
|
2870
|
+
});
|
|
1987
2871
|
}
|
|
1988
2872
|
query() {
|
|
1989
2873
|
return new Query(this);
|
|
1990
2874
|
}
|
|
1991
|
-
|
|
2875
|
+
cursor() {
|
|
2876
|
+
return this.#track(async () => {
|
|
2877
|
+
await this.#ready();
|
|
2878
|
+
let initializing = true;
|
|
2879
|
+
const cursor = new Cursor(await this.#driver.keys(this.#name), (key) => this.#readCursor(key), (key, changes) => this.#updateCursor(key, changes), (key) => this.#deleteCursor(key), (operation) => initializing ? operation() : this.#track(operation));
|
|
2880
|
+
await cursor.next();
|
|
2881
|
+
initializing = false;
|
|
2882
|
+
return cursor;
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
async *#scan(input, options) {
|
|
2886
|
+
checkAbort(options?.signal);
|
|
1992
2887
|
await this.#ready();
|
|
1993
|
-
const
|
|
1994
|
-
|
|
1995
|
-
|
|
2888
|
+
const conditions = input?.conditions;
|
|
2889
|
+
const offset = input?.offset ?? 0;
|
|
2890
|
+
const limit = input?.limit;
|
|
2891
|
+
let matched = 0;
|
|
2892
|
+
let yielded = 0;
|
|
2893
|
+
const iterator = (this.#driver.stream === void 0 ? this.#driver.scan(this.#name) : this.#driver.stream(this.#name, conditions === void 0 ? {} : { conditions }))[Symbol.asyncIterator]();
|
|
2894
|
+
try {
|
|
2895
|
+
while (true) {
|
|
2896
|
+
await this.#ready();
|
|
2897
|
+
checkAbort(options?.signal);
|
|
2898
|
+
if (limit !== void 0 && yielded >= limit) return;
|
|
2899
|
+
const step = await iterator.next();
|
|
2900
|
+
await this.#ready();
|
|
2901
|
+
checkAbort(options?.signal);
|
|
2902
|
+
if (step.done === true) return;
|
|
2903
|
+
const row = step.value;
|
|
2904
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesQuery(row, conditions)) continue;
|
|
2905
|
+
const narrowed = this.#cast(row);
|
|
2906
|
+
if (narrowed === void 0) continue;
|
|
2907
|
+
if (matched < offset) {
|
|
2908
|
+
matched += 1;
|
|
2909
|
+
continue;
|
|
2910
|
+
}
|
|
2911
|
+
matched += 1;
|
|
2912
|
+
yielded += 1;
|
|
2913
|
+
yield narrowed;
|
|
2914
|
+
}
|
|
2915
|
+
} finally {
|
|
2916
|
+
await iterator.return?.();
|
|
2917
|
+
}
|
|
1996
2918
|
}
|
|
1997
|
-
async #each(
|
|
2919
|
+
async #each(elements, operation, signal) {
|
|
1998
2920
|
const results = [];
|
|
1999
|
-
for (const
|
|
2921
|
+
for (const element of elements) {
|
|
2000
2922
|
checkAbort(signal);
|
|
2001
|
-
results.push(await operation(
|
|
2923
|
+
results.push(await operation(element));
|
|
2002
2924
|
}
|
|
2003
2925
|
return results;
|
|
2004
2926
|
}
|
|
2005
2927
|
async #read(key) {
|
|
2006
2928
|
return this.#cast(await this.#driver.read(this.#name, key));
|
|
2007
2929
|
}
|
|
2930
|
+
async #readCursor(key) {
|
|
2931
|
+
await this.#ready();
|
|
2932
|
+
return this.#read(key);
|
|
2933
|
+
}
|
|
2008
2934
|
async #resolveOne(key) {
|
|
2009
2935
|
const row = await this.#read(key);
|
|
2010
2936
|
if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
|
|
@@ -2013,29 +2939,79 @@ var Table = class {
|
|
|
2013
2939
|
});
|
|
2014
2940
|
return row;
|
|
2015
2941
|
}
|
|
2016
|
-
async #put(row,
|
|
2942
|
+
async #put(row, insert, options) {
|
|
2017
2943
|
const validated = this.#validate(this.#prepare(row));
|
|
2018
2944
|
const key = this.#resolveKey(validated);
|
|
2019
|
-
if (
|
|
2020
|
-
|
|
2021
|
-
key
|
|
2022
|
-
});
|
|
2023
|
-
await this.#driver.write(this.#name, key, validated);
|
|
2945
|
+
if (insert) await this.#driver.insert(this.#name, key, validated, options);
|
|
2946
|
+
else await this.#driver.write(this.#name, key, validated, options);
|
|
2024
2947
|
this.#emitter.emit("write", key);
|
|
2025
2948
|
return key;
|
|
2026
2949
|
}
|
|
2027
|
-
async #updateOne(key, changes) {
|
|
2950
|
+
async #updateOne(key, changes, options) {
|
|
2028
2951
|
const existing = await this.#driver.read(this.#name, key);
|
|
2029
|
-
if (existing === void 0)
|
|
2030
|
-
|
|
2952
|
+
if (existing === void 0) {
|
|
2953
|
+
checkAbort(options?.signal);
|
|
2954
|
+
return false;
|
|
2955
|
+
}
|
|
2956
|
+
const input = changes;
|
|
2957
|
+
if (isRecord(input) && Object.hasOwn(input, this.#key) && !equalsValue(input[this.#key], key)) throw new DatabaseError("VALIDATION", `Update cannot change primary column '${this.#key}' on table '${this.#name}'`, {
|
|
2958
|
+
table: this.#name,
|
|
2959
|
+
column: this.#key,
|
|
2960
|
+
key
|
|
2961
|
+
});
|
|
2962
|
+
await this.#driver.write(this.#name, key, this.#validate(Object.assign({}, existing, changes)), options);
|
|
2031
2963
|
this.#emitter.emit("write", key);
|
|
2032
2964
|
return true;
|
|
2033
2965
|
}
|
|
2034
|
-
async #
|
|
2035
|
-
|
|
2966
|
+
async #updateCursor(key, changes) {
|
|
2967
|
+
await this.#wait(void 0);
|
|
2968
|
+
return this.#updateOne(key, changes);
|
|
2969
|
+
}
|
|
2970
|
+
async #delete(key, options) {
|
|
2971
|
+
const removed = await this.#driver.delete(this.#name, key, options);
|
|
2036
2972
|
if (removed) this.#emitter.emit("remove", key);
|
|
2037
2973
|
return removed;
|
|
2038
2974
|
}
|
|
2975
|
+
async #deleteCursor(key) {
|
|
2976
|
+
await this.#wait(void 0);
|
|
2977
|
+
return this.#delete(key);
|
|
2978
|
+
}
|
|
2979
|
+
async #wait(signal) {
|
|
2980
|
+
checkAbort(signal);
|
|
2981
|
+
const ready = this.#ready();
|
|
2982
|
+
if (signal === void 0) {
|
|
2983
|
+
await ready;
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
const cleanup = new AbortController();
|
|
2987
|
+
try {
|
|
2988
|
+
await new Promise((resolve, reject) => {
|
|
2989
|
+
signal.addEventListener("abort", () => {
|
|
2990
|
+
ready.catch(() => {});
|
|
2991
|
+
try {
|
|
2992
|
+
checkAbort(signal);
|
|
2993
|
+
} catch (error) {
|
|
2994
|
+
reject(error);
|
|
2995
|
+
}
|
|
2996
|
+
}, {
|
|
2997
|
+
once: true,
|
|
2998
|
+
signal: cleanup.signal
|
|
2999
|
+
});
|
|
3000
|
+
ready.then(resolve, reject);
|
|
3001
|
+
if (signal.aborted) {
|
|
3002
|
+
ready.catch(() => {});
|
|
3003
|
+
try {
|
|
3004
|
+
checkAbort(signal);
|
|
3005
|
+
} catch (error) {
|
|
3006
|
+
reject(error);
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
});
|
|
3010
|
+
} finally {
|
|
3011
|
+
cleanup.abort();
|
|
3012
|
+
}
|
|
3013
|
+
checkAbort(signal);
|
|
3014
|
+
}
|
|
2039
3015
|
async #collect() {
|
|
2040
3016
|
const rows = [];
|
|
2041
3017
|
for await (const row of this.#driver.scan(this.#name)) rows.push(row);
|
|
@@ -2044,21 +3020,38 @@ var Table = class {
|
|
|
2044
3020
|
#prepare(row) {
|
|
2045
3021
|
if (!isRecord(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
|
|
2046
3022
|
const prepared = { ...row };
|
|
2047
|
-
if (prepared[this.#key] === void 0) {
|
|
2048
|
-
|
|
3023
|
+
if (prepared[this.#key] === void 0) if (this.#generate !== void 0) try {
|
|
3024
|
+
prepared[this.#key] = this.#generate();
|
|
3025
|
+
} catch (cause) {
|
|
3026
|
+
throw new DatabaseError("VALIDATION", `Failed to generate primary column '${this.#key}' for table '${this.#name}'`, {
|
|
2049
3027
|
table: this.#name,
|
|
2050
|
-
column: this.#key
|
|
3028
|
+
column: this.#key,
|
|
3029
|
+
cause
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
else try {
|
|
3033
|
+
prepared[this.#key] = crypto.randomUUID();
|
|
3034
|
+
} catch (cause) {
|
|
3035
|
+
throw new DatabaseError("DRIVER", `Host failed to generate primary column '${this.#key}' for table '${this.#name}'`, {
|
|
3036
|
+
table: this.#name,
|
|
3037
|
+
column: this.#key,
|
|
3038
|
+
cause
|
|
2051
3039
|
});
|
|
2052
|
-
prepared[this.#key] = this.#generate();
|
|
2053
3040
|
}
|
|
2054
3041
|
return prepared;
|
|
2055
3042
|
}
|
|
2056
3043
|
#validate(row) {
|
|
2057
3044
|
const parsed = this.#contract.parse(row);
|
|
2058
|
-
if (parsed === void 0 || !isRecord(parsed))
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
3045
|
+
if (parsed === void 0 || !isRecord(parsed)) {
|
|
3046
|
+
const [fault] = this.#contract.explain(row);
|
|
3047
|
+
throw new DatabaseError("VALIDATION", `Row failed the '${this.#name}' contract`, {
|
|
3048
|
+
table: this.#name,
|
|
3049
|
+
...fault === void 0 ? {} : {
|
|
3050
|
+
field: fault.path,
|
|
3051
|
+
reason: fault.reason
|
|
3052
|
+
}
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
2062
3055
|
return parsed;
|
|
2063
3056
|
}
|
|
2064
3057
|
#resolveKey(row) {
|
|
@@ -2072,80 +3065,99 @@ var Table = class {
|
|
|
2072
3065
|
#cast(row) {
|
|
2073
3066
|
return row !== void 0 && this.#guard(row) ? row : void 0;
|
|
2074
3067
|
}
|
|
3068
|
+
#track(operation) {
|
|
3069
|
+
if (this.#context !== void 0) return this.#context.track(operation);
|
|
3070
|
+
return this.#scope === void 0 ? operation() : this.#scope.track(operation);
|
|
3071
|
+
}
|
|
2075
3072
|
};
|
|
2076
3073
|
//#endregion
|
|
2077
|
-
//#region src/core/
|
|
3074
|
+
//#region src/core/DatabaseTransaction.ts
|
|
2078
3075
|
/**
|
|
2079
|
-
* A database
|
|
3076
|
+
* A table-only database view bound to one driver transaction scope.
|
|
3077
|
+
*
|
|
3078
|
+
* @typeParam T - The declared table shape map
|
|
2080
3079
|
*
|
|
2081
3080
|
* @remarks
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
3081
|
+
* The view gives transaction work the same typed tables as its owning database
|
|
3082
|
+
* while enforcing a materially narrower contract and lifetime. `table` and
|
|
3083
|
+
* every table operation call the owning scope check, so a captured capability
|
|
3084
|
+
* cannot escape its transaction.
|
|
3085
|
+
*/
|
|
3086
|
+
var DatabaseTransaction = class {
|
|
3087
|
+
#driver;
|
|
3088
|
+
#tables;
|
|
3089
|
+
#primary;
|
|
3090
|
+
#generate;
|
|
3091
|
+
#error;
|
|
3092
|
+
#scope;
|
|
3093
|
+
constructor(driver, tables, primary, generate, error, scope) {
|
|
3094
|
+
this.#driver = driver;
|
|
3095
|
+
this.#tables = tables;
|
|
3096
|
+
this.#primary = primary;
|
|
3097
|
+
this.#generate = generate;
|
|
3098
|
+
this.#error = error;
|
|
3099
|
+
this.#scope = scope;
|
|
3100
|
+
}
|
|
3101
|
+
table(name) {
|
|
3102
|
+
this.#scope.check();
|
|
3103
|
+
const columns = this.#columns(name);
|
|
3104
|
+
return this.#build(name, this.#key(name), createContract(objectShape(columns)));
|
|
3105
|
+
}
|
|
3106
|
+
#build(name, key, contract) {
|
|
3107
|
+
return new Table(() => Promise.resolve(), this.#driver, name, key, contract, this.#generate, this.#error, void 0, this.#scope);
|
|
3108
|
+
}
|
|
3109
|
+
#key(name) {
|
|
3110
|
+
return this.#primary[name] ?? "id";
|
|
3111
|
+
}
|
|
3112
|
+
#columns(name) {
|
|
3113
|
+
const columns = this.#tables[name];
|
|
3114
|
+
if (columns === void 0) throw new DatabaseError("NOT_FOUND", `Table '${name}' is not declared`, { table: name });
|
|
3115
|
+
return columns;
|
|
3116
|
+
}
|
|
3117
|
+
};
|
|
3118
|
+
//#endregion
|
|
3119
|
+
//#region src/core/Database.ts
|
|
3120
|
+
/**
|
|
3121
|
+
* A typed database view over one shared internal lifecycle and storage context.
|
|
2090
3122
|
*
|
|
2091
3123
|
* @remarks
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
2095
|
-
*
|
|
2096
|
-
* {@link DatabaseOptions.version} for the full reconciliation contract.
|
|
2097
|
-
* - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
|
|
2098
|
-
* connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
|
|
2099
|
-
* `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
|
|
2100
|
-
* event is emitted directly, strictly AFTER the relevant transition: `commit` only after
|
|
2101
|
-
* the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
|
|
2102
|
-
* OBSERVES the propagated error — it never swallows it (the original throw propagates
|
|
2103
|
-
* exactly as before). The emitter isolates a listener throw and routes it to its `error`
|
|
2104
|
-
* handler (the `error` option), so observation can never reorder, throw into, or corrupt
|
|
2105
|
-
* the snapshot / commit / rollback flow.
|
|
3124
|
+
* Each view owns only its table contracts, primary columns, indexes, and key
|
|
3125
|
+
* generator. Imported views register their physical schemas with the same
|
|
3126
|
+
* internal context before opening begins, so every view observes one driver,
|
|
3127
|
+
* merged schema, emitter, status, transaction boundary, and terminal close.
|
|
2106
3128
|
*/
|
|
2107
3129
|
var Database = class Database {
|
|
2108
|
-
#
|
|
3130
|
+
#context;
|
|
2109
3131
|
#tables;
|
|
2110
|
-
#
|
|
3132
|
+
#primary;
|
|
2111
3133
|
#indexes;
|
|
2112
|
-
#name;
|
|
2113
3134
|
#generate;
|
|
2114
|
-
#version;
|
|
2115
|
-
#emitter;
|
|
2116
|
-
#status = "idle";
|
|
2117
|
-
#ready;
|
|
2118
3135
|
constructor(options) {
|
|
2119
|
-
this.#driver = options.driver;
|
|
2120
3136
|
this.#tables = options.tables;
|
|
2121
|
-
this.#
|
|
3137
|
+
this.#primary = options.primary ?? {};
|
|
2122
3138
|
this.#indexes = options.indexes ?? {};
|
|
2123
|
-
this.#
|
|
2124
|
-
this.#
|
|
2125
|
-
this.#
|
|
2126
|
-
this.#emitter = new Emitter({
|
|
2127
|
-
...options.on !== void 0 ? { on: options.on } : {},
|
|
2128
|
-
...options.error !== void 0 ? { error: options.error } : {}
|
|
2129
|
-
});
|
|
3139
|
+
this.#generate = options.generator;
|
|
3140
|
+
this.#context = new DatabaseContext(options);
|
|
3141
|
+
this.#context.register(this.#schema());
|
|
2130
3142
|
}
|
|
2131
3143
|
get emitter() {
|
|
2132
|
-
return this.#emitter;
|
|
3144
|
+
return this.#context.emitter;
|
|
2133
3145
|
}
|
|
2134
3146
|
get name() {
|
|
2135
|
-
return this.#name;
|
|
3147
|
+
return this.#context.name;
|
|
2136
3148
|
}
|
|
2137
3149
|
get status() {
|
|
2138
|
-
return this.#status;
|
|
3150
|
+
return this.#context.status;
|
|
2139
3151
|
}
|
|
2140
3152
|
table(name) {
|
|
2141
|
-
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
3153
|
+
if (this.#context.status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#context.name}' is closed`, { name: this.#context.name });
|
|
2142
3154
|
const columns = this.#columns(name);
|
|
2143
3155
|
return this.#build(name, this.#key(name), createContract(objectShape(columns)));
|
|
2144
3156
|
}
|
|
2145
|
-
import(tables,
|
|
3157
|
+
import(tables, primary) {
|
|
2146
3158
|
return this.#spawn(tables, {
|
|
2147
|
-
...this.#
|
|
2148
|
-
...
|
|
3159
|
+
...this.#primary,
|
|
3160
|
+
...primary
|
|
2149
3161
|
});
|
|
2150
3162
|
}
|
|
2151
3163
|
export() {
|
|
@@ -2153,109 +3165,44 @@ var Database = class Database {
|
|
|
2153
3165
|
for (const name of Object.keys(this.#tables)) {
|
|
2154
3166
|
const columns = this.#columns(name);
|
|
2155
3167
|
result[name] = {
|
|
2156
|
-
|
|
3168
|
+
primary: this.#key(name),
|
|
2157
3169
|
columns,
|
|
2158
3170
|
schema: compileSchema(objectShape(columns))
|
|
2159
3171
|
};
|
|
2160
3172
|
}
|
|
2161
3173
|
return result;
|
|
2162
3174
|
}
|
|
2163
|
-
|
|
2164
|
-
|
|
3175
|
+
open() {
|
|
3176
|
+
return this.#context.open();
|
|
2165
3177
|
}
|
|
2166
|
-
|
|
2167
|
-
this.#
|
|
2168
|
-
this.#ready = void 0;
|
|
2169
|
-
await this.#driver.close();
|
|
2170
|
-
this.#emitter.emit("close");
|
|
3178
|
+
close() {
|
|
3179
|
+
return this.#context.close();
|
|
2171
3180
|
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
* When the driver implements the optional native {@link DriverInterface.transaction}
|
|
2178
|
-
* hook, that native `commit` / `rollback` handle drives the transaction; otherwise
|
|
2179
|
-
* the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
|
|
2180
|
-
* emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
|
|
2181
|
-
* `options.signal` is checked ONCE at entry, before connecting or starting any
|
|
2182
|
-
* transactional work — an already-aborted signal throws `ABORTED` and neither the
|
|
2183
|
-
* native hook nor the snapshot floor is invoked. Nesting is unguarded and
|
|
2184
|
-
* unsupported exactly as before: this is a single-writer model, not reentrant.
|
|
2185
|
-
* On the native path, a `scope` throw rolls back via the native handle; a
|
|
2186
|
-
* native `commit` failure propagates as-is with no rollback attempt — the
|
|
2187
|
-
* engine owns transaction state after a failed COMMIT.
|
|
2188
|
-
*
|
|
2189
|
-
* @param scope - The transactional work to run
|
|
2190
|
-
* @param options - `{ signal }` to abort before the transaction starts
|
|
2191
|
-
* @returns The scope's resolved value
|
|
2192
|
-
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
|
|
2193
|
-
*/
|
|
2194
|
-
async transaction(scope, options) {
|
|
2195
|
-
checkAbort(options?.signal);
|
|
2196
|
-
await this.#connect();
|
|
2197
|
-
const native = await this.#driver.transaction?.();
|
|
2198
|
-
if (native !== void 0) {
|
|
2199
|
-
this.#emitter.emit("transaction");
|
|
2200
|
-
let value;
|
|
2201
|
-
try {
|
|
2202
|
-
value = await scope();
|
|
2203
|
-
} catch (error) {
|
|
2204
|
-
await native.rollback();
|
|
2205
|
-
this.#emitter.emit("rollback", error);
|
|
2206
|
-
throw error;
|
|
2207
|
-
}
|
|
2208
|
-
await native.commit();
|
|
2209
|
-
this.#emitter.emit("commit");
|
|
2210
|
-
return value;
|
|
2211
|
-
}
|
|
2212
|
-
const rollback = await this.#driver.snapshot();
|
|
2213
|
-
this.#emitter.emit("transaction");
|
|
2214
|
-
try {
|
|
2215
|
-
const value = await scope();
|
|
2216
|
-
this.#emitter.emit("commit");
|
|
2217
|
-
return value;
|
|
2218
|
-
} catch (error) {
|
|
2219
|
-
await rollback();
|
|
2220
|
-
this.#emitter.emit("rollback", error);
|
|
2221
|
-
throw error;
|
|
2222
|
-
}
|
|
3181
|
+
transaction(scope, options) {
|
|
3182
|
+
return this.#context.transaction(async (storage, lifetime) => {
|
|
3183
|
+
const transaction = new DatabaseTransaction(storage, this.#tables, this.#primary, this.#generate, this.#context.error, lifetime);
|
|
3184
|
+
return this.#settle(scope, transaction, lifetime);
|
|
3185
|
+
}, options);
|
|
2223
3186
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
* resulting plan through the driver's optional `migrate` hook.
|
|
2227
|
-
*
|
|
2228
|
-
* @param deployed - The schema currently deployed, as {@link TableSchema}s
|
|
2229
|
-
* @param options - `{ signal }` to abort before the migration starts
|
|
2230
|
-
* @returns The applied {@link Migration} plan
|
|
2231
|
-
* @throws A `MIGRATION` {@link DatabaseError} when the driver does not
|
|
2232
|
-
* implement `migrate`, or when a step references an unknown table
|
|
2233
|
-
* (propagated from the driver)
|
|
2234
|
-
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
|
|
2235
|
-
* already fired at entry
|
|
2236
|
-
*/
|
|
2237
|
-
async migrate(deployed, options) {
|
|
2238
|
-
checkAbort(options?.signal);
|
|
2239
|
-
await this.#connect();
|
|
2240
|
-
const plan = planMigration(deployed, this.#schema());
|
|
2241
|
-
if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
|
|
2242
|
-
await this.#apply(plan);
|
|
2243
|
-
return plan;
|
|
3187
|
+
migrate(deployed, options) {
|
|
3188
|
+
return this.#context.migrate(deployed, options);
|
|
2244
3189
|
}
|
|
2245
3190
|
#build(name, key, contract) {
|
|
2246
|
-
return new Table(() => this.#connect(), this.#driver, name, key, contract, this.#generate);
|
|
3191
|
+
return new Table(() => this.#context.connect(), this.#context.driver, name, key, contract, this.#generate, this.#context.error, this.#context);
|
|
2247
3192
|
}
|
|
2248
|
-
#spawn(tables,
|
|
2249
|
-
return
|
|
2250
|
-
driver: this.#driver,
|
|
3193
|
+
#spawn(tables, primary) {
|
|
3194
|
+
return Database.#attach({
|
|
3195
|
+
driver: this.#context.driver,
|
|
2251
3196
|
tables,
|
|
2252
|
-
|
|
2253
|
-
name: this.#name,
|
|
2254
|
-
...this.#
|
|
2255
|
-
|
|
3197
|
+
primary,
|
|
3198
|
+
name: this.#context.name,
|
|
3199
|
+
...this.#context.error === void 0 ? {} : { error: this.#context.error },
|
|
3200
|
+
...this.#generate === void 0 ? {} : { generator: this.#generate },
|
|
3201
|
+
...this.#context.version === void 0 ? {} : { version: this.#context.version }
|
|
3202
|
+
}, this.#context);
|
|
2256
3203
|
}
|
|
2257
3204
|
#key(name) {
|
|
2258
|
-
return this.#
|
|
3205
|
+
return this.#primary[name] ?? "id";
|
|
2259
3206
|
}
|
|
2260
3207
|
#columns(name) {
|
|
2261
3208
|
const columns = this.#tables[name];
|
|
@@ -2268,74 +3215,36 @@ var Database = class Database {
|
|
|
2268
3215
|
return {
|
|
2269
3216
|
name,
|
|
2270
3217
|
primary: this.#key(name),
|
|
2271
|
-
columns: Object.entries(columns).map(([column, shape]) =>
|
|
2272
|
-
return {
|
|
2273
|
-
name: column,
|
|
2274
|
-
type: shapeToColumnType(shape),
|
|
2275
|
-
nullable: shape.type === "optional" || shape.type === "nullable"
|
|
2276
|
-
};
|
|
2277
|
-
}),
|
|
3218
|
+
columns: Object.entries(columns).map(([column, shape]) => shapeToColumnSchema(column, shape)),
|
|
2278
3219
|
indexes: this.#indexes[name] ?? []
|
|
2279
3220
|
};
|
|
2280
3221
|
});
|
|
2281
3222
|
}
|
|
2282
|
-
#
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
if (
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
name: this.#name,
|
|
2308
|
-
stored: meta.version,
|
|
2309
|
-
declared: this.#version
|
|
2310
|
-
});
|
|
2311
|
-
await this.#apply(plan);
|
|
2312
|
-
}
|
|
2313
|
-
}
|
|
2314
|
-
async #apply(plan) {
|
|
2315
|
-
const native = await this.#driver.transaction?.();
|
|
2316
|
-
if (native !== void 0) {
|
|
2317
|
-
try {
|
|
2318
|
-
await this.#driver.migrate?.(plan);
|
|
2319
|
-
await this.#stamp();
|
|
2320
|
-
} catch (error) {
|
|
2321
|
-
await native.rollback();
|
|
2322
|
-
throw error;
|
|
2323
|
-
}
|
|
2324
|
-
await native.commit();
|
|
2325
|
-
this.#emitter.emit("migrate", plan);
|
|
2326
|
-
return;
|
|
2327
|
-
}
|
|
2328
|
-
await this.#driver.migrate?.(plan);
|
|
2329
|
-
await this.#stamp();
|
|
2330
|
-
this.#emitter.emit("migrate", plan);
|
|
2331
|
-
}
|
|
2332
|
-
async #stamp() {
|
|
2333
|
-
if (this.#version === void 0 || this.#driver.stamp === void 0) return;
|
|
2334
|
-
const meta = {
|
|
2335
|
-
version: this.#version,
|
|
2336
|
-
schema: this.#schema()
|
|
2337
|
-
};
|
|
2338
|
-
await this.#driver.stamp(meta);
|
|
3223
|
+
async #settle(scope, transaction, lifetime) {
|
|
3224
|
+
const outcome = await Promise.resolve().then(() => scope(transaction)).then((value) => ({
|
|
3225
|
+
success: true,
|
|
3226
|
+
value
|
|
3227
|
+
}), (error) => ({
|
|
3228
|
+
success: false,
|
|
3229
|
+
error
|
|
3230
|
+
}));
|
|
3231
|
+
lifetime.stop();
|
|
3232
|
+
const drained = await lifetime.drain().then(() => ({
|
|
3233
|
+
success: true,
|
|
3234
|
+
value: void 0
|
|
3235
|
+
}), (error) => ({
|
|
3236
|
+
success: false,
|
|
3237
|
+
error
|
|
3238
|
+
}));
|
|
3239
|
+
if (!outcome.success) return outcome;
|
|
3240
|
+
if (!drained.success) return drained;
|
|
3241
|
+
return outcome;
|
|
3242
|
+
}
|
|
3243
|
+
static #attach(options, context) {
|
|
3244
|
+
const database = new Database(options);
|
|
3245
|
+
database.#context = context;
|
|
3246
|
+
context.register(database.#schema());
|
|
3247
|
+
return database;
|
|
2339
3248
|
}
|
|
2340
3249
|
};
|
|
2341
3250
|
//#endregion
|
|
@@ -2351,29 +3260,54 @@ var Database = class Database {
|
|
|
2351
3260
|
* snapshot capture and restore — so a caller mutating a nested field of an input
|
|
2352
3261
|
* row, a returned row, or a row mutated in place between snapshot and rollback
|
|
2353
3262
|
* can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
|
|
2354
|
-
* would still share nested object/array references.
|
|
3263
|
+
* would still share nested object/array references. Metadata instead routes
|
|
3264
|
+
* through `cloneDriverMetadata`: `stamp` and migration snapshot exact JSON at
|
|
3265
|
+
* ingress, and `metadata` returns a distinct deeply frozen owned copy. `snapshot`
|
|
2355
3266
|
* clones every table to give transactions an exact rollback point. `scan` and
|
|
2356
3267
|
* `keys` yield in key order — sorted by the core {@link compareValues} total
|
|
2357
3268
|
* order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
|
|
2358
3269
|
* reads) backends honor, so an unordered read agrees across every backend rather
|
|
2359
3270
|
* than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
|
|
2360
|
-
* implements the same
|
|
3271
|
+
* implements the same required methods over real storage.
|
|
2361
3272
|
*/
|
|
2362
3273
|
var MemoryDriver = class {
|
|
2363
3274
|
#tables = /* @__PURE__ */ new Map();
|
|
2364
|
-
#
|
|
3275
|
+
#identities = /* @__PURE__ */ new Map();
|
|
3276
|
+
#schema = [];
|
|
3277
|
+
#metadata;
|
|
2365
3278
|
async open(schema) {
|
|
2366
|
-
|
|
3279
|
+
const owned = normalizeDriverSchema(schema);
|
|
3280
|
+
const deployed = normalizeDriverSchema(this.#metadata?.schema ?? owned);
|
|
3281
|
+
const names = new Set(deployed.map((table) => table.name));
|
|
3282
|
+
for (const name of this.#identities.keys()) if (!names.has(name)) this.#identities.delete(name);
|
|
3283
|
+
for (const table of deployed) {
|
|
3284
|
+
if (!this.#tables.has(table.name)) this.#tables.set(table.name, /* @__PURE__ */ new Map());
|
|
3285
|
+
if (!this.#identities.has(table.name)) this.#identities.set(table.name, {});
|
|
3286
|
+
}
|
|
3287
|
+
this.#schema = deployed;
|
|
2367
3288
|
}
|
|
2368
3289
|
async close() {}
|
|
2369
3290
|
async read(table, key) {
|
|
2370
3291
|
const row = this.#store(table).get(key);
|
|
2371
3292
|
return row === void 0 ? void 0 : structuredClone(row);
|
|
2372
3293
|
}
|
|
2373
|
-
async write(table, key, row) {
|
|
2374
|
-
|
|
3294
|
+
async write(table, key, row, options) {
|
|
3295
|
+
checkAbort(options?.signal);
|
|
3296
|
+
const primary = this.#table(table).primary;
|
|
3297
|
+
this.#store(table).set(key, structuredClone(bindRowKey(row, primary, key)));
|
|
3298
|
+
}
|
|
3299
|
+
async insert(table, key, row, options) {
|
|
3300
|
+
checkAbort(options?.signal);
|
|
3301
|
+
const store = this.#store(table);
|
|
3302
|
+
if (store.has(key)) throw new DatabaseError("CONFLICT", `Row '${key}' already exists in table '${table}'`, {
|
|
3303
|
+
table,
|
|
3304
|
+
key
|
|
3305
|
+
});
|
|
3306
|
+
const primary = this.#table(table).primary;
|
|
3307
|
+
store.set(key, structuredClone(bindRowKey(row, primary, key)));
|
|
2375
3308
|
}
|
|
2376
|
-
async delete(table, key) {
|
|
3309
|
+
async delete(table, key, options) {
|
|
3310
|
+
checkAbort(options?.signal);
|
|
2377
3311
|
return this.#store(table).delete(key);
|
|
2378
3312
|
}
|
|
2379
3313
|
async keys(table) {
|
|
@@ -2392,17 +3326,17 @@ var MemoryDriver = class {
|
|
|
2392
3326
|
* @remarks
|
|
2393
3327
|
* Iterates the table's keys in the same key order `scan` and `keys` yield
|
|
2394
3328
|
* (sorted by {@link compareValues}), testing each row against
|
|
2395
|
-
* `
|
|
3329
|
+
* `input.conditions` (via {@link matchesQuery}) before counting it
|
|
2396
3330
|
* toward `offset` / `limit`. Both are applied lazily as matches are found —
|
|
2397
3331
|
* `offset` matches are skipped without being yielded, and iteration stops the
|
|
2398
3332
|
* instant `limit` yields have been produced, so a large table is never fully
|
|
2399
|
-
* walked for a small page. `
|
|
3333
|
+
* walked for a small page. `input.order` is IGNORED (the same contract as
|
|
2400
3334
|
* `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
|
|
2401
3335
|
* order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
|
|
2402
3336
|
* §11), and an unknown table mirrors `scan`'s empty-yield behavior.
|
|
2403
3337
|
*
|
|
2404
3338
|
* @param table - The table to stream
|
|
2405
|
-
* @param
|
|
3339
|
+
* @param input - The filter / offset / limit to apply lazily
|
|
2406
3340
|
*
|
|
2407
3341
|
* @example
|
|
2408
3342
|
* ```ts
|
|
@@ -2411,18 +3345,22 @@ var MemoryDriver = class {
|
|
|
2411
3345
|
* }
|
|
2412
3346
|
* ```
|
|
2413
3347
|
*/
|
|
2414
|
-
|
|
3348
|
+
stream(table, input) {
|
|
3349
|
+
validatePage(input);
|
|
3350
|
+
return this.#stream(table, input);
|
|
3351
|
+
}
|
|
3352
|
+
async *#stream(table, input) {
|
|
2415
3353
|
const store = this.#store(table);
|
|
2416
|
-
const conditions =
|
|
2417
|
-
const offset =
|
|
2418
|
-
const limit =
|
|
3354
|
+
const conditions = input.conditions;
|
|
3355
|
+
const offset = input.offset ?? 0;
|
|
3356
|
+
const limit = input.limit;
|
|
2419
3357
|
let skipped = 0;
|
|
2420
3358
|
let yielded = 0;
|
|
2421
3359
|
for (const key of this.#ordered(table)) {
|
|
2422
3360
|
if (limit !== void 0 && yielded >= limit) return;
|
|
2423
3361
|
const row = store.get(key);
|
|
2424
3362
|
if (row === void 0) continue;
|
|
2425
|
-
if (conditions !== void 0 && conditions.length > 0 && !
|
|
3363
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesQuery(row, conditions)) continue;
|
|
2426
3364
|
if (skipped < offset) {
|
|
2427
3365
|
skipped += 1;
|
|
2428
3366
|
continue;
|
|
@@ -2438,90 +3376,149 @@ var MemoryDriver = class {
|
|
|
2438
3376
|
* Capture the current state and return a thunk that rolls back to it.
|
|
2439
3377
|
*
|
|
2440
3378
|
* @remarks
|
|
2441
|
-
*
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
2444
|
-
*
|
|
3379
|
+
* Capture owns rows, schema, and one session-local table identity. Replay
|
|
3380
|
+
* adapts rows to each surviving same-identity table's current schema before
|
|
3381
|
+
* changing storage. Removed or replaced tables are skipped; uncaptured and
|
|
3382
|
+
* later-added tables retain their current rows. Schema and metadata are never
|
|
3383
|
+
* restored.
|
|
2445
3384
|
*
|
|
2446
3385
|
* @param tables - The table names to scope the snapshot to; omitted captures every table
|
|
2447
3386
|
* @returns A thunk that restores the captured tables
|
|
2448
3387
|
*/
|
|
2449
3388
|
async snapshot(tables) {
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
2455
|
-
copy.set(name, cloned);
|
|
2456
|
-
}
|
|
2457
|
-
return async () => {
|
|
2458
|
-
this.#tables.clear();
|
|
2459
|
-
for (const [name, store] of copy) {
|
|
2460
|
-
const restored = /* @__PURE__ */ new Map();
|
|
2461
|
-
for (const [key, row] of store) restored.set(key, structuredClone(row));
|
|
2462
|
-
this.#tables.set(name, restored);
|
|
2463
|
-
}
|
|
2464
|
-
};
|
|
2465
|
-
}
|
|
2466
|
-
const copy = /* @__PURE__ */ new Map();
|
|
2467
|
-
for (const name of tables) {
|
|
3389
|
+
const names = tables === void 0 ? this.#schema.map((table) => table.name) : [...new Set(tables)].filter((name) => this.#schema.some((table) => table.name === name));
|
|
3390
|
+
const captured = /* @__PURE__ */ new Map();
|
|
3391
|
+
for (const name of names) {
|
|
3392
|
+
const schema = this.#schema.find((table) => table.name === name);
|
|
2468
3393
|
const store = this.#tables.get(name);
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
3394
|
+
const identity = this.#identities.get(name);
|
|
3395
|
+
if (schema === void 0 || store === void 0 || identity === void 0) continue;
|
|
3396
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3397
|
+
for (const [key, row] of store) rows.set(key, structuredClone(row));
|
|
3398
|
+
captured.set(name, {
|
|
3399
|
+
identity,
|
|
3400
|
+
rows,
|
|
3401
|
+
schema
|
|
3402
|
+
});
|
|
2473
3403
|
}
|
|
2474
3404
|
return async () => {
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
this.#tables.
|
|
3405
|
+
const replacements = /* @__PURE__ */ new Map();
|
|
3406
|
+
for (const [name, capture] of captured) {
|
|
3407
|
+
const schema = this.#schema.find((table) => table.name === name);
|
|
3408
|
+
const store = this.#tables.get(name);
|
|
3409
|
+
if (schema === void 0 || store === void 0 || this.#identities.get(name) !== capture.identity) continue;
|
|
3410
|
+
const plan = planMigration([capture.schema], [schema]);
|
|
3411
|
+
const entries = [...capture.rows.entries()];
|
|
3412
|
+
const migrated = migrateRows(entries.map(([, row]) => row), plan.steps);
|
|
3413
|
+
if (migrated.length !== entries.length) throw new DatabaseError("MIGRATION", "Snapshot row count changed during migration", { table: name });
|
|
3414
|
+
const rows = /* @__PURE__ */ new Map();
|
|
3415
|
+
for (const [index, [key]] of entries.entries()) {
|
|
3416
|
+
const row = migrated[index];
|
|
3417
|
+
if (!isKey(key) || row === void 0) throw new DatabaseError("MIGRATION", "Snapshot row has no usable primary key", {
|
|
3418
|
+
table: name,
|
|
3419
|
+
column: schema.primary,
|
|
3420
|
+
index
|
|
3421
|
+
});
|
|
3422
|
+
rows.set(key, structuredClone(bindRowKey(row, schema.primary, key)));
|
|
3423
|
+
}
|
|
3424
|
+
replacements.set(store, rows);
|
|
3425
|
+
}
|
|
3426
|
+
for (const [store, rows] of replacements) {
|
|
3427
|
+
store.clear();
|
|
3428
|
+
for (const [key, row] of rows) store.set(key, row);
|
|
2479
3429
|
}
|
|
2480
3430
|
};
|
|
2481
3431
|
}
|
|
2482
3432
|
/**
|
|
2483
|
-
* Return the persisted {@link
|
|
3433
|
+
* Return the persisted {@link DriverMetadata}, or `undefined` when the store has
|
|
2484
3434
|
* never been stamped.
|
|
2485
3435
|
*
|
|
2486
3436
|
* @remarks
|
|
2487
3437
|
* In-process only — the metadata lives in this instance's memory, exactly
|
|
2488
|
-
* like the rest of this driver's storage.
|
|
2489
|
-
*
|
|
3438
|
+
* like the rest of this driver's storage. The returned value is a distinct
|
|
3439
|
+
* deeply frozen owned snapshot. A driver-conformance-valid implementation of
|
|
3440
|
+
* the optional `metadata` / `stamp` pair.
|
|
2490
3441
|
*
|
|
2491
|
-
* @returns The last-stamped {@link
|
|
3442
|
+
* @returns The last-stamped {@link DriverMetadata}, or `undefined`
|
|
2492
3443
|
*/
|
|
2493
|
-
async
|
|
2494
|
-
return this.#
|
|
3444
|
+
async metadata() {
|
|
3445
|
+
return this.#metadata === void 0 ? void 0 : cloneDriverMetadata(this.#metadata);
|
|
2495
3446
|
}
|
|
2496
3447
|
/**
|
|
2497
|
-
* Persist
|
|
3448
|
+
* Persist an owned snapshot for a later `metadata()` to return.
|
|
2498
3449
|
*
|
|
2499
|
-
* @param
|
|
3450
|
+
* @param metadata - The {@link DriverMetadata} to persist
|
|
2500
3451
|
*/
|
|
2501
|
-
async stamp(
|
|
2502
|
-
this.#
|
|
3452
|
+
async stamp(metadata) {
|
|
3453
|
+
this.#metadata = cloneDriverMetadata(metadata);
|
|
2503
3454
|
}
|
|
2504
3455
|
/**
|
|
2505
3456
|
* Apply a {@link Migration} plan's steps against the in-memory store.
|
|
2506
3457
|
*
|
|
2507
3458
|
* @remarks
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
3459
|
+
* Steps apply against an isolated candidate. Rows, schema changes, and
|
|
3460
|
+
* optional metadata publish together only after the whole request succeeds.
|
|
2510
3461
|
*
|
|
2511
|
-
* @param
|
|
3462
|
+
* @param input - The migration plan and optional metadata to settle atomically
|
|
2512
3463
|
*/
|
|
2513
|
-
async migrate(
|
|
2514
|
-
|
|
3464
|
+
async migrate(input) {
|
|
3465
|
+
const owned = cloneMigrationInput(input);
|
|
3466
|
+
const schema = projectMigrationSchema(this.#schema, owned.plan.steps);
|
|
3467
|
+
if (owned.metadata !== void 0 && !equalsValue(normalizeDriverSchema(owned.metadata.schema), schema)) throw new DatabaseError("MIGRATION", "Migration metadata schema does not match the plan", {
|
|
3468
|
+
projected: schema,
|
|
3469
|
+
metadata: owned.metadata.schema
|
|
3470
|
+
});
|
|
3471
|
+
const candidate = this.#copy(this.#tables);
|
|
3472
|
+
const identities = this.#projectIdentities(this.#identities, owned.plan.steps);
|
|
3473
|
+
for (const step of owned.plan.steps) this.#migrate(candidate, step);
|
|
3474
|
+
this.#tables.clear();
|
|
3475
|
+
for (const [name, store] of candidate) this.#tables.set(name, store);
|
|
3476
|
+
this.#identities = identities;
|
|
3477
|
+
this.#schema = schema;
|
|
3478
|
+
if (owned.metadata !== void 0) this.#metadata = owned.metadata;
|
|
3479
|
+
}
|
|
3480
|
+
#ordered(table) {
|
|
3481
|
+
return [...this.#store(table).keys()].sort(compareValues);
|
|
3482
|
+
}
|
|
3483
|
+
#require(tables, table) {
|
|
3484
|
+
const store = tables.get(table);
|
|
3485
|
+
if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
|
|
3486
|
+
return store;
|
|
3487
|
+
}
|
|
3488
|
+
#copy(tables) {
|
|
3489
|
+
const copy = /* @__PURE__ */ new Map();
|
|
3490
|
+
for (const [name, store] of tables) {
|
|
3491
|
+
const cloned = /* @__PURE__ */ new Map();
|
|
3492
|
+
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
3493
|
+
copy.set(name, cloned);
|
|
3494
|
+
}
|
|
3495
|
+
return copy;
|
|
3496
|
+
}
|
|
3497
|
+
#projectIdentities(identities, steps) {
|
|
3498
|
+
const projected = new Map(identities);
|
|
3499
|
+
for (const step of steps) {
|
|
3500
|
+
if (step.operation === "table.add") projected.set(step.table.name, {});
|
|
3501
|
+
if (step.operation === "table.remove") projected.delete(step.table);
|
|
3502
|
+
}
|
|
3503
|
+
return projected;
|
|
3504
|
+
}
|
|
3505
|
+
#table(name) {
|
|
3506
|
+
const schema = this.#schema.find((table) => table.name === name);
|
|
3507
|
+
if (schema === void 0) throw new DatabaseError("NOT_FOUND", `Unknown table '${name}'`, { table: name });
|
|
3508
|
+
return schema;
|
|
3509
|
+
}
|
|
3510
|
+
#migrate(tables, step) {
|
|
3511
|
+
switch (step.operation) {
|
|
2515
3512
|
case "table.add":
|
|
2516
|
-
if (!
|
|
3513
|
+
if (!tables.has(step.table.name)) tables.set(step.table.name, /* @__PURE__ */ new Map());
|
|
2517
3514
|
break;
|
|
2518
3515
|
case "table.remove":
|
|
2519
|
-
this.#require(step.table);
|
|
2520
|
-
|
|
3516
|
+
this.#require(tables, step.table);
|
|
3517
|
+
tables.delete(step.table);
|
|
2521
3518
|
break;
|
|
2522
3519
|
case "column.add":
|
|
2523
3520
|
case "column.remove": {
|
|
2524
|
-
const store = this.#require(step.table);
|
|
3521
|
+
const store = this.#require(tables, step.table);
|
|
2525
3522
|
const rows = [...store.entries()];
|
|
2526
3523
|
const migrated = migrateRows(rows.map(([, row]) => row), [step]);
|
|
2527
3524
|
for (const [index, [key]] of rows.entries()) {
|
|
@@ -2535,25 +3532,13 @@ var MemoryDriver = class {
|
|
|
2535
3532
|
break;
|
|
2536
3533
|
}
|
|
2537
3534
|
case "index.add":
|
|
2538
|
-
case "index.remove":
|
|
2539
|
-
this.#require(step.table);
|
|
2540
|
-
break;
|
|
3535
|
+
case "index.remove": this.#require(tables, step.table);
|
|
2541
3536
|
}
|
|
2542
3537
|
}
|
|
2543
|
-
#ordered(table) {
|
|
2544
|
-
return [...this.#store(table).keys()].sort(compareValues);
|
|
2545
|
-
}
|
|
2546
|
-
#require(table) {
|
|
2547
|
-
const store = this.#tables.get(table);
|
|
2548
|
-
if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
|
|
2549
|
-
return store;
|
|
2550
|
-
}
|
|
2551
3538
|
#store(table) {
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
this.#tables.set(table, store);
|
|
2556
|
-
}
|
|
3539
|
+
this.#table(table);
|
|
3540
|
+
const store = this.#tables.get(table);
|
|
3541
|
+
if (store === void 0) throw new DatabaseError("NOT_FOUND", `Table '${table}' has no backing store`, { table });
|
|
2557
3542
|
return store;
|
|
2558
3543
|
}
|
|
2559
3544
|
};
|
|
@@ -2568,10 +3553,10 @@ var MemoryDriver = class {
|
|
|
2568
3553
|
* level. The `const` type parameter captures the literal names and columns, so
|
|
2569
3554
|
* `db.table('users')` is checked against the schema and typed by `Infer` of its
|
|
2570
3555
|
* columns — no annotations. Name a non-`id` primary-key column per table via the
|
|
2571
|
-
* optional `
|
|
3556
|
+
* optional `primary` and `indexes` maps.
|
|
2572
3557
|
*
|
|
2573
|
-
* @param options - The driver,
|
|
2574
|
-
*
|
|
3558
|
+
* @param options - The driver, `tables`, and optional `primary`, `indexes`,
|
|
3559
|
+
* `name`, `generator`, `version`, and emitter hooks
|
|
2575
3560
|
* @returns A typed {@link DatabaseInterface}
|
|
2576
3561
|
*
|
|
2577
3562
|
* @example
|
|
@@ -2585,7 +3570,7 @@ var MemoryDriver = class {
|
|
|
2585
3570
|
* users: { id: stringShape(), age: integerShape() },
|
|
2586
3571
|
* posts: { slug: stringShape(), title: stringShape() },
|
|
2587
3572
|
* },
|
|
2588
|
-
*
|
|
3573
|
+
* primary: { posts: 'slug' },
|
|
2589
3574
|
* })
|
|
2590
3575
|
* await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
|
|
2591
3576
|
* ```
|
|
@@ -2606,6 +3591,6 @@ function createMemoryDriver() {
|
|
|
2606
3591
|
return new MemoryDriver();
|
|
2607
3592
|
}
|
|
2608
3593
|
//#endregion
|
|
2609
|
-
export {
|
|
3594
|
+
export { DEFAULT_PRIMARY, Database, DatabaseError, MAX_PATTERN_LENGTH, MemoryDriver, applyQuery, auditDriver, bindRowKey, checkAbort, cloneDriverMetadata, cloneDriverSchema, cloneMigrationInput, compareValues, computeAggregate, conformDriver, createDatabase, createMemoryDriver, driverFindings, equalsValue, extractKey, filterRows, isColumnSchema, isDatabaseError, isDriverMetadata, isDriverSchema, isKey, isMigration, isMigrationInput, isMigrationStep, isTableSchema, matchesCondition, matchesGlobPattern, matchesLikePattern, matchesQuery, matchesWildcardPattern, migrateRows, normalizeDriverSchema, planMigration, projectMigrationSchema, shapeToColumnSchema, shapeToColumnStorage, sortRows, validatePage };
|
|
2610
3595
|
|
|
2611
3596
|
//# sourceMappingURL=index.js.map
|