@orkestrel/database 0.0.5 → 0.0.7
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 +100 -87
- package/dist/src/browser/index.js +369 -219
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +2389 -1244
- 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 +2368 -1231
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1620 -827
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +466 -571
- package/dist/src/server/index.d.ts +466 -571
- package/dist/src/server/index.js +1603 -811
- package/dist/src/server/index.js.map +1 -1
- package/package.json +21 -19
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
|
|
@@ -52,7 +48,7 @@ var DatabaseError = class extends Error {
|
|
|
52
48
|
super(message);
|
|
53
49
|
this.name = "DatabaseError";
|
|
54
50
|
this.code = code;
|
|
55
|
-
this.context = context;
|
|
51
|
+
if (context !== void 0) this.context = context;
|
|
56
52
|
}
|
|
57
53
|
};
|
|
58
54
|
/**
|
|
@@ -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
|
|
@@ -90,16 +299,7 @@ function isDatabaseError(value) {
|
|
|
90
299
|
* @returns `-1`, `0`, or `1`
|
|
91
300
|
*/
|
|
92
301
|
function compareValues(left, right) {
|
|
93
|
-
const
|
|
94
|
-
if (value === void 0) return 0;
|
|
95
|
-
if (value === null) return 1;
|
|
96
|
-
if (typeof value === "boolean") return 2;
|
|
97
|
-
if (typeof value === "number") return 3;
|
|
98
|
-
if (typeof value === "string") return 4;
|
|
99
|
-
return 5;
|
|
100
|
-
};
|
|
101
|
-
const leftRank = rankOf(left);
|
|
102
|
-
const rightRank = rankOf(right);
|
|
302
|
+
const [leftRank = 5, rightRank = 5] = [left, right].map((value) => value === void 0 ? 0 : value === null ? 1 : typeof value === "boolean" ? 2 : typeof value === "number" ? 3 : typeof value === "string" ? 4 : 5);
|
|
103
303
|
if (leftRank !== rightRank) return leftRank < rightRank ? -1 : 1;
|
|
104
304
|
if (typeof left === "number" && typeof right === "number") {
|
|
105
305
|
if (Number.isNaN(left) || Number.isNaN(right)) return Number.isNaN(left) ? Number.isNaN(right) ? 0 : 1 : -1;
|
|
@@ -115,14 +315,15 @@ function compareValues(left, right) {
|
|
|
115
315
|
*
|
|
116
316
|
* @remarks
|
|
117
317
|
* Primitives compare by SameValueZero (`NaN` equals itself; `+0` equals `-0`).
|
|
118
|
-
* Arrays compare by index (same length, every element `
|
|
318
|
+
* Arrays compare by index (same length, every element `equalsValue`). Plain
|
|
119
319
|
* records (via `isRecord`) compare by their OWN enumerable keys: same key
|
|
120
320
|
* COUNT and, for every key in `left`, `right` has that key (`Object.hasOwn`)
|
|
121
|
-
* with a `
|
|
321
|
+
* with a `equalsValue` value — so a key present with value `undefined` is NOT
|
|
122
322
|
* equal to that key being absent (both differ in `Object.keys` membership).
|
|
123
323
|
* Anything else (functions, class instances, mismatched shapes) falls through
|
|
124
|
-
* to `false`.
|
|
125
|
-
*
|
|
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.
|
|
126
327
|
*
|
|
127
328
|
* @param left - The left value
|
|
128
329
|
* @param right - The right value
|
|
@@ -130,32 +331,67 @@ function compareValues(left, right) {
|
|
|
130
331
|
*
|
|
131
332
|
* @example
|
|
132
333
|
* ```ts
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
|
136
337
|
* ```
|
|
137
338
|
*/
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
385
|
}
|
|
150
386
|
/**
|
|
151
387
|
* Match a value against a wildcard pattern in LINEAR time — the shared, ReDoS-SAFE
|
|
152
|
-
* engine behind {@link
|
|
388
|
+
* engine behind {@link matchesLikePattern} and {@link matchesGlobPattern}.
|
|
153
389
|
*
|
|
154
390
|
* @remarks
|
|
155
391
|
* A backtracking RegExp (`a%b%c` → `^a.*b.*c$`) is CATASTROPHIC on a hostile pattern:
|
|
156
392
|
* `.*` segments separated by literals, matched against a long non-matching input, blow
|
|
157
393
|
* up super-linearly — and JS has no atomic groups / possessive quantifiers to bound it
|
|
158
|
-
* (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
|
|
159
395
|
* wire). So this builds NO regex. It runs the classic GREEDY TWO-POINTER wildcard match:
|
|
160
396
|
* the `any` wildcard records its position and, on a later mismatch, backtracks ONLY to
|
|
161
397
|
* that last `any` (letting it absorb one more char) — so the work is O(value × pattern),
|
|
@@ -178,7 +414,7 @@ function deepEqual(left, right) {
|
|
|
178
414
|
* @returns Whether `value` matches `pattern`
|
|
179
415
|
* @throws A `VALIDATION` {@link DatabaseError} when `pattern` exceeds {@link MAX_PATTERN_LENGTH}
|
|
180
416
|
*/
|
|
181
|
-
function
|
|
417
|
+
function matchesWildcardPattern(value, pattern, any, single, fold) {
|
|
182
418
|
if (pattern.length > 1024) throw new DatabaseError("VALIDATION", `Pattern exceeds the maximum length of ${MAX_PATTERN_LENGTH}`, {
|
|
183
419
|
length: pattern.length,
|
|
184
420
|
limit: MAX_PATTERN_LENGTH
|
|
@@ -207,11 +443,11 @@ function wildcardMatch(value, pattern, any, single, fold) {
|
|
|
207
443
|
while (pi < needle.length && needle[pi] === any) pi += 1;
|
|
208
444
|
return pi === needle.length;
|
|
209
445
|
}
|
|
210
|
-
function
|
|
211
|
-
return
|
|
446
|
+
function matchesLikePattern(value, pattern) {
|
|
447
|
+
return matchesWildcardPattern(value, pattern, "%", "_", true);
|
|
212
448
|
}
|
|
213
|
-
function
|
|
214
|
-
return
|
|
449
|
+
function matchesGlobPattern(value, pattern) {
|
|
450
|
+
return matchesWildcardPattern(value, pattern, "*", "?", false);
|
|
215
451
|
}
|
|
216
452
|
/**
|
|
217
453
|
* Evaluate one {@link Condition} against a row — the per-operator predicate.
|
|
@@ -221,10 +457,10 @@ function globMatch(value, pattern) {
|
|
|
221
457
|
* string is one column; an array descends a nested value) — and applies the
|
|
222
458
|
* operator. Range operators (`above` / `below` / `from` / `to` / `between`) use
|
|
223
459
|
* {@link compareValues}, the total order; the equality family (`equals` / `not`
|
|
224
|
-
* / `any` / `none`) uses {@link
|
|
460
|
+
* / `any` / `none`) uses {@link equalsValue} — STRUCTURAL equality, not the total
|
|
225
461
|
* order's rank-5-collapses-all-objects behavior, so `equals` on an object/array
|
|
226
462
|
* operand only matches a structurally-equal value, never every row holding any
|
|
227
|
-
* object. This is a semantics change from ranking: `
|
|
463
|
+
* object. This is a semantics change from ranking: `equalsValue` is SameValueZero
|
|
228
464
|
* on leaves, so `NaN` now equals `NaN` under `equals` / `any` (it never matched
|
|
229
465
|
* anything under the old rank-based comparison). `like` / `glob` / `starts` /
|
|
230
466
|
* `ends` match only strings; `absent` / `present` test nullishness. Total — a
|
|
@@ -239,19 +475,19 @@ function matchesCondition(row, condition) {
|
|
|
239
475
|
const first = condition.values[0];
|
|
240
476
|
const second = condition.values[1];
|
|
241
477
|
switch (condition.operator) {
|
|
242
|
-
case "equals": return
|
|
243
|
-
case "not": return !
|
|
478
|
+
case "equals": return equalsValue(value, first);
|
|
479
|
+
case "not": return !equalsValue(value, first);
|
|
244
480
|
case "above": return compareValues(value, first) > 0;
|
|
245
481
|
case "below": return compareValues(value, first) < 0;
|
|
246
482
|
case "from": return compareValues(value, first) >= 0;
|
|
247
483
|
case "to": return compareValues(value, first) <= 0;
|
|
248
484
|
case "between": return compareValues(value, first) >= 0 && compareValues(value, second) <= 0;
|
|
249
|
-
case "like": return isString(value) && isString(first) &&
|
|
250
|
-
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);
|
|
251
487
|
case "starts": return isString(value) && isString(first) && value.startsWith(first);
|
|
252
488
|
case "ends": return isString(value) && isString(first) && value.endsWith(first);
|
|
253
|
-
case "any": return condition.values.some((candidate) =>
|
|
254
|
-
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));
|
|
255
491
|
case "absent": return value === void 0 || value === null;
|
|
256
492
|
case "present": return value !== void 0 && value !== null;
|
|
257
493
|
}
|
|
@@ -269,7 +505,7 @@ function matchesCondition(row, condition) {
|
|
|
269
505
|
* @param conditions - The conditions to fold
|
|
270
506
|
* @returns Whether the row satisfies the combined conditions
|
|
271
507
|
*/
|
|
272
|
-
function
|
|
508
|
+
function matchesQuery(row, conditions) {
|
|
273
509
|
let result = true;
|
|
274
510
|
let seeded = false;
|
|
275
511
|
for (const condition of conditions) {
|
|
@@ -283,11 +519,11 @@ function matchesCriteria(row, conditions) {
|
|
|
283
519
|
}
|
|
284
520
|
/**
|
|
285
521
|
* Filter rows by a list of conditions — the shared basis for a table's count
|
|
286
|
-
* and aggregate paths (no sort/page, unlike {@link
|
|
522
|
+
* and aggregate paths (no sort/page, unlike {@link applyQuery}).
|
|
287
523
|
*
|
|
288
524
|
* @remarks
|
|
289
525
|
* An empty condition list matches every row (returned as-is, no copy). Folds
|
|
290
|
-
* each row through {@link
|
|
526
|
+
* each row through {@link matchesQuery}.
|
|
291
527
|
*
|
|
292
528
|
* @param rows - The rows to filter
|
|
293
529
|
* @param conditions - The conditions to apply (empty matches everything)
|
|
@@ -303,7 +539,7 @@ function matchesCriteria(row, conditions) {
|
|
|
303
539
|
*/
|
|
304
540
|
function filterRows(rows, conditions) {
|
|
305
541
|
if (conditions.length === 0) return rows;
|
|
306
|
-
return rows.filter((row) =>
|
|
542
|
+
return rows.filter((row) => matchesQuery(row, conditions));
|
|
307
543
|
}
|
|
308
544
|
/**
|
|
309
545
|
* Sort rows by an ordering specification, leaving the input untouched.
|
|
@@ -328,26 +564,27 @@ function sortRows(rows, order) {
|
|
|
328
564
|
return sorted;
|
|
329
565
|
}
|
|
330
566
|
/**
|
|
331
|
-
* Apply a {@link
|
|
567
|
+
* Apply a {@link QueryInput} to rows — filter, then sort, then page.
|
|
332
568
|
*
|
|
333
569
|
* @remarks
|
|
334
570
|
* The whole portable read pipeline in one place: conditions filter, `order`
|
|
335
571
|
* sorts, and `offset` / `limit` window the result. Each step is skipped when its
|
|
336
|
-
* part of the
|
|
572
|
+
* part of the input is absent. The reference {@link DriverInterface} backends
|
|
337
573
|
* lean on this rather than each re-deriving it.
|
|
338
574
|
*
|
|
339
575
|
* @param rows - The rows to process (typically a table's full `scan`)
|
|
340
|
-
* @param
|
|
576
|
+
* @param input - The read specification, or `undefined` for all rows as-is
|
|
341
577
|
* @returns The filtered, sorted, paged rows
|
|
342
578
|
*/
|
|
343
|
-
function
|
|
579
|
+
function applyQuery(rows, input) {
|
|
580
|
+
validatePage(input);
|
|
344
581
|
let result = rows;
|
|
345
|
-
const conditions =
|
|
346
|
-
if (conditions !== void 0 && conditions.length > 0) result = result.filter((row) =>
|
|
347
|
-
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;
|
|
348
585
|
if (order !== void 0 && order.length > 0) result = sortRows(result, order);
|
|
349
|
-
const offset =
|
|
350
|
-
const limit =
|
|
586
|
+
const offset = input?.offset ?? 0;
|
|
587
|
+
const limit = input?.limit;
|
|
351
588
|
if (offset > 0 || limit !== void 0) result = result.slice(offset, limit !== void 0 ? offset + limit : void 0);
|
|
352
589
|
return result;
|
|
353
590
|
}
|
|
@@ -389,11 +626,24 @@ function computeAggregate(rows, operation, column) {
|
|
|
389
626
|
*/
|
|
390
627
|
function extractKey(row, column) {
|
|
391
628
|
const value = row[column];
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
};
|
|
394
644
|
}
|
|
395
645
|
/**
|
|
396
|
-
* Map a column's {@link ContractShape} to its portable {@link
|
|
646
|
+
* Map a column's {@link ContractShape} to its portable {@link ColumnStorage} — the
|
|
397
647
|
* value a `TableSchema` carries so a native backend can declare a real column.
|
|
398
648
|
*
|
|
399
649
|
* @remarks
|
|
@@ -410,13 +660,13 @@ function extractKey(row, column) {
|
|
|
410
660
|
*
|
|
411
661
|
* @example
|
|
412
662
|
* ```ts
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
663
|
+
* shapeToColumnStorage(stringShape()) // 'text'
|
|
664
|
+
* shapeToColumnStorage(integerShape()) // 'integer'
|
|
665
|
+
* shapeToColumnStorage(optionalShape(integerShape())) // 'integer'
|
|
666
|
+
* shapeToColumnStorage(objectShape({ a: stringShape() })) // 'json'
|
|
417
667
|
* ```
|
|
418
668
|
*/
|
|
419
|
-
function
|
|
669
|
+
function shapeToColumnStorage(shape) {
|
|
420
670
|
switch (shape.type) {
|
|
421
671
|
case "string": return "text";
|
|
422
672
|
case "number": return shape.integer === true ? "integer" : "real";
|
|
@@ -426,7 +676,7 @@ function shapeToColumnType(shape) {
|
|
|
426
676
|
if (shape.values.every((value) => typeof value === "number")) return shape.values.every((value) => Number.isInteger(value)) ? "integer" : "real";
|
|
427
677
|
return "text";
|
|
428
678
|
case "optional":
|
|
429
|
-
case "nullable": return
|
|
679
|
+
case "nullable": return shapeToColumnStorage(shape.inner);
|
|
430
680
|
case "null":
|
|
431
681
|
case "object":
|
|
432
682
|
case "array":
|
|
@@ -436,53 +686,31 @@ function shapeToColumnType(shape) {
|
|
|
436
686
|
}
|
|
437
687
|
}
|
|
438
688
|
/**
|
|
439
|
-
*
|
|
440
|
-
* versioning driver's `meta()` narrows a stored (structured-clone or
|
|
441
|
-
* `JSON.parse`d) value through before trusting it, replacing the per-driver
|
|
442
|
-
* duplicated narrowing every backend used to hand-roll (AGENTS §14: never `as`).
|
|
689
|
+
* Project one contract shape into a portable column schema.
|
|
443
690
|
*
|
|
444
|
-
* @
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* `primary` string pair, a `columns` array of well-formed {@link ColumnSchema}
|
|
448
|
-
* entries (a `name` string, a {@link ColumnType} literal, a `nullable`
|
|
449
|
-
* boolean), and an `indexes` array of string arrays. Anything off-shape
|
|
450
|
-
* (including a non-record) returns `false` rather than throwing.
|
|
451
|
-
*
|
|
452
|
-
* @param value - The value to test
|
|
453
|
-
* @returns `true` when `value` is a well-formed `DriverMeta`
|
|
454
|
-
*
|
|
455
|
-
* @example
|
|
456
|
-
* ```ts
|
|
457
|
-
* isDriverMeta({ version: 1, schema: [] }) // true
|
|
458
|
-
* isDriverMeta({ version: 1, schema: [{ name: 'users' }] }) // false
|
|
459
|
-
* ```
|
|
691
|
+
* @param name - The column name
|
|
692
|
+
* @param shape - The column contract shape
|
|
693
|
+
* @returns The portable storage and independent absence/null acceptance
|
|
460
694
|
*/
|
|
461
|
-
function
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
];
|
|
470
|
-
const isColumnType = (candidate) => isString(candidate) && COLUMN_TYPES.some((type) => type === candidate);
|
|
471
|
-
const isColumnSchema = (candidate) => isRecord(candidate) && isString(candidate.name) && isColumnType(candidate.type) && isBoolean(candidate.nullable);
|
|
472
|
-
const isIndexGroup = (candidate) => isArray(candidate) && candidate.every((entry) => isString(entry));
|
|
473
|
-
const isTableSchema = (candidate) => isRecord(candidate) && isString(candidate.name) && isString(candidate.primary) && isArray(candidate.columns) && candidate.columns.every(isColumnSchema) && isArray(candidate.indexes) && candidate.indexes.every(isIndexGroup);
|
|
474
|
-
return isRecord(value) && isFiniteNumber(value.version) && isArray(value.schema) && value.schema.every(isTableSchema);
|
|
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
|
+
};
|
|
475
703
|
}
|
|
476
704
|
/**
|
|
477
|
-
* Throw when an {@link
|
|
478
|
-
*
|
|
705
|
+
* Throw when an {@link OperationOptions.signal | AbortSignal} has fired — the shared
|
|
706
|
+
* abort gate checked at operation boundaries and between streamed rows.
|
|
479
707
|
*
|
|
480
708
|
* @remarks
|
|
481
709
|
* A no-op for `undefined` or a live signal, so callers thread `options?.signal`
|
|
482
710
|
* straight through. When the signal has aborted, throws an `ABORTED`
|
|
483
711
|
* {@link DatabaseError} carrying the signal's `reason` in its context — callers
|
|
484
|
-
* mint signals with
|
|
485
|
-
* `new AbortController()
|
|
712
|
+
* mint signals with native APIs such as `AbortSignal.timeout(ms)` or
|
|
713
|
+
* `new AbortController()`.
|
|
486
714
|
*
|
|
487
715
|
* @param signal - The signal to check, if any
|
|
488
716
|
* @returns Nothing — returns normally while the signal is live
|
|
@@ -514,11 +742,12 @@ function checkAbort(signal) {
|
|
|
514
742
|
* `column.remove` / `index.add` / `index.remove` steps. Step order is
|
|
515
743
|
* deterministic: every `table.remove`, then every `table.add`, then each
|
|
516
744
|
* shared table's column/index changes in `declared` order. `from` / `to` are
|
|
517
|
-
* plan labels only
|
|
518
|
-
*
|
|
745
|
+
* plan labels only; versioning drivers persist and reconcile them through
|
|
746
|
+
* {@link DriverMetadata}.
|
|
519
747
|
*
|
|
520
748
|
* A column present in BOTH schemas under the same name but with a different
|
|
521
|
-
* `
|
|
749
|
+
* `storage`, `optional`, or `nullable` value throws a `MIGRATION`
|
|
750
|
+
* {@link DatabaseError} naming the
|
|
522
751
|
* table, the column, and the from→to difference — a name-only diff would
|
|
523
752
|
* otherwise silently produce NO step for the drift, and versioned
|
|
524
753
|
* reconciliation would stamp over it. There is no automatic in-place
|
|
@@ -531,42 +760,66 @@ function checkAbort(signal) {
|
|
|
531
760
|
* @param from - The plan's source version label (defaults to `0`)
|
|
532
761
|
* @param to - The plan's target version label (defaults to `1`)
|
|
533
762
|
* @returns The migration plan moving `deployed` toward `declared`
|
|
534
|
-
* @throws A `MIGRATION` {@link DatabaseError} when a shared
|
|
535
|
-
*
|
|
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
|
|
536
767
|
*
|
|
537
768
|
* @example
|
|
538
769
|
* ```ts
|
|
539
770
|
* const plan = planMigration(
|
|
540
|
-
* [{ name: 'users', primary: 'id', columns: [], indexes: [] }],
|
|
541
|
-
* [{ 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: [] }],
|
|
542
773
|
* )
|
|
543
774
|
* // plan.steps === [{ operation: 'column.add', table: 'users', column: { name: 'age', ... } }]
|
|
544
775
|
* ```
|
|
545
776
|
*/
|
|
546
777
|
function planMigration(deployed, declared, from = 0, to = 1) {
|
|
547
|
-
|
|
548
|
-
|
|
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]));
|
|
549
792
|
const steps = [];
|
|
550
|
-
for (const table of
|
|
793
|
+
for (const table of beforeSchema) if (!declaredByName.has(table.name)) steps.push({
|
|
551
794
|
operation: "table.remove",
|
|
552
795
|
table: table.name
|
|
553
796
|
});
|
|
554
|
-
for (const table of
|
|
797
|
+
for (const table of targetSchema) if (!deployedByName.has(table.name)) steps.push({
|
|
555
798
|
operation: "table.add",
|
|
556
799
|
table
|
|
557
800
|
});
|
|
558
|
-
for (const table of
|
|
801
|
+
for (const table of targetSchema) {
|
|
559
802
|
const before = deployedByName.get(table.name);
|
|
560
803
|
if (before === void 0) continue;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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({
|
|
564
817
|
operation: "column.remove",
|
|
565
818
|
table: table.name,
|
|
566
819
|
column: column.name
|
|
567
820
|
});
|
|
568
821
|
for (const column of table.columns) {
|
|
569
|
-
const previous =
|
|
822
|
+
const previous = beforeColumnMap.get(column.name);
|
|
570
823
|
if (previous === void 0) {
|
|
571
824
|
steps.push({
|
|
572
825
|
operation: "column.add",
|
|
@@ -575,36 +828,158 @@ function planMigration(deployed, declared, from = 0, to = 1) {
|
|
|
575
828
|
});
|
|
576
829
|
continue;
|
|
577
830
|
}
|
|
578
|
-
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`, {
|
|
579
832
|
table: table.name,
|
|
580
833
|
column: column.name,
|
|
581
834
|
from: {
|
|
582
|
-
|
|
835
|
+
storage: previous.storage,
|
|
836
|
+
optional: previous.optional,
|
|
583
837
|
nullable: previous.nullable
|
|
584
838
|
},
|
|
585
839
|
to: {
|
|
586
|
-
|
|
840
|
+
storage: column.storage,
|
|
841
|
+
optional: column.optional,
|
|
587
842
|
nullable: column.nullable
|
|
588
843
|
}
|
|
589
844
|
});
|
|
590
845
|
}
|
|
591
|
-
|
|
592
|
-
for (const index of before.indexes) if (!table.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
593
|
-
operation: "index.remove",
|
|
594
|
-
table: table.name,
|
|
595
|
-
index
|
|
596
|
-
});
|
|
597
|
-
for (const index of table.indexes) if (!before.indexes.some((candidate) => sameIndex(candidate, index))) steps.push({
|
|
846
|
+
for (const index of table.indexes) if (!before.indexes.some((candidate) => equalsValue(candidate, index))) steps.push({
|
|
598
847
|
operation: "index.add",
|
|
599
848
|
table: table.name,
|
|
600
849
|
index
|
|
601
850
|
});
|
|
602
851
|
}
|
|
603
|
-
|
|
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: {
|
|
604
858
|
from,
|
|
605
859
|
to,
|
|
606
860
|
steps
|
|
607
|
-
};
|
|
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);
|
|
608
983
|
}
|
|
609
984
|
/**
|
|
610
985
|
* Apply one table's {@link MigrationStep}s to its rows — a pure row transform.
|
|
@@ -652,21 +1027,24 @@ function migrateRows(rows, steps) {
|
|
|
652
1027
|
* `read` of a missing key returns `undefined`; `write`/`read` round-trip with
|
|
653
1028
|
* DEEP copy-in/copy-out isolation (mutating the caller's row — including a
|
|
654
1029
|
* NESTED field — after `write`, or a row `read` returns, never perturbs
|
|
655
|
-
* 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`;
|
|
656
1034
|
* `keys`/`scan` yield in ascending key order; `clear` empties only its target
|
|
657
1035
|
* table; `snapshot`'s rollback thunk restores pre-snapshot state, including a
|
|
658
1036
|
* NESTED field mutated in place on a read-back row between capture and
|
|
659
1037
|
* restore; a scoped `snapshot(['users'])` rolls back only the named table,
|
|
660
1038
|
* leaving a concurrent mutation to another table intact; a
|
|
661
1039
|
* non-`id` primary key (`posts.slug`) round-trips; a nested-object row
|
|
662
|
-
* round-trips structurally (via {@link
|
|
1040
|
+
* round-trips structurally (via {@link equalsValue}). The optional surface is
|
|
663
1041
|
* presence-gated: when `migrate` exists, a `column.remove` plan strips the
|
|
664
1042
|
* column from stored rows and a plan referencing an unknown table throws
|
|
665
1043
|
* `DatabaseError` `MIGRATION`; when `stream` exists, it yields only
|
|
666
1044
|
* condition-matching rows and honors `offset`/`limit`; when `transaction`
|
|
667
|
-
* exists, `commit` persists and `rollback` restores; when both `
|
|
668
|
-
* `stamp` exist, a fresh store's `
|
|
669
|
-
* `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.
|
|
670
1048
|
*
|
|
671
1049
|
* Each phase runs within a `try`/`catch`: an EXPECTED mismatch yields a
|
|
672
1050
|
* finding built from the assertion, while an UNEXPECTED throw (a driver
|
|
@@ -698,23 +1076,27 @@ async function* driverFindings(factory) {
|
|
|
698
1076
|
columns: [
|
|
699
1077
|
{
|
|
700
1078
|
name: "id",
|
|
701
|
-
|
|
1079
|
+
storage: "text",
|
|
1080
|
+
optional: false,
|
|
702
1081
|
nullable: false
|
|
703
1082
|
},
|
|
704
1083
|
{
|
|
705
1084
|
name: "name",
|
|
706
|
-
|
|
1085
|
+
storage: "text",
|
|
1086
|
+
optional: false,
|
|
707
1087
|
nullable: false
|
|
708
1088
|
},
|
|
709
1089
|
{
|
|
710
1090
|
name: "age",
|
|
711
|
-
|
|
712
|
-
|
|
1091
|
+
storage: "integer",
|
|
1092
|
+
optional: true,
|
|
1093
|
+
nullable: false
|
|
713
1094
|
},
|
|
714
1095
|
{
|
|
715
1096
|
name: "meta",
|
|
716
|
-
|
|
717
|
-
|
|
1097
|
+
storage: "json",
|
|
1098
|
+
optional: true,
|
|
1099
|
+
nullable: false
|
|
718
1100
|
}
|
|
719
1101
|
],
|
|
720
1102
|
indexes: []
|
|
@@ -724,539 +1106,801 @@ async function* driverFindings(factory) {
|
|
|
724
1106
|
primary: "slug",
|
|
725
1107
|
columns: [{
|
|
726
1108
|
name: "slug",
|
|
727
|
-
|
|
1109
|
+
storage: "text",
|
|
1110
|
+
optional: false,
|
|
728
1111
|
nullable: false
|
|
729
1112
|
}, {
|
|
730
1113
|
name: "title",
|
|
731
|
-
|
|
1114
|
+
storage: "text",
|
|
1115
|
+
optional: false,
|
|
732
1116
|
nullable: false
|
|
733
1117
|
}],
|
|
734
1118
|
indexes: []
|
|
735
1119
|
};
|
|
736
1120
|
const CONFORMANCE_SCHEMA = [CONFORMANCE_USERS_SCHEMA, CONFORMANCE_POSTS_SCHEMA];
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
})
|
|
742
|
-
|
|
743
|
-
{
|
|
1121
|
+
try {
|
|
1122
|
+
const driver = factory();
|
|
1123
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1124
|
+
await driver.close();
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
yield {
|
|
744
1127
|
check: "open-close",
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1128
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1129
|
+
context: { error }
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
try {
|
|
1133
|
+
const driver = factory();
|
|
1134
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1135
|
+
const missing = await driver.read("users", "nope");
|
|
1136
|
+
await driver.close();
|
|
1137
|
+
if (missing !== void 0) yield {
|
|
1138
|
+
check: "read-missing",
|
|
1139
|
+
message: "read of a missing key must return undefined",
|
|
1140
|
+
context: {
|
|
1141
|
+
table: "users",
|
|
1142
|
+
expected: void 0,
|
|
1143
|
+
actual: missing
|
|
749
1144
|
}
|
|
750
|
-
}
|
|
751
|
-
|
|
1145
|
+
};
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
yield {
|
|
752
1148
|
check: "read-missing",
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1149
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1150
|
+
context: { error }
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
writeRead: try {
|
|
1154
|
+
const driver = factory();
|
|
1155
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1156
|
+
const input = {
|
|
1157
|
+
id: "caller",
|
|
1158
|
+
name: "Ada",
|
|
1159
|
+
age: 30,
|
|
1160
|
+
meta: { tags: ["a"] }
|
|
1161
|
+
};
|
|
1162
|
+
await driver.write("users", "u1", input);
|
|
1163
|
+
input.name = "Mutated after write";
|
|
1164
|
+
if (isRecord(input.meta) && Array.isArray(input.meta.tags)) input.meta.tags.push("mutated");
|
|
1165
|
+
const stored = await driver.read("users", "u1");
|
|
1166
|
+
const original = {
|
|
1167
|
+
id: "u1",
|
|
1168
|
+
name: "Ada",
|
|
1169
|
+
age: 30,
|
|
1170
|
+
meta: { tags: ["a"] }
|
|
1171
|
+
};
|
|
1172
|
+
if (stored === void 0 || !equalsValue(stored, original)) {
|
|
1173
|
+
await driver.close();
|
|
1174
|
+
yield {
|
|
1175
|
+
check: "copy-in",
|
|
1176
|
+
message: "write must deep-copy the input row (including nested fields) rather than store it by reference",
|
|
1177
|
+
context: {
|
|
759
1178
|
table: "users",
|
|
760
|
-
expected:
|
|
761
|
-
actual:
|
|
762
|
-
}
|
|
1179
|
+
expected: original,
|
|
1180
|
+
actual: stored
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
break writeRead;
|
|
1184
|
+
}
|
|
1185
|
+
stored.name = "Mutated after read";
|
|
1186
|
+
if (isRecord(stored.meta) && Array.isArray(stored.meta.tags)) stored.meta.tags.push("mutated");
|
|
1187
|
+
const reread = await driver.read("users", "u1");
|
|
1188
|
+
if (reread === void 0 || !equalsValue(reread, original)) {
|
|
1189
|
+
await driver.close();
|
|
1190
|
+
yield {
|
|
1191
|
+
check: "copy-out",
|
|
1192
|
+
message: "read must deep-copy the stored row (including nested fields) rather than return it by reference",
|
|
1193
|
+
context: {
|
|
1194
|
+
table: "users",
|
|
1195
|
+
expected: original,
|
|
1196
|
+
actual: reread
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
break writeRead;
|
|
1200
|
+
}
|
|
1201
|
+
const overwrite = {
|
|
1202
|
+
id: "caller",
|
|
1203
|
+
name: "Ada Overwritten",
|
|
1204
|
+
age: 31
|
|
1205
|
+
};
|
|
1206
|
+
await driver.write("users", "u1", overwrite);
|
|
1207
|
+
const overwritten = await driver.read("users", "u1");
|
|
1208
|
+
await driver.close();
|
|
1209
|
+
const expectedOverwrite = {
|
|
1210
|
+
...overwrite,
|
|
1211
|
+
id: "u1"
|
|
1212
|
+
};
|
|
1213
|
+
if (overwritten === void 0 || !equalsValue(overwritten, expectedOverwrite)) yield {
|
|
1214
|
+
check: "upsert",
|
|
1215
|
+
message: "write must upsert-overwrite an existing key",
|
|
1216
|
+
context: {
|
|
1217
|
+
table: "users",
|
|
1218
|
+
expected: expectedOverwrite,
|
|
1219
|
+
actual: overwritten
|
|
763
1220
|
}
|
|
764
|
-
}
|
|
765
|
-
|
|
1221
|
+
};
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
yield {
|
|
766
1224
|
check: "write-read",
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
actual: reread
|
|
803
|
-
});
|
|
1225
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1226
|
+
context: { error }
|
|
1227
|
+
};
|
|
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
|
|
804
1260
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
+
}
|
|
1270
|
+
deletePhase: try {
|
|
1271
|
+
const driver = factory();
|
|
1272
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1273
|
+
await driver.write("users", "u1", {
|
|
1274
|
+
id: "u1",
|
|
1275
|
+
name: "Ada",
|
|
1276
|
+
age: 30
|
|
1277
|
+
});
|
|
1278
|
+
const first = await driver.delete("users", "u1");
|
|
1279
|
+
if (first !== true) {
|
|
1280
|
+
await driver.close();
|
|
1281
|
+
yield {
|
|
1282
|
+
check: "delete-true",
|
|
1283
|
+
message: "delete of an existing key must return true",
|
|
1284
|
+
context: {
|
|
814
1285
|
table: "users",
|
|
815
|
-
expected:
|
|
816
|
-
actual:
|
|
817
|
-
}
|
|
1286
|
+
expected: true,
|
|
1287
|
+
actual: first
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
break deletePhase;
|
|
1291
|
+
}
|
|
1292
|
+
const second = await driver.delete("users", "u1");
|
|
1293
|
+
await driver.close();
|
|
1294
|
+
if (second !== false) yield {
|
|
1295
|
+
check: "delete-false",
|
|
1296
|
+
message: "delete of an already-removed key must return false",
|
|
1297
|
+
context: {
|
|
1298
|
+
table: "users",
|
|
1299
|
+
expected: false,
|
|
1300
|
+
actual: second
|
|
818
1301
|
}
|
|
819
|
-
}
|
|
820
|
-
|
|
1302
|
+
};
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
yield {
|
|
821
1305
|
check: "delete",
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1306
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1307
|
+
context: { error }
|
|
1308
|
+
};
|
|
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
|
|
838
1363
|
}
|
|
839
|
-
const second = await driver.delete("users", "u1");
|
|
840
|
-
await driver.close();
|
|
841
|
-
if (second !== false) return findingOf("delete-false", "delete of an already-removed key must return false", {
|
|
842
|
-
table: "users",
|
|
843
|
-
expected: false,
|
|
844
|
-
actual: second
|
|
845
|
-
});
|
|
846
1364
|
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
await driver.close();
|
|
888
|
-
if (!deepEqual(scannedIds, expected)) return findingOf("scan-order", "scan must yield rows in ascending key order", {
|
|
1365
|
+
};
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
yield {
|
|
1368
|
+
check: "mutation-abort",
|
|
1369
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1370
|
+
context: { error }
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
orderPhase: try {
|
|
1374
|
+
const driver = factory();
|
|
1375
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1376
|
+
for (const row of [
|
|
1377
|
+
{
|
|
1378
|
+
id: "c",
|
|
1379
|
+
name: "C",
|
|
1380
|
+
age: 3
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
id: "a",
|
|
1384
|
+
name: "A",
|
|
1385
|
+
age: 1
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
id: "b",
|
|
1389
|
+
name: "B",
|
|
1390
|
+
age: 2
|
|
1391
|
+
}
|
|
1392
|
+
]) await driver.write("users", row.id, row);
|
|
1393
|
+
const expected = [
|
|
1394
|
+
"a",
|
|
1395
|
+
"b",
|
|
1396
|
+
"c"
|
|
1397
|
+
];
|
|
1398
|
+
const keys = [...await driver.keys("users")];
|
|
1399
|
+
if (!equalsValue(keys, expected)) {
|
|
1400
|
+
await driver.close();
|
|
1401
|
+
yield {
|
|
1402
|
+
check: "keys-order",
|
|
1403
|
+
message: "keys must be returned in ascending key order",
|
|
1404
|
+
context: {
|
|
889
1405
|
table: "users",
|
|
890
1406
|
expected,
|
|
891
|
-
actual:
|
|
892
|
-
}
|
|
1407
|
+
actual: keys
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
break orderPhase;
|
|
1411
|
+
}
|
|
1412
|
+
const scanned = [];
|
|
1413
|
+
for await (const row of driver.scan("users")) scanned.push(row);
|
|
1414
|
+
const scannedIds = scanned.map((row) => row.id);
|
|
1415
|
+
await driver.close();
|
|
1416
|
+
if (!equalsValue(scannedIds, expected)) yield {
|
|
1417
|
+
check: "scan-order",
|
|
1418
|
+
message: "scan must yield rows in ascending key order",
|
|
1419
|
+
context: {
|
|
1420
|
+
table: "users",
|
|
1421
|
+
expected,
|
|
1422
|
+
actual: scannedIds
|
|
893
1423
|
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1424
|
+
};
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
yield {
|
|
1427
|
+
check: "order",
|
|
1428
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1429
|
+
context: { error }
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
clearPhase: try {
|
|
1433
|
+
const driver = factory();
|
|
1434
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1435
|
+
await driver.write("users", "u1", {
|
|
1436
|
+
id: "u1",
|
|
1437
|
+
name: "Ada",
|
|
1438
|
+
age: 30
|
|
1439
|
+
});
|
|
1440
|
+
await driver.write("posts", "p1", {
|
|
1441
|
+
slug: "p1",
|
|
1442
|
+
title: "Post"
|
|
1443
|
+
});
|
|
1444
|
+
await driver.clear("users");
|
|
1445
|
+
const usersKeys = await driver.keys("users");
|
|
1446
|
+
const postsKeys = await driver.keys("posts");
|
|
1447
|
+
await driver.close();
|
|
1448
|
+
if (usersKeys.length !== 0) {
|
|
1449
|
+
yield {
|
|
1450
|
+
check: "clear-target",
|
|
1451
|
+
message: "clear must empty the targeted table",
|
|
1452
|
+
context: {
|
|
914
1453
|
table: "users",
|
|
915
1454
|
expected: [],
|
|
916
1455
|
actual: usersKeys
|
|
917
|
-
});
|
|
918
|
-
if (postsKeys.length !== 1) return findingOf("clear-other", "clear must not affect other tables", {
|
|
919
|
-
table: "posts",
|
|
920
|
-
expected: 1,
|
|
921
|
-
actual: postsKeys.length
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
},
|
|
925
|
-
{
|
|
926
|
-
check: "snapshot",
|
|
927
|
-
run: async () => {
|
|
928
|
-
const driver = factory();
|
|
929
|
-
await driver.open(CONFORMANCE_SCHEMA);
|
|
930
|
-
const original = {
|
|
931
|
-
id: "u1",
|
|
932
|
-
name: "Ada",
|
|
933
|
-
age: 30
|
|
934
|
-
};
|
|
935
|
-
await driver.write("users", "u1", original);
|
|
936
|
-
const rollback = await driver.snapshot();
|
|
937
|
-
await driver.write("users", "u2", {
|
|
938
|
-
id: "u2",
|
|
939
|
-
name: "Grace",
|
|
940
|
-
age: 40
|
|
941
|
-
});
|
|
942
|
-
await driver.delete("users", "u1");
|
|
943
|
-
await rollback();
|
|
944
|
-
const keys = [...await driver.keys("users")];
|
|
945
|
-
if (!deepEqual(keys, ["u1"])) {
|
|
946
|
-
await driver.close();
|
|
947
|
-
return findingOf("snapshot-rollback", "snapshot rollback must restore the pre-snapshot key set", {
|
|
948
|
-
table: "users",
|
|
949
|
-
expected: ["u1"],
|
|
950
|
-
actual: keys
|
|
951
|
-
});
|
|
952
1456
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1457
|
+
};
|
|
1458
|
+
break clearPhase;
|
|
1459
|
+
}
|
|
1460
|
+
if (postsKeys.length !== 1) yield {
|
|
1461
|
+
check: "clear-other",
|
|
1462
|
+
message: "clear must not affect other tables",
|
|
1463
|
+
context: {
|
|
1464
|
+
table: "posts",
|
|
1465
|
+
expected: 1,
|
|
1466
|
+
actual: postsKeys.length
|
|
960
1467
|
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1468
|
+
};
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
yield {
|
|
1471
|
+
check: "clear",
|
|
1472
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1473
|
+
context: { error }
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
snapshotPhase: try {
|
|
1477
|
+
const driver = factory();
|
|
1478
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1479
|
+
const original = {
|
|
1480
|
+
id: "u1",
|
|
1481
|
+
name: "Ada",
|
|
1482
|
+
age: 30
|
|
1483
|
+
};
|
|
1484
|
+
await driver.write("users", "u1", original);
|
|
1485
|
+
const rollback = await driver.snapshot();
|
|
1486
|
+
await driver.write("users", "u2", {
|
|
1487
|
+
id: "u2",
|
|
1488
|
+
name: "Grace",
|
|
1489
|
+
age: 40
|
|
1490
|
+
});
|
|
1491
|
+
await driver.delete("users", "u1");
|
|
1492
|
+
await rollback();
|
|
1493
|
+
const keys = [...await driver.keys("users")];
|
|
1494
|
+
if (!equalsValue(keys, ["u1"])) {
|
|
1495
|
+
await driver.close();
|
|
1496
|
+
yield {
|
|
1497
|
+
check: "snapshot-rollback",
|
|
1498
|
+
message: "snapshot rollback must restore the pre-snapshot key set",
|
|
1499
|
+
context: {
|
|
987
1500
|
table: "users",
|
|
988
|
-
expected:
|
|
989
|
-
actual:
|
|
990
|
-
}
|
|
1501
|
+
expected: ["u1"],
|
|
1502
|
+
actual: keys
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
break snapshotPhase;
|
|
1506
|
+
}
|
|
1507
|
+
const restored = await driver.read("users", "u1");
|
|
1508
|
+
await driver.close();
|
|
1509
|
+
if (restored === void 0 || !equalsValue(restored, original)) yield {
|
|
1510
|
+
check: "snapshot-rollback-value",
|
|
1511
|
+
message: "snapshot rollback must restore pre-snapshot row values",
|
|
1512
|
+
context: {
|
|
1513
|
+
table: "users",
|
|
1514
|
+
expected: original,
|
|
1515
|
+
actual: restored
|
|
991
1516
|
}
|
|
992
|
-
}
|
|
993
|
-
|
|
1517
|
+
};
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
yield {
|
|
1520
|
+
check: "snapshot",
|
|
1521
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1522
|
+
context: { error }
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
try {
|
|
1526
|
+
const driver = factory();
|
|
1527
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1528
|
+
const original = {
|
|
1529
|
+
id: "u3",
|
|
1530
|
+
name: "Nested",
|
|
1531
|
+
age: 20,
|
|
1532
|
+
meta: { tags: ["a"] }
|
|
1533
|
+
};
|
|
1534
|
+
await driver.write("users", "u3", original);
|
|
1535
|
+
const rollback = await driver.snapshot();
|
|
1536
|
+
const before = await driver.read("users", "u3");
|
|
1537
|
+
if (isRecord(before) && isRecord(before.meta) && Array.isArray(before.meta.tags)) before.meta.tags.push("mutated-before-restore");
|
|
1538
|
+
await driver.write("users", "u3", {
|
|
1539
|
+
id: "u3",
|
|
1540
|
+
name: "Nested",
|
|
1541
|
+
age: 20,
|
|
1542
|
+
meta: { tags: ["a", "mutated-after-write"] }
|
|
1543
|
+
});
|
|
1544
|
+
await rollback();
|
|
1545
|
+
const restored = await driver.read("users", "u3");
|
|
1546
|
+
await driver.close();
|
|
1547
|
+
if (restored === void 0 || !equalsValue(restored, original)) yield {
|
|
1548
|
+
check: "snapshot-nested",
|
|
1549
|
+
message: "snapshot rollback must restore pre-snapshot nested field values, unaffected by a later in-place mutation of a read-back row",
|
|
1550
|
+
context: {
|
|
1551
|
+
table: "users",
|
|
1552
|
+
expected: original,
|
|
1553
|
+
actual: restored
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
yield {
|
|
1558
|
+
check: "snapshot-nested",
|
|
1559
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1560
|
+
context: { error }
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
const driver = factory();
|
|
1565
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1566
|
+
await driver.write("posts", "hello-world", {
|
|
1567
|
+
slug: "caller",
|
|
1568
|
+
title: "Hello"
|
|
1569
|
+
});
|
|
1570
|
+
const post = await driver.read("posts", "hello-world");
|
|
1571
|
+
const key = post === void 0 ? void 0 : extractKey(post, "slug");
|
|
1572
|
+
await driver.close();
|
|
1573
|
+
if (key !== "hello-world") yield {
|
|
994
1574
|
check: "non-id-primary",
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
title: "Hello"
|
|
1001
|
-
});
|
|
1002
|
-
const post = await driver.read("posts", "hello-world");
|
|
1003
|
-
const key = post === void 0 ? void 0 : extractKey(post, "slug");
|
|
1004
|
-
await driver.close();
|
|
1005
|
-
if (key !== "hello-world") return findingOf("non-id-primary", "a non-id primary key column must round-trip through the store", {
|
|
1006
|
-
table: "posts",
|
|
1007
|
-
expected: "hello-world",
|
|
1008
|
-
actual: key
|
|
1009
|
-
});
|
|
1575
|
+
message: "a non-id primary key column must round-trip through the store",
|
|
1576
|
+
context: {
|
|
1577
|
+
table: "posts",
|
|
1578
|
+
expected: "hello-world",
|
|
1579
|
+
actual: key
|
|
1010
1580
|
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1581
|
+
};
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
yield {
|
|
1584
|
+
check: "non-id-primary",
|
|
1585
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1586
|
+
context: { error }
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const driver = factory();
|
|
1591
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1592
|
+
const nested = {
|
|
1593
|
+
id: "u3",
|
|
1594
|
+
name: "Nested",
|
|
1595
|
+
age: 20,
|
|
1596
|
+
meta: {
|
|
1597
|
+
tags: ["a", "b"],
|
|
1598
|
+
deep: { flag: true }
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
await driver.write("users", "u3", nested);
|
|
1602
|
+
const readBack = await driver.read("users", "u3");
|
|
1603
|
+
await driver.close();
|
|
1604
|
+
if (readBack === void 0 || !equalsValue(readBack, nested)) yield {
|
|
1013
1605
|
check: "nested-roundtrip",
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1606
|
+
message: "a nested-object row must round-trip structurally",
|
|
1607
|
+
context: {
|
|
1608
|
+
table: "users",
|
|
1609
|
+
expected: nested,
|
|
1610
|
+
actual: readBack
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
yield {
|
|
1615
|
+
check: "nested-roundtrip",
|
|
1616
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1617
|
+
context: { error }
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
migratePhase: try {
|
|
1621
|
+
const driver = factory();
|
|
1622
|
+
if (driver.migrate === void 0) break migratePhase;
|
|
1623
|
+
const deployedUsers = {
|
|
1624
|
+
...CONFORMANCE_USERS_SCHEMA,
|
|
1625
|
+
columns: [...CONFORMANCE_USERS_SCHEMA.columns, {
|
|
1626
|
+
name: "legacy",
|
|
1627
|
+
storage: "boolean",
|
|
1628
|
+
optional: true,
|
|
1629
|
+
nullable: false
|
|
1630
|
+
}]
|
|
1631
|
+
};
|
|
1632
|
+
await driver.open([deployedUsers, CONFORMANCE_POSTS_SCHEMA]);
|
|
1633
|
+
await driver.write("users", "u1", {
|
|
1634
|
+
id: "u1",
|
|
1635
|
+
name: "Ada",
|
|
1636
|
+
age: 30,
|
|
1637
|
+
legacy: true
|
|
1638
|
+
});
|
|
1639
|
+
const removePlan = planMigration([deployedUsers], [CONFORMANCE_USERS_SCHEMA]);
|
|
1640
|
+
await driver.migrate({ plan: removePlan });
|
|
1641
|
+
const migrated = await driver.read("users", "u1");
|
|
1642
|
+
if (migrated === void 0 || "legacy" in migrated) {
|
|
1643
|
+
await driver.close();
|
|
1644
|
+
yield {
|
|
1645
|
+
check: "migrate-column-remove",
|
|
1646
|
+
message: "a column.remove migration must strip the column from stored rows",
|
|
1647
|
+
context: {
|
|
1030
1648
|
table: "users",
|
|
1031
|
-
expected:
|
|
1032
|
-
actual:
|
|
1033
|
-
}
|
|
1649
|
+
expected: void 0,
|
|
1650
|
+
actual: migrated === void 0 ? void 0 : migrated.legacy
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
break migratePhase;
|
|
1654
|
+
}
|
|
1655
|
+
let caught;
|
|
1656
|
+
try {
|
|
1657
|
+
await driver.migrate({ plan: {
|
|
1658
|
+
from: 0,
|
|
1659
|
+
to: 1,
|
|
1660
|
+
steps: [{
|
|
1661
|
+
operation: "table.remove",
|
|
1662
|
+
table: "ghost"
|
|
1663
|
+
}]
|
|
1664
|
+
} });
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
caught = error;
|
|
1667
|
+
}
|
|
1668
|
+
await driver.close();
|
|
1669
|
+
if (!isDatabaseError(caught) || caught.code !== "MIGRATION") yield {
|
|
1670
|
+
check: "migrate-unknown-table",
|
|
1671
|
+
message: "a migration step referencing an unknown table must throw a MIGRATION DatabaseError",
|
|
1672
|
+
context: {
|
|
1673
|
+
table: "ghost",
|
|
1674
|
+
expected: "MIGRATION",
|
|
1675
|
+
actual: isDatabaseError(caught) ? caught.code : caught
|
|
1034
1676
|
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1677
|
+
};
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
yield {
|
|
1037
1680
|
check: "migrate",
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1681
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1682
|
+
context: { error }
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
streamPhase: try {
|
|
1686
|
+
const driver = factory();
|
|
1687
|
+
if (driver.stream === void 0) break streamPhase;
|
|
1688
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1689
|
+
for (const row of [
|
|
1690
|
+
{
|
|
1691
|
+
id: "a",
|
|
1692
|
+
name: "A",
|
|
1693
|
+
age: 10
|
|
1694
|
+
},
|
|
1695
|
+
{
|
|
1696
|
+
id: "b",
|
|
1697
|
+
name: "B",
|
|
1698
|
+
age: 20
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
id: "c",
|
|
1702
|
+
name: "C",
|
|
1703
|
+
age: 30
|
|
1704
|
+
}
|
|
1705
|
+
]) await driver.write("users", row.id, row);
|
|
1706
|
+
const input = { conditions: [{
|
|
1707
|
+
column: "age",
|
|
1708
|
+
operator: "above",
|
|
1709
|
+
values: [10],
|
|
1710
|
+
connector: "and"
|
|
1711
|
+
}] };
|
|
1712
|
+
const matched = [];
|
|
1713
|
+
for await (const row of driver.stream("users", input)) matched.push(row);
|
|
1714
|
+
const matchedIds = matched.map((row) => row.id).sort();
|
|
1715
|
+
if (!equalsValue(matchedIds, ["b", "c"])) {
|
|
1716
|
+
await driver.close();
|
|
1717
|
+
yield {
|
|
1718
|
+
check: "stream-match",
|
|
1719
|
+
message: "stream must yield only condition-matching rows",
|
|
1720
|
+
context: {
|
|
1721
|
+
table: "users",
|
|
1722
|
+
expected: ["b", "c"],
|
|
1723
|
+
actual: matchedIds
|
|
1079
1724
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1725
|
+
};
|
|
1726
|
+
break streamPhase;
|
|
1727
|
+
}
|
|
1728
|
+
const paged = [];
|
|
1729
|
+
for await (const row of driver.stream("users", {
|
|
1730
|
+
offset: 1,
|
|
1731
|
+
limit: 1
|
|
1732
|
+
})) paged.push(row);
|
|
1733
|
+
await driver.close();
|
|
1734
|
+
if (paged.length !== 1) yield {
|
|
1735
|
+
check: "stream-page",
|
|
1736
|
+
message: "stream must honor offset and limit",
|
|
1737
|
+
context: {
|
|
1738
|
+
table: "users",
|
|
1739
|
+
expected: 1,
|
|
1740
|
+
actual: paged.length
|
|
1086
1741
|
}
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1742
|
+
};
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
yield {
|
|
1089
1745
|
check: "stream",
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
const matched = [];
|
|
1118
|
-
for await (const row of driver.stream("users", criteria)) matched.push(row);
|
|
1119
|
-
const matchedIds = matched.map((row) => row.id).sort();
|
|
1120
|
-
if (!deepEqual(matchedIds, ["b", "c"])) {
|
|
1121
|
-
await driver.close();
|
|
1122
|
-
return findingOf("stream-match", "stream must yield only condition-matching rows", {
|
|
1123
|
-
table: "users",
|
|
1124
|
-
expected: ["b", "c"],
|
|
1125
|
-
actual: matchedIds
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
const paged = [];
|
|
1129
|
-
for await (const row of driver.stream("users", {
|
|
1130
|
-
offset: 1,
|
|
1131
|
-
limit: 1
|
|
1132
|
-
})) paged.push(row);
|
|
1133
|
-
await driver.close();
|
|
1134
|
-
if (paged.length !== 1) return findingOf("stream-page", "stream must honor offset and limit", {
|
|
1746
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1747
|
+
context: { error }
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
transactionPhase: try {
|
|
1751
|
+
const driver = factory();
|
|
1752
|
+
if (driver.transaction === void 0) break transactionPhase;
|
|
1753
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1754
|
+
await driver.write("users", "u1", {
|
|
1755
|
+
id: "u1",
|
|
1756
|
+
name: "Ada",
|
|
1757
|
+
age: 30
|
|
1758
|
+
});
|
|
1759
|
+
await driver.transaction(async (transaction) => {
|
|
1760
|
+
await transaction.write("users", "u2", {
|
|
1761
|
+
id: "u2",
|
|
1762
|
+
name: "Grace",
|
|
1763
|
+
age: 40
|
|
1764
|
+
});
|
|
1765
|
+
});
|
|
1766
|
+
const afterCommit = [...await driver.keys("users")].sort();
|
|
1767
|
+
if (!equalsValue(afterCommit, ["u1", "u2"])) {
|
|
1768
|
+
await driver.close();
|
|
1769
|
+
yield {
|
|
1770
|
+
check: "transaction-commit",
|
|
1771
|
+
message: "transaction commit must persist writes made during the scope",
|
|
1772
|
+
context: {
|
|
1135
1773
|
table: "users",
|
|
1136
|
-
expected:
|
|
1137
|
-
actual:
|
|
1138
|
-
});
|
|
1139
|
-
}
|
|
1140
|
-
},
|
|
1141
|
-
{
|
|
1142
|
-
check: "transaction",
|
|
1143
|
-
run: async () => {
|
|
1144
|
-
const driver = factory();
|
|
1145
|
-
if (driver.transaction === void 0) return void 0;
|
|
1146
|
-
await driver.open(CONFORMANCE_SCHEMA);
|
|
1147
|
-
await driver.write("users", "u1", {
|
|
1148
|
-
id: "u1",
|
|
1149
|
-
name: "Ada",
|
|
1150
|
-
age: 30
|
|
1151
|
-
});
|
|
1152
|
-
const committing = await driver.transaction();
|
|
1153
|
-
await driver.write("users", "u2", {
|
|
1154
|
-
id: "u2",
|
|
1155
|
-
name: "Grace",
|
|
1156
|
-
age: 40
|
|
1157
|
-
});
|
|
1158
|
-
await committing.commit();
|
|
1159
|
-
const afterCommit = [...await driver.keys("users")].sort();
|
|
1160
|
-
if (!deepEqual(afterCommit, ["u1", "u2"])) {
|
|
1161
|
-
await driver.close();
|
|
1162
|
-
return findingOf("transaction-commit", "transaction commit must persist writes made during the scope", {
|
|
1163
|
-
table: "users",
|
|
1164
|
-
expected: ["u1", "u2"],
|
|
1165
|
-
actual: afterCommit
|
|
1166
|
-
});
|
|
1774
|
+
expected: ["u1", "u2"],
|
|
1775
|
+
actual: afterCommit
|
|
1167
1776
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1777
|
+
};
|
|
1778
|
+
break transactionPhase;
|
|
1779
|
+
}
|
|
1780
|
+
const reason = {};
|
|
1781
|
+
try {
|
|
1782
|
+
await driver.transaction(async (transaction) => {
|
|
1783
|
+
await transaction.write("users", "u3", {
|
|
1170
1784
|
id: "u3",
|
|
1171
1785
|
name: "Marie",
|
|
1172
1786
|
age: 50
|
|
1173
1787
|
});
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1788
|
+
throw reason;
|
|
1789
|
+
});
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
if (error !== reason) throw error;
|
|
1792
|
+
}
|
|
1793
|
+
const afterRollback = [...await driver.keys("users")].sort();
|
|
1794
|
+
await driver.close();
|
|
1795
|
+
if (!equalsValue(afterRollback, ["u1", "u2"])) yield {
|
|
1796
|
+
check: "transaction-rollback",
|
|
1797
|
+
message: "transaction rollback must restore pre-transaction state",
|
|
1798
|
+
context: {
|
|
1799
|
+
table: "users",
|
|
1800
|
+
expected: ["u1", "u2"],
|
|
1801
|
+
actual: afterRollback
|
|
1182
1802
|
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1803
|
+
};
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
yield {
|
|
1806
|
+
check: "transaction",
|
|
1807
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1808
|
+
context: { error }
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
metadataPhase: try {
|
|
1812
|
+
const driver = factory();
|
|
1813
|
+
if (driver.metadata === void 0 || driver.stamp === void 0) break metadataPhase;
|
|
1814
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1815
|
+
const fresh = await driver.metadata();
|
|
1816
|
+
if (fresh !== void 0) {
|
|
1817
|
+
await driver.close();
|
|
1818
|
+
yield {
|
|
1819
|
+
check: "metadata-fresh",
|
|
1820
|
+
message: "a fresh store must report undefined metadata",
|
|
1821
|
+
context: {
|
|
1822
|
+
expected: void 0,
|
|
1823
|
+
actual: fresh
|
|
1197
1824
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1825
|
+
};
|
|
1826
|
+
break metadataPhase;
|
|
1827
|
+
}
|
|
1828
|
+
const stamped = {
|
|
1829
|
+
version: 1,
|
|
1830
|
+
schema: CONFORMANCE_SCHEMA
|
|
1831
|
+
};
|
|
1832
|
+
await driver.stamp(stamped);
|
|
1833
|
+
const read = await driver.metadata();
|
|
1834
|
+
await driver.close();
|
|
1835
|
+
if (read === void 0 || !equalsValue(read, stamped)) yield {
|
|
1836
|
+
check: "metadata-stamp",
|
|
1837
|
+
message: "metadata() must return exactly the last-stamped value",
|
|
1838
|
+
context: {
|
|
1839
|
+
expected: stamped,
|
|
1840
|
+
actual: read
|
|
1209
1841
|
}
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1842
|
+
};
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
yield {
|
|
1845
|
+
check: "metadata-stamp",
|
|
1846
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1847
|
+
context: { error }
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
scopedPhase: try {
|
|
1851
|
+
const driver = factory();
|
|
1852
|
+
await driver.open(CONFORMANCE_SCHEMA);
|
|
1853
|
+
await driver.write("users", "u1", {
|
|
1854
|
+
id: "u1",
|
|
1855
|
+
name: "Ada",
|
|
1856
|
+
age: 30
|
|
1857
|
+
});
|
|
1858
|
+
await driver.write("posts", "p1", {
|
|
1859
|
+
slug: "p1",
|
|
1860
|
+
title: "Post"
|
|
1861
|
+
});
|
|
1862
|
+
const rollback = await driver.snapshot(["users"]);
|
|
1863
|
+
await driver.write("users", "u2", {
|
|
1864
|
+
id: "u2",
|
|
1865
|
+
name: "Grace",
|
|
1866
|
+
age: 40
|
|
1867
|
+
});
|
|
1868
|
+
await driver.write("posts", "p2", {
|
|
1869
|
+
slug: "p2",
|
|
1870
|
+
title: "Another post"
|
|
1871
|
+
});
|
|
1872
|
+
await rollback();
|
|
1873
|
+
const usersKeys = [...await driver.keys("users")];
|
|
1874
|
+
if (!equalsValue(usersKeys, ["u1"])) {
|
|
1875
|
+
await driver.close();
|
|
1876
|
+
yield {
|
|
1877
|
+
check: "snapshot-scoped-users",
|
|
1878
|
+
message: "a scoped snapshot must roll back only the named table",
|
|
1879
|
+
context: {
|
|
1880
|
+
table: "users",
|
|
1881
|
+
expected: ["u1"],
|
|
1882
|
+
actual: usersKeys
|
|
1244
1883
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
if (!deepEqual(postsKeys, ["p1", "p2"])) return findingOf("snapshot-scoped-posts", "a scoped snapshot must leave an unnamed table's mutations intact", {
|
|
1248
|
-
table: "posts",
|
|
1249
|
-
expected: ["p1", "p2"],
|
|
1250
|
-
actual: postsKeys
|
|
1251
|
-
});
|
|
1252
|
-
}
|
|
1884
|
+
};
|
|
1885
|
+
break scopedPhase;
|
|
1253
1886
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1887
|
+
const postsKeys = [...await driver.keys("posts")].sort();
|
|
1888
|
+
await driver.close();
|
|
1889
|
+
if (!equalsValue(postsKeys, ["p1", "p2"])) yield {
|
|
1890
|
+
check: "snapshot-scoped-posts",
|
|
1891
|
+
message: "a scoped snapshot must leave an unnamed table's mutations intact",
|
|
1892
|
+
context: {
|
|
1893
|
+
table: "posts",
|
|
1894
|
+
expected: ["p1", "p2"],
|
|
1895
|
+
actual: postsKeys
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1258
1898
|
} catch (error) {
|
|
1259
|
-
yield
|
|
1899
|
+
yield {
|
|
1900
|
+
check: "snapshot-scoped",
|
|
1901
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1902
|
+
context: { error }
|
|
1903
|
+
};
|
|
1260
1904
|
}
|
|
1261
1905
|
}
|
|
1262
1906
|
/**
|
|
@@ -1315,40 +1959,424 @@ async function auditDriver(factory) {
|
|
|
1315
1959
|
for await (const finding of driverFindings(factory)) findings.push(finding);
|
|
1316
1960
|
return findings;
|
|
1317
1961
|
}
|
|
1962
|
+
//#endregion
|
|
1963
|
+
//#region src/core/TransactionIterator.ts
|
|
1318
1964
|
/**
|
|
1319
|
-
*
|
|
1965
|
+
* The internal continuation boundary for one transaction-scoped async iterable.
|
|
1320
1966
|
*
|
|
1321
1967
|
* @remarks
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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.
|
|
1971
|
+
*/
|
|
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.
|
|
1335
2039
|
*
|
|
1336
|
-
* @
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
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.
|
|
1340
2096
|
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
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.
|
|
1344
2101
|
*/
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
+
};
|
|
1352
2380
|
//#endregion
|
|
1353
2381
|
//#region src/core/Cursor.ts
|
|
1354
2382
|
/**
|
|
@@ -1361,14 +2389,21 @@ function generateUUID(random = Math.random) {
|
|
|
1361
2389
|
* skipped. `update` and `remove` act on the row at the current position.
|
|
1362
2390
|
*/
|
|
1363
2391
|
var Cursor = class {
|
|
1364
|
-
#table;
|
|
1365
2392
|
#keys;
|
|
2393
|
+
#read;
|
|
2394
|
+
#update;
|
|
2395
|
+
#remove;
|
|
2396
|
+
#track;
|
|
2397
|
+
#tail = Promise.resolve();
|
|
1366
2398
|
#index = -1;
|
|
1367
2399
|
#value;
|
|
1368
2400
|
#closed = false;
|
|
1369
|
-
constructor(
|
|
1370
|
-
this.#table = table;
|
|
2401
|
+
constructor(keys, read, update, remove, track) {
|
|
1371
2402
|
this.#keys = keys;
|
|
2403
|
+
this.#read = read;
|
|
2404
|
+
this.#update = update;
|
|
2405
|
+
this.#remove = remove;
|
|
2406
|
+
this.#track = track;
|
|
1372
2407
|
}
|
|
1373
2408
|
get value() {
|
|
1374
2409
|
return this.#value;
|
|
@@ -1379,11 +2414,31 @@ var Cursor = class {
|
|
|
1379
2414
|
get done() {
|
|
1380
2415
|
return this.#closed || this.#index >= this.#keys.length;
|
|
1381
2416
|
}
|
|
1382
|
-
|
|
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() {
|
|
1383
2431
|
if (this.#closed) return;
|
|
1384
2432
|
this.#index += 1;
|
|
1385
2433
|
while (this.#index < this.#keys.length) {
|
|
1386
|
-
|
|
2434
|
+
if (this.#closed) return;
|
|
2435
|
+
const key = this.#keys[this.#index];
|
|
2436
|
+
if (key === void 0) {
|
|
2437
|
+
this.#index += 1;
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
const row = await this.#read(key);
|
|
2441
|
+
if (this.#closed) return;
|
|
1387
2442
|
if (row !== void 0) {
|
|
1388
2443
|
this.#value = row;
|
|
1389
2444
|
return;
|
|
@@ -1392,94 +2447,103 @@ var Cursor = class {
|
|
|
1392
2447
|
}
|
|
1393
2448
|
this.#value = void 0;
|
|
1394
2449
|
}
|
|
1395
|
-
async
|
|
2450
|
+
async #revise(changes) {
|
|
1396
2451
|
if (this.#closed || this.#value === void 0) return;
|
|
1397
2452
|
const key = this.#keys[this.#index];
|
|
1398
|
-
|
|
1399
|
-
|
|
2453
|
+
if (key === void 0) return;
|
|
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;
|
|
1400
2459
|
}
|
|
1401
|
-
async
|
|
2460
|
+
async #delete() {
|
|
1402
2461
|
if (this.#closed || this.#value === void 0) return;
|
|
1403
|
-
|
|
2462
|
+
const key = this.#keys[this.#index];
|
|
2463
|
+
if (key === void 0) return;
|
|
2464
|
+
await this.#remove(key);
|
|
2465
|
+
if (this.#closed) return;
|
|
1404
2466
|
this.#value = void 0;
|
|
1405
2467
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
this.#
|
|
2468
|
+
#queue(operation) {
|
|
2469
|
+
const result = this.#tail.then(operation);
|
|
2470
|
+
this.#tail = result.then(() => void 0, () => void 0);
|
|
2471
|
+
return result;
|
|
1409
2472
|
}
|
|
1410
2473
|
};
|
|
1411
2474
|
//#endregion
|
|
1412
|
-
//#region src/core/
|
|
2475
|
+
//#region src/core/DatabaseIterator.ts
|
|
1413
2476
|
/**
|
|
1414
|
-
*
|
|
2477
|
+
* The internal continuation admission boundary for a root database stream.
|
|
1415
2478
|
*
|
|
1416
2479
|
* @remarks
|
|
1417
|
-
*
|
|
1418
|
-
*
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
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.
|
|
1421
2484
|
*/
|
|
1422
|
-
var
|
|
1423
|
-
#
|
|
1424
|
-
#
|
|
1425
|
-
#
|
|
1426
|
-
constructor(
|
|
1427
|
-
this.#
|
|
1428
|
-
this.#
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
return this.#apply("equals", [value]);
|
|
1433
|
-
}
|
|
1434
|
-
not(value) {
|
|
1435
|
-
return this.#apply("not", [value]);
|
|
1436
|
-
}
|
|
1437
|
-
above(value) {
|
|
1438
|
-
return this.#apply("above", [value]);
|
|
1439
|
-
}
|
|
1440
|
-
below(value) {
|
|
1441
|
-
return this.#apply("below", [value]);
|
|
1442
|
-
}
|
|
1443
|
-
from(value) {
|
|
1444
|
-
return this.#apply("from", [value]);
|
|
1445
|
-
}
|
|
1446
|
-
to(value) {
|
|
1447
|
-
return this.#apply("to", [value]);
|
|
1448
|
-
}
|
|
1449
|
-
between(lower, upper) {
|
|
1450
|
-
return this.#apply("between", [lower, upper]);
|
|
1451
|
-
}
|
|
1452
|
-
like(pattern) {
|
|
1453
|
-
return this.#apply("like", [pattern]);
|
|
1454
|
-
}
|
|
1455
|
-
glob(pattern) {
|
|
1456
|
-
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;
|
|
1457
2495
|
}
|
|
1458
|
-
|
|
1459
|
-
return this.#
|
|
2496
|
+
next() {
|
|
2497
|
+
return this.#continue(() => this.#next());
|
|
1460
2498
|
}
|
|
1461
|
-
|
|
1462
|
-
return this.#
|
|
2499
|
+
return() {
|
|
2500
|
+
return this.#continue(() => this.#return());
|
|
1463
2501
|
}
|
|
1464
|
-
|
|
1465
|
-
return this.#
|
|
2502
|
+
throw(error) {
|
|
2503
|
+
return this.#continue(() => this.#throw(error));
|
|
1466
2504
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
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;
|
|
1469
2514
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
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;
|
|
1472
2536
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
2537
|
+
#continue(operation) {
|
|
2538
|
+
if (!this.#context.accepting) this.#cleanup();
|
|
2539
|
+
return this.#context.track(operation);
|
|
1475
2540
|
}
|
|
1476
|
-
#
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
});
|
|
2541
|
+
#cleanup() {
|
|
2542
|
+
if (this.#cleaned) return;
|
|
2543
|
+
this.#cleaned = true;
|
|
2544
|
+
try {
|
|
2545
|
+
(this.#source.return?.())?.catch(() => {});
|
|
2546
|
+
} catch {}
|
|
1483
2547
|
}
|
|
1484
2548
|
};
|
|
1485
2549
|
//#endregion
|
|
@@ -1488,12 +2552,9 @@ var Clause = class {
|
|
|
1488
2552
|
* A fluent query builder bound to one table.
|
|
1489
2553
|
*
|
|
1490
2554
|
* @remarks
|
|
1491
|
-
* Accumulates conditions, ordering, JS filters, and a page
|
|
1492
|
-
* mutates and returns the same instance
|
|
1493
|
-
*
|
|
1494
|
-
* table resolves; a `filter` predicate is applied in memory after the read and
|
|
1495
|
-
* before paging, so it composes with the rest without a backend ever seeing a
|
|
1496
|
-
* 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.
|
|
1497
2558
|
*/
|
|
1498
2559
|
var Query = class {
|
|
1499
2560
|
#table;
|
|
@@ -1505,47 +2566,34 @@ var Query = class {
|
|
|
1505
2566
|
constructor(table) {
|
|
1506
2567
|
this.#table = table;
|
|
1507
2568
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
}
|
|
1511
|
-
and(column) {
|
|
1512
|
-
return this.#clause(column, "and");
|
|
1513
|
-
}
|
|
1514
|
-
or(column) {
|
|
1515
|
-
return this.#clause(column, "or");
|
|
1516
|
-
}
|
|
1517
|
-
filter(predicate) {
|
|
1518
|
-
this.#filters.push(predicate);
|
|
2569
|
+
condition(input) {
|
|
2570
|
+
this.#conditions.push(input);
|
|
1519
2571
|
return this;
|
|
1520
2572
|
}
|
|
1521
|
-
|
|
1522
|
-
this.#orders.push(
|
|
1523
|
-
column,
|
|
1524
|
-
direction: "ascending"
|
|
1525
|
-
});
|
|
2573
|
+
order(input) {
|
|
2574
|
+
this.#orders.push(input);
|
|
1526
2575
|
return this;
|
|
1527
2576
|
}
|
|
1528
|
-
|
|
1529
|
-
this.#
|
|
1530
|
-
column,
|
|
1531
|
-
direction: "descending"
|
|
1532
|
-
});
|
|
2577
|
+
filter(predicate) {
|
|
2578
|
+
this.#filters.push(predicate);
|
|
1533
2579
|
return this;
|
|
1534
2580
|
}
|
|
1535
2581
|
limit(count) {
|
|
2582
|
+
validatePage({ limit: count });
|
|
1536
2583
|
this.#limit = count;
|
|
1537
2584
|
return this;
|
|
1538
2585
|
}
|
|
1539
2586
|
offset(count) {
|
|
2587
|
+
validatePage({ offset: count });
|
|
1540
2588
|
this.#offset = count;
|
|
1541
2589
|
return this;
|
|
1542
2590
|
}
|
|
1543
|
-
async
|
|
2591
|
+
async collect() {
|
|
1544
2592
|
if (this.#filters.length === 0) return this.#table.records({
|
|
1545
2593
|
conditions: this.#conditions,
|
|
1546
2594
|
order: this.#orders,
|
|
1547
|
-
limit: this.#limit,
|
|
1548
|
-
offset: this.#offset
|
|
2595
|
+
...this.#limit !== void 0 ? { limit: this.#limit } : {},
|
|
2596
|
+
...this.#offset !== void 0 ? { offset: this.#offset } : {}
|
|
1549
2597
|
});
|
|
1550
2598
|
const fetched = await this.#table.records({
|
|
1551
2599
|
conditions: this.#conditions,
|
|
@@ -1553,8 +2601,8 @@ var Query = class {
|
|
|
1553
2601
|
});
|
|
1554
2602
|
return this.#page(this.#filtered(fetched));
|
|
1555
2603
|
}
|
|
1556
|
-
async
|
|
1557
|
-
return (await this.
|
|
2604
|
+
async find() {
|
|
2605
|
+
return (await this.collect())[0];
|
|
1558
2606
|
}
|
|
1559
2607
|
async count() {
|
|
1560
2608
|
if (this.#filters.length === 0) return this.#table.count({ conditions: this.#conditions });
|
|
@@ -1562,24 +2610,17 @@ var Query = class {
|
|
|
1562
2610
|
return this.#filtered(fetched).length;
|
|
1563
2611
|
}
|
|
1564
2612
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
* limit.
|
|
2613
|
+
* Lazily evaluate conditions, filters, offset, and limit.
|
|
1567
2614
|
*
|
|
1568
|
-
* @
|
|
1569
|
-
*
|
|
1570
|
-
* are evaluated one at a time). Same abort semantics as
|
|
1571
|
-
* `TableInterface.scan`: the signal (if any) is checked before each yield,
|
|
1572
|
-
* and breaking out early closes the underlying source.
|
|
1573
|
-
*
|
|
1574
|
-
* @param options - `signal` to cancel the iteration; checked before each yield
|
|
1575
|
-
* @returns An async iterable of matching rows
|
|
2615
|
+
* @param options - Optional abort options
|
|
2616
|
+
* @returns Matching rows in storage order
|
|
1576
2617
|
*/
|
|
1577
2618
|
async *stream(options) {
|
|
1578
2619
|
if (this.#filters.length === 0) {
|
|
1579
2620
|
yield* this.#table.scan({
|
|
1580
2621
|
conditions: this.#conditions,
|
|
1581
|
-
limit: this.#limit,
|
|
1582
|
-
offset: this.#offset
|
|
2622
|
+
...this.#limit !== void 0 ? { limit: this.#limit } : {},
|
|
2623
|
+
...this.#offset !== void 0 ? { offset: this.#offset } : {}
|
|
1583
2624
|
}, options);
|
|
1584
2625
|
return;
|
|
1585
2626
|
}
|
|
@@ -1607,24 +2648,6 @@ var Query = class {
|
|
|
1607
2648
|
if (this.#filters.length === 0) return this.#table.aggregate(operation, column, { conditions: this.#conditions });
|
|
1608
2649
|
return this.#table.records({ conditions: this.#conditions }).then((fetched) => computeAggregate(this.#filtered(fetched), operation, column));
|
|
1609
2650
|
}
|
|
1610
|
-
sum(column) {
|
|
1611
|
-
return this.aggregate("sum", column);
|
|
1612
|
-
}
|
|
1613
|
-
average(column) {
|
|
1614
|
-
return this.aggregate("average", column);
|
|
1615
|
-
}
|
|
1616
|
-
minimum(column) {
|
|
1617
|
-
return this.aggregate("minimum", column);
|
|
1618
|
-
}
|
|
1619
|
-
maximum(column) {
|
|
1620
|
-
return this.aggregate("maximum", column);
|
|
1621
|
-
}
|
|
1622
|
-
#clause(column, connector) {
|
|
1623
|
-
return new Clause((condition) => {
|
|
1624
|
-
this.#conditions.push(condition);
|
|
1625
|
-
return this;
|
|
1626
|
-
}, column, connector);
|
|
1627
|
-
}
|
|
1628
2651
|
#filtered(rows) {
|
|
1629
2652
|
let result = rows;
|
|
1630
2653
|
for (const predicate of this.#filters) result = result.filter(predicate);
|
|
@@ -1667,19 +2690,20 @@ var Table = class {
|
|
|
1667
2690
|
#contract;
|
|
1668
2691
|
#guard;
|
|
1669
2692
|
#generate;
|
|
2693
|
+
#context;
|
|
2694
|
+
#scope;
|
|
1670
2695
|
#emitter;
|
|
1671
|
-
constructor(ready, driver, name, key, contract, generate,
|
|
2696
|
+
constructor(ready, driver, name, key, contract, generate, error, context, scope) {
|
|
1672
2697
|
this.#ready = ready;
|
|
1673
2698
|
this.#driver = driver;
|
|
1674
2699
|
this.#name = name;
|
|
1675
2700
|
this.#key = key;
|
|
1676
2701
|
this.#contract = contract;
|
|
1677
|
-
this.#guard = contract.is;
|
|
1678
|
-
this.#generate = generate;
|
|
1679
|
-
this.#
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
});
|
|
2702
|
+
this.#guard = contract.is;
|
|
2703
|
+
this.#generate = generate;
|
|
2704
|
+
this.#context = context;
|
|
2705
|
+
this.#scope = scope;
|
|
2706
|
+
this.#emitter = new Emitter({ ...error !== void 0 ? { error } : {} });
|
|
1683
2707
|
}
|
|
1684
2708
|
get emitter() {
|
|
1685
2709
|
return this.#emitter;
|
|
@@ -1693,58 +2717,77 @@ var Table = class {
|
|
|
1693
2717
|
get contract() {
|
|
1694
2718
|
return this.#contract;
|
|
1695
2719
|
}
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
+
});
|
|
1700
2726
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
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
|
+
});
|
|
1705
2733
|
}
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
+
});
|
|
1710
2740
|
}
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
2741
|
+
keys() {
|
|
2742
|
+
return this.#track(async () => {
|
|
2743
|
+
await this.#ready();
|
|
2744
|
+
return this.#driver.keys(this.#name);
|
|
2745
|
+
});
|
|
1714
2746
|
}
|
|
1715
|
-
async records(
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
+
});
|
|
1722
2763
|
}
|
|
1723
2764
|
/**
|
|
1724
|
-
* Count rows matching `
|
|
2765
|
+
* Count contract-valid rows matching `input`'s conditions.
|
|
1725
2766
|
*
|
|
1726
2767
|
* @remarks
|
|
1727
|
-
*
|
|
1728
|
-
* contract guard
|
|
1729
|
-
*
|
|
1730
|
-
* `filterRows`-over-`#collect()` fallback count raw storage) — so it can
|
|
1731
|
-
* exceed `(await records(criteria)).length` when storage holds rows that
|
|
1732
|
-
* no longer conform to the table's contract (legacy or migrated data).
|
|
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()`.
|
|
1733
2771
|
*
|
|
1734
|
-
* @param
|
|
2772
|
+
* @param input - Optional conditions to filter by (paging is ignored)
|
|
1735
2773
|
* @param options - `{ signal }` to abort
|
|
1736
|
-
* @returns The count of matching
|
|
2774
|
+
* @returns The count of matching contract-valid rows
|
|
1737
2775
|
*/
|
|
1738
|
-
async count(
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
+
});
|
|
1745
2788
|
}
|
|
1746
2789
|
/**
|
|
1747
|
-
* Compute an aggregate over `column` across rows matching `
|
|
2790
|
+
* Compute an aggregate over `column` across rows matching `input`'s
|
|
1748
2791
|
* conditions.
|
|
1749
2792
|
*
|
|
1750
2793
|
* @remarks
|
|
@@ -1756,115 +2799,138 @@ var Table = class {
|
|
|
1756
2799
|
*
|
|
1757
2800
|
* @param operation - The aggregate to compute
|
|
1758
2801
|
* @param column - The column to aggregate
|
|
1759
|
-
* @param
|
|
2802
|
+
* @param input - Optional conditions to filter by (paging is ignored)
|
|
1760
2803
|
* @param options - `{ signal }` to abort
|
|
1761
2804
|
* @returns The aggregate value, or `undefined` when undefined for the inputs
|
|
1762
2805
|
*/
|
|
1763
|
-
async aggregate(operation, column,
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
+
});
|
|
1771
2817
|
}
|
|
1772
2818
|
/**
|
|
1773
|
-
* Stream the table's rows matching `
|
|
2819
|
+
* Stream the table's rows matching `input`, applying offset/limit paging.
|
|
1774
2820
|
*
|
|
1775
2821
|
* @remarks
|
|
1776
|
-
* `
|
|
1777
|
-
* table's contract guard
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
* the contract guard runs, when storage holds rows that no longer conform
|
|
1781
|
-
* 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.
|
|
1782
2826
|
*
|
|
1783
|
-
* @param
|
|
2827
|
+
* @param input - Optional conditions plus offset/limit paging
|
|
1784
2828
|
* @param options - `{ signal }` to abort mid-stream
|
|
1785
2829
|
* @returns An async iterable of matching, guard-conforming rows
|
|
1786
2830
|
*/
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
if (this.#
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
return;
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
const offset = criteria?.offset ?? 0;
|
|
1800
|
-
const limit = criteria?.limit;
|
|
1801
|
-
let matched = 0;
|
|
1802
|
-
let yielded = 0;
|
|
1803
|
-
for await (const row of this.#driver.scan(this.#name)) {
|
|
1804
|
-
checkAbort(options?.signal);
|
|
1805
|
-
if (limit !== void 0 && yielded >= limit) break;
|
|
1806
|
-
if (conditions !== void 0 && conditions.length > 0 && !matchesCriteria(row, conditions)) continue;
|
|
1807
|
-
if (matched < offset) {
|
|
1808
|
-
matched += 1;
|
|
1809
|
-
continue;
|
|
1810
|
-
}
|
|
1811
|
-
matched += 1;
|
|
1812
|
-
const narrowed = this.#cast(row);
|
|
1813
|
-
if (narrowed !== void 0) {
|
|
1814
|
-
yielded += 1;
|
|
1815
|
-
yield narrowed;
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
async set(rows, options) {
|
|
1820
|
-
checkAbort(options?.signal);
|
|
1821
|
-
await this.#ready();
|
|
1822
|
-
if (isArray(rows)) return this.#each(rows, (row) => this.#put(row, false), options?.signal);
|
|
1823
|
-
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
|
+
});
|
|
1824
2843
|
}
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
+
});
|
|
1830
2850
|
}
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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
|
+
});
|
|
1836
2857
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
+
});
|
|
1842
2864
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
2865
|
+
clear() {
|
|
2866
|
+
return this.#track(async () => {
|
|
2867
|
+
await this.#ready();
|
|
2868
|
+
await this.#driver.clear(this.#name);
|
|
2869
|
+
this.#emitter.emit("clear");
|
|
2870
|
+
});
|
|
1847
2871
|
}
|
|
1848
2872
|
query() {
|
|
1849
2873
|
return new Query(this);
|
|
1850
2874
|
}
|
|
1851
|
-
|
|
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);
|
|
1852
2887
|
await this.#ready();
|
|
1853
|
-
const
|
|
1854
|
-
|
|
1855
|
-
|
|
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
|
+
}
|
|
1856
2918
|
}
|
|
1857
|
-
async #each(
|
|
2919
|
+
async #each(elements, operation, signal) {
|
|
1858
2920
|
const results = [];
|
|
1859
|
-
for (const
|
|
2921
|
+
for (const element of elements) {
|
|
1860
2922
|
checkAbort(signal);
|
|
1861
|
-
results.push(await operation(
|
|
2923
|
+
results.push(await operation(element));
|
|
1862
2924
|
}
|
|
1863
2925
|
return results;
|
|
1864
2926
|
}
|
|
1865
2927
|
async #read(key) {
|
|
1866
2928
|
return this.#cast(await this.#driver.read(this.#name, key));
|
|
1867
2929
|
}
|
|
2930
|
+
async #readCursor(key) {
|
|
2931
|
+
await this.#ready();
|
|
2932
|
+
return this.#read(key);
|
|
2933
|
+
}
|
|
1868
2934
|
async #resolveOne(key) {
|
|
1869
2935
|
const row = await this.#read(key);
|
|
1870
2936
|
if (row === void 0) throw new DatabaseError("NOT_FOUND", `No row '${key}' in table '${this.#name}'`, {
|
|
@@ -1873,29 +2939,79 @@ var Table = class {
|
|
|
1873
2939
|
});
|
|
1874
2940
|
return row;
|
|
1875
2941
|
}
|
|
1876
|
-
async #put(row,
|
|
2942
|
+
async #put(row, insert, options) {
|
|
1877
2943
|
const validated = this.#validate(this.#prepare(row));
|
|
1878
2944
|
const key = this.#resolveKey(validated);
|
|
1879
|
-
if (
|
|
1880
|
-
|
|
1881
|
-
key
|
|
1882
|
-
});
|
|
1883
|
-
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);
|
|
1884
2947
|
this.#emitter.emit("write", key);
|
|
1885
2948
|
return key;
|
|
1886
2949
|
}
|
|
1887
|
-
async #updateOne(key, changes) {
|
|
2950
|
+
async #updateOne(key, changes, options) {
|
|
1888
2951
|
const existing = await this.#driver.read(this.#name, key);
|
|
1889
|
-
if (existing === void 0)
|
|
1890
|
-
|
|
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);
|
|
1891
2963
|
this.#emitter.emit("write", key);
|
|
1892
2964
|
return true;
|
|
1893
2965
|
}
|
|
1894
|
-
async #
|
|
1895
|
-
|
|
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);
|
|
1896
2972
|
if (removed) this.#emitter.emit("remove", key);
|
|
1897
2973
|
return removed;
|
|
1898
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
|
+
}
|
|
1899
3015
|
async #collect() {
|
|
1900
3016
|
const rows = [];
|
|
1901
3017
|
for await (const row of this.#driver.scan(this.#name)) rows.push(row);
|
|
@@ -1904,21 +3020,38 @@ var Table = class {
|
|
|
1904
3020
|
#prepare(row) {
|
|
1905
3021
|
if (!isRecord(row)) throw new DatabaseError("VALIDATION", `Row for table '${this.#name}' is not a record`, { table: this.#name });
|
|
1906
3022
|
const prepared = { ...row };
|
|
1907
|
-
if (prepared[this.#key] === void 0) {
|
|
1908
|
-
|
|
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}'`, {
|
|
1909
3027
|
table: this.#name,
|
|
1910
|
-
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
|
|
1911
3039
|
});
|
|
1912
|
-
prepared[this.#key] = this.#generate();
|
|
1913
3040
|
}
|
|
1914
3041
|
return prepared;
|
|
1915
3042
|
}
|
|
1916
3043
|
#validate(row) {
|
|
1917
3044
|
const parsed = this.#contract.parse(row);
|
|
1918
|
-
if (parsed === void 0 || !isRecord(parsed))
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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
|
+
}
|
|
1922
3055
|
return parsed;
|
|
1923
3056
|
}
|
|
1924
3057
|
#resolveKey(row) {
|
|
@@ -1932,265 +3065,186 @@ var Table = class {
|
|
|
1932
3065
|
#cast(row) {
|
|
1933
3066
|
return row !== void 0 && this.#guard(row) ? row : void 0;
|
|
1934
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
|
+
}
|
|
1935
3072
|
};
|
|
1936
3073
|
//#endregion
|
|
1937
|
-
//#region src/core/
|
|
3074
|
+
//#region src/core/DatabaseTransaction.ts
|
|
1938
3075
|
/**
|
|
1939
|
-
* A database
|
|
3076
|
+
* A table-only database view bound to one driver transaction scope.
|
|
3077
|
+
*
|
|
3078
|
+
* @typeParam T - The declared table shape map
|
|
1940
3079
|
*
|
|
1941
3080
|
* @remarks
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
1944
|
-
*
|
|
1945
|
-
*
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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.
|
|
1950
3122
|
*
|
|
1951
3123
|
* @remarks
|
|
1952
|
-
*
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
* {@link DatabaseOptions.version} for the full reconciliation contract.
|
|
1957
|
-
* - **Observable (§13).** The owned {@link emitter} ({@link DatabaseEventMap}) carries the
|
|
1958
|
-
* connection + transaction lifecycle — `open` / `close` / `transaction` / `commit` /
|
|
1959
|
-
* `rollback` — for fire-and-forget observers, ALONGSIDE each table's per-row events. Every
|
|
1960
|
-
* event is emitted directly, strictly AFTER the relevant transition: `commit` only after
|
|
1961
|
-
* the scope succeeds, `rollback` only after every table is restored. The `rollback` emit
|
|
1962
|
-
* OBSERVES the propagated error — it never swallows it (the original throw propagates
|
|
1963
|
-
* exactly as before). The emitter isolates a listener throw and routes it to its `error`
|
|
1964
|
-
* handler (the `error` option), so observation can never reorder, throw into, or corrupt
|
|
1965
|
-
* 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.
|
|
1966
3128
|
*/
|
|
1967
3129
|
var Database = class Database {
|
|
1968
|
-
#
|
|
3130
|
+
#context;
|
|
1969
3131
|
#tables;
|
|
1970
|
-
#
|
|
3132
|
+
#primary;
|
|
1971
3133
|
#indexes;
|
|
1972
|
-
#name;
|
|
1973
3134
|
#generate;
|
|
1974
|
-
#version;
|
|
1975
|
-
#emitter;
|
|
1976
|
-
#status = "idle";
|
|
1977
|
-
#ready;
|
|
1978
3135
|
constructor(options) {
|
|
1979
|
-
this.#driver = options.driver;
|
|
1980
3136
|
this.#tables = options.tables;
|
|
1981
|
-
this.#
|
|
3137
|
+
this.#primary = options.primary ?? {};
|
|
1982
3138
|
this.#indexes = options.indexes ?? {};
|
|
1983
|
-
this.#
|
|
1984
|
-
this.#
|
|
1985
|
-
this.#
|
|
1986
|
-
this.#emitter = new Emitter({
|
|
1987
|
-
on: options.on,
|
|
1988
|
-
error: options.error
|
|
1989
|
-
});
|
|
3139
|
+
this.#generate = options.generator;
|
|
3140
|
+
this.#context = new DatabaseContext(options);
|
|
3141
|
+
this.#context.register(this.#schema());
|
|
1990
3142
|
}
|
|
1991
3143
|
get emitter() {
|
|
1992
|
-
return this.#emitter;
|
|
3144
|
+
return this.#context.emitter;
|
|
1993
3145
|
}
|
|
1994
3146
|
get name() {
|
|
1995
|
-
return this.#name;
|
|
3147
|
+
return this.#context.name;
|
|
1996
3148
|
}
|
|
1997
3149
|
get status() {
|
|
1998
|
-
return this.#status;
|
|
3150
|
+
return this.#context.status;
|
|
1999
3151
|
}
|
|
2000
3152
|
table(name) {
|
|
2001
|
-
if (this.#status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#name}' is closed`, { name: this.#name });
|
|
2002
|
-
|
|
3153
|
+
if (this.#context.status === "closed") throw new DatabaseError("CLOSED", `Database '${this.#context.name}' is closed`, { name: this.#context.name });
|
|
3154
|
+
const columns = this.#columns(name);
|
|
3155
|
+
return this.#build(name, this.#key(name), createContract(objectShape(columns)));
|
|
2003
3156
|
}
|
|
2004
|
-
import(tables,
|
|
3157
|
+
import(tables, primary) {
|
|
2005
3158
|
return this.#spawn(tables, {
|
|
2006
|
-
...this.#
|
|
2007
|
-
...
|
|
3159
|
+
...this.#primary,
|
|
3160
|
+
...primary
|
|
2008
3161
|
});
|
|
2009
3162
|
}
|
|
2010
3163
|
export() {
|
|
2011
3164
|
const result = {};
|
|
2012
3165
|
for (const name of Object.keys(this.#tables)) {
|
|
2013
|
-
const columns = this.#
|
|
3166
|
+
const columns = this.#columns(name);
|
|
2014
3167
|
result[name] = {
|
|
2015
|
-
|
|
3168
|
+
primary: this.#key(name),
|
|
2016
3169
|
columns,
|
|
2017
3170
|
schema: compileSchema(objectShape(columns))
|
|
2018
3171
|
};
|
|
2019
3172
|
}
|
|
2020
3173
|
return result;
|
|
2021
3174
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
3175
|
+
open() {
|
|
3176
|
+
return this.#context.open();
|
|
2024
3177
|
}
|
|
2025
|
-
|
|
2026
|
-
this.#
|
|
2027
|
-
this.#ready = void 0;
|
|
2028
|
-
await this.#driver.close();
|
|
2029
|
-
this.#emitter.emit("close");
|
|
3178
|
+
close() {
|
|
3179
|
+
return this.#context.close();
|
|
2030
3180
|
}
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
* When the driver implements the optional native {@link DriverInterface.transaction}
|
|
2037
|
-
* hook, that native `commit` / `rollback` handle drives the transaction; otherwise
|
|
2038
|
-
* the universal snapshot floor (`driver.snapshot()`) runs unchanged. Either path
|
|
2039
|
-
* emits the same `transaction` / `commit` / `rollback` lifecycle (AGENTS §13).
|
|
2040
|
-
* `options.signal` is checked ONCE at entry, before connecting or starting any
|
|
2041
|
-
* transactional work — an already-aborted signal throws `ABORTED` and neither the
|
|
2042
|
-
* native hook nor the snapshot floor is invoked. Nesting is unguarded and
|
|
2043
|
-
* unsupported exactly as before: this is a single-writer model, not reentrant.
|
|
2044
|
-
* On the native path, a `scope` throw rolls back via the native handle; a
|
|
2045
|
-
* native `commit` failure propagates as-is with no rollback attempt — the
|
|
2046
|
-
* engine owns transaction state after a failed COMMIT.
|
|
2047
|
-
*
|
|
2048
|
-
* @param scope - The transactional work to run
|
|
2049
|
-
* @param options - `{ signal }` to abort before the transaction starts
|
|
2050
|
-
* @returns The scope's resolved value
|
|
2051
|
-
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has already fired
|
|
2052
|
-
*/
|
|
2053
|
-
async transaction(scope, options) {
|
|
2054
|
-
checkAbort(options?.signal);
|
|
2055
|
-
await this.#connect();
|
|
2056
|
-
const native = await this.#driver.transaction?.();
|
|
2057
|
-
if (native !== void 0) {
|
|
2058
|
-
this.#emitter.emit("transaction");
|
|
2059
|
-
let value;
|
|
2060
|
-
try {
|
|
2061
|
-
value = await scope();
|
|
2062
|
-
} catch (error) {
|
|
2063
|
-
await native.rollback();
|
|
2064
|
-
this.#emitter.emit("rollback", error);
|
|
2065
|
-
throw error;
|
|
2066
|
-
}
|
|
2067
|
-
await native.commit();
|
|
2068
|
-
this.#emitter.emit("commit");
|
|
2069
|
-
return value;
|
|
2070
|
-
}
|
|
2071
|
-
const rollback = await this.#driver.snapshot();
|
|
2072
|
-
this.#emitter.emit("transaction");
|
|
2073
|
-
try {
|
|
2074
|
-
const value = await scope();
|
|
2075
|
-
this.#emitter.emit("commit");
|
|
2076
|
-
return value;
|
|
2077
|
-
} catch (error) {
|
|
2078
|
-
await rollback();
|
|
2079
|
-
this.#emitter.emit("rollback", error);
|
|
2080
|
-
throw error;
|
|
2081
|
-
}
|
|
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);
|
|
2082
3186
|
}
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
* resulting plan through the driver's optional `migrate` hook.
|
|
2086
|
-
*
|
|
2087
|
-
* @param deployed - The schema currently deployed, as {@link TableSchema}s
|
|
2088
|
-
* @param options - `{ signal }` to abort before the migration starts
|
|
2089
|
-
* @returns The applied {@link Migration} plan
|
|
2090
|
-
* @throws A `MIGRATION` {@link DatabaseError} when the driver does not
|
|
2091
|
-
* implement `migrate`, or when a step references an unknown table
|
|
2092
|
-
* (propagated from the driver)
|
|
2093
|
-
* @throws An `ABORTED` {@link DatabaseError} when `options.signal` has
|
|
2094
|
-
* already fired at entry
|
|
2095
|
-
*/
|
|
2096
|
-
async migrate(deployed, options) {
|
|
2097
|
-
checkAbort(options?.signal);
|
|
2098
|
-
await this.#connect();
|
|
2099
|
-
const plan = planMigration(deployed, this.#schema());
|
|
2100
|
-
if (this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, { name: this.#name });
|
|
2101
|
-
await this.#apply(plan);
|
|
2102
|
-
return plan;
|
|
3187
|
+
migrate(deployed, options) {
|
|
3188
|
+
return this.#context.migrate(deployed, options);
|
|
2103
3189
|
}
|
|
2104
3190
|
#build(name, key, contract) {
|
|
2105
|
-
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);
|
|
2106
3192
|
}
|
|
2107
|
-
#spawn(tables,
|
|
2108
|
-
return
|
|
2109
|
-
driver: this.#driver,
|
|
3193
|
+
#spawn(tables, primary) {
|
|
3194
|
+
return Database.#attach({
|
|
3195
|
+
driver: this.#context.driver,
|
|
2110
3196
|
tables,
|
|
2111
|
-
|
|
2112
|
-
name: this.#name,
|
|
2113
|
-
...this.#
|
|
2114
|
-
|
|
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);
|
|
2115
3203
|
}
|
|
2116
3204
|
#key(name) {
|
|
2117
|
-
return this.#
|
|
3205
|
+
return this.#primary[name] ?? "id";
|
|
3206
|
+
}
|
|
3207
|
+
#columns(name) {
|
|
3208
|
+
const columns = this.#tables[name];
|
|
3209
|
+
if (columns === void 0) throw new DatabaseError("NOT_FOUND", `Table '${name}' is not declared`, { table: name });
|
|
3210
|
+
return columns;
|
|
2118
3211
|
}
|
|
2119
3212
|
#schema() {
|
|
2120
3213
|
return Object.keys(this.#tables).map((name) => {
|
|
2121
|
-
const columns = this.#
|
|
3214
|
+
const columns = this.#columns(name);
|
|
2122
3215
|
return {
|
|
2123
3216
|
name,
|
|
2124
3217
|
primary: this.#key(name),
|
|
2125
|
-
columns: Object.
|
|
2126
|
-
const shape = columns[column];
|
|
2127
|
-
return {
|
|
2128
|
-
name: column,
|
|
2129
|
-
type: shapeToColumnType(shape),
|
|
2130
|
-
nullable: shape.type === "optional" || shape.type === "nullable"
|
|
2131
|
-
};
|
|
2132
|
-
}),
|
|
3218
|
+
columns: Object.entries(columns).map(([column, shape]) => shapeToColumnSchema(column, shape)),
|
|
2133
3219
|
indexes: this.#indexes[name] ?? []
|
|
2134
3220
|
};
|
|
2135
3221
|
});
|
|
2136
3222
|
}
|
|
2137
|
-
#
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
if (
|
|
2155
|
-
|
|
2156
|
-
stored: meta.version,
|
|
2157
|
-
declared: this.#version
|
|
2158
|
-
});
|
|
2159
|
-
if (meta.version < this.#version) {
|
|
2160
|
-
const plan = planMigration(meta.schema, declared, meta.version, this.#version);
|
|
2161
|
-
if (plan.steps.length > 0 && this.#driver.migrate === void 0) throw new DatabaseError("MIGRATION", `Database '${this.#name}' driver does not support migration`, {
|
|
2162
|
-
name: this.#name,
|
|
2163
|
-
stored: meta.version,
|
|
2164
|
-
declared: this.#version
|
|
2165
|
-
});
|
|
2166
|
-
await this.#apply(plan);
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
async #apply(plan) {
|
|
2170
|
-
const native = await this.#driver.transaction?.();
|
|
2171
|
-
if (native !== void 0) {
|
|
2172
|
-
try {
|
|
2173
|
-
await this.#driver.migrate?.(plan);
|
|
2174
|
-
await this.#stamp();
|
|
2175
|
-
} catch (error) {
|
|
2176
|
-
await native.rollback();
|
|
2177
|
-
throw error;
|
|
2178
|
-
}
|
|
2179
|
-
await native.commit();
|
|
2180
|
-
this.#emitter.emit("migrate", plan);
|
|
2181
|
-
return;
|
|
2182
|
-
}
|
|
2183
|
-
await this.#driver.migrate?.(plan);
|
|
2184
|
-
await this.#stamp();
|
|
2185
|
-
this.#emitter.emit("migrate", plan);
|
|
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;
|
|
2186
3242
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
};
|
|
2193
|
-
await this.#driver.stamp(meta);
|
|
3243
|
+
static #attach(options, context) {
|
|
3244
|
+
const database = new Database(options);
|
|
3245
|
+
database.#context = context;
|
|
3246
|
+
context.register(database.#schema());
|
|
3247
|
+
return database;
|
|
2194
3248
|
}
|
|
2195
3249
|
};
|
|
2196
3250
|
//#endregion
|
|
@@ -2206,29 +3260,54 @@ var Database = class Database {
|
|
|
2206
3260
|
* snapshot capture and restore — so a caller mutating a nested field of an input
|
|
2207
3261
|
* row, a returned row, or a row mutated in place between snapshot and rollback
|
|
2208
3262
|
* can never perturb stored state (AGENTS §11); a shallow `{ ...row }` spread
|
|
2209
|
-
* 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`
|
|
2210
3266
|
* clones every table to give transactions an exact rollback point. `scan` and
|
|
2211
3267
|
* `keys` yield in key order — sorted by the core {@link compareValues} total
|
|
2212
3268
|
* order, the same contract the SQLite (`ORDER BY`) and IndexedDB (key-ordered
|
|
2213
3269
|
* reads) backends honor, so an unordered read agrees across every backend rather
|
|
2214
3270
|
* than leaking Map insertion order. A persistent backend (IndexedDB, SQLite)
|
|
2215
|
-
* implements the same
|
|
3271
|
+
* implements the same required methods over real storage.
|
|
2216
3272
|
*/
|
|
2217
3273
|
var MemoryDriver = class {
|
|
2218
3274
|
#tables = /* @__PURE__ */ new Map();
|
|
2219
|
-
#
|
|
3275
|
+
#identities = /* @__PURE__ */ new Map();
|
|
3276
|
+
#schema = [];
|
|
3277
|
+
#metadata;
|
|
2220
3278
|
async open(schema) {
|
|
2221
|
-
|
|
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;
|
|
2222
3288
|
}
|
|
2223
3289
|
async close() {}
|
|
2224
3290
|
async read(table, key) {
|
|
2225
3291
|
const row = this.#store(table).get(key);
|
|
2226
3292
|
return row === void 0 ? void 0 : structuredClone(row);
|
|
2227
3293
|
}
|
|
2228
|
-
async write(table, key, row) {
|
|
2229
|
-
|
|
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)));
|
|
2230
3308
|
}
|
|
2231
|
-
async delete(table, key) {
|
|
3309
|
+
async delete(table, key, options) {
|
|
3310
|
+
checkAbort(options?.signal);
|
|
2232
3311
|
return this.#store(table).delete(key);
|
|
2233
3312
|
}
|
|
2234
3313
|
async keys(table) {
|
|
@@ -2247,17 +3326,17 @@ var MemoryDriver = class {
|
|
|
2247
3326
|
* @remarks
|
|
2248
3327
|
* Iterates the table's keys in the same key order `scan` and `keys` yield
|
|
2249
3328
|
* (sorted by {@link compareValues}), testing each row against
|
|
2250
|
-
* `
|
|
3329
|
+
* `input.conditions` (via {@link matchesQuery}) before counting it
|
|
2251
3330
|
* toward `offset` / `limit`. Both are applied lazily as matches are found —
|
|
2252
3331
|
* `offset` matches are skipped without being yielded, and iteration stops the
|
|
2253
3332
|
* instant `limit` yields have been produced, so a large table is never fully
|
|
2254
|
-
* walked for a small page. `
|
|
3333
|
+
* walked for a small page. `input.order` is IGNORED (the same contract as
|
|
2255
3334
|
* `TableInterface.scan` and `QueryInterface.stream`): streaming yields key
|
|
2256
3335
|
* order, sorted output is `records()`'s job. Rows yield copy-out (AGENTS
|
|
2257
3336
|
* §11), and an unknown table mirrors `scan`'s empty-yield behavior.
|
|
2258
3337
|
*
|
|
2259
3338
|
* @param table - The table to stream
|
|
2260
|
-
* @param
|
|
3339
|
+
* @param input - The filter / offset / limit to apply lazily
|
|
2261
3340
|
*
|
|
2262
3341
|
* @example
|
|
2263
3342
|
* ```ts
|
|
@@ -2266,18 +3345,22 @@ var MemoryDriver = class {
|
|
|
2266
3345
|
* }
|
|
2267
3346
|
* ```
|
|
2268
3347
|
*/
|
|
2269
|
-
|
|
3348
|
+
stream(table, input) {
|
|
3349
|
+
validatePage(input);
|
|
3350
|
+
return this.#stream(table, input);
|
|
3351
|
+
}
|
|
3352
|
+
async *#stream(table, input) {
|
|
2270
3353
|
const store = this.#store(table);
|
|
2271
|
-
const conditions =
|
|
2272
|
-
const offset =
|
|
2273
|
-
const limit =
|
|
3354
|
+
const conditions = input.conditions;
|
|
3355
|
+
const offset = input.offset ?? 0;
|
|
3356
|
+
const limit = input.limit;
|
|
2274
3357
|
let skipped = 0;
|
|
2275
3358
|
let yielded = 0;
|
|
2276
3359
|
for (const key of this.#ordered(table)) {
|
|
2277
3360
|
if (limit !== void 0 && yielded >= limit) return;
|
|
2278
3361
|
const row = store.get(key);
|
|
2279
3362
|
if (row === void 0) continue;
|
|
2280
|
-
if (conditions !== void 0 && conditions.length > 0 && !
|
|
3363
|
+
if (conditions !== void 0 && conditions.length > 0 && !matchesQuery(row, conditions)) continue;
|
|
2281
3364
|
if (skipped < offset) {
|
|
2282
3365
|
skipped += 1;
|
|
2283
3366
|
continue;
|
|
@@ -2293,115 +3376,169 @@ var MemoryDriver = class {
|
|
|
2293
3376
|
* Capture the current state and return a thunk that rolls back to it.
|
|
2294
3377
|
*
|
|
2295
3378
|
* @remarks
|
|
2296
|
-
*
|
|
2297
|
-
*
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
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.
|
|
2300
3384
|
*
|
|
2301
3385
|
* @param tables - The table names to scope the snapshot to; omitted captures every table
|
|
2302
3386
|
* @returns A thunk that restores the captured tables
|
|
2303
3387
|
*/
|
|
2304
3388
|
async snapshot(tables) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
for (const [key, row] of store) cloned.set(key, structuredClone(row));
|
|
2310
|
-
copy.set(name, cloned);
|
|
2311
|
-
}
|
|
2312
|
-
return async () => {
|
|
2313
|
-
this.#tables.clear();
|
|
2314
|
-
for (const [name, store] of copy) {
|
|
2315
|
-
const restored = /* @__PURE__ */ new Map();
|
|
2316
|
-
for (const [key, row] of store) restored.set(key, structuredClone(row));
|
|
2317
|
-
this.#tables.set(name, restored);
|
|
2318
|
-
}
|
|
2319
|
-
};
|
|
2320
|
-
}
|
|
2321
|
-
const copy = /* @__PURE__ */ new Map();
|
|
2322
|
-
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);
|
|
2323
3393
|
const store = this.#tables.get(name);
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
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
|
+
});
|
|
2328
3403
|
}
|
|
2329
3404
|
return async () => {
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
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);
|
|
2334
3429
|
}
|
|
2335
3430
|
};
|
|
2336
3431
|
}
|
|
2337
3432
|
/**
|
|
2338
|
-
* Return the persisted {@link
|
|
3433
|
+
* Return the persisted {@link DriverMetadata}, or `undefined` when the store has
|
|
2339
3434
|
* never been stamped.
|
|
2340
3435
|
*
|
|
2341
3436
|
* @remarks
|
|
2342
3437
|
* In-process only — the metadata lives in this instance's memory, exactly
|
|
2343
|
-
* like the rest of this driver's storage.
|
|
2344
|
-
*
|
|
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.
|
|
2345
3441
|
*
|
|
2346
|
-
* @returns The last-stamped {@link
|
|
3442
|
+
* @returns The last-stamped {@link DriverMetadata}, or `undefined`
|
|
2347
3443
|
*/
|
|
2348
|
-
async
|
|
2349
|
-
return this.#
|
|
3444
|
+
async metadata() {
|
|
3445
|
+
return this.#metadata === void 0 ? void 0 : cloneDriverMetadata(this.#metadata);
|
|
2350
3446
|
}
|
|
2351
3447
|
/**
|
|
2352
|
-
* Persist
|
|
3448
|
+
* Persist an owned snapshot for a later `metadata()` to return.
|
|
2353
3449
|
*
|
|
2354
|
-
* @param
|
|
3450
|
+
* @param metadata - The {@link DriverMetadata} to persist
|
|
2355
3451
|
*/
|
|
2356
|
-
async stamp(
|
|
2357
|
-
this.#
|
|
3452
|
+
async stamp(metadata) {
|
|
3453
|
+
this.#metadata = cloneDriverMetadata(metadata);
|
|
2358
3454
|
}
|
|
2359
3455
|
/**
|
|
2360
3456
|
* Apply a {@link Migration} plan's steps against the in-memory store.
|
|
2361
3457
|
*
|
|
2362
3458
|
* @remarks
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
3459
|
+
* Steps apply against an isolated candidate. Rows, schema changes, and
|
|
3460
|
+
* optional metadata publish together only after the whole request succeeds.
|
|
2365
3461
|
*
|
|
2366
|
-
* @param
|
|
3462
|
+
* @param input - The migration plan and optional metadata to settle atomically
|
|
2367
3463
|
*/
|
|
2368
|
-
async migrate(
|
|
2369
|
-
|
|
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) {
|
|
2370
3512
|
case "table.add":
|
|
2371
|
-
if (!
|
|
3513
|
+
if (!tables.has(step.table.name)) tables.set(step.table.name, /* @__PURE__ */ new Map());
|
|
2372
3514
|
break;
|
|
2373
3515
|
case "table.remove":
|
|
2374
|
-
this.#require(step.table);
|
|
2375
|
-
|
|
3516
|
+
this.#require(tables, step.table);
|
|
3517
|
+
tables.delete(step.table);
|
|
2376
3518
|
break;
|
|
2377
3519
|
case "column.add":
|
|
2378
3520
|
case "column.remove": {
|
|
2379
|
-
const store = this.#require(step.table);
|
|
3521
|
+
const store = this.#require(tables, step.table);
|
|
2380
3522
|
const rows = [...store.entries()];
|
|
2381
3523
|
const migrated = migrateRows(rows.map(([, row]) => row), [step]);
|
|
2382
|
-
|
|
3524
|
+
for (const [index, [key]] of rows.entries()) {
|
|
3525
|
+
const row = migrated[index];
|
|
3526
|
+
if (row === void 0) throw new DatabaseError("MIGRATION", "migrate: transformed row is missing", {
|
|
3527
|
+
table: step.table,
|
|
3528
|
+
index
|
|
3529
|
+
});
|
|
3530
|
+
store.set(key, row);
|
|
3531
|
+
}
|
|
2383
3532
|
break;
|
|
2384
3533
|
}
|
|
2385
3534
|
case "index.add":
|
|
2386
|
-
case "index.remove":
|
|
2387
|
-
this.#require(step.table);
|
|
2388
|
-
break;
|
|
3535
|
+
case "index.remove": this.#require(tables, step.table);
|
|
2389
3536
|
}
|
|
2390
3537
|
}
|
|
2391
|
-
#ordered(table) {
|
|
2392
|
-
return [...this.#store(table).keys()].sort(compareValues);
|
|
2393
|
-
}
|
|
2394
|
-
#require(table) {
|
|
2395
|
-
const store = this.#tables.get(table);
|
|
2396
|
-
if (store === void 0) throw new DatabaseError("MIGRATION", `migrate: unknown table '${table}'`, { table });
|
|
2397
|
-
return store;
|
|
2398
|
-
}
|
|
2399
3538
|
#store(table) {
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
this.#tables.set(table, store);
|
|
2404
|
-
}
|
|
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 });
|
|
2405
3542
|
return store;
|
|
2406
3543
|
}
|
|
2407
3544
|
};
|
|
@@ -2416,10 +3553,10 @@ var MemoryDriver = class {
|
|
|
2416
3553
|
* level. The `const` type parameter captures the literal names and columns, so
|
|
2417
3554
|
* `db.table('users')` is checked against the schema and typed by `Infer` of its
|
|
2418
3555
|
* columns — no annotations. Name a non-`id` primary-key column per table via the
|
|
2419
|
-
* optional `
|
|
3556
|
+
* optional `primary` and `indexes` maps.
|
|
2420
3557
|
*
|
|
2421
|
-
* @param options - The driver,
|
|
2422
|
-
*
|
|
3558
|
+
* @param options - The driver, `tables`, and optional `primary`, `indexes`,
|
|
3559
|
+
* `name`, `generator`, `version`, and emitter hooks
|
|
2423
3560
|
* @returns A typed {@link DatabaseInterface}
|
|
2424
3561
|
*
|
|
2425
3562
|
* @example
|
|
@@ -2433,7 +3570,7 @@ var MemoryDriver = class {
|
|
|
2433
3570
|
* users: { id: stringShape(), age: integerShape() },
|
|
2434
3571
|
* posts: { slug: stringShape(), title: stringShape() },
|
|
2435
3572
|
* },
|
|
2436
|
-
*
|
|
3573
|
+
* primary: { posts: 'slug' },
|
|
2437
3574
|
* })
|
|
2438
3575
|
* await db.table('users').set({ id: 'u1', age: 36 }) // typed; coerced + validated
|
|
2439
3576
|
* ```
|
|
@@ -2454,6 +3591,6 @@ function createMemoryDriver() {
|
|
|
2454
3591
|
return new MemoryDriver();
|
|
2455
3592
|
}
|
|
2456
3593
|
//#endregion
|
|
2457
|
-
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 };
|
|
2458
3595
|
|
|
2459
3596
|
//# sourceMappingURL=index.js.map
|