@orkestrel/database 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -10
- package/dist/src/browser/index.d.ts +342 -0
- package/dist/src/browser/index.js +686 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/core/index.cjs +2494 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1766 -0
- package/dist/src/core/index.d.ts +1766 -11
- package/dist/src/core/index.js +237 -1142
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +836 -153
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +1054 -0
- package/dist/src/server/index.d.ts +1054 -5
- package/dist/src/server/index.js +1663 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +40 -16
- package/dist/src/core/Clause.d.ts +0 -30
- package/dist/src/core/Cursor.d.ts +0 -21
- package/dist/src/core/Database.d.ts +0 -80
- package/dist/src/core/Query.d.ts +0 -47
- package/dist/src/core/Table.d.ts +0 -69
- package/dist/src/core/constants.d.ts +0 -22
- package/dist/src/core/drivers/MemoryDriver.d.ts +0 -94
- package/dist/src/core/errors.d.ts +0 -38
- package/dist/src/core/factories.d.ts +0 -43
- package/dist/src/core/helpers.d.ts +0 -383
- package/dist/src/core/types.d.ts +0 -739
- package/dist/src/server/compilers.d.ts +0 -169
- package/dist/src/server/drivers/JSONDriver.d.ts +0 -106
- package/dist/src/server/factories.d.ts +0 -31
- package/dist/src/server/helpers.d.ts +0 -222
- package/dist/src/server/types.d.ts +0 -36
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { DatabaseError, applyCriteria, compareValues, extractKey, isDriverMeta, matchesCriteria, migrateRows } from "../core/index.js";
|
|
2
|
+
import { createIndexedDBDatabase, isIndexedDBError, range } from "@orkestrel/indexeddb";
|
|
3
|
+
//#region src/browser/constants.ts
|
|
4
|
+
var INDEXABLE_TYPES = /* @__PURE__ */ new Set([
|
|
5
|
+
"text",
|
|
6
|
+
"integer",
|
|
7
|
+
"real"
|
|
8
|
+
]);
|
|
9
|
+
var META_STORE = "__meta__";
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/browser/helpers.ts
|
|
12
|
+
/**
|
|
13
|
+
* The `IDBKeyRange` a single {@link Condition} maps to, when its operator is one
|
|
14
|
+
* of the six exact key comparisons over scalar operands — else `null`.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* Only the comparison operators (`equals`/`above`/`below`/`from`/`to`/`between`)
|
|
18
|
+
* translate to a key range that a typed (string/number) column can back with an
|
|
19
|
+
* IndexedDB store/index read — see {@link selectPlan} for the caveats that
|
|
20
|
+
* decide WHICH of `below`/`to` may drive a SECONDARY-index read versus the
|
|
21
|
+
* primary store only (a column-type / absent-row concern, not a range-shape
|
|
22
|
+
* one). `starts` is excluded — its prefix range can miss strings past U+FFFF;
|
|
23
|
+
* the membership / negation / pattern / existence operators (`not`/`like`/`glob`/
|
|
24
|
+
* `ends`/`any`/`none`/`absent`/`present`) have no single exact range. The operand
|
|
25
|
+
* guard (`typeof` string/number) rejects a non-scalar value (e.g. an array, a
|
|
26
|
+
* boolean) that is not a usable key. `between` additionally guards against a
|
|
27
|
+
* REVERSED pair (`first > second`): native `IDBKeyRange.bound` throws a raw
|
|
28
|
+
* `DataError` `DOMException` for a lower bound above the upper bound, so a
|
|
29
|
+
* reversed pair returns `null` here (falls back to a full scan, which the
|
|
30
|
+
* engine then correctly resolves to an empty result) rather than letting a
|
|
31
|
+
* native exception escape untyped — the same defensive posture as every other
|
|
32
|
+
* backend, which returns empty for a reversed/empty range instead of throwing.
|
|
33
|
+
* The switch is exhaustive over every {@link ConditionOperator}, so a new
|
|
34
|
+
* operator forces a deliberate decision here rather than silently defaulting
|
|
35
|
+
* to a (possibly lossy) range.
|
|
36
|
+
*
|
|
37
|
+
* @param condition - The condition to translate
|
|
38
|
+
* @returns Its exact key range, or `null` when the operator/operands cannot push
|
|
39
|
+
*/
|
|
40
|
+
function conditionRange(condition) {
|
|
41
|
+
const first = condition.values[0];
|
|
42
|
+
const second = condition.values[1];
|
|
43
|
+
switch (condition.operator) {
|
|
44
|
+
case "equals": return isKey(first) ? range.only(first) : null;
|
|
45
|
+
case "above": return isKey(first) ? range.above(first) : null;
|
|
46
|
+
case "below": return isKey(first) ? range.below(first) : null;
|
|
47
|
+
case "from": return isKey(first) ? range.from(first) : null;
|
|
48
|
+
case "to": return isKey(first) ? range.to(first) : null;
|
|
49
|
+
case "between": return isKey(first) && isKey(second) && compareValues(first, second) <= 0 ? range.between(first, second) : null;
|
|
50
|
+
case "not":
|
|
51
|
+
case "like":
|
|
52
|
+
case "glob":
|
|
53
|
+
case "starts":
|
|
54
|
+
case "ends":
|
|
55
|
+
case "any":
|
|
56
|
+
case "none":
|
|
57
|
+
case "absent":
|
|
58
|
+
case "present": return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function isKey(value) {
|
|
62
|
+
return typeof value === "string" || typeof value === "number";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Plan an IndexedDB read for a {@link Criteria} — pick the index (or the primary
|
|
66
|
+
* store) and {@link IDBKeyRange} to narrow by, falling back to a full scan.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* Pushdown is sound ONLY when every condition is `and`-joined: the engine folds
|
|
70
|
+
* conditions left-to-right (`c1 && c2 && … && cn`), so the result is a subset of
|
|
71
|
+
* each — narrowing on any one is then a valid superset. A single `or` breaks that
|
|
72
|
+
* (a row can match through a later condition the range would exclude), so any `or`
|
|
73
|
+
* forces a full scan. Otherwise it scans the conditions in order and selects the
|
|
74
|
+
* **first** one that is provably range-exact and backed by a key: a comparison
|
|
75
|
+
* operator (`conditionRange`) over a single, orderable (`text`/`integer`/`real`)
|
|
76
|
+
* column that is either the table's primary key (read the store directly, `index:
|
|
77
|
+
* null`) or has a single-column secondary index (named exactly the column — read
|
|
78
|
+
* that index). A condition whose column is a nested {@link FieldPath} array
|
|
79
|
+
* (descends a json value, not a key), is absent from the schema, is a non-orderable
|
|
80
|
+
* type (`boolean`/`json`/`blob`), uses a non-comparison operator, or has a
|
|
81
|
+
* non-scalar operand cannot push and is skipped.
|
|
82
|
+
*
|
|
83
|
+
* **`below`/`to` may drive a SECONDARY-index range only when the column has NO
|
|
84
|
+
* absent/null rows to lose — which this planner cannot verify from the schema
|
|
85
|
+
* alone, so it restricts them to the PRIMARY store, where that is always true.**
|
|
86
|
+
* The engine's total order (`compareValues`, see `@src/core`) ranks
|
|
87
|
+
* `undefined` (absent) and `null` BELOW every number/string, so
|
|
88
|
+
* `matchesCondition('below' | 'to', …)` is TRUE for a row whose field is absent
|
|
89
|
+
* or `null` — but a secondary IndexedDB index has NO ENTRY for a row whose
|
|
90
|
+
* indexed field is absent/`null`, so a `below`/`to` range read against that
|
|
91
|
+
* index would SILENTLY DROP those rows (they can never be over-fetched, only
|
|
92
|
+
* missed — the one shape of lossiness this planner must never produce). The
|
|
93
|
+
* table's PRIMARY key is exempt: a row's primary-key value is always present
|
|
94
|
+
* and never `null` (it is the row's identity, enforced at write time), so a
|
|
95
|
+
* `below`/`to` range against the primary store can never exclude an
|
|
96
|
+
* absent/null-keyed row because no such row exists. `equals`/`above`/`from`/
|
|
97
|
+
* `between` stay index-eligible on ANY orderable column, primary or secondary:
|
|
98
|
+
* each is bounded below by a scalar (`equals`/`between`'s lower bound, `above`/
|
|
99
|
+
* `from`'s lower bound), and every scalar strictly out-ranks `undefined`/`null`
|
|
100
|
+
* in the total order, so an absent/null-valued row can never satisfy them — the
|
|
101
|
+
* index's silence on such a row is harmless (it was never going to match).
|
|
102
|
+
* **Declared-type trust caveat:** this reasoning holds under the contract that
|
|
103
|
+
* an {@link INDEXABLE_TYPES} column, once contract-validated at write time,
|
|
104
|
+
* holds only `string | number | null` (or is absent) — never some other
|
|
105
|
+
* runtime value that could rank differently; a driver bypassing the write
|
|
106
|
+
* contract (writing raw rows directly to the store) could defeat this
|
|
107
|
+
* argument, but that is out of scope for a planner reading validated schema
|
|
108
|
+
* metadata.
|
|
109
|
+
*
|
|
110
|
+
* When no condition qualifies the plan is a full scan (`{ index: null, range:
|
|
111
|
+
* null }`) and the engine does everything. The plan is always a SUPERSET of the
|
|
112
|
+
* matching rows — the only correctness contract — so the driver may safely run
|
|
113
|
+
* the exact engine over it.
|
|
114
|
+
*
|
|
115
|
+
* @param criteria - The read specification (its `conditions` drive the plan), or
|
|
116
|
+
* `undefined` for an unconditional read
|
|
117
|
+
* @param schema - The table's schema — its `primary` key and column types
|
|
118
|
+
* @param available - The secondary-index names that physically exist on the store
|
|
119
|
+
* (`store.indexes`); a single-column index is named exactly its column
|
|
120
|
+
* @returns The index + range to read, narrowing to a superset (never lossy)
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* selectPlan({ conditions: [eq('id', 'u1')] }, schema, []) // { index: null, range: only('u1') }
|
|
125
|
+
* selectPlan({ conditions: [from('age', 18)] }, schema, ['age']) // { index: 'age', range: from(18) }
|
|
126
|
+
* selectPlan({ conditions: [contains('name', 'a')] }, schema, []) // { index: null, range: null }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
function selectPlan(criteria, schema, available) {
|
|
130
|
+
const conditions = criteria?.conditions ?? [];
|
|
131
|
+
if (conditions.slice(1).some((condition) => condition.connector === "or")) return {
|
|
132
|
+
index: null,
|
|
133
|
+
range: null
|
|
134
|
+
};
|
|
135
|
+
for (const condition of conditions) {
|
|
136
|
+
if (typeof condition.column !== "string") continue;
|
|
137
|
+
const column = schema.columns.find((candidate) => candidate.name === condition.column);
|
|
138
|
+
if (column === void 0 || !INDEXABLE_TYPES.has(column.type)) continue;
|
|
139
|
+
const keyRange = conditionRange(condition);
|
|
140
|
+
if (keyRange === null) continue;
|
|
141
|
+
if (condition.column === schema.primary) return {
|
|
142
|
+
index: null,
|
|
143
|
+
range: keyRange
|
|
144
|
+
};
|
|
145
|
+
if (condition.operator === "below" || condition.operator === "to") continue;
|
|
146
|
+
if (available.includes(condition.column)) return {
|
|
147
|
+
index: condition.column,
|
|
148
|
+
range: keyRange
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
index: null,
|
|
153
|
+
range: null
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
|
|
158
|
+
* — the default mapping used everywhere except inside `migrate()`.
|
|
159
|
+
*
|
|
160
|
+
* @remarks
|
|
161
|
+
* No backend fault may leak through `DriverInterface` as a raw `IndexedDBError`.
|
|
162
|
+
* `CONSTRAINT` (a unique-key violation) is a `CONFLICT` — the same code every
|
|
163
|
+
* other backend uses for a duplicate key. `CLOSED`/`NOT_OPEN`/`INVALID` (the
|
|
164
|
+
* connection is gone, never opened, or the native handle is stale) collapse to
|
|
165
|
+
* `CLOSED`. `QUOTA` and `BLOCKED` are genuine infrastructure faults (`DRIVER`),
|
|
166
|
+
* carrying a machine-readable `context.code` (`'QUOTA'` / `'BLOCKED'`) so a
|
|
167
|
+
* caller can branch without parsing the message; `BLOCKED` additionally marks
|
|
168
|
+
* `context.retryable: true` — a concurrent connection holding the database open
|
|
169
|
+
* is a transient condition, not a permanent one. Every other code (`UPGRADE`
|
|
170
|
+
* here — see {@link mapMigrationError} for the `migrate()`-only remapping to
|
|
171
|
+
* `MIGRATION` — `ABORTED`, `NOT_FOUND`, `DATA`, `OPEN`, `INACTIVE`, `READONLY`,
|
|
172
|
+
* `UNKNOWN`) is an unexpected infrastructure fault and maps to `DRIVER` — the
|
|
173
|
+
* driver opens its own readwrite transactions, so a `READONLY` fault can only
|
|
174
|
+
* mean the backend behaved unexpectedly. The original error is always
|
|
175
|
+
* preserved as `context.cause` for diagnostics.
|
|
176
|
+
*
|
|
177
|
+
* @param error - The backend error to translate
|
|
178
|
+
* @returns The portable `DatabaseError`
|
|
179
|
+
*/
|
|
180
|
+
function mapIndexedDBError(error) {
|
|
181
|
+
switch (error.code) {
|
|
182
|
+
case "CONSTRAINT": return new DatabaseError("CONFLICT", error.message, { cause: error });
|
|
183
|
+
case "CLOSED":
|
|
184
|
+
case "NOT_OPEN":
|
|
185
|
+
case "INVALID": return new DatabaseError("CLOSED", error.message, { cause: error });
|
|
186
|
+
case "QUOTA": return new DatabaseError("DRIVER", error.message, {
|
|
187
|
+
cause: error,
|
|
188
|
+
code: "QUOTA"
|
|
189
|
+
});
|
|
190
|
+
case "BLOCKED": return new DatabaseError("DRIVER", error.message, {
|
|
191
|
+
cause: error,
|
|
192
|
+
code: "BLOCKED",
|
|
193
|
+
retryable: true
|
|
194
|
+
});
|
|
195
|
+
case "UPGRADE":
|
|
196
|
+
case "ABORTED":
|
|
197
|
+
case "NOT_FOUND":
|
|
198
|
+
case "DATA":
|
|
199
|
+
case "OPEN":
|
|
200
|
+
case "INACTIVE":
|
|
201
|
+
case "READONLY":
|
|
202
|
+
case "UNKNOWN": return new DatabaseError("DRIVER", error.message, { cause: error });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
|
|
207
|
+
* for use INSIDE `migrate()` — the one context where `UPGRADE` means the
|
|
208
|
+
* migration itself failed, not a generic driver fault.
|
|
209
|
+
*
|
|
210
|
+
* @remarks
|
|
211
|
+
* `migrate()` reconnects at a bumped version inside `onupgradeneeded`; a
|
|
212
|
+
* rejection there (an inapplicable step, a native `ConstraintError` from a
|
|
213
|
+
* duplicate index, …) surfaces as `IndexedDBError` `UPGRADE` and must become a
|
|
214
|
+
* `MIGRATION` `DatabaseError` so a caller can distinguish "this migration plan
|
|
215
|
+
* failed" from "the driver hit an unrelated infrastructure fault". Every other
|
|
216
|
+
* code defers to {@link mapIndexedDBError} unchanged.
|
|
217
|
+
*
|
|
218
|
+
* @param error - The backend error to translate
|
|
219
|
+
* @returns The portable `DatabaseError`
|
|
220
|
+
*/
|
|
221
|
+
function mapMigrationError(error) {
|
|
222
|
+
if (error.code === "UPGRADE") return new DatabaseError("MIGRATION", error.message, { cause: error });
|
|
223
|
+
return mapIndexedDBError(error);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Derive an IndexedDB index name for a declared column group — a bare column
|
|
227
|
+
* name for a single-column index, a deterministic collision-free encoding for a
|
|
228
|
+
* compound one.
|
|
229
|
+
*
|
|
230
|
+
* @remarks
|
|
231
|
+
* Naming a compound index by joining its columns with `_` (`['a', 'b'] →
|
|
232
|
+
* 'a_b'`) collides with a single-column index over a column LITERALLY named
|
|
233
|
+
* `'a_b'` — the same name, two different key paths (`'a_b'` vs `['a', 'b']`),
|
|
234
|
+
* which either throws a native `ConstraintError` from a duplicate
|
|
235
|
+
* `createIndex` call at open, or (worse) lets {@link selectPlan}'s name-based
|
|
236
|
+
* lookup match the wrong index. A single-column index keeps the BARE column
|
|
237
|
+
* name — {@link selectPlan} matches `available.includes(condition.column)` by
|
|
238
|
+
* that exact name, so a single-column index must stay named after its column
|
|
239
|
+
* verbatim. A compound index instead encodes each column as a LENGTH-PREFIXED
|
|
240
|
+
* segment (`'2#1:a1:b'`), so the boundary between columns is self-describing
|
|
241
|
+
* and cannot be reconstructed by any other column list — including one
|
|
242
|
+
* containing a column that happens to look like an encoded segment.
|
|
243
|
+
*
|
|
244
|
+
* @param columns - The index's column group, in declared order
|
|
245
|
+
* @returns The index name to pass to `createIndex` / read back from `indexNames`
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* deriveIndexName(['age']) // 'age'
|
|
250
|
+
* deriveIndexName(['a', 'b']) // '2#1:a1:b'
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
function deriveIndexName(columns) {
|
|
254
|
+
if (columns.length === 1) return columns[0];
|
|
255
|
+
return `${columns.length}#${columns.map((column) => `${column.length}:${column}`).join("")}`;
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/browser/drivers/IndexedDBDriver.ts
|
|
259
|
+
/**
|
|
260
|
+
* The IndexedDB {@link DriverInterface} — the persistent browser backend, built on
|
|
261
|
+
* the published `@orkestrel/indexeddb` wrapper.
|
|
262
|
+
*
|
|
263
|
+
* @remarks
|
|
264
|
+
* A thin adapter: it implements the storage primitives the core database layer
|
|
265
|
+
* needs (`open` / `close` / `read` / `write` / `delete` / `keys` / `scan` / `clear`
|
|
266
|
+
* / `snapshot`) by delegating to the wrapper's typed store operations — it never
|
|
267
|
+
* touches raw IndexedDB. Rows are stored with **out-of-line keys** (the database
|
|
268
|
+
* passes the key explicitly, `store.set(row, key)`), so each table is declared as a
|
|
269
|
+
* key-path-less store. The wrapper opens in **auto-managed** mode (no fixed
|
|
270
|
+
* version), creating any missing store on demand, so a table added to the schema is
|
|
271
|
+
* created on the next open with no manual version bump. The driver's bulk reads
|
|
272
|
+
* (`scan` / `keys`) use the wrapper's native `getAll` / `getAllKeys`, and `snapshot`
|
|
273
|
+
* rolls back through one atomic wrapper transaction.
|
|
274
|
+
*
|
|
275
|
+
* It also implements the optional native `records` / `count` / `stream` hooks
|
|
276
|
+
* (AGENTS §21): `selectPlan` ({@link selectPlan}) turns the {@link Criteria} into a
|
|
277
|
+
* key-range pushdown over the primary key or a single-column secondary index,
|
|
278
|
+
* fetching a candidate **superset** that the core engine (`applyCriteria` /
|
|
279
|
+
* `matchesCriteria`) then refines — so a native read is byte-identical to a full
|
|
280
|
+
* scan, just cheaper. Pushdown is conservative: only the exact-comparison
|
|
281
|
+
* operators over orderable columns narrow to a range; everything else falls back
|
|
282
|
+
* to a full scan + the engine.
|
|
283
|
+
*
|
|
284
|
+
* @remarks
|
|
285
|
+
* This driver also implements `migrate` / `meta` / `stamp`. `meta` / `stamp`
|
|
286
|
+
* persist the {@link DriverMeta} in a reserved out-of-line store,
|
|
287
|
+
* {@link META_STORE} (`__meta__`) — excluded from a whole-store `snapshot`
|
|
288
|
+
* capture, since it is driver bookkeeping, not caller data. `migrate` applies a
|
|
289
|
+
* {@link Migration} plan natively: IndexedDB schema DDL (creating/dropping a
|
|
290
|
+
* store, creating/dropping an index) is legal only inside a versionchange
|
|
291
|
+
* transaction (`onupgradeneeded`), so `migrate` closes the current connection
|
|
292
|
+
* and opens a FRESH one at `version + 1` with an `upgrade` hook that walks the
|
|
293
|
+
* plan's steps — dropping stores, adding/removing indexes on the raw
|
|
294
|
+
* `IDBTransaction`, and rewriting rows for `column.remove` via a cursor walk
|
|
295
|
+
* (the one step needing to touch existing data; `column.add` is a no-op — this
|
|
296
|
+
* driver stores whatever a row carries, so there is nothing to backfill). A
|
|
297
|
+
* step referencing an unknown table is validated BEFORE the reconnect, so a
|
|
298
|
+
* `MIGRATION` `DatabaseError` never wastes a version bump.
|
|
299
|
+
*
|
|
300
|
+
* @remarks
|
|
301
|
+
* This unit deliberately OMITS `aggregate` / `transaction`. There is no native
|
|
302
|
+
* `aggregate` (IndexedDB has no native SUM/AVG); the engine over the narrowed
|
|
303
|
+
* `records` covers it. `transaction` is impossible here: the wrapper auto-commits
|
|
304
|
+
* an `IDBTransaction` the moment control yields to a non-IDB `await`, so a
|
|
305
|
+
* BEGIN-now / commit-or-rollback-later handle spanning arbitrary caller code
|
|
306
|
+
* cannot be built on top of it — every atomic multi-op sequence in this driver
|
|
307
|
+
* (`snapshot`'s rollback) instead runs entirely inside ONE `db.write(...)` scope.
|
|
308
|
+
*/
|
|
309
|
+
var IndexedDBDriver = class {
|
|
310
|
+
#name;
|
|
311
|
+
#schema = /* @__PURE__ */ new Map();
|
|
312
|
+
#database;
|
|
313
|
+
constructor(name) {
|
|
314
|
+
this.#name = name;
|
|
315
|
+
}
|
|
316
|
+
async open(schema) {
|
|
317
|
+
if (schema.some((table) => table.name === "__meta__")) throw new DatabaseError("VALIDATION", `open: table name '${META_STORE}' is reserved for driver metadata`, { table: META_STORE });
|
|
318
|
+
try {
|
|
319
|
+
this.#database?.close();
|
|
320
|
+
const map = /* @__PURE__ */ new Map();
|
|
321
|
+
for (const table of schema) map.set(table.name, table);
|
|
322
|
+
const database = createIndexedDBDatabase({
|
|
323
|
+
name: this.#name,
|
|
324
|
+
stores: this.#stores(map)
|
|
325
|
+
});
|
|
326
|
+
await database.connect();
|
|
327
|
+
this.#database = database;
|
|
328
|
+
this.#schema = map;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw this.#wrap(error);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async close() {
|
|
334
|
+
this.#database?.close();
|
|
335
|
+
this.#database = void 0;
|
|
336
|
+
}
|
|
337
|
+
async read(table, key) {
|
|
338
|
+
try {
|
|
339
|
+
return await this.#store(table).get(key);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
throw this.#wrap(error);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async write(table, key, row) {
|
|
345
|
+
try {
|
|
346
|
+
await this.#store(table).set(row, key);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
throw this.#wrap(error);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async delete(table, key) {
|
|
352
|
+
try {
|
|
353
|
+
const store = this.#store(table);
|
|
354
|
+
const present = await store.has(key);
|
|
355
|
+
await store.remove(key);
|
|
356
|
+
return present;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw this.#wrap(error);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async keys(table) {
|
|
362
|
+
try {
|
|
363
|
+
return (await this.#store(table).keys()).filter((key) => typeof key === "string" || typeof key === "number");
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw this.#wrap(error);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async *scan(table) {
|
|
369
|
+
try {
|
|
370
|
+
for (const row of await this.#store(table).records()) yield row;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw this.#wrap(error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async clear(table) {
|
|
376
|
+
try {
|
|
377
|
+
await this.#store(table).clear();
|
|
378
|
+
} catch (error) {
|
|
379
|
+
throw this.#wrap(error);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async records(table, criteria) {
|
|
383
|
+
try {
|
|
384
|
+
const schema = this.#table(table);
|
|
385
|
+
const store = this.#store(table);
|
|
386
|
+
const plan = selectPlan(criteria, schema, store.indexes);
|
|
387
|
+
return applyCriteria(await this.#candidates(store, schema, plan), criteria);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
throw this.#wrap(error);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async count(table, criteria) {
|
|
393
|
+
try {
|
|
394
|
+
const schema = this.#table(table);
|
|
395
|
+
const store = this.#store(table);
|
|
396
|
+
const conditions = criteria.conditions ?? [];
|
|
397
|
+
if (conditions.length === 0) return await store.count();
|
|
398
|
+
const plan = selectPlan(criteria, schema, store.indexes);
|
|
399
|
+
if (conditions.length === 1 && plan.range !== null) return plan.index === null ? await store.count(plan.range) : await store.index(plan.index).count(plan.range);
|
|
400
|
+
return (plan.index === null ? await store.records(plan.range) : await store.index(plan.index).records(plan.range)).reduce((total, row) => matchesCriteria(row, conditions) ? total + 1 : total, 0);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw this.#wrap(error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async *stream(table, criteria) {
|
|
406
|
+
try {
|
|
407
|
+
const schema = this.#table(table);
|
|
408
|
+
const store = this.#store(table);
|
|
409
|
+
const plan = selectPlan(criteria, schema, store.indexes);
|
|
410
|
+
const conditions = criteria.conditions ?? [];
|
|
411
|
+
const offset = criteria.offset ?? 0;
|
|
412
|
+
const limit = criteria.limit;
|
|
413
|
+
let skipped = 0;
|
|
414
|
+
let yielded = 0;
|
|
415
|
+
for (const row of await this.#candidates(store, schema, plan)) {
|
|
416
|
+
if (limit !== void 0 && yielded >= limit) break;
|
|
417
|
+
if (!matchesCriteria(row, conditions)) continue;
|
|
418
|
+
if (skipped < offset) {
|
|
419
|
+
skipped += 1;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
yielded += 1;
|
|
423
|
+
yield row;
|
|
424
|
+
}
|
|
425
|
+
} catch (error) {
|
|
426
|
+
throw this.#wrap(error);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async snapshot(tables) {
|
|
430
|
+
try {
|
|
431
|
+
const database = this.#require();
|
|
432
|
+
const names = tables ?? database.stores.filter((name) => name !== "__meta__");
|
|
433
|
+
const captured = /* @__PURE__ */ new Map();
|
|
434
|
+
if (names.length > 0) await database.read(names, async (transaction) => {
|
|
435
|
+
for (const name of names) {
|
|
436
|
+
const store = transaction.store(name);
|
|
437
|
+
captured.set(name, {
|
|
438
|
+
keys: await store.keys(),
|
|
439
|
+
rows: await store.records()
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
return async () => {
|
|
444
|
+
try {
|
|
445
|
+
const current = this.#require();
|
|
446
|
+
const restorable = names.filter((name) => current.stores.includes(name));
|
|
447
|
+
if (restorable.length === 0) return;
|
|
448
|
+
await current.write(restorable, async (transaction) => {
|
|
449
|
+
for (const name of restorable) {
|
|
450
|
+
const snapshot = captured.get(name);
|
|
451
|
+
if (snapshot === void 0) continue;
|
|
452
|
+
const store = transaction.store(name);
|
|
453
|
+
await store.clear();
|
|
454
|
+
for (let index = 0; index < snapshot.keys.length; index += 1) await store.set(snapshot.rows[index], snapshot.keys[index]);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
throw this.#wrap(error);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
} catch (error) {
|
|
462
|
+
throw this.#wrap(error);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Return the persisted {@link DriverMeta}, or `undefined` when the store has
|
|
467
|
+
* never been stamped.
|
|
468
|
+
*
|
|
469
|
+
* @remarks
|
|
470
|
+
* Reads `'meta'` from the reserved {@link META_STORE}, narrowing the
|
|
471
|
+
* structured-clone value with the core {@link isDriverMeta} guard (never
|
|
472
|
+
* asserted, AGENTS §14) — a missing or malformed record returns `undefined`,
|
|
473
|
+
* exactly like a fresh, never-stamped store.
|
|
474
|
+
*
|
|
475
|
+
* @returns The last-stamped {@link DriverMeta}, or `undefined`
|
|
476
|
+
*/
|
|
477
|
+
async meta() {
|
|
478
|
+
try {
|
|
479
|
+
const record = await this.#require().store(META_STORE).get("meta");
|
|
480
|
+
if (!isDriverMeta(record)) return void 0;
|
|
481
|
+
return record;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
throw this.#wrap(error);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Persist `meta` verbatim for a later `meta()` to return.
|
|
488
|
+
*
|
|
489
|
+
* @param meta - The {@link DriverMeta} to persist
|
|
490
|
+
*/
|
|
491
|
+
async stamp(meta) {
|
|
492
|
+
try {
|
|
493
|
+
await this.#require().store(META_STORE).set({ ...meta }, "meta");
|
|
494
|
+
} catch (error) {
|
|
495
|
+
throw this.#wrap(error);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Apply a {@link Migration} plan by reconnecting at a bumped version and
|
|
500
|
+
* running the plan's steps inside the wrapper's `upgrade` hook.
|
|
501
|
+
*
|
|
502
|
+
* @remarks
|
|
503
|
+
* IndexedDB schema DDL is legal only inside `onupgradeneeded`, so this closes
|
|
504
|
+
* the current connection and opens a FRESH one at `version + 1`, declaring
|
|
505
|
+
* every currently-known store (plus {@link META_STORE}) so nothing is lost,
|
|
506
|
+
* and applying `table.remove` / `index.add` / `index.remove` /
|
|
507
|
+
* `column.remove` inside `upgrade`. Every step's `table` is validated against
|
|
508
|
+
* the driver's own `#schema` BEFORE the reconnect — an unknown-table step
|
|
509
|
+
* throws `DatabaseError` `MIGRATION` without ever bumping the version.
|
|
510
|
+
* `table.add` / `column.add` need no upgrade-time action: `table.add` is
|
|
511
|
+
* created by the wrapper's built-in create-missing-stores pass (its
|
|
512
|
+
* definition is already in the declared `stores`), and this driver stores
|
|
513
|
+
* whatever a row carries — there is nothing to backfill for a new column.
|
|
514
|
+
* `#schema` bookkeeping is updated to match the applied plan, mirroring what
|
|
515
|
+
* `open` tracks, so subsequent pushdown planning and a later `migrate` /
|
|
516
|
+
* `open` see the new shape.
|
|
517
|
+
*
|
|
518
|
+
* @param plan - The migration plan to apply
|
|
519
|
+
*/
|
|
520
|
+
async migrate(plan) {
|
|
521
|
+
for (const step of plan.steps) if (step.operation !== "table.add" && !this.#schema.has(step.table)) throw new DatabaseError("MIGRATION", `migrate: unknown table '${step.table}'`, { table: step.table });
|
|
522
|
+
const current = this.#require();
|
|
523
|
+
const version = current.version;
|
|
524
|
+
const schema = new Map(this.#schema);
|
|
525
|
+
this.#applySteps(schema, plan.steps);
|
|
526
|
+
current.close();
|
|
527
|
+
try {
|
|
528
|
+
const database = createIndexedDBDatabase({
|
|
529
|
+
name: this.#name,
|
|
530
|
+
version: version + 1,
|
|
531
|
+
stores: this.#stores(schema),
|
|
532
|
+
upgrade: (context) => this.#upgrade(context, plan.steps)
|
|
533
|
+
});
|
|
534
|
+
await database.connect();
|
|
535
|
+
this.#database = database;
|
|
536
|
+
this.#schema = schema;
|
|
537
|
+
} catch (error) {
|
|
538
|
+
await this.#reopen();
|
|
539
|
+
throw isIndexedDBError(error) ? mapMigrationError(error) : error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
#require() {
|
|
543
|
+
if (this.#database === void 0) throw new DatabaseError("CLOSED", `IndexedDB database '${this.#name}' is not open`, { name: this.#name });
|
|
544
|
+
return this.#database;
|
|
545
|
+
}
|
|
546
|
+
#wrap(error) {
|
|
547
|
+
return isIndexedDBError(error) ? mapIndexedDBError(error) : error;
|
|
548
|
+
}
|
|
549
|
+
#store(table) {
|
|
550
|
+
return this.#require().store(table);
|
|
551
|
+
}
|
|
552
|
+
#stores(schema) {
|
|
553
|
+
const stores = { [META_STORE]: {} };
|
|
554
|
+
for (const table of schema.values()) stores[table.name] = { indexes: table.indexes.map((columns) => ({
|
|
555
|
+
name: deriveIndexName(columns),
|
|
556
|
+
path: columns.length === 1 ? columns[0] : [...columns]
|
|
557
|
+
})) };
|
|
558
|
+
return stores;
|
|
559
|
+
}
|
|
560
|
+
async #reopen() {
|
|
561
|
+
const database = createIndexedDBDatabase({
|
|
562
|
+
name: this.#name,
|
|
563
|
+
stores: this.#stores(this.#schema)
|
|
564
|
+
});
|
|
565
|
+
await database.connect();
|
|
566
|
+
this.#database = database;
|
|
567
|
+
}
|
|
568
|
+
async #candidates(store, schema, plan) {
|
|
569
|
+
if (plan.index === null) return store.records(plan.range);
|
|
570
|
+
const rows = [...await store.index(plan.index).records(plan.range)];
|
|
571
|
+
rows.sort((left, right) => compareValues(extractKey(left, schema.primary), extractKey(right, schema.primary)));
|
|
572
|
+
return rows;
|
|
573
|
+
}
|
|
574
|
+
#table(name) {
|
|
575
|
+
const schema = this.#schema.get(name);
|
|
576
|
+
if (schema === void 0) throw new DatabaseError("NOT_FOUND", `table '${name}' is not declared`, { table: name });
|
|
577
|
+
return schema;
|
|
578
|
+
}
|
|
579
|
+
#applySteps(schema, steps) {
|
|
580
|
+
const sameIndex = (left, right) => left.length === right.length && left.every((column, position) => column === right[position]);
|
|
581
|
+
for (const step of steps) switch (step.operation) {
|
|
582
|
+
case "table.add":
|
|
583
|
+
if (!schema.has(step.table.name)) schema.set(step.table.name, step.table);
|
|
584
|
+
break;
|
|
585
|
+
case "table.remove":
|
|
586
|
+
schema.delete(step.table);
|
|
587
|
+
break;
|
|
588
|
+
case "column.add": {
|
|
589
|
+
const table = schema.get(step.table);
|
|
590
|
+
if (table !== void 0 && !table.columns.some((column) => column.name === step.column.name)) schema.set(step.table, {
|
|
591
|
+
...table,
|
|
592
|
+
columns: [...table.columns, step.column]
|
|
593
|
+
});
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case "column.remove": {
|
|
597
|
+
const table = schema.get(step.table);
|
|
598
|
+
if (table !== void 0) schema.set(step.table, {
|
|
599
|
+
...table,
|
|
600
|
+
columns: table.columns.filter((column) => column.name !== step.column)
|
|
601
|
+
});
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case "index.add": {
|
|
605
|
+
const table = schema.get(step.table);
|
|
606
|
+
if (table !== void 0) schema.set(step.table, {
|
|
607
|
+
...table,
|
|
608
|
+
indexes: [...table.indexes, step.index]
|
|
609
|
+
});
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case "index.remove": {
|
|
613
|
+
const table = schema.get(step.table);
|
|
614
|
+
if (table !== void 0) schema.set(step.table, {
|
|
615
|
+
...table,
|
|
616
|
+
indexes: table.indexes.filter((index) => !sameIndex(index, step.index))
|
|
617
|
+
});
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async #upgrade(context, steps) {
|
|
623
|
+
for (const step of steps) switch (step.operation) {
|
|
624
|
+
case "table.remove":
|
|
625
|
+
context.drop(step.table);
|
|
626
|
+
break;
|
|
627
|
+
case "index.add": {
|
|
628
|
+
const name = deriveIndexName(step.index);
|
|
629
|
+
const path = step.index.length === 1 ? step.index[0] : [...step.index];
|
|
630
|
+
context.transaction.objectStore(step.table).createIndex(name, path);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case "index.remove":
|
|
634
|
+
context.transaction.objectStore(step.table).deleteIndex(deriveIndexName(step.index));
|
|
635
|
+
break;
|
|
636
|
+
case "column.remove": {
|
|
637
|
+
let cursor = await context.store(step.table).cursor();
|
|
638
|
+
while (cursor !== null) {
|
|
639
|
+
const [migrated] = migrateRows([cursor.value], [step]);
|
|
640
|
+
await cursor.update(migrated);
|
|
641
|
+
cursor = await cursor.continue();
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
case "table.add":
|
|
646
|
+
case "column.add": break;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/browser/factories.ts
|
|
652
|
+
/**
|
|
653
|
+
* Create a persistent IndexedDB {@link DriverInterface} for the core database layer.
|
|
654
|
+
*
|
|
655
|
+
* @remarks
|
|
656
|
+
* Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
|
|
657
|
+
* relations stack against IndexedDB instead of memory — the `Database` / `Table` /
|
|
658
|
+
* `Query` / relations API is unchanged; only where the bytes live changes. The
|
|
659
|
+
* driver is built on the published `@orkestrel/indexeddb` wrapper in auto-managed
|
|
660
|
+
* mode, so a table added to the `tables` map is created on the next open with no
|
|
661
|
+
* version bump. This unit omits `transaction` / `aggregate` (see
|
|
662
|
+
* {@link IndexedDBDriver} `@remarks`).
|
|
663
|
+
*
|
|
664
|
+
* @param name - The IndexedDB database name to open or create
|
|
665
|
+
* @returns A {@link DriverInterface} backed by IndexedDB
|
|
666
|
+
*
|
|
667
|
+
* @example
|
|
668
|
+
* ```ts
|
|
669
|
+
* import { createDatabase } from '@orkestrel/database'
|
|
670
|
+
* import { stringShape } from '@orkestrel/contract'
|
|
671
|
+
* import { createIndexedDBDriver } from '@orkestrel/database/browser'
|
|
672
|
+
*
|
|
673
|
+
* const db = createDatabase({
|
|
674
|
+
* driver: createIndexedDBDriver('app'),
|
|
675
|
+
* tables: { users: { id: stringShape(), name: stringShape() } },
|
|
676
|
+
* })
|
|
677
|
+
* await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to IndexedDB
|
|
678
|
+
* ```
|
|
679
|
+
*/
|
|
680
|
+
function createIndexedDBDriver(name) {
|
|
681
|
+
return new IndexedDBDriver(name);
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
export { INDEXABLE_TYPES, IndexedDBDriver, META_STORE, conditionRange, createIndexedDBDriver, deriveIndexName, isKey, mapIndexedDBError, mapMigrationError, selectPlan };
|
|
685
|
+
|
|
686
|
+
//# sourceMappingURL=index.js.map
|