@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 CHANGED
@@ -14,22 +14,55 @@ npm install @orkestrel/database
14
14
 
15
15
  ## Requirements
16
16
 
17
- - Node.js >= 24
18
- - Core is ESM; the `./server` subpath ships a CommonJS build
17
+ - Node.js >= 24 (`node:sqlite`, used by the `./server` SQLite driver, emits an
18
+ `ExperimentalWarning` on Node's current stable line)
19
+ - Core is ESM; the `./server` subpath ships dual ESM+CJS builds; `./browser`
20
+ is ESM-only
19
21
 
20
22
  ## Status
21
23
 
22
- Pre-release (`0.0.1`): the core engine and the memory and JSON file drivers
23
- are implemented and tested, but the public API is still unstable and may
24
- change without notice. See [guides/src/database.md](./guides/src/database.md)
25
- for the full documented surface.
24
+ Pre-release (`0.0.2`): the core engine, and the memory, JSON file, SQLite,
25
+ and IndexedDB drivers are all implemented and tested, but the public API is
26
+ still unstable and may change without notice. See
27
+ [guides/src/database.md](./guides/src/database.md) for the full documented
28
+ surface.
26
29
 
27
30
  ## Package
28
31
 
29
- Published as two environment-scoped entry points per the `exports` field in
30
- `package.json`: a shared core (with the in-memory driver) and `./server` (the
31
- JSON file driver). IndexedDB and SQLite drivers are planned once their
32
- backing packages exist.
32
+ Published as three environment-scoped entry points per the `exports` field
33
+ in `package.json`: `.` (the shared, environment-agnostic core engine plus
34
+ the in-memory driver), `./server` (adds the JSON file and SQLite drivers),
35
+ and `./browser` (adds the IndexedDB driver). Core and `./server` ship dual
36
+ ESM+CJS builds; `./browser` is ESM-only.
37
+
38
+ ### Release order
39
+
40
+ Everything currently on the npm registry is at `0.0.1` — the wrapper repos'
41
+ `0.0.2`s were never published, so `0.0.2` is the next version for all three
42
+ packages and absorbs every change on this line.
43
+
44
+ This package's SQLite and IndexedDB drivers are built against the wrapper
45
+ surfaces documented by the mirrored guides in this repo — that is,
46
+ `@orkestrel/sqlite@0.0.2` and `@orkestrel/indexeddb@0.0.2` — and the
47
+ dependency ranges pin exactly those versions (`^0.0.2`; on a `0.0.x` version
48
+ a caret means exactly that patch: `>=0.0.2 <0.0.3`). Publish in this order:
49
+
50
+ 1. Publish `@orkestrel/sqlite@0.0.2` and `@orkestrel/indexeddb@0.0.2` — they
51
+ are independent of each other (either order; both depend only on the
52
+ already-published `@orkestrel/contract`).
53
+ 2. In this repo, run `npm install` to re-resolve `package-lock.json` against
54
+ the newly published wrappers and commit the refreshed lockfile.
55
+ 3. Run the `prepublishOnly` gates and publish `@orkestrel/database@0.0.2`.
56
+
57
+ Until step 1 happens, a fresh `npm ci` in this repo fails to resolve
58
+ `^0.0.2` — deliberately. The exact pin makes it impossible to install or
59
+ publish this package against the older `0.0.1` wrappers, which lack driver
60
+ fixes this package's behavior relies on (the SQLite wrapper's mid-stream
61
+ `iterate` fault mapping, the IndexedDB wrapper's abnormal-close recovery and
62
+ `READONLY` fault code) and whose surfaces the mirrored guides here no longer
63
+ describe. The same discipline applies to every future wrapper release: bump
64
+ the pinned range, re-mirror the wrapper guides, and republish this package
65
+ deliberately — wrapper changes never flow in silently.
33
66
 
34
67
  ## License
35
68
 
@@ -0,0 +1,342 @@
1
+ import { ColumnType } from '../core/index.js';
2
+ import { Condition } from '../core/index.js';
3
+ import { Criteria } from '../core/index.js';
4
+ import { Criteria as Criteria_2 } from '../core/index.js';
5
+ import { DatabaseError } from '../core/index.js';
6
+ import { DriverInterface } from '../core/index.js';
7
+ import { DriverInterface as DriverInterface_2 } from '../core/index.js';
8
+ import { DriverMeta } from '../core/index.js';
9
+ import { IndexedDBError } from '@orkestrel/indexeddb';
10
+ import { Key } from '../core/index.js';
11
+ import { Migration } from '../core/index.js';
12
+ import { Row } from '../core/index.js';
13
+ import { TableSchema } from '../core/index.js';
14
+ import { TableSchema as TableSchema_2 } from '../core/index.js';
15
+
16
+ /**
17
+ * The `IDBKeyRange` a single {@link Condition} maps to, when its operator is one
18
+ * of the six exact key comparisons over scalar operands — else `null`.
19
+ *
20
+ * @remarks
21
+ * Only the comparison operators (`equals`/`above`/`below`/`from`/`to`/`between`)
22
+ * translate to a key range that a typed (string/number) column can back with an
23
+ * IndexedDB store/index read — see {@link selectPlan} for the caveats that
24
+ * decide WHICH of `below`/`to` may drive a SECONDARY-index read versus the
25
+ * primary store only (a column-type / absent-row concern, not a range-shape
26
+ * one). `starts` is excluded — its prefix range can miss strings past U+FFFF;
27
+ * the membership / negation / pattern / existence operators (`not`/`like`/`glob`/
28
+ * `ends`/`any`/`none`/`absent`/`present`) have no single exact range. The operand
29
+ * guard (`typeof` string/number) rejects a non-scalar value (e.g. an array, a
30
+ * boolean) that is not a usable key. `between` additionally guards against a
31
+ * REVERSED pair (`first > second`): native `IDBKeyRange.bound` throws a raw
32
+ * `DataError` `DOMException` for a lower bound above the upper bound, so a
33
+ * reversed pair returns `null` here (falls back to a full scan, which the
34
+ * engine then correctly resolves to an empty result) rather than letting a
35
+ * native exception escape untyped — the same defensive posture as every other
36
+ * backend, which returns empty for a reversed/empty range instead of throwing.
37
+ * The switch is exhaustive over every {@link ConditionOperator}, so a new
38
+ * operator forces a deliberate decision here rather than silently defaulting
39
+ * to a (possibly lossy) range.
40
+ *
41
+ * @param condition - The condition to translate
42
+ * @returns Its exact key range, or `null` when the operator/operands cannot push
43
+ */
44
+ export declare function conditionRange(condition: Condition): IDBKeyRange | null;
45
+
46
+ /**
47
+ * Create a persistent IndexedDB {@link DriverInterface} for the core database layer.
48
+ *
49
+ * @remarks
50
+ * Pass it to `createDatabase` from `@orkestrel/database` to run the whole typed database +
51
+ * relations stack against IndexedDB instead of memory — the `Database` / `Table` /
52
+ * `Query` / relations API is unchanged; only where the bytes live changes. The
53
+ * driver is built on the published `@orkestrel/indexeddb` wrapper in auto-managed
54
+ * mode, so a table added to the `tables` map is created on the next open with no
55
+ * version bump. This unit omits `transaction` / `aggregate` (see
56
+ * {@link IndexedDBDriver} `@remarks`).
57
+ *
58
+ * @param name - The IndexedDB database name to open or create
59
+ * @returns A {@link DriverInterface} backed by IndexedDB
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * import { createDatabase } from '@orkestrel/database'
64
+ * import { stringShape } from '@orkestrel/contract'
65
+ * import { createIndexedDBDriver } from '@orkestrel/database/browser'
66
+ *
67
+ * const db = createDatabase({
68
+ * driver: createIndexedDBDriver('app'),
69
+ * tables: { users: { id: stringShape(), name: stringShape() } },
70
+ * })
71
+ * await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to IndexedDB
72
+ * ```
73
+ */
74
+ export declare function createIndexedDBDriver(name: string): DriverInterface_2;
75
+
76
+ /**
77
+ * Derive an IndexedDB index name for a declared column group — a bare column
78
+ * name for a single-column index, a deterministic collision-free encoding for a
79
+ * compound one.
80
+ *
81
+ * @remarks
82
+ * Naming a compound index by joining its columns with `_` (`['a', 'b'] →
83
+ * 'a_b'`) collides with a single-column index over a column LITERALLY named
84
+ * `'a_b'` — the same name, two different key paths (`'a_b'` vs `['a', 'b']`),
85
+ * which either throws a native `ConstraintError` from a duplicate
86
+ * `createIndex` call at open, or (worse) lets {@link selectPlan}'s name-based
87
+ * lookup match the wrong index. A single-column index keeps the BARE column
88
+ * name — {@link selectPlan} matches `available.includes(condition.column)` by
89
+ * that exact name, so a single-column index must stay named after its column
90
+ * verbatim. A compound index instead encodes each column as a LENGTH-PREFIXED
91
+ * segment (`'2#1:a1:b'`), so the boundary between columns is self-describing
92
+ * and cannot be reconstructed by any other column list — including one
93
+ * containing a column that happens to look like an encoded segment.
94
+ *
95
+ * @param columns - The index's column group, in declared order
96
+ * @returns The index name to pass to `createIndex` / read back from `indexNames`
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * deriveIndexName(['age']) // 'age'
101
+ * deriveIndexName(['a', 'b']) // '2#1:a1:b'
102
+ * ```
103
+ */
104
+ export declare function deriveIndexName(columns: readonly string[]): string;
105
+
106
+ export declare const INDEXABLE_TYPES: ReadonlySet<ColumnType>;
107
+
108
+ /**
109
+ * The IndexedDB {@link DriverInterface} — the persistent browser backend, built on
110
+ * the published `@orkestrel/indexeddb` wrapper.
111
+ *
112
+ * @remarks
113
+ * A thin adapter: it implements the storage primitives the core database layer
114
+ * needs (`open` / `close` / `read` / `write` / `delete` / `keys` / `scan` / `clear`
115
+ * / `snapshot`) by delegating to the wrapper's typed store operations — it never
116
+ * touches raw IndexedDB. Rows are stored with **out-of-line keys** (the database
117
+ * passes the key explicitly, `store.set(row, key)`), so each table is declared as a
118
+ * key-path-less store. The wrapper opens in **auto-managed** mode (no fixed
119
+ * version), creating any missing store on demand, so a table added to the schema is
120
+ * created on the next open with no manual version bump. The driver's bulk reads
121
+ * (`scan` / `keys`) use the wrapper's native `getAll` / `getAllKeys`, and `snapshot`
122
+ * rolls back through one atomic wrapper transaction.
123
+ *
124
+ * It also implements the optional native `records` / `count` / `stream` hooks
125
+ * (AGENTS §21): `selectPlan` ({@link selectPlan}) turns the {@link Criteria} into a
126
+ * key-range pushdown over the primary key or a single-column secondary index,
127
+ * fetching a candidate **superset** that the core engine (`applyCriteria` /
128
+ * `matchesCriteria`) then refines — so a native read is byte-identical to a full
129
+ * scan, just cheaper. Pushdown is conservative: only the exact-comparison
130
+ * operators over orderable columns narrow to a range; everything else falls back
131
+ * to a full scan + the engine.
132
+ *
133
+ * @remarks
134
+ * This driver also implements `migrate` / `meta` / `stamp`. `meta` / `stamp`
135
+ * persist the {@link DriverMeta} in a reserved out-of-line store,
136
+ * {@link META_STORE} (`__meta__`) — excluded from a whole-store `snapshot`
137
+ * capture, since it is driver bookkeeping, not caller data. `migrate` applies a
138
+ * {@link Migration} plan natively: IndexedDB schema DDL (creating/dropping a
139
+ * store, creating/dropping an index) is legal only inside a versionchange
140
+ * transaction (`onupgradeneeded`), so `migrate` closes the current connection
141
+ * and opens a FRESH one at `version + 1` with an `upgrade` hook that walks the
142
+ * plan's steps — dropping stores, adding/removing indexes on the raw
143
+ * `IDBTransaction`, and rewriting rows for `column.remove` via a cursor walk
144
+ * (the one step needing to touch existing data; `column.add` is a no-op — this
145
+ * driver stores whatever a row carries, so there is nothing to backfill). A
146
+ * step referencing an unknown table is validated BEFORE the reconnect, so a
147
+ * `MIGRATION` `DatabaseError` never wastes a version bump.
148
+ *
149
+ * @remarks
150
+ * This unit deliberately OMITS `aggregate` / `transaction`. There is no native
151
+ * `aggregate` (IndexedDB has no native SUM/AVG); the engine over the narrowed
152
+ * `records` covers it. `transaction` is impossible here: the wrapper auto-commits
153
+ * an `IDBTransaction` the moment control yields to a non-IDB `await`, so a
154
+ * BEGIN-now / commit-or-rollback-later handle spanning arbitrary caller code
155
+ * cannot be built on top of it — every atomic multi-op sequence in this driver
156
+ * (`snapshot`'s rollback) instead runs entirely inside ONE `db.write(...)` scope.
157
+ */
158
+ export declare class IndexedDBDriver implements DriverInterface {
159
+ #private;
160
+ constructor(name: string);
161
+ open(schema: readonly TableSchema_2[]): Promise<void>;
162
+ close(): Promise<void>;
163
+ read(table: string, key: Key): Promise<Row | undefined>;
164
+ write(table: string, key: Key, row: Row): Promise<void>;
165
+ delete(table: string, key: Key): Promise<boolean>;
166
+ keys(table: string): Promise<readonly Key[]>;
167
+ scan(table: string): AsyncIterable<Row>;
168
+ clear(table: string): Promise<void>;
169
+ records(table: string, criteria: Criteria_2): Promise<readonly Row[]>;
170
+ count(table: string, criteria: Criteria_2): Promise<number>;
171
+ stream(table: string, criteria: Criteria_2): AsyncIterable<Row>;
172
+ snapshot(tables?: readonly string[]): Promise<() => Promise<void>>;
173
+ /**
174
+ * Return the persisted {@link DriverMeta}, or `undefined` when the store has
175
+ * never been stamped.
176
+ *
177
+ * @remarks
178
+ * Reads `'meta'` from the reserved {@link META_STORE}, narrowing the
179
+ * structured-clone value with the core {@link isDriverMeta} guard (never
180
+ * asserted, AGENTS §14) — a missing or malformed record returns `undefined`,
181
+ * exactly like a fresh, never-stamped store.
182
+ *
183
+ * @returns The last-stamped {@link DriverMeta}, or `undefined`
184
+ */
185
+ meta(): Promise<DriverMeta | undefined>;
186
+ /**
187
+ * Persist `meta` verbatim for a later `meta()` to return.
188
+ *
189
+ * @param meta - The {@link DriverMeta} to persist
190
+ */
191
+ stamp(meta: DriverMeta): Promise<void>;
192
+ /**
193
+ * Apply a {@link Migration} plan by reconnecting at a bumped version and
194
+ * running the plan's steps inside the wrapper's `upgrade` hook.
195
+ *
196
+ * @remarks
197
+ * IndexedDB schema DDL is legal only inside `onupgradeneeded`, so this closes
198
+ * the current connection and opens a FRESH one at `version + 1`, declaring
199
+ * every currently-known store (plus {@link META_STORE}) so nothing is lost,
200
+ * and applying `table.remove` / `index.add` / `index.remove` /
201
+ * `column.remove` inside `upgrade`. Every step's `table` is validated against
202
+ * the driver's own `#schema` BEFORE the reconnect — an unknown-table step
203
+ * throws `DatabaseError` `MIGRATION` without ever bumping the version.
204
+ * `table.add` / `column.add` need no upgrade-time action: `table.add` is
205
+ * created by the wrapper's built-in create-missing-stores pass (its
206
+ * definition is already in the declared `stores`), and this driver stores
207
+ * whatever a row carries — there is nothing to backfill for a new column.
208
+ * `#schema` bookkeeping is updated to match the applied plan, mirroring what
209
+ * `open` tracks, so subsequent pushdown planning and a later `migrate` /
210
+ * `open` see the new shape.
211
+ *
212
+ * @param plan - The migration plan to apply
213
+ */
214
+ migrate(plan: Migration): Promise<void>;
215
+ }
216
+
217
+ export declare function isKey(value: unknown): value is string | number;
218
+
219
+ /**
220
+ * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
221
+ * — the default mapping used everywhere except inside `migrate()`.
222
+ *
223
+ * @remarks
224
+ * No backend fault may leak through `DriverInterface` as a raw `IndexedDBError`.
225
+ * `CONSTRAINT` (a unique-key violation) is a `CONFLICT` — the same code every
226
+ * other backend uses for a duplicate key. `CLOSED`/`NOT_OPEN`/`INVALID` (the
227
+ * connection is gone, never opened, or the native handle is stale) collapse to
228
+ * `CLOSED`. `QUOTA` and `BLOCKED` are genuine infrastructure faults (`DRIVER`),
229
+ * carrying a machine-readable `context.code` (`'QUOTA'` / `'BLOCKED'`) so a
230
+ * caller can branch without parsing the message; `BLOCKED` additionally marks
231
+ * `context.retryable: true` — a concurrent connection holding the database open
232
+ * is a transient condition, not a permanent one. Every other code (`UPGRADE`
233
+ * here — see {@link mapMigrationError} for the `migrate()`-only remapping to
234
+ * `MIGRATION` — `ABORTED`, `NOT_FOUND`, `DATA`, `OPEN`, `INACTIVE`, `READONLY`,
235
+ * `UNKNOWN`) is an unexpected infrastructure fault and maps to `DRIVER` — the
236
+ * driver opens its own readwrite transactions, so a `READONLY` fault can only
237
+ * mean the backend behaved unexpectedly. The original error is always
238
+ * preserved as `context.cause` for diagnostics.
239
+ *
240
+ * @param error - The backend error to translate
241
+ * @returns The portable `DatabaseError`
242
+ */
243
+ export declare function mapIndexedDBError(error: IndexedDBError): DatabaseError;
244
+
245
+ /**
246
+ * Map a backend {@link IndexedDBError} to the portable `DatabaseError` taxonomy
247
+ * for use INSIDE `migrate()` — the one context where `UPGRADE` means the
248
+ * migration itself failed, not a generic driver fault.
249
+ *
250
+ * @remarks
251
+ * `migrate()` reconnects at a bumped version inside `onupgradeneeded`; a
252
+ * rejection there (an inapplicable step, a native `ConstraintError` from a
253
+ * duplicate index, …) surfaces as `IndexedDBError` `UPGRADE` and must become a
254
+ * `MIGRATION` `DatabaseError` so a caller can distinguish "this migration plan
255
+ * failed" from "the driver hit an unrelated infrastructure fault". Every other
256
+ * code defers to {@link mapIndexedDBError} unchanged.
257
+ *
258
+ * @param error - The backend error to translate
259
+ * @returns The portable `DatabaseError`
260
+ */
261
+ export declare function mapMigrationError(error: IndexedDBError): DatabaseError;
262
+
263
+ export declare const META_STORE = "__meta__";
264
+
265
+ /**
266
+ * A pushdown plan — which index (or the primary store, `null`) to read and the
267
+ * `IDBKeyRange` to narrow by (`null` = full scan). Always a SUPERSET of the
268
+ * matching rows; the core engine refines it to the exact result.
269
+ */
270
+ export declare interface QueryPlan {
271
+ readonly index: string | null;
272
+ readonly range: IDBKeyRange | null;
273
+ }
274
+
275
+ /**
276
+ * Plan an IndexedDB read for a {@link Criteria} — pick the index (or the primary
277
+ * store) and {@link IDBKeyRange} to narrow by, falling back to a full scan.
278
+ *
279
+ * @remarks
280
+ * Pushdown is sound ONLY when every condition is `and`-joined: the engine folds
281
+ * conditions left-to-right (`c1 && c2 && … && cn`), so the result is a subset of
282
+ * each — narrowing on any one is then a valid superset. A single `or` breaks that
283
+ * (a row can match through a later condition the range would exclude), so any `or`
284
+ * forces a full scan. Otherwise it scans the conditions in order and selects the
285
+ * **first** one that is provably range-exact and backed by a key: a comparison
286
+ * operator (`conditionRange`) over a single, orderable (`text`/`integer`/`real`)
287
+ * column that is either the table's primary key (read the store directly, `index:
288
+ * null`) or has a single-column secondary index (named exactly the column — read
289
+ * that index). A condition whose column is a nested {@link FieldPath} array
290
+ * (descends a json value, not a key), is absent from the schema, is a non-orderable
291
+ * type (`boolean`/`json`/`blob`), uses a non-comparison operator, or has a
292
+ * non-scalar operand cannot push and is skipped.
293
+ *
294
+ * **`below`/`to` may drive a SECONDARY-index range only when the column has NO
295
+ * absent/null rows to lose — which this planner cannot verify from the schema
296
+ * alone, so it restricts them to the PRIMARY store, where that is always true.**
297
+ * The engine's total order (`compareValues`, see `@src/core`) ranks
298
+ * `undefined` (absent) and `null` BELOW every number/string, so
299
+ * `matchesCondition('below' | 'to', …)` is TRUE for a row whose field is absent
300
+ * or `null` — but a secondary IndexedDB index has NO ENTRY for a row whose
301
+ * indexed field is absent/`null`, so a `below`/`to` range read against that
302
+ * index would SILENTLY DROP those rows (they can never be over-fetched, only
303
+ * missed — the one shape of lossiness this planner must never produce). The
304
+ * table's PRIMARY key is exempt: a row's primary-key value is always present
305
+ * and never `null` (it is the row's identity, enforced at write time), so a
306
+ * `below`/`to` range against the primary store can never exclude an
307
+ * absent/null-keyed row because no such row exists. `equals`/`above`/`from`/
308
+ * `between` stay index-eligible on ANY orderable column, primary or secondary:
309
+ * each is bounded below by a scalar (`equals`/`between`'s lower bound, `above`/
310
+ * `from`'s lower bound), and every scalar strictly out-ranks `undefined`/`null`
311
+ * in the total order, so an absent/null-valued row can never satisfy them — the
312
+ * index's silence on such a row is harmless (it was never going to match).
313
+ * **Declared-type trust caveat:** this reasoning holds under the contract that
314
+ * an {@link INDEXABLE_TYPES} column, once contract-validated at write time,
315
+ * holds only `string | number | null` (or is absent) — never some other
316
+ * runtime value that could rank differently; a driver bypassing the write
317
+ * contract (writing raw rows directly to the store) could defeat this
318
+ * argument, but that is out of scope for a planner reading validated schema
319
+ * metadata.
320
+ *
321
+ * When no condition qualifies the plan is a full scan (`{ index: null, range:
322
+ * null }`) and the engine does everything. The plan is always a SUPERSET of the
323
+ * matching rows — the only correctness contract — so the driver may safely run
324
+ * the exact engine over it.
325
+ *
326
+ * @param criteria - The read specification (its `conditions` drive the plan), or
327
+ * `undefined` for an unconditional read
328
+ * @param schema - The table's schema — its `primary` key and column types
329
+ * @param available - The secondary-index names that physically exist on the store
330
+ * (`store.indexes`); a single-column index is named exactly its column
331
+ * @returns The index + range to read, narrowing to a superset (never lossy)
332
+ *
333
+ * @example
334
+ * ```ts
335
+ * selectPlan({ conditions: [eq('id', 'u1')] }, schema, []) // { index: null, range: only('u1') }
336
+ * selectPlan({ conditions: [from('age', 18)] }, schema, ['age']) // { index: 'age', range: from(18) }
337
+ * selectPlan({ conditions: [contains('name', 'a')] }, schema, []) // { index: null, range: null }
338
+ * ```
339
+ */
340
+ export declare function selectPlan(criteria: Criteria | undefined, schema: TableSchema, available: readonly string[]): QueryPlan;
341
+
342
+ export { }