@orkestrel/scaffold 0.0.67 → 0.0.68
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/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +4 -4
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +38 -16
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +37 -17
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,2518 @@
|
|
|
1
|
+
# Database
|
|
2
|
+
|
|
3
|
+
> One typed database API for keyed rows, fluent queries, cursors, and
|
|
4
|
+
> whole-store transactions, running unchanged over an in-memory map, a JSON
|
|
5
|
+
> file, SQLite, or IndexedDB.
|
|
6
|
+
|
|
7
|
+
A table is a contract. Declare a `tables` map of
|
|
8
|
+
[`ContractShape`](contract.md)s once, and the row type, write-time coercion and
|
|
9
|
+
validation, JSON-Schema introspection, and seed data all flow from that one
|
|
10
|
+
declaration — no separate schema, no annotations, no `as`.
|
|
11
|
+
|
|
12
|
+
The design stance is one engine, thin drivers. A backend implements only an
|
|
13
|
+
irreducible storage primitive — keyed read/write/insert/delete, an ordered
|
|
14
|
+
`scan`, key listing, and a `snapshot` — and inherits the entire WHERE / order /
|
|
15
|
+
page / aggregate surface from a single pure query engine in the core. A backend
|
|
16
|
+
that can go faster (a SQL `WHERE`, an index range) implements the optional
|
|
17
|
+
native hooks the engine falls back from; it never re-derives query semantics.
|
|
18
|
+
So this is deliberately not an ORM and not a query abstraction layer: there is
|
|
19
|
+
no entity graph, no migration runner, and no raw-SQL escape hatch — only the
|
|
20
|
+
smallest cross-environment core that earns its keep.
|
|
21
|
+
|
|
22
|
+
Source: [`src/core`](../src/core), published through `@orkestrel/database`. The
|
|
23
|
+
persistent drivers ship alongside it: a trusted-mode SQLite driver in
|
|
24
|
+
[`src/server`](../src/server) (surfaced through `@orkestrel/database/server`)
|
|
25
|
+
with native querying, paging, aggregation, transactions, and atomic migration,
|
|
26
|
+
and a narrow-then-refine IndexedDB driver in [`src/browser`](../src/browser)
|
|
27
|
+
(surfaced through `@orkestrel/database/browser`) that pushes a key-range
|
|
28
|
+
candidate set down to the index and lets the core engine refine it to the exact
|
|
29
|
+
result — beside the I/O-free `MemoryDriver` and the file-persisted
|
|
30
|
+
`JSONDriver`.
|
|
31
|
+
|
|
32
|
+
## Surface
|
|
33
|
+
|
|
34
|
+
Declare a `tables` shape map (keys are table names) once, and reach each
|
|
35
|
+
table — fully typed, no annotations — with `table(name)`.
|
|
36
|
+
|
|
37
|
+
### Create a database
|
|
38
|
+
|
|
39
|
+
Declares two tables, opens a memory-backed database over them, and runs a keyed write, a keyed read, and a fluent query:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
43
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
44
|
+
|
|
45
|
+
const db = createDatabase({
|
|
46
|
+
driver: createMemoryDriver(), // any DriverInterface — a persistent backend swaps in, same API
|
|
47
|
+
tables: {
|
|
48
|
+
users: { id: stringShape(), name: stringShape(), age: integerShape() },
|
|
49
|
+
posts: { slug: stringShape(), title: stringShape() },
|
|
50
|
+
},
|
|
51
|
+
primary: { posts: 'slug' }, // non-`id` primary-key columns, per table
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const users = db.table('users') // hold the handle; TableInterface<{ id; name; age }>
|
|
55
|
+
|
|
56
|
+
await users.set({ id: 'u1', name: 'Ada', age: 36 }) // coerced + validated through the contract
|
|
57
|
+
await users.get('u1') // typed { id; name; age } | undefined — narrowed, never `as`
|
|
58
|
+
await users
|
|
59
|
+
.query()
|
|
60
|
+
.condition({ column: 'age', operator: 'from', values: [18], connector: 'and' })
|
|
61
|
+
.order({ column: 'age', direction: 'descending' })
|
|
62
|
+
.collect() // typed rows
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Each `tables` value is a column map (a `column → shape` map) — a table row is
|
|
66
|
+
always an object, so the database wraps it in an `objectShape` for you; you
|
|
67
|
+
never write `objectShape` at the table level. The row type is `Infer` of
|
|
68
|
+
those columns, so `db.table('users')` is checked against the schema (a
|
|
69
|
+
typo'd column name or a wrong-typed write fails at compile time) and returns
|
|
70
|
+
a `TableInterface` typed by that row. That one declaration is the single
|
|
71
|
+
source of truth: it types the table, drives write coercion + validation,
|
|
72
|
+
produces the JSON Schema, and seeds fixtures.
|
|
73
|
+
|
|
74
|
+
### Factories
|
|
75
|
+
|
|
76
|
+
| API | Kind | Summary |
|
|
77
|
+
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
|
+
| `createDatabase` | function | Creates a database over a driver and a declared `tables` schema. |
|
|
79
|
+
| `createMemoryDriver` | function | Creates the in-memory reference `DriverInterface`. |
|
|
80
|
+
| `createJSONDriver` | function | Creates a persistent JSON-file `DriverInterface` for a given path. |
|
|
81
|
+
| `createSQLiteDriver` | function | Creates a trusted-mode, server-native SQLite `DriverInterface` for a database path, or for `:memory:` when the options bag omits one. |
|
|
82
|
+
| `createIndexedDBDriver` | function | Creates a persistent IndexedDB `DriverInterface` for a browser database name. |
|
|
83
|
+
|
|
84
|
+
### Classes
|
|
85
|
+
|
|
86
|
+
| Class | Kind | Summary |
|
|
87
|
+
| ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
88
|
+
| `Database` | class | Exposes a typed view over one shared internal lifecycle and storage context. |
|
|
89
|
+
| `DriverIterator` | class | Forms the internal continuation boundary for a root driver async iterator. |
|
|
90
|
+
| `MemoryDriver` | class | Implements the reference `DriverInterface` — nested maps, no I/O. |
|
|
91
|
+
| `JSONDriver` | class | Implements a persistent `DriverInterface` backed by a single JSON file — the reference `MemoryDriver` plus file load / flush. |
|
|
92
|
+
| `SQLiteDriver` | class | Implements the `DriverInterface` over SQLite — the server-native, trusted-mode backend built on the published `@orkestrel/sqlite` synchronous wrapper. |
|
|
93
|
+
| `IndexedDBDriver` | class | Implements the `DriverInterface` over IndexedDB — the persistent browser backend, built on the published `@orkestrel/indexeddb` wrapper. |
|
|
94
|
+
|
|
95
|
+
### Server
|
|
96
|
+
|
|
97
|
+
| API | Kind | Summary |
|
|
98
|
+
| ---------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `METADATA_TABLE` | const | Names the reserved metadata table the `SQLiteDriver` creates on `open` to persist its stamped `DriverMetadata` (`version` + declared schema JSON) — the SQLite realization of the `metadata` / `stamp` driver hooks. |
|
|
100
|
+
| `matchesConditionExactly` | function | Reports whether one `Condition` compiles to SQL that is provably identical to the core engine's `matchesCondition` for every value its column's declared type can store. |
|
|
101
|
+
| `matchesOrderExactly` | function | Reports whether one `Order` term's column compiles to an `ORDER BY` that matches the engine's `sortRows` exactly. |
|
|
102
|
+
| `matchesQueryExactly` | function | Reports whether a whole `QueryInput` is exact — every condition and every order term is exact. `limit` / `offset` never affect exactness (SQL `LIMIT` / `OFFSET` are always engine-identical). |
|
|
103
|
+
| `matchesDeclaredStorage` | function | Reports whether a value's runtime type matches a column's declared exact type — the operand side of the declared-type-trust proof. |
|
|
104
|
+
| `EXACT_COLUMN_STORAGE` | const | Lists the declared `ColumnStorage`s whose SQL equality comparisons (`equals` / `not` / `any` / `none`) and `starts` / `ends` compiles are provably engine-exact under declared-type trust — `text` / `integer` / `real` / `boolean`; a `json` or `blob` column always refines instead. |
|
|
105
|
+
| `EXACT_RANGE_COLUMN_STORAGE` | const | Lists the declared `ColumnStorage`s whose SQL range comparisons (`above` / `below` / `from` / `to` / `between`) and `ORDER BY` compiles are provably engine-exact — `integer` / `real` / `boolean` only. `text` is excluded: see `EXACT_COLUMN_STORAGE`'s remarks for the BINARY-collation (code-point) vs. JS `<` (code-unit) divergence on supplementary-plane characters. |
|
|
106
|
+
| `extractValues` | function | Extracts a stored row's values in a declared positional order. |
|
|
107
|
+
| `deriveSQLiteIndexName` | function | Builds a collision-free SQL index name for a table + column-group index — shared by the compiler module's `schemaToIndexes` and `stepToSQL`, so a plan-built index name always matches one `open` would have created. |
|
|
108
|
+
|
|
109
|
+
### SQL compilation
|
|
110
|
+
|
|
111
|
+
Pure, server-only functions that turn a core `QueryInput` / `TableSchema` into
|
|
112
|
+
parameterized SQL text — the native-query payoff for a SQLite-backed driver.
|
|
113
|
+
None of these import a SQLite package; they speak strings and values only.
|
|
114
|
+
|
|
115
|
+
| API | Kind | Summary |
|
|
116
|
+
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `inferValueStorage` | function | Reads the storage type a nested (`json_extract`) operand encodes as from its runtime value, never as `json`. |
|
|
118
|
+
| `compileJSONTypeSQL` | function | Compiles a nested `FieldPath` to the `json_type(<col>, <path>)` SQL expression — the `compileFieldSQL` `json_extract` sibling used to tell a present JSON `null` apart from an absent path (both read back as SQL `NULL` through `json_extract`, but `json_type` reports `'null'` for the former and SQL `NULL` for the latter). |
|
|
119
|
+
| `compileConditionSQL` | function | Compiles one condition to its `<column> <operator>` SQL fragment and the parameters it binds — engine-exact under SQL's three-valued NULL logic. |
|
|
120
|
+
| `compileWhereSQL` | function | Folds the conditions into one WHERE clause, parenthesizing progressively left-to-right so the grouping matches the engine's `matchesQuery` fold. |
|
|
121
|
+
| `compileOrderSQL` | function | Compiles the ORDER BY clause from the order terms, always ending with the primary key as the final determinant. |
|
|
122
|
+
| `compilePageSQL` | function | Compiles the LIMIT / OFFSET clause. |
|
|
123
|
+
| `compileQuerySQL` | function | Compiles a `QueryInput` into the SQL clause that follows a table name, with its bound parameters in clause order. |
|
|
124
|
+
| `quoteIdentifier` | function | Quotes a SQL identifier (a table or column name) so any characters are literal. |
|
|
125
|
+
| `compileFieldSQL` | function | Compiles a `FieldPath` to the SQL expression that reads it. |
|
|
126
|
+
| `compileColumnSQL` | function | Maps a portable `ColumnStorage` to its SQLite column type. |
|
|
127
|
+
| `compileAggregateSQL` | function | Compiles an `AggregateOperation` over a `FieldPath`. |
|
|
128
|
+
| `matchesAggregateExactly` | function | Reports whether SQLite can execute an aggregate exactly like the core engine. |
|
|
129
|
+
| `matchesSQLiteAffinity` | function | Checks a declared SQLite type against a portable storage affinity. |
|
|
130
|
+
| `matchesAbsentPath` | function | Reports whether a caught filesystem error says that nothing is there to read. |
|
|
131
|
+
| `encodeValue` | function | Encodes a JS value to its stored `SQLiteValue` for a declared column. |
|
|
132
|
+
| `decodeValue` | function | Decodes a stored `SQLiteValue` back to its JS value for a declared column — the exact inverse of `encodeValue`. |
|
|
133
|
+
| `encodeRow` | function | Encodes a whole `Row` to a `SQLiteRow` by its table's schema. |
|
|
134
|
+
| `decodeRow` | function | Decodes a stored `SQLiteRow` back to a `Row` by its table's schema. |
|
|
135
|
+
| `schemaToTable` | function | Projects a `TableSchema` to its `CREATE TABLE IF NOT EXISTS` statement. |
|
|
136
|
+
| `schemaToIndexes` | function | Projects a `TableSchema` to its declared SQLite indexes. |
|
|
137
|
+
| `stepToSQL` | function | Projects one `MigrationStep` to SQLite DDL. |
|
|
138
|
+
|
|
139
|
+
### Browser
|
|
140
|
+
|
|
141
|
+
Pure functions behind the IndexedDB driver's key-range pushdown planner — a
|
|
142
|
+
candidate superset the core engine then refines to the exact result, never
|
|
143
|
+
lossy.
|
|
144
|
+
|
|
145
|
+
| API | Kind | Summary |
|
|
146
|
+
| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
147
|
+
| `selectPlan` | function | Plans an IndexedDB read for a `QueryInput` — picks the index (or the primary store) and `IDBKeyRange` to narrow by, falling back to a full scan. |
|
|
148
|
+
| `conditionToRange` | function | Translates one `Condition` to the `IDBKeyRange` it maps to, when its operator is one of the exact key comparisons over scalar operands; otherwise returns `undefined`. |
|
|
149
|
+
| `INDEXABLE_STORAGE` | const | Lists the declared `ColumnStorage`s that are valid, orderable IndexedDB keys. |
|
|
150
|
+
| `METADATA_STORE` | const | Names the reserved out-of-line store `__metadata__` the `IndexedDBDriver` stamps its `DriverMetadata` into. |
|
|
151
|
+
| `mapIndexedDBError` | function | Maps a backend `IndexedDBError` to the portable `DatabaseError` taxonomy — the default mapping used everywhere except inside `migrate()`. |
|
|
152
|
+
| `mapMigrationError` | function | Maps a backend `IndexedDBError` to the portable `DatabaseError` taxonomy for use inside `migrate()` — the one context where `UPGRADE` means the migration itself failed, not a generic driver fault. |
|
|
153
|
+
| `deriveIndexedDBIndexName` | function | Derives an IndexedDB index name for a declared column group — a bare column name for a single-column index, a deterministic collision-free encoding for a compound one. |
|
|
154
|
+
| `schemaToStore` | function | Projects a table schema into the IndexedDB wrapper's store definition. |
|
|
155
|
+
|
|
156
|
+
### Errors
|
|
157
|
+
|
|
158
|
+
| API | Kind | Summary |
|
|
159
|
+
| ----------------- | -------- | ----------------------------------------------------- |
|
|
160
|
+
| `DatabaseError` | class | Represents an error thrown by the database layer. |
|
|
161
|
+
| `isDatabaseError` | function | Narrows an unknown caught value to a `DatabaseError`. |
|
|
162
|
+
|
|
163
|
+
### Query engine
|
|
164
|
+
|
|
165
|
+
The portable semantics every backend shares — pure, total functions the
|
|
166
|
+
driver never re-implements.
|
|
167
|
+
|
|
168
|
+
| Helper | Kind | Summary |
|
|
169
|
+
| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
170
|
+
| `compareValues` | function | Compares two arbitrary values under one total order — the comparator behind sorting and the range operators. |
|
|
171
|
+
| `matchesCondition` | function | Evaluates one `Condition` against a row — the per-operator predicate. |
|
|
172
|
+
| `matchesQuery` | function | Folds a row through a list of conditions, joining each by its connector. |
|
|
173
|
+
| `sortRows` | function | Sorts rows by an ordering specification, leaving the input untouched. |
|
|
174
|
+
| `applyQuery` | function | Applies a `QueryInput` to rows — filter, then sort, then page. |
|
|
175
|
+
| `validatePage` | function | Validates the paging fields of a portable query. |
|
|
176
|
+
| `computeAggregate` | function | Computes an aggregate over a column across rows. |
|
|
177
|
+
| `extractKey` | function | Reads a row's primary key from a column, when it is a usable `Key`. |
|
|
178
|
+
| `bindRowKey` | function | Returns a fresh row whose primary column is authoritatively bound to its storage key. |
|
|
179
|
+
| `shapeToColumnSchema` | function | Projects one contract shape into a portable column schema. |
|
|
180
|
+
| `findColumn` | function | Reads one flat column's declaration out of a table schema. |
|
|
181
|
+
| `resolvePrimary` | function | Resolves the primary-key column one table keys its rows by. |
|
|
182
|
+
| `requireColumns` | function | Requires one declared table's columns out of a table map. |
|
|
183
|
+
| `shapeToColumnStorage` | function | Maps a column's `ContractShape` to its portable `ColumnStorage` — the value a `TableSchema` carries so a native backend can declare a real column. |
|
|
184
|
+
| `filterRows` | function | Filters rows by a list of conditions — the shared basis for a table's count and aggregate paths (no sort/page, unlike `applyQuery`). |
|
|
185
|
+
| `equalsValue` | function | Compares two values structurally by SameValueZero leaves — the comparator behind conformance checks and any test/fixture that needs "same data", not "same reference". |
|
|
186
|
+
|
|
187
|
+
For `minimum` and `maximum`, `computeAggregate` compares each numeric value
|
|
188
|
+
with a scalar `Math.min` or `Math.max` call. The helper never passes a
|
|
189
|
+
row-sized argument list.
|
|
190
|
+
|
|
191
|
+
### Abort
|
|
192
|
+
|
|
193
|
+
| API | Kind | Summary |
|
|
194
|
+
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
195
|
+
| `checkAbort` | function | Throws when an `AbortSignal` has fired — the shared abort gate checked at operation boundaries and between streamed rows. |
|
|
196
|
+
|
|
197
|
+
### Migrations
|
|
198
|
+
|
|
199
|
+
Caller-driven schema migration — a pure structural diff plus a pure row
|
|
200
|
+
transform. Versioning drivers persist reconciliation metadata through the paired
|
|
201
|
+
`metadata` / `stamp` hooks.
|
|
202
|
+
|
|
203
|
+
| API | Kind | Summary |
|
|
204
|
+
| ------------------------ | -------- | ------------------------------------------------------------------------------- |
|
|
205
|
+
| `planMigration` | function | Diffs a deployed and a declared table set structurally into a `Migration` plan. |
|
|
206
|
+
| `migrateRows` | function | Applies one table's `MigrationStep`s to its rows — a pure row transform. |
|
|
207
|
+
| `projectMigrationSchema` | function | Projects migration steps sequentially over a canonical validated owned schema. |
|
|
208
|
+
| `normalizeDriverSchema` | function | Canonicalizes an unknown driver schema into a distinct deeply frozen snapshot. |
|
|
209
|
+
|
|
210
|
+
### Conformance
|
|
211
|
+
|
|
212
|
+
| API | Kind | Summary |
|
|
213
|
+
| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
214
|
+
| `conformDriver` | function | Runs the driver-conformance battery, throwing on the first violated invariant — the fail-fast entry point most callers (test setup, CI smoke checks) want. |
|
|
215
|
+
| `scanDriver` | function | Walks the driver-conformance battery against a fresh `DriverInterface` per phase, yielding one `ConformanceFinding` per violated invariant — the shared invariant suite every backend (in-memory, SQLite, IndexedDB) must uphold to be a drop-in `DriverInterface`. |
|
|
216
|
+
| `auditDriver` | function | Runs the full driver-conformance battery and collects every violation — the audit entry point for a driver author who wants a complete report rather than a single fail-fast throw. |
|
|
217
|
+
|
|
218
|
+
### Helpers & guards
|
|
219
|
+
|
|
220
|
+
Pure helpers behind the query engine's pattern matching.
|
|
221
|
+
|
|
222
|
+
| API | Kind | Summary |
|
|
223
|
+
| ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
224
|
+
| `cloneDriverMetadata` | function | Clones unknown driver metadata into a distinct deeply frozen snapshot. |
|
|
225
|
+
| `matchesWildcardPattern` | function | Matches a value against a wildcard pattern in linear time — the shared, ReDoS-safe engine behind `matchesLikePattern` and `matchesGlobPattern`. |
|
|
226
|
+
| `matchesLikePattern` | function | Matches a value against a SQL `LIKE` pattern, folding case. |
|
|
227
|
+
| `matchesGlobPattern` | function | Matches a value against a `GLOB` pattern, preserving case. |
|
|
228
|
+
| `isDriverMetadata` | function | Checks whether a value is persisted driver metadata. |
|
|
229
|
+
| `isDriverSchema` | function | Checks whether a value is a complete portable driver schema. |
|
|
230
|
+
| `isColumnSchema` | function | Checks whether a value is a portable column schema. |
|
|
231
|
+
| `isTableSchema` | function | Checks whether a value is a portable table schema. |
|
|
232
|
+
| `isMigrationStep` | function | Checks whether a value is one ordered migration step. |
|
|
233
|
+
| `isMigration` | function | Checks whether a value is an ordered migration plan. |
|
|
234
|
+
| `isMigrationInput` | function | Checks whether a value is one atomic migration request. |
|
|
235
|
+
| `isKey` | function | Checks whether a value is a usable database key. |
|
|
236
|
+
| `cloneDriverSchema` | function | Clones unknown driver schema into a distinct deeply frozen snapshot. |
|
|
237
|
+
| `cloneMigrationInput` | function | Clones unknown migration input into a distinct deeply frozen snapshot. |
|
|
238
|
+
|
|
239
|
+
### Constants
|
|
240
|
+
|
|
241
|
+
A `Shape` cell holds the constant's declared type.
|
|
242
|
+
|
|
243
|
+
| Constant | Kind | Shape | Summary |
|
|
244
|
+
| -------------------------- | ----- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
|
245
|
+
| `DEFAULT_PRIMARY` | const | `string` | Supplies the primary-key column, `'id'`, assumed when `PrimaryMap` does not name one. |
|
|
246
|
+
| `MAX_PATTERN_LENGTH` | const | `number` | Sets the longest `LIKE` / `GLOB` pattern the wildcard matcher accepts, 1024 characters, before rejecting it. |
|
|
247
|
+
| `CONFORMANCE_USERS_SCHEMA` | const | `TableSchema` | Describes the `users` table the driver-conformance battery opens — keyed by the default `id` primary column. |
|
|
248
|
+
| `CONFORMANCE_POSTS_SCHEMA` | const | `TableSchema` | Describes the `posts` table the driver-conformance battery opens — keyed by a non-`id` `slug` primary column. |
|
|
249
|
+
| `CONFORMANCE_SCHEMA` | const | `readonly TableSchema[]` | Holds the fixed `users` and `posts` schema every driver-conformance phase opens. |
|
|
250
|
+
|
|
251
|
+
### Types
|
|
252
|
+
|
|
253
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
|
|
254
|
+
|
|
255
|
+
| Type | Kind | Shape | Summary |
|
|
256
|
+
| -------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
257
|
+
| `Key` | type | `string \| number` | Represents a primary key — the value identifying a row within its table. |
|
|
258
|
+
| `KeyFunction` | type | `() => Key` | Represents a key-generating function. |
|
|
259
|
+
| `Row` | type | `Record<string, unknown>` | Represents a table row — a plain record of column values keyed by column name. |
|
|
260
|
+
| `ConditionOperator` | type | `'equals' \| 'not' \| 'above' \| 'below' \| 'from' \| 'to' \| 'between' \| 'like' \| 'glob' \| 'starts' \| 'ends' \| 'any' \| 'none' \| 'absent' \| 'present'` | Represents a WHERE operator — the comparison a single `Condition` applies. |
|
|
261
|
+
| `ConditionConnector` | type | `'and' \| 'or'` | Names how a `Condition` joins to the running result of the conditions before it. |
|
|
262
|
+
| `Condition` | interface | `{ column, operator, values, connector }` | Represents one compiled WHERE condition. |
|
|
263
|
+
| `OrderDirection` | type | `'ascending' \| 'descending'` | Names a sort direction. |
|
|
264
|
+
| `Order` | interface | `{ column, direction }` | Represents one ordering term — a column (`FieldPath`, flat or nested) and its direction. |
|
|
265
|
+
| `QueryInput` | interface | `{ conditions?, order?, limit?, offset? }` | Represents a serializable read specification — everything a backend needs to compile one read, free of JS callbacks so any backend can honor it. |
|
|
266
|
+
| `AggregateOperation` | type | `'count' \| 'sum' \| 'average' \| 'minimum' \| 'maximum'` | Names an aggregate computed over a numeric column. |
|
|
267
|
+
| `OperationOptions` | interface | `{ signal? }` | Options for an abortable operation. |
|
|
268
|
+
| `DatabaseStatus` | type | `'idle' \| 'open' \| 'closed'` | Names the lifecycle state of a `DatabaseInterface`. |
|
|
269
|
+
| `AdmissionInterface` | interface | `{ accepting } plus track` | Represents the admission boundary a scoped operation enters before it runs. |
|
|
270
|
+
| `DatabaseErrorCode` | type | `'CLOSED' \| 'NOT_FOUND' \| 'CONFLICT' \| 'VALIDATION' \| 'ABORTED' \| 'MIGRATION' \| 'CONFORMANCE' \| 'DRIVER'` | Names a machine-readable `DatabaseError` code. |
|
|
271
|
+
| `ConformanceFinding` | interface | `{ check, message, context }` | Represents one violated invariant from the driver-conformance battery. |
|
|
272
|
+
| `DatabaseEventMap` | type | `{ open, close, transaction, commit, rollback, migrate }` | Describes the push observation surface of a `DatabaseInterface` — the connection + transaction lifecycle a fire-and-forget observer (logging, metrics, tracing, cache invalidation) subscribes to. |
|
|
273
|
+
| `TableEventMap` | type | `{ write, remove, clear }` | Describes the push observation surface of a `TableInterface` — the per-row mutation moments a fire-and-forget observer (cache invalidation, sync, an audit log) subscribes to, alongside the database-level `DatabaseEventMap`. |
|
|
274
|
+
| `ColumnMap` | type | `Readonly<Record<string, ContractShape>>` | Represents one table's columns — a map of column name to its value `ContractShape`. |
|
|
275
|
+
| `TableMap` | type | `Readonly<Record<string, ColumnMap>>` | Represents a database's table schema — a map of table name to its `ColumnMap`. |
|
|
276
|
+
| `RowOf` | type | `Infer<{ category: 'object'; properties: C }>` | Represents the row type a table's `ColumnMap` describe — `Infer` of the `objectShape` the database wraps them in. |
|
|
277
|
+
| `PrimaryMap` | type | `Readonly<Record<string, string>>` | Holds per-table primary-key column overrides — `{ [table]: column }`. |
|
|
278
|
+
| `IndexMap` | type | `Readonly<Record<string, ReadonlyArray<readonly string[]>>>` | Holds per-table secondary indexes — `{ [table]: groups }`, each group one (possibly compound) index of column names. |
|
|
279
|
+
| `ColumnStorage` | type | `'text' \| 'integer' \| 'real' \| 'boolean' \| 'json' \| 'blob'` | Names a portable storage type for a column — the backend maps it to its native type (SQLite affinity, an IndexedDB value). Derived from a column's `ContractShape` by `shapeToColumnStorage`; `json` covers object/array/union/raw values a backend stores as JSON text and can `json_extract` for nested-field queries. |
|
|
280
|
+
| `ColumnSchema` | interface | `{ name, storage, optional, nullable }` | Represents one column of a `TableSchema` — its name, portable `ColumnStorage`, and whether it independently accepts absence (`optional`) and explicit `null` (`nullable`). |
|
|
281
|
+
| `TableSchema` | interface | `{ name, primary, columns, indexes }` | Represents a backend-agnostic description of one table — what `open` hands each driver so a native backend can create real tables and indexes. |
|
|
282
|
+
| `MigrationStep` | type | `{ operation: 'table.add', table } \| { operation: 'table.remove', table } \| { operation: 'column.add', table, column } \| { operation: 'column.remove', table, column } \| { operation: 'index.add', table, index } \| { operation: 'index.remove', table, index }` | Represents one step of a `Migration` plan — a single schema change applied to one table. |
|
|
283
|
+
| `Migration` | interface | `{ from, to, steps }` | Represents a schema migration plan — an ordered set of `MigrationStep`s moving a database from one schema version to another. |
|
|
284
|
+
| `MigrationInput` | interface | `{ plan, metadata? }` | Represents one atomic migration request. |
|
|
285
|
+
| `StorageInterface` | interface | `{} plus read, write, insert, delete, keys, scan, clear, records?, aggregate?, stream?, migrate?, metadata?, stamp?` | Declares the storage operations available only inside a driver's transaction scope. |
|
|
286
|
+
| `DriverMetadata` | interface | `{ version, schema }` | Represents persisted schema metadata a versioning driver owns as an immutable snapshot. |
|
|
287
|
+
| `DriverInterface` | interface | `StorageInterface plus {} plus open, close, snapshot, transaction?` | Declares the storage primitive every backend implements — the whole of the bridge. |
|
|
288
|
+
| `DatabaseOptions` | interface | `{ on?, error?, driver, tables, primary?, indexes?, name?, generator?, version? }` | Options for `createDatabase`. |
|
|
289
|
+
| `CompiledSQL` | interface | `{ sql, parameters }` | Represents a parameterized SQL fragment or statement plus its bind values. The `@orkestrel/database/server` entry point exports this type. |
|
|
290
|
+
| `SQLiteDriverOptions` | interface | `{ path?, readonly?, timeout?, references?, pragmas? }` | Configures `createSQLiteDriver`. The `@orkestrel/database/server` entry point exports this type. |
|
|
291
|
+
| `QueryPlan` | interface | `{ index?, range? }` | Represents a pushdown plan — an optional index and optional `IDBKeyRange` used to narrow a read. An omitted `index` selects the primary store; an omitted `range` performs a full scan. The plan is always a superset of the matching rows; the core engine refines it to the exact result. An empty plan (`{}`) is a primary-store full scan. The `@orkestrel/database/browser` entry point exports this type. |
|
|
292
|
+
| `TableDefinition` | interface | `{ primary, columns, schema }` | Represents one table's portable definition, produced by `export` — the unit of schema / migration exchange across environments. |
|
|
293
|
+
| `DatabaseStorageInterface` | interface | `{} plus table` | Represents a database view valid only inside one `DatabaseInterface.transaction` scope. |
|
|
294
|
+
| `DatabaseInterface` | interface | `{ emitter, name, status } plus table, import, export, open, close, transaction, migrate` | Represents a database — the ergonomic entry point that owns the driver and its tables. |
|
|
295
|
+
| `TableInterface` | interface | `{ emitter, name, primary, contract } plus get, resolve, has, keys, records, count, aggregate, scan, set, add, update, remove, clear, query, cursor` | Exposes typed keyed CRUD plus fluent query and cursor access. |
|
|
296
|
+
| `QueryInterface` | interface | `{} plus condition, order, filter, limit, offset, collect, find, count, stream, aggregate` | Builds a read through a fluent chain. |
|
|
297
|
+
| `CursorInterface` | interface | `{ value, index, done } plus next, update, remove, close` | Walks a table's rows forward for bulk in-place mutation. |
|
|
298
|
+
|
|
299
|
+
## Methods
|
|
300
|
+
|
|
301
|
+
The public methods of each behavioral interface — one table per type, keyed
|
|
302
|
+
by its backticked name, every call-signature member listed (its `readonly`
|
|
303
|
+
data members, for example `emitter` / `name` / `status` / `primary` / `contract` /
|
|
304
|
+
`value` / `index` / `done`, stay in the preceding Surface rows — `emitter` is the
|
|
305
|
+
typed push observation surface, see [Observing](#observing)). The database and
|
|
306
|
+
driver classes in `### Classes` implement their interfaces exactly, so this
|
|
307
|
+
doubles as the per-instance method surface; `DriverIterator` is the internal
|
|
308
|
+
continuation boundary and implements none of these interfaces (see
|
|
309
|
+
`.claude/rules/documentation.md` § Parity).
|
|
310
|
+
|
|
311
|
+
#### `StorageInterface`
|
|
312
|
+
|
|
313
|
+
The storage capability a native driver passes into one transaction scope.
|
|
314
|
+
It exposes work, not settlement: the driver commits when the callback fulfills,
|
|
315
|
+
rolls back when it rejects, and invalidates the capability afterward. Every
|
|
316
|
+
method below runs inside that scope, and each carries the same contract its
|
|
317
|
+
`DriverInterface` twin carries against the whole backend, so the
|
|
318
|
+
`StorageInterface` and `DriverInterface` tables share one description per
|
|
319
|
+
method.
|
|
320
|
+
|
|
321
|
+
| Method | Returns | Summary |
|
|
322
|
+
| ----------- | -------------------------------------- | -------------------------------------------------------------------------------- |
|
|
323
|
+
| `read` | `Promise<Row \| undefined>` | Reads one row by key. |
|
|
324
|
+
| `write` | `Promise<void>` | Writes one row at a key. |
|
|
325
|
+
| `insert` | `Promise<void>` | Inserts one row atomically, rejecting `CONFLICT` when its key already exists. |
|
|
326
|
+
| `delete` | `Promise<boolean>` | Deletes one row by key. |
|
|
327
|
+
| `keys` | `Promise<readonly Key[]>` | Lists a table's keys. |
|
|
328
|
+
| `scan` | `AsyncIterable<Row>` | Iterates a table's rows in ascending key order. |
|
|
329
|
+
| `clear` | `Promise<void>` | Empties a table. |
|
|
330
|
+
| `records` | `Promise<readonly Row[]>` | Reads the rows matching a `QueryInput` natively — an optional hook. |
|
|
331
|
+
| `aggregate` | `Promise<number \| undefined>` | Computes an aggregate over a column natively — an optional hook. |
|
|
332
|
+
| `stream` | `AsyncIterable<Row>` | Iterates the natively filtered rows lazily — an optional hook. |
|
|
333
|
+
| `migrate` | `Promise<void>` | Applies one atomic `MigrationInput` — an optional hook. |
|
|
334
|
+
| `metadata` | `Promise<DriverMetadata \| undefined>` | Reads the persisted `DriverMetadata` as a deeply frozen copy — an optional hook. |
|
|
335
|
+
| `stamp` | `Promise<void>` | Writes the persisted `DriverMetadata`, snapshot at entry — an optional hook. |
|
|
336
|
+
|
|
337
|
+
#### `DriverInterface`
|
|
338
|
+
|
|
339
|
+
The complete backend extends `StorageInterface` with lifecycle, the
|
|
340
|
+
snapshot floor, and an optional native transaction callback. The inherited
|
|
341
|
+
storage/query/migration/metadata methods carry the same contract they carry on
|
|
342
|
+
`StorageInterface`, addressed against the whole backend rather than one
|
|
343
|
+
transaction scope. `open` receives the derived `TableSchema` list: a native
|
|
344
|
+
backend builds real tables and indexes from it, a scan-only backend reads
|
|
345
|
+
`name` alone. `snapshot` with no `tables` captures the whole store, while a
|
|
346
|
+
list scopes capture and restore to those tables. An omitted optional hook
|
|
347
|
+
costs nothing — the core query engine answers `records`, `aggregate`, and
|
|
348
|
+
`stream` over `scan` instead, and a driver without `transaction` runs a scope
|
|
349
|
+
on the snapshot floor (see [Native transactions](#native-transactions)).
|
|
350
|
+
|
|
351
|
+
| Method | Returns | Summary |
|
|
352
|
+
| ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
353
|
+
| `open` | `Promise<void>` | Readies the tables from a derived `TableSchema` list. |
|
|
354
|
+
| `close` | `Promise<void>` | Releases the backend. |
|
|
355
|
+
| `snapshot` | `Promise<() => Promise<void>>` | Captures table rows and returns a repeatable thunk that restores those rows — the primitive transactions are built on. |
|
|
356
|
+
| `read` | `Promise<Row \| undefined>` | Reads one row by key. |
|
|
357
|
+
| `write` | `Promise<void>` | Writes one row at a key. |
|
|
358
|
+
| `insert` | `Promise<void>` | Inserts one row atomically, rejecting `CONFLICT` when its key already exists. |
|
|
359
|
+
| `delete` | `Promise<boolean>` | Deletes one row by key. |
|
|
360
|
+
| `keys` | `Promise<readonly Key[]>` | Lists a table's keys. |
|
|
361
|
+
| `scan` | `AsyncIterable<Row>` | Iterates a table's rows in ascending key order. |
|
|
362
|
+
| `clear` | `Promise<void>` | Empties a table. |
|
|
363
|
+
| `records` | `Promise<readonly Row[]>` | Reads the rows matching a `QueryInput` natively — an optional hook. |
|
|
364
|
+
| `aggregate` | `Promise<number \| undefined>` | Computes an aggregate over a column natively — an optional hook. |
|
|
365
|
+
| `stream` | `AsyncIterable<Row>` | Iterates the natively filtered rows lazily — an optional hook. |
|
|
366
|
+
| `migrate` | `Promise<void>` | Applies one atomic `MigrationInput` — an optional hook. |
|
|
367
|
+
| `metadata` | `Promise<DriverMetadata \| undefined>` | Reads the persisted `DriverMetadata` as a deeply frozen copy — an optional hook. |
|
|
368
|
+
| `stamp` | `Promise<void>` | Writes the persisted `DriverMetadata`, snapshot at entry — an optional hook. |
|
|
369
|
+
| `transaction` | `Promise<R>` | Opens a native transaction scope — an optional driver hook. The driver owns acquisition, commit or rollback, release, and invalidation of the scoped capability. |
|
|
370
|
+
|
|
371
|
+
#### `DatabaseInterface`
|
|
372
|
+
|
|
373
|
+
`transaction` and `migrate` each take an optional `OperationOptions`, whose
|
|
374
|
+
`signal` is checked once, at entry.
|
|
375
|
+
|
|
376
|
+
| Method | Returns | Summary |
|
|
377
|
+
| ------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
378
|
+
| `table` | `TableInterface<RowOf<T[K]>>` | Returns the typed handle for a declared table. |
|
|
379
|
+
| `import` | `DatabaseInterface<U>` | Defines a further shape map of tables as a typed view over the same driver and storage. |
|
|
380
|
+
| `export` | `Readonly<Record<string, TableDefinition>>` | Returns one portable `TableDefinition` per declared table. |
|
|
381
|
+
| `open` | `Promise<void>` | Connects the driver eagerly, ahead of the lazy connect on first use. |
|
|
382
|
+
| `close` | `Promise<void>` | Closes the database and releases its driver. |
|
|
383
|
+
| `transaction` | `Promise<R>` | Runs a scope over a `DatabaseStorageInterface`, committing when the callback fulfills and rolling back when it rejects. |
|
|
384
|
+
| `migrate` | `Promise<Migration>` | Diffs a caller-supplied deployed schema against this database's declared schema (its `tables`, as configured) through `planMigration`, applies the resulting plan through the driver's optional `migrate` hook, and returns the applied plan. |
|
|
385
|
+
|
|
386
|
+
#### `DatabaseStorageInterface`
|
|
387
|
+
|
|
388
|
+
The scoped view and every table taken from it throw `CONFLICT` for work started
|
|
389
|
+
after the transaction settles.
|
|
390
|
+
|
|
391
|
+
| Method | Returns | Summary |
|
|
392
|
+
| ------- | ----------------------------- | ------------------------------------------------------ |
|
|
393
|
+
| `table` | `TableInterface<RowOf<T[K]>>` | Returns a table bound to the active transaction scope. |
|
|
394
|
+
|
|
395
|
+
#### `AdmissionInterface`
|
|
396
|
+
|
|
397
|
+
The admission boundary the root database context and a transaction scope both
|
|
398
|
+
expose; its `accepting` data member stays in the Surface row above.
|
|
399
|
+
|
|
400
|
+
| Method | Returns | Summary |
|
|
401
|
+
| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
402
|
+
| `track` | `Promise<R>` | Enters one operation into the boundary's ledger so whoever stops the boundary contains everything already accepted. |
|
|
403
|
+
|
|
404
|
+
#### `TableInterface`
|
|
405
|
+
|
|
406
|
+
The keyed methods batch by overload (one in → one out; array in → array
|
|
407
|
+
out) — a single verb, never `getMany` / `setAll`. `records()` / `scan()`
|
|
408
|
+
narrow every row through the table's contract guard, so a non-conforming
|
|
409
|
+
stored row (legacy data, a row from before a migration) never appears in
|
|
410
|
+
their results. `count()` uses the same contract-valid candidate semantics as
|
|
411
|
+
`records()` while ignoring paging, so invalid stored rows do not consume the
|
|
412
|
+
count. `aggregate()` remains a stored-row operation; its `count` aggregate may
|
|
413
|
+
therefore include a row that `TableInterface.count()` excludes. `records`,
|
|
414
|
+
`count`, `aggregate`, `scan`, `set`, `add`, `update`, and `remove` each take an
|
|
415
|
+
optional `OperationOptions`: its `signal` reaches every backend commit point,
|
|
416
|
+
and an aborted batch keeps the items already committed.
|
|
417
|
+
|
|
418
|
+
| Method | Returns | Summary |
|
|
419
|
+
| ----------- | ------------------------------------ | ------------------------------------------------------------------------------------ |
|
|
420
|
+
| `get` | `Promise<T \| undefined>` (or array) | Reads one row by key, or one row per key for a list — `undefined` for each miss. |
|
|
421
|
+
| `resolve` | `Promise<T>` (or array) | Reads one row by key, or one row per key for a list, throwing `NOT_FOUND` on a miss. |
|
|
422
|
+
| `has` | `Promise<boolean>` (or array) | Reports whether one key exists, or one result per key for a list. |
|
|
423
|
+
| `keys` | `Promise<readonly Key[]>` | Lists every primary key in order. |
|
|
424
|
+
| `records` | `Promise<readonly T[]>` | Reads the contract-valid rows matching an optional `QueryInput`. |
|
|
425
|
+
| `count` | `Promise<number>` | Counts contract-valid rows matching `input`'s conditions. |
|
|
426
|
+
| `aggregate` | `Promise<number \| undefined>` | Computes an aggregate over `column` across rows matching `input`'s conditions. |
|
|
427
|
+
| `scan` | `AsyncIterable<T>` | Iterates the table's rows lazily with filtering. |
|
|
428
|
+
| `set` | `Promise<Key>` (or array) | Upserts one or more rows. |
|
|
429
|
+
| `add` | `Promise<Key>` (or array) | Inserts one or more rows, throwing `CONFLICT` on a duplicate key. |
|
|
430
|
+
| `update` | `Promise<boolean>` (or array) | Applies a partial change to one or more rows. |
|
|
431
|
+
| `remove` | `Promise<boolean>` (or array) | Deletes one or more rows. |
|
|
432
|
+
| `clear` | `Promise<void>` | Empties the table. |
|
|
433
|
+
| `query` | `QueryInterface<T>` | Opens a fluent query builder over the table. |
|
|
434
|
+
| `cursor` | `Promise<CursorInterface<T>>` | Opens a forward row cursor for bulk mutation. |
|
|
435
|
+
|
|
436
|
+
#### `QueryInterface`
|
|
437
|
+
|
|
438
|
+
Each modifier mutates and returns the same builder. `condition` accepts the
|
|
439
|
+
portable condition directly, `order` accepts one portable order, and the
|
|
440
|
+
terminal methods execute the accumulated `QueryInput`. `stream` takes an
|
|
441
|
+
optional `OperationOptions` and ignores `order`, because rows are evaluated one
|
|
442
|
+
at a time.
|
|
443
|
+
|
|
444
|
+
| Method | Returns | Summary |
|
|
445
|
+
| ----------- | ------------------------------ | -------------------------------------------------------------------------------- |
|
|
446
|
+
| `condition` | `QueryInterface<T>` | Adds one portable condition, including its explicit connector. |
|
|
447
|
+
| `order` | `QueryInterface<T>` | Adds one portable ordering term — a column and a direction. |
|
|
448
|
+
| `filter` | `QueryInterface<T>` | Adds a post-fetch JavaScript predicate. |
|
|
449
|
+
| `limit` | `QueryInterface<T>` | Caps the result count. |
|
|
450
|
+
| `offset` | `QueryInterface<T>` | Skips the leading rows. |
|
|
451
|
+
| `collect` | `Promise<readonly T[]>` | Executes the accumulated read and collects every matching row. |
|
|
452
|
+
| `find` | `Promise<T \| undefined>` | Executes the accumulated read and returns the first match, or `undefined`. |
|
|
453
|
+
| `count` | `Promise<number>` | Executes the accumulated read and returns the match count. |
|
|
454
|
+
| `stream` | `AsyncIterable<T>` | Evaluates this query's conditions / filters / offset / limit lazily, row by row. |
|
|
455
|
+
| `aggregate` | `Promise<number \| undefined>` | Executes a named aggregate over one column. |
|
|
456
|
+
|
|
457
|
+
#### `CursorInterface`
|
|
458
|
+
|
|
459
|
+
| Method | Returns | Summary |
|
|
460
|
+
| -------- | --------------- | ------------------------------------------------------------------ |
|
|
461
|
+
| `next` | `Promise<void>` | Advances to the next present row. |
|
|
462
|
+
| `update` | `Promise<void>` | Merges changes into the row at the current position. |
|
|
463
|
+
| `remove` | `Promise<void>` | Deletes the row at the current position. |
|
|
464
|
+
| `close` | `void` | Closes the cursor terminally, so every later operation is a no-op. |
|
|
465
|
+
|
|
466
|
+
## Contract
|
|
467
|
+
|
|
468
|
+
These invariants hold across the core database source tree ↔ this guide:
|
|
469
|
+
|
|
470
|
+
1. **Doc ↔ public entry bijection.** Every `function` / `const` / `class` /
|
|
471
|
+
`interface` / `type` row in the `## Surface` tables is reachable from the
|
|
472
|
+
`src/core`, `src/server`, or `src/browser` entry barrel, and every reachable
|
|
473
|
+
public export appears as a Surface row — compiler-resolved and exhaustive in
|
|
474
|
+
both directions (see `.claude/rules/documentation.md` § Parity). Exported
|
|
475
|
+
implementation declarations outside
|
|
476
|
+
an entry barrel remain internal.
|
|
477
|
+
2. **A table is a contract.** Every write is coerced **and** validated
|
|
478
|
+
through the table's compiled contract — `set` / `add` / `update` run the
|
|
479
|
+
row through the installed Contract 0.0.9 `contract.parse`, one step that
|
|
480
|
+
both coerces and enforces constraints such as `min` / `pattern`. Every
|
|
481
|
+
parsed result already satisfies `contract.is`, so `Table` does not run a
|
|
482
|
+
second guard after parsing; a row that fails throws `VALIDATION`. Reads are
|
|
483
|
+
narrowed back to the table's row type through the guard — never an `as`
|
|
484
|
+
(see `AGENTS.md` § Non-negotiable rules). The
|
|
485
|
+
row type is the shape's `Infer`, so a `tables` map types every table from
|
|
486
|
+
one declaration. A contract rejection reports only the table plus the first
|
|
487
|
+
bounded contract fault (`field` and `reason` when one exists); the rejected
|
|
488
|
+
row, received value, and parser cause never enter the message, context,
|
|
489
|
+
serialized error, or table events.
|
|
490
|
+
3. **Thin driver, one engine, native overrides.** The required
|
|
491
|
+
`DriverInterface` surface is the irreducible storage primitive — keyed
|
|
492
|
+
read/write/atomic-insert/delete, an ordered `scan`, key listing, and `snapshot`. `open`
|
|
493
|
+
hands the driver a derived `TableSchema[]` (each table's `columns`, their
|
|
494
|
+
portable `ColumnStorage` through `shapeToColumnStorage`, the `primary` key, and declared
|
|
495
|
+
`indexes`) so a native backend can build real tables and indexes; a
|
|
496
|
+
scan-only backend reads only `name`. The pure, total query engine
|
|
497
|
+
(`applyQuery` / `matchesQuery` / `computeAggregate` / …) over `scan`
|
|
498
|
+
is the default and the only required path. A backend can implement the
|
|
499
|
+
optional native `records?` / `aggregate?` / `transaction?` /
|
|
500
|
+
`stream?` / `migrate?` where it has a faster or more native path, and the
|
|
501
|
+
engine prefers each when present — falling back to the portable path
|
|
502
|
+
otherwise (see `.claude/rules/architecture.md` § System constraints).
|
|
503
|
+
Because `aggregate?` legitimately resolves to
|
|
504
|
+
`undefined` (a sum over zero rows), `Table.aggregate` decides the hook ran
|
|
505
|
+
by its **presence** (a present method returns a Promise; `?.()` is
|
|
506
|
+
`undefined` only when the method is absent), never by the resolved value.
|
|
507
|
+
Neither reference driver implements `records?` / `aggregate?`,
|
|
508
|
+
so every query runs the engine over key-ordered `scan`; both do implement
|
|
509
|
+
`stream?` and `migrate?` (`MemoryDriver.stream` lazily filters `scan` through
|
|
510
|
+
the engine; `JSONDriver.stream` delegates to its inner `MemoryDriver`).
|
|
511
|
+
`MemoryDriver` still lacks a native `transaction?`, so its transactions
|
|
512
|
+
always use the snapshot floor; `JSONDriver` does implement
|
|
513
|
+
`transaction?` — it clones the committed memory/schema/metadata into an
|
|
514
|
+
isolated candidate, passes only that candidate's capability to the callback,
|
|
515
|
+
and publishes it with one atomic file replacement only when the callback
|
|
516
|
+
fulfills. Rejection or persistence failure discards the candidate, leaving
|
|
517
|
+
committed memory, metadata, and file bytes exact; nesting and root operations
|
|
518
|
+
while active throw `CONFLICT`. Root `scan` / `stream` iterators created
|
|
519
|
+
before a JSON or SQLite transaction guard every continuation before and
|
|
520
|
+
after the underlying read. Resuming one while the transaction is active
|
|
521
|
+
throws `CONFLICT`, discards any concurrently produced row, cleans the source
|
|
522
|
+
exactly once, and terminalizes the iterator; the transaction and driver
|
|
523
|
+
remain usable. Memory and IndexedDB need no equivalent root wrapper because
|
|
524
|
+
neither exposes a callback transaction. `Database.transaction` over a `JSONDriver`
|
|
525
|
+
therefore prefers this native path over the snapshot floor. Both reference drivers also
|
|
526
|
+
implement the paired `metadata?` / `stamp?` — `MemoryDriver` in-process only
|
|
527
|
+
(the owned `DriverMetadata` snapshot lives in instance memory), `JSONDriver` persisted:
|
|
528
|
+
the file is `{ metadata?: DriverMetadata, tables }`, with `metadata` present only
|
|
529
|
+
once the store has been `stamp`ed at least once (an old, pre-versioning
|
|
530
|
+
file — bare `{ tables }` — reads back as unstamped, that is, `metadata()`
|
|
531
|
+
resolves `undefined`; a bare `{ tables }` document is therefore unstamped). A `JSONDriver` write-path
|
|
532
|
+
fault (`mkdir` / `writeFile` / `rename` failing during `#serialize`)
|
|
533
|
+
surfaces as a `DatabaseError` `DRIVER` after temporary-file cleanup; its
|
|
534
|
+
context carries `path` and the native `cause`. If that cleanup also fails,
|
|
535
|
+
the top-level `DRIVER` context is exactly the persistence evidence
|
|
536
|
+
`{ path, temp, cause, cleanup }`; a precommit abort remains an `ABORTED`
|
|
537
|
+
`DatabaseError` nested at `cause`, while cleanup failure determines the
|
|
538
|
+
top-level code. Durable reads fail closed: only a proven absent JSON path or
|
|
539
|
+
absent native metadata record is fresh. A fresh SQLite/IndexedDB store may create
|
|
540
|
+
missing declared tables/stores while retaining unrelated physical objects. Once
|
|
541
|
+
metadata exists, every table/store in its persisted schema must already exist
|
|
542
|
+
physically: SQLite throws
|
|
543
|
+
`DatabaseError('DRIVER', 'Stored SQLite table is missing')` before any DDL in its
|
|
544
|
+
open transaction, and IndexedDB throws
|
|
545
|
+
`DatabaseError('DRIVER', 'Stored IndexedDB store is missing')` before its final
|
|
546
|
+
open. Extra physical objects remain allowed, and SQLite may reconstruct a
|
|
547
|
+
missing declared index because the table and its records still exist. IndexedDB
|
|
548
|
+
captures the bootstrap connection's exact stores and version in the same
|
|
549
|
+
lifetime, then pins the persisted final open to that version; a concurrent
|
|
550
|
+
versionchange makes the stale open reject instead of silently bumping and
|
|
551
|
+
recreating storage. Existing unreadable, malformed, structurally incompatible,
|
|
552
|
+
or physically incomplete state throws `DRIVER` without publishing a handle or
|
|
553
|
+
attempting table/store repair; after external repair, the same driver instance
|
|
554
|
+
may retry `open`.
|
|
555
|
+
`SQLiteDriver` and `IndexedDBDriver` complete the native-override
|
|
556
|
+
picture from opposite ends, each earning trust its own way (see
|
|
557
|
+
`.claude/rules/architecture.md` § System constraints).
|
|
558
|
+
`SQLiteDriver` is **prove-exactness-or-refine**: real `CREATE TABLE` /
|
|
559
|
+
`CREATE INDEX` DDL backs every table, but `records?` /
|
|
560
|
+
`aggregate?` / `stream?` compile a `QueryInput` straight to SQL
|
|
561
|
+
(`compileQuerySQL`, `compileAggregateSQL`) and run it natively only when
|
|
562
|
+
`matchesQueryExactly` (built from `matchesConditionExactly` / `matchesOrderExactly`) first
|
|
563
|
+
proves the SQL and the engine's semantics are identical for every
|
|
564
|
+
condition and order term — otherwise the driver falls back to a full
|
|
565
|
+
`scan` refined through the same core engine every scan-only driver uses
|
|
566
|
+
(`applyQuery` / `filterRows` / `computeAggregate` / `matchesQuery`).
|
|
567
|
+
Refine, not native SQL, is the path for: `like` / `glob` patterns (SQL
|
|
568
|
+
`LIKE`/`GLOB` semantics can diverge from the engine's `matchesWildcardPattern`),
|
|
569
|
+
every scalar condition over a column that is optional or nullable,
|
|
570
|
+
`absent` / `present` only when the column is optional and nullable, a `null`
|
|
571
|
+
or `undefined` scalar operand, an empty `any` / `none` operand list,
|
|
572
|
+
mismatched operand types, `json` / `blob` columns, and any nested
|
|
573
|
+
`FieldPath` — and a
|
|
574
|
+
range operator (`above` / `below` / `from` / `to` / `between`) or an
|
|
575
|
+
`ORDER BY` term over a `text` column: SQLite's default BINARY collation
|
|
576
|
+
orders `TEXT` by Unicode code point while the core engine's `compareValues`
|
|
577
|
+
orders JS strings by UTF-16 code unit, and the two diverge on
|
|
578
|
+
supplementary-plane characters (code points ≥ U+10000, for example many emoji) —
|
|
579
|
+
so text ranges and text ordering always refine through the engine, even
|
|
580
|
+
though text equality (`equals`/`not`/`any`/`none`) and `starts`/`ends` stay
|
|
581
|
+
native on a required non-null text column. `starts` / `ends` compile
|
|
582
|
+
case-sensitively (a `substr` comparison plus a
|
|
583
|
+
`typeof text === 'string'` guard; an empty operand falls back to a
|
|
584
|
+
`typeof` check) when they do qualify as exact. `IndexedDBDriver` is **narrow-then-refine**:
|
|
585
|
+
`records?` / `stream?` first ask `selectPlan` for a key-range pushdown over
|
|
586
|
+
the primary key or a single-column secondary index — a candidate superset,
|
|
587
|
+
never lossy — then hands that superset to the same core engine
|
|
588
|
+
(`applyQuery` / `matchesQuery`) every scan-only driver uses, which
|
|
589
|
+
refines it to the exact result; a plan that cannot prove itself range-exact
|
|
590
|
+
(a nested path, a non-orderable column type, an `or`-joined condition, a
|
|
591
|
+
non-comparison operator) falls back to a full scan. `below` / `to` push
|
|
592
|
+
down only onto the primary store, never a secondary index — a secondary
|
|
593
|
+
index has no entry for a row whose indexed column is absent or `null`,
|
|
594
|
+
while the engine's total order (`compareValues`) lets those rows match a
|
|
595
|
+
`below` / `to` bound; `equals` / `above` / `from` / `between` remain
|
|
596
|
+
index-eligible on either. `conditionToRange` returns `undefined` for a `between`
|
|
597
|
+
whose bounds are reversed (`compareValues(first, second) > 0`), so
|
|
598
|
+
`selectPlan` falls back to a full scan instead of handing a raw
|
|
599
|
+
backwards `IDBKeyRange` to the store (which would throw a `DataError`).
|
|
600
|
+
`IndexedDBDriver.snapshot()` captures every store in one read transaction,
|
|
601
|
+
so the capture is point-in-time consistent across stores even under
|
|
602
|
+
concurrent writers; `restore` was already atomic. `SQLiteDriver` and
|
|
603
|
+
`IndexedDBDriver` each implement `migrate?` natively and treat `MigrationInput` as one commit unit:
|
|
604
|
+
`SQLiteDriver` applies schema, rows, and optional metadata inside one native
|
|
605
|
+
SQLite transaction (`stepToSQL` projects each step's DDL), while a migration
|
|
606
|
+
invoked inside an existing callback transaction uses one fixed internal
|
|
607
|
+
savepoint SQL literal. The published `@orkestrel/sqlite` wrapper
|
|
608
|
+
intentionally exposes raw `execute` but no savepoint manager; the savepoint
|
|
609
|
+
contains a caught inner migration so it cannot leak partial DDL and the
|
|
610
|
+
outer transaction remains active for unrelated work.
|
|
611
|
+
`IndexedDBDriver` performs a non-empty plan in one versionchange transaction,
|
|
612
|
+
writing `metadata` in that same upgrade; a metadata-only input uses one ordinary
|
|
613
|
+
`__metadata__` readwrite transaction. A `column.remove` step that meets a stored
|
|
614
|
+
value which is not a record fails the migration closed with `MIGRATION` and
|
|
615
|
+
nothing is rewritten. Both implement the paired `metadata?` /
|
|
616
|
+
`stamp?` into a reserved store name a user table must avoid:
|
|
617
|
+
`SQLiteDriver` uses a single-row `_metadata` table (`METADATA_TABLE`), and
|
|
618
|
+
`IndexedDBDriver` uses an out-of-line `__metadata__` store (`METADATA_STORE`), both
|
|
619
|
+
excluded from a whole-store `snapshot`. `SQLiteDriver` implements
|
|
620
|
+
`transaction?` as a callback-scoped real `BEGIN` / `COMMIT` / `ROLLBACK`;
|
|
621
|
+
the capability performs reads, writes, migration, and metadata work inside
|
|
622
|
+
that one native transaction, then becomes invalid. `IndexedDBDriver`
|
|
623
|
+
deliberately omits `transaction?`: an `IDBTransaction` can auto-commit when
|
|
624
|
+
control yields to a non-IDB `await`, so arbitrary callback awaits cannot
|
|
625
|
+
truthfully remain inside one native transaction. It also omits `aggregate?`
|
|
626
|
+
because IndexedDB has no native SUM/AVG/MIN/MAX; the engine over the narrowed
|
|
627
|
+
`records?` covers it. A new backend implements a handful of small methods and
|
|
628
|
+
inherits the entire query surface unchanged.
|
|
629
|
+
4. **Total query helpers; the equality family is structural, not ranked.**
|
|
630
|
+
`compareValues`, `matchesCondition`, and `matchesQuery` never throw — a
|
|
631
|
+
type mismatch is a non-match and the comparator is a total order (it
|
|
632
|
+
never returns `NaN`), mirroring the contracts guards' totality (see
|
|
633
|
+
`.claude/rules/patterns.md` § Validation and contracts). The range
|
|
634
|
+
operators (`above` / `below` / `from` / `to` /
|
|
635
|
+
`between`) still rank through `compareValues`'s total order (which
|
|
636
|
+
collapses every object/array to one rank-5 bucket). The equality-family
|
|
637
|
+
operators (`equals` / `not` / `any` / `none`) instead compare through
|
|
638
|
+
`equalsValue` — structural equality by SameValueZero leaves, so `equals` on
|
|
639
|
+
an object/array compares field-by-field rather than by reference or rank,
|
|
640
|
+
and `NaN` equals `NaN` under `equals` / `any` (it never matched anything
|
|
641
|
+
under the old rank-based comparison).
|
|
642
|
+
5. **Scoped transactions, with explicit admission and drain.**
|
|
643
|
+
`transaction(scope, options?)` checks `options?.signal` once at entry, then
|
|
644
|
+
gives `scope` a `DatabaseStorageInterface`: a table-only view backed by a
|
|
645
|
+
scoped `StorageInterface`. Every operation accepted while the callback
|
|
646
|
+
is active is tracked, and settlement waits for that whole accepted operation
|
|
647
|
+
graph to drain — not only the promise the callback returns. A rejected
|
|
648
|
+
accepted operation aborts the transaction even when caller code catches that
|
|
649
|
+
rejection. If the callback itself throws synchronously or rejects
|
|
650
|
+
asynchronously, that exact reason wins over a drain error; otherwise the
|
|
651
|
+
first tracked failure becomes the transaction error.
|
|
652
|
+
Admission closes when the callback returns, so later work conflicts rather
|
|
653
|
+
than escaping settlement. Each `scan` / `stream` continuation is tracked
|
|
654
|
+
independently: an in-flight `next()` drains, but an idle iterator does not pin
|
|
655
|
+
commit, and a continuation requested after settlement throws `CONFLICT`.
|
|
656
|
+
Root tables (including imported views), `open`, `close`, `migrate`, and
|
|
657
|
+
nesting throw `CONFLICT` while the scope is active; the scoped view, its
|
|
658
|
+
tables, queries, cursors, and streams also throw `CONFLICT` after settlement.
|
|
659
|
+
When the driver implements `transaction?(scope)`, the driver owns
|
|
660
|
+
acquisition, commit on fulfillment, rollback on rejection, release, and
|
|
661
|
+
invalidation. Otherwise the universal single-writer floor snapshots the
|
|
662
|
+
whole store, runs the same scoped callback, and restores the snapshot on
|
|
663
|
+
rejection. Either path emits the identical `transaction` / `commit`
|
|
664
|
+
lifecycle; `rollback(error)` is emitted only when rollback completed and the
|
|
665
|
+
original scope/drain error remains the propagated rejection, never when
|
|
666
|
+
cleanup itself replaced that error.
|
|
667
|
+
6. **Observation is a pure side-channel.** The core `Database` owns a
|
|
668
|
+
typed `emitter` (`DatabaseEventMap` — `open` / `close` / `transaction` /
|
|
669
|
+
`commit` / `rollback` / `migrate`) and each `Table` owns one (`TableEventMap` —
|
|
670
|
+
`write` / `remove` / `clear`, key only, no value payload to avoid heavy
|
|
671
|
+
fan-out / leaking row data). Every event is emitted directly (the
|
|
672
|
+
`.claude/rules/patterns.md` § Listener isolation convention: the emitter
|
|
673
|
+
isolates a listener throw, routing it to its
|
|
674
|
+
own `error` handler — the `error` option, surfaced as `(error, event)`,
|
|
675
|
+
not a domain event — itself re-entrancy-guarded) strictly after the
|
|
676
|
+
relevant transition — `commit` only after a scope succeeds, `rollback`
|
|
677
|
+
only after restoration succeeds and the observed error is the same rejection
|
|
678
|
+
that propagates (a cleanup failure is not mislabeled as a rollback), a
|
|
679
|
+
`write` / `remove` / `clear` only after the driver op
|
|
680
|
+
completes. So a buggy observer can never corrupt a write or a
|
|
681
|
+
transaction: the committed state stays intact, the rollback still
|
|
682
|
+
restores, and the original transaction error still propagates (proven by
|
|
683
|
+
the emit-safety tests). Reads / queries / counts are not emitted (a
|
|
684
|
+
reader does not mutate, and those paths are too hot). The observation
|
|
685
|
+
lives in the core layer; the drivers stay storage primitives.
|
|
686
|
+
7. **Views share a driver.** A database is a typed view over a set of tables
|
|
687
|
+
on one driver. `import(tables)` returns a new view of only those tables
|
|
688
|
+
over the **same** driver (sharing storage and transactions); `export()`
|
|
689
|
+
emits a portable `TableDefinition` per table — `schema` is the universally
|
|
690
|
+
portable JSON Schema, `columns` re-imports losslessly through `import` within
|
|
691
|
+
a TypeScript environment.
|
|
692
|
+
8. **Doc ↔ source method bijection.** Every behavioral interface's
|
|
693
|
+
`## Methods` table lists exactly its public methods (call-signature
|
|
694
|
+
members) — exhaustive, both directions — and each implementing class
|
|
695
|
+
(`Database` / `MemoryDriver` / `JSONDriver` / `SQLiteDriver` /
|
|
696
|
+
`IndexedDBDriver` / `Table` / `Query`) implements
|
|
697
|
+
every required method and adds none beyond the interface (optional members
|
|
698
|
+
like `records?` / `aggregate?` / `transaction?` / `stream?` /
|
|
699
|
+
`migrate?` / `metadata?` / `stamp?` may be omitted). `MemoryDriver` and
|
|
700
|
+
`JSONDriver` both omit `records?` / `aggregate?`, and both
|
|
701
|
+
implement `stream?` / `migrate?` / `metadata?` / `stamp?`; `MemoryDriver` still
|
|
702
|
+
omits `transaction?` (snapshot floor only) while `JSONDriver`
|
|
703
|
+
implements `transaction?` too (isolated candidate state plus one atomic
|
|
704
|
+
publish on callback fulfillment). `SQLiteDriver` implements every optional hook — `records?` /
|
|
705
|
+
`aggregate?` / `transaction?` / `stream?` / `migrate?` / `metadata?`
|
|
706
|
+
/ `stamp?` — the fully-native backend. `IndexedDBDriver` implements
|
|
707
|
+
`records?` / `stream?` / `migrate?` / `metadata?` / `stamp?` but
|
|
708
|
+
omits `transaction?` and `aggregate?` by IndexedDB's nature, not by
|
|
709
|
+
choice (see `.claude/rules/documentation.md` § Parity). A renamed / added /
|
|
710
|
+
removed method breaks the gate
|
|
711
|
+
until the table is reconciled.
|
|
712
|
+
9. **Abort is a shared gate, not per-method reinvention.**
|
|
713
|
+
`checkAbort(signal)` is the one place `ABORTED` is thrown — a no-op for
|
|
714
|
+
`undefined` or a live signal. `records` / `count` / `aggregate` check it
|
|
715
|
+
at entry; `TableInterface.scan` and `QueryInterface.stream` check it
|
|
716
|
+
before each yield (so an abort mid-iteration stops promptly) and ignore
|
|
717
|
+
`order` (streaming yields driver key-order; sorted output stays
|
|
718
|
+
`records()`'s job). Breaking out of a stream early (`break`) closes the
|
|
719
|
+
underlying source. Each call to `scan` / `stream` returns a fresh
|
|
720
|
+
iterable — reusing a `QueryInterface` across calls never leaks state
|
|
721
|
+
between them. `set` / `update` route through `write`, `add` through the
|
|
722
|
+
required atomic `insert`, and `remove` through `delete`; all carry the same
|
|
723
|
+
`OperationOptions` to the real backend commit point. An abort while the shared lazy open is pending
|
|
724
|
+
rejects that mutation promptly; the open may finish for other waiters, but
|
|
725
|
+
the rejected mutation never dispatches later. Memory and SQLite re-check
|
|
726
|
+
immediately before their synchronous mutation. IndexedDB runs each point
|
|
727
|
+
mutation in one explicit readwrite transaction and aborts that transaction
|
|
728
|
+
only while it is active. JSON queues each nontransactional point mutation
|
|
729
|
+
through preimage capture, staging, atomic rename, and success or restoration;
|
|
730
|
+
queued aborts never start, active precommit aborts restore memory and clean
|
|
731
|
+
the temp file before rejection, and reads wait behind that unit. Once an
|
|
732
|
+
commit point that cannot be aborted (`SQLite` call entry, IndexedDB transaction
|
|
733
|
+
completion, JSON `rename` dispatch) has won, the operation awaits and reports its real
|
|
734
|
+
result; a late signal cannot convert success to `ABORTED`. Batch items remain
|
|
735
|
+
sequential and independent: earlier committed items survive an abort of a
|
|
736
|
+
later item.
|
|
737
|
+
10. **Key generation is host-neutral and overridable.** When an optional
|
|
738
|
+
primary column is omitted, a table uses global `crypto.randomUUID()`.
|
|
739
|
+
`DatabaseOptions.generator` (a `KeyFunction`) authoritatively replaces
|
|
740
|
+
that default, which is required for numeric generated primaries. An
|
|
741
|
+
explicit primary value always wins and never invokes the generator.
|
|
742
|
+
11. **Atomic migration input, plus opt-in versioned reconciliation.**
|
|
743
|
+
`planMigration(deployed, declared, from?, to?)` structurally diffs two
|
|
744
|
+
`TableSchema[]` into an ordered `Migration` plan (`table.add` /
|
|
745
|
+
`table.remove`, then each shared table's `column.add` / `column.remove`
|
|
746
|
+
/ `index.add` / `index.remove`). A driver receives
|
|
747
|
+
`driver.migrate?.({ plan, metadata? })`: one `MigrationInput` whose schema
|
|
748
|
+
changes and optional target metadata publish atomically. A step
|
|
749
|
+
referencing an unknown table throws
|
|
750
|
+
`DatabaseError('MIGRATION')`; so does a `column.add` / `column.remove`-adjacent
|
|
751
|
+
shared column whose declared `storage`, `optional`, or `nullable` differs between
|
|
752
|
+
`deployed` and `declared` (an in-place storage/optionality/nullability change is not
|
|
753
|
+
auto-migrated — the JSDoc on `planMigration` documents the manual path:
|
|
754
|
+
add a new column, copy/convert the data, then remove the old one).
|
|
755
|
+
A `column.add` that is required and non-null is rejected before DDL because
|
|
756
|
+
existing rows cannot satisfy it without an explicit data backfill; add an
|
|
757
|
+
optional or nullable column first, populate it, then tighten the schema
|
|
758
|
+
through an explicit application-managed migration.
|
|
759
|
+
`Database.migrate(deployed, options?)`
|
|
760
|
+
is the explicit pre-open migration path. It diffs `deployed` against the
|
|
761
|
+
database's own declared `tables`, applies the resulting plan through the
|
|
762
|
+
driver's `migrate?` hook (throwing `MIGRATION` when the driver lacks
|
|
763
|
+
one), emits the `migrate` event on success, and returns the applied
|
|
764
|
+
plan — `options?.signal` is checked once, at entry, throwing `ABORTED`
|
|
765
|
+
on an already-fired signal. The explicit path opens the caller-declared
|
|
766
|
+
deployed physical schema, applies the migration (including target metadata
|
|
767
|
+
when `version` is configured), and publishes `open` as one readiness
|
|
768
|
+
transition. It is an alternative admission path, so the same handle does
|
|
769
|
+
not run a second automatic reconciliation. A failed explicit apply remains
|
|
770
|
+
the exact readiness failure for ordinary table/open work until another
|
|
771
|
+
explicit `migrate` succeeds; `close` remains available. `migrateRows` is the pure per-table row
|
|
772
|
+
transform (`column.remove` drops the field from a fresh copy of each
|
|
773
|
+
row; the other operations act on storage shape, not row shape, so they
|
|
774
|
+
are no-ops here) — a driver's own `migrate` decides how to apply it to
|
|
775
|
+
stored rows. This caller-driven path remains the way to migrate against
|
|
776
|
+
an unversioned driver (one that implements neither `metadata` nor `stamp`),
|
|
777
|
+
which still owns knowing what is deployed. A driver that does
|
|
778
|
+
implement both `metadata` and `stamp` can instead opt into automatic
|
|
779
|
+
reconciliation by passing `DatabaseOptions.version`. A versioning driver's
|
|
780
|
+
`open()` first discovers persisted `DriverMetadata.schema` and opens that
|
|
781
|
+
deployed physical schema; it must not pre-create the target schema before
|
|
782
|
+
reconciliation. `Database.open()` then compares deployed metadata with the
|
|
783
|
+
declared version inside the same lazy-connect chain. A fresh store
|
|
784
|
+
(`metadata()` is `undefined`) stamps `{ version, schema }` for next time. A
|
|
785
|
+
stored version below the declared one computes the plan from the persisted
|
|
786
|
+
schema and passes `{ plan, metadata: { version, schema } }` to `migrate`, so
|
|
787
|
+
schema, rows, and new metadata commit or roll back together. A stored
|
|
788
|
+
version above the declared one throws `MIGRATION`. At an equal version,
|
|
789
|
+
the persisted and declared schemas must still match; drift throws
|
|
790
|
+
`MIGRATION`, while an exact match is a no-op with no metadata rewrite.
|
|
791
|
+
Comparison canonicalizes table order, column order, and the outer index-list
|
|
792
|
+
order, while preserving each compound index's inner column order because
|
|
793
|
+
`['city', 'age']` and `['age', 'city']` are different indexes.
|
|
794
|
+
`version` left unset, or set against a non-versioning driver, leaves
|
|
795
|
+
`open()` unchanged — versioning is opt-in per driver and per database.
|
|
796
|
+
Versioning drivers own the migration input's atomicity even when they do
|
|
797
|
+
not expose a general callback `transaction` hook. On a fresh handle, call
|
|
798
|
+
either `open()` for metadata-driven reconciliation or `migrate(deployed)`
|
|
799
|
+
for caller-driven reconciliation. Both converge on the same declared schema
|
|
800
|
+
and publish readiness once.
|
|
801
|
+
12. **Driver conformance.** `conformDriver(factory)` is a framework-agnostic
|
|
802
|
+
battery (no test-runner import) any new `DriverInterface` backend can run
|
|
803
|
+
against itself — a smoke script, a unit test, or a new driver's own
|
|
804
|
+
README all call it the same way. It opens the fixed `users` and `posts`
|
|
805
|
+
schema per phase (calling `factory()` fresh each time so failures stay
|
|
806
|
+
isolated) and verifies the required surface's invariants (copy-in/copy-out
|
|
807
|
+
isolation, upsert-overwrite, key-ordered `keys`/`scan`, `snapshot` rollback, a
|
|
808
|
+
non-`id` primary key, structural round-tripping through `equalsValue`), then
|
|
809
|
+
presence-gates the optional `migrate?` / `stream?` / `transaction?`
|
|
810
|
+
hooks when the driver implements them. The battery's `write-read` phase
|
|
811
|
+
is deepened with nested-field checks (a written row's nested object/array
|
|
812
|
+
fields must copy-in/copy-out isolated, not only its top-level fields),
|
|
813
|
+
and a dedicated `snapshot-nested` phase asserts the same nested isolation
|
|
814
|
+
across a `snapshot()` capture/restore round-trip — a driver that
|
|
815
|
+
shallow-copies anywhere in its write/read/scan/snapshot boundary
|
|
816
|
+
fails conformance (`MemoryDriver` passes by deep-copying through
|
|
817
|
+
`structuredClone` at every one of those boundaries). The first violated
|
|
818
|
+
invariant throws a `CONFORMANCE` `DatabaseError` naming the failed check.
|
|
819
|
+
13. **Backend faults surface as `DatabaseError`, never raw.** No native
|
|
820
|
+
wrapper error (a SQLite fault, an IndexedDB `DOMException`) crosses a
|
|
821
|
+
`DriverInterface` implementation — `SQLiteDriver` and `IndexedDBDriver`
|
|
822
|
+
each map every backend fault to a `DatabaseError` at the boundary
|
|
823
|
+
(`#guard` internally on the SQLite side; `mapIndexedDBError` /
|
|
824
|
+
`mapMigrationError` on the IndexedDB side), preserving the original
|
|
825
|
+
error as `context.cause`. `SQLiteDriver`: a constraint violation maps to
|
|
826
|
+
`CONFLICT`, a closed-connection fault to `CLOSED`, a busy/locked database
|
|
827
|
+
to `DRIVER` with a `retryable` context flag, anything else to `DRIVER`.
|
|
828
|
+
Snapshot capture puts the open gate, every prepare/read, and captured-map
|
|
829
|
+
population inside one `#guard`; replay likewise contains the open gate,
|
|
830
|
+
native transaction, deletes, prepares, and reinserts in one `#guard`.
|
|
831
|
+
Public `close()` crosses that same boundary. A physically dropped declared
|
|
832
|
+
table therefore rejects capture as top-level `DRIVER`, and a zero-timeout
|
|
833
|
+
exclusive lock rejects replay as retryable `DRIVER` with
|
|
834
|
+
`context.code === 'BUSY'`; both retain the actual `SQLiteError` only at
|
|
835
|
+
`context.cause`.
|
|
836
|
+
`IndexedDBDriver`: a constraint violation maps to `CONFLICT`; a
|
|
837
|
+
closed/not-open/invalid-state fault to `CLOSED`; a quota fault to
|
|
838
|
+
`DRIVER` with `code: 'QUOTA'`; `migrate`'s versionchange path remaps an
|
|
839
|
+
upgrade fault to `MIGRATION`; anything else to `DRIVER`. A blocked open
|
|
840
|
+
or versionchange is nonterminal and remains pending until the competing
|
|
841
|
+
connection closes, rather than surfacing as an error.
|
|
842
|
+
14. **The reserved metadata table/store is a hard guard, not a naming
|
|
843
|
+
convention.** `SQLiteDriver.open` throws `DatabaseError('VALIDATION')`
|
|
844
|
+
when the declared tables include one literally named `_metadata`
|
|
845
|
+
(`METADATA_TABLE`); `IndexedDBDriver.open` does the same for `__metadata__`
|
|
846
|
+
(`METADATA_STORE`) — a collision is caught at `open`, not discovered later
|
|
847
|
+
as corrupted metadata. Because both drivers derive their index names from
|
|
848
|
+
a length-prefixed scheme (`deriveSQLiteIndexName` for SQLite,
|
|
849
|
+
`deriveIndexedDBIndexName` for IndexedDB) to stay collision-free across
|
|
850
|
+
compound indexes, a database
|
|
851
|
+
file/store created under an older naming scheme leaves its old-named
|
|
852
|
+
indexes orphaned (unreferenced, harmless) on reopen under the new scheme —
|
|
853
|
+
they are never queried and never collide, but a storage audit may notice
|
|
854
|
+
them.
|
|
855
|
+
|
|
856
|
+
What ships is the **core in-between** (schema-aware: `open` receives a
|
|
857
|
+
derived `TableSchema[]`, with `shapeToColumnStorage` mapping each column's shape), its
|
|
858
|
+
reference `MemoryDriver`, and the persistent `JSONDriver`, `SQLiteDriver`, and
|
|
859
|
+
`IndexedDBDriver` backends. `JSONDriver` in `src/server` is a decorator over
|
|
860
|
+
`MemoryDriver` that loads/flushes a single JSON file — every primitive
|
|
861
|
+
delegates to the inner memory driver, so querying, key-order `scan` / `keys`,
|
|
862
|
+
and capture-replay `snapshot` are inherited unchanged; `JSONDriver.migrate`
|
|
863
|
+
additionally persists the migrated state, and every flush is atomic — written
|
|
864
|
+
to a sibling temp file and `rename`d onto the target path, so a crash mid-flush
|
|
865
|
+
can never truncate or corrupt the previous good file. Outside a `transaction`,
|
|
866
|
+
`JSONDriver` still flushes once per mutation (`write` / `insert` / `delete` /
|
|
867
|
+
`clear`); its native `transaction?(scope)` clones committed rows, schema, and
|
|
868
|
+
metadata into an isolated candidate. The callback can observe only that
|
|
869
|
+
candidate; fulfillment serializes it once and publishes memory only after the
|
|
870
|
+
atomic file replacement, while rejection or persistence failure discards it
|
|
871
|
+
without changing committed state. Nested transactions, root operations while
|
|
872
|
+
active, and a captured capability used after settlement throw `CONFLICT`.
|
|
873
|
+
`SQLiteDriver`, also in `src/server`, is the
|
|
874
|
+
fully-native, **trusted-mode** backend on the published `@orkestrel/sqlite`
|
|
875
|
+
wrapper — real typed `CREATE TABLE` / `CREATE INDEX` DDL, native
|
|
876
|
+
`records?` / `aggregate?` / `stream?` compiled straight to SQL,
|
|
877
|
+
real `BEGIN` / `COMMIT` / `ROLLBACK` transactions, atomic DDL migration
|
|
878
|
+
(`stepToSQL`; a root input uses one native transaction, while an input inside
|
|
879
|
+
an existing callback transaction uses the guarded fixed internal savepoint
|
|
880
|
+
literal for caught-inner-failure containment while the outer transaction stays
|
|
881
|
+
active),
|
|
882
|
+
and a reserved
|
|
883
|
+
`_metadata` table (`METADATA_TABLE`) for `metadata?` / `stamp?` versioning — every
|
|
884
|
+
optional `DriverInterface` hook, none skipped. `IndexedDBDriver` in
|
|
885
|
+
`src/browser`, on the published `@orkestrel/indexeddb` wrapper, is the
|
|
886
|
+
**narrow-then-refine** persistent browser backend — `selectPlan` turns a
|
|
887
|
+
`QueryInput` into a key-range pushdown over the primary key or a single-column
|
|
888
|
+
secondary index (a candidate superset, never lossy) that the same core
|
|
889
|
+
engine then refines to the exact result; `migrate?` applies a non-empty plan
|
|
890
|
+
and its metadata in one versionchange transaction, and `metadata?` / `stamp?`
|
|
891
|
+
persist into a reserved
|
|
892
|
+
`__metadata__` store (`METADATA_STORE`) — it omits `transaction?` because arbitrary
|
|
893
|
+
callback awaits outlive an auto-committing `IDBTransaction`, and
|
|
894
|
+
`aggregate?` (no native SUM/AVG/MIN/MAX) by IndexedDB's own nature. The core
|
|
895
|
+
`Database` / `Table` are also **observable** — each owns a typed `emitter`
|
|
896
|
+
(`DatabaseEventMap` / `TableEventMap`) carrying the transaction +
|
|
897
|
+
per-row lifecycle (see [Observing](#observing)); a driver stays a storage
|
|
898
|
+
primitive (the observation lives in the core layer above it).
|
|
899
|
+
|
|
900
|
+
## Patterns
|
|
901
|
+
|
|
902
|
+
### Declaring tables in options
|
|
903
|
+
|
|
904
|
+
Declares two tables with per-column contracts, a non-default primary key, and a secondary index, then reads back each table's resolved primary column:
|
|
905
|
+
|
|
906
|
+
```ts
|
|
907
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
908
|
+
import { integerShape, literalShape, optionalShape, stringShape } from '@orkestrel/contract'
|
|
909
|
+
|
|
910
|
+
const db = createDatabase({
|
|
911
|
+
driver: createMemoryDriver(),
|
|
912
|
+
name: 'app',
|
|
913
|
+
tables: {
|
|
914
|
+
// Each table's value is its columns — wrapped in an `objectShape` for you.
|
|
915
|
+
users: {
|
|
916
|
+
id: stringShape(),
|
|
917
|
+
name: stringShape({ min: 1 }),
|
|
918
|
+
age: integerShape({ min: 0 }),
|
|
919
|
+
role: literalShape(['admin', 'member', 'guest']),
|
|
920
|
+
bio: optionalShape(stringShape()), // nested object columns still use objectShape
|
|
921
|
+
},
|
|
922
|
+
posts: { slug: stringShape(), title: stringShape() },
|
|
923
|
+
},
|
|
924
|
+
primary: { posts: 'slug' }, // default primary column is 'id'
|
|
925
|
+
indexes: { posts: [['title']] }, // secondary indexes — contracts don't express them
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
const users = db.table('users') // hold the handle; reuse it
|
|
929
|
+
const posts = db.table('posts')
|
|
930
|
+
|
|
931
|
+
users.primary // 'id' — the default primary column
|
|
932
|
+
posts.primary // 'slug' — the declared override
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
Each `indexes` entry is one (possibly compound) index of column names; they
|
|
936
|
+
flow into each table's derived `TableSchema`. Neither driver here declares a
|
|
937
|
+
native index, so both ignore them; SQLite and IndexedDB use supported
|
|
938
|
+
declarations for native indexes and still refine through the shared engine when
|
|
939
|
+
required.
|
|
940
|
+
|
|
941
|
+
### Swapping the driver
|
|
942
|
+
|
|
943
|
+
The `tables` declaration and every call against the database are identical
|
|
944
|
+
across backends — only the `driver` changes, so the same code runs in tests
|
|
945
|
+
and in production. Pick the driver per environment and pass it to
|
|
946
|
+
`createDatabase`:
|
|
947
|
+
|
|
948
|
+
```ts
|
|
949
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database' // tests / ephemeral — no I/O
|
|
950
|
+
import { createJSONDriver } from '@orkestrel/database/server' // node — persisted to a file
|
|
951
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
952
|
+
|
|
953
|
+
const driver =
|
|
954
|
+
process.env.NODE_ENV === 'test' ? createMemoryDriver() : createJSONDriver('data/app.json')
|
|
955
|
+
const db = createDatabase({
|
|
956
|
+
driver,
|
|
957
|
+
tables: { users: { id: stringShape(), age: integerShape() } },
|
|
958
|
+
indexes: { users: [['age']] },
|
|
959
|
+
})
|
|
960
|
+
void db
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
`MemoryDriver` implements the native `stream` hook and neither `records` nor
|
|
964
|
+
`aggregate`, so the core engine's `matchesQuery` answers every query on either
|
|
965
|
+
path. It is I/O-free, making it the storage behind tests, ephemeral caches, and any code
|
|
966
|
+
that wants the database API without a persistent backend. Its row boundary
|
|
967
|
+
continues to use native `structuredClone`, retaining supported non-JSON values
|
|
968
|
+
such as `Blob` and `Uint8Array`; only `DriverMetadata` crosses the stricter exact-JSON
|
|
969
|
+
`cloneDriverMetadata` boundary. `JSONDriver` adds file persistence on top of the same
|
|
970
|
+
in-memory engine — both return identical query results, so the choice is purely
|
|
971
|
+
about where the bytes live, never about behavior.
|
|
972
|
+
|
|
973
|
+
### Keyed CRUD
|
|
974
|
+
|
|
975
|
+
Runs every keyed operation — `set`, `add`, `update`, `get`, `resolve`, `has`, `remove`, and `clear` — against one table:
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
979
|
+
import { integerShape, optionalShape, stringShape } from '@orkestrel/contract'
|
|
980
|
+
|
|
981
|
+
const users = createDatabase({
|
|
982
|
+
driver: createMemoryDriver(),
|
|
983
|
+
tables: {
|
|
984
|
+
users: {
|
|
985
|
+
id: stringShape(),
|
|
986
|
+
name: stringShape(),
|
|
987
|
+
age: integerShape(),
|
|
988
|
+
role: stringShape(),
|
|
989
|
+
bio: optionalShape(stringShape()),
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
}).table('users')
|
|
993
|
+
|
|
994
|
+
await users.set({ id: 'u1', name: 'Ada', age: 36, role: 'admin' }) // upsert → key
|
|
995
|
+
await users.add({ id: 'u1', name: 'Ada', age: 36, role: 'admin' }) // throws CONFLICT (exists)
|
|
996
|
+
await users.update('u1', { age: 37 }) // merge + re-validate → boolean
|
|
997
|
+
await users.get('u1') // row or undefined (typed)
|
|
998
|
+
await users.resolve('u1') // row or throw NOT_FOUND
|
|
999
|
+
await users.has('u1') // boolean
|
|
1000
|
+
await users.remove('u1') // boolean
|
|
1001
|
+
await users.clear() // empty the table
|
|
1002
|
+
|
|
1003
|
+
// A missing primary uses global crypto.randomUUID(), or DatabaseOptions.generator when supplied.
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
`add` is a storage-level claim, not `read` followed by `write`: `Table.add`
|
|
1007
|
+
calls the required `DriverInterface.insert`, and each backend rejects a
|
|
1008
|
+
duplicate at its own atomic insertion boundary. Two concurrent adds for the
|
|
1009
|
+
same key therefore cannot both succeed; exactly one wins and the other rejects
|
|
1010
|
+
with `CONFLICT`.
|
|
1011
|
+
|
|
1012
|
+
### Filtered records, count, and aggregate
|
|
1013
|
+
|
|
1014
|
+
`records` / `count` / `aggregate` take an optional `QueryInput` directly —
|
|
1015
|
+
`query()` compiles one for you, but a caller with a pre-built `QueryInput` can
|
|
1016
|
+
call these directly:
|
|
1017
|
+
|
|
1018
|
+
```ts
|
|
1019
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1020
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1021
|
+
|
|
1022
|
+
const users = createDatabase({
|
|
1023
|
+
driver: createMemoryDriver(),
|
|
1024
|
+
tables: { users: { id: stringShape(), age: integerShape() } },
|
|
1025
|
+
}).table('users')
|
|
1026
|
+
|
|
1027
|
+
await users.records({
|
|
1028
|
+
conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }],
|
|
1029
|
+
}) // every row aged 18 or over
|
|
1030
|
+
await users.count() // every row, unfiltered
|
|
1031
|
+
await users.aggregate('average', 'age') // number | undefined
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
### Streaming with early exit
|
|
1035
|
+
|
|
1036
|
+
`scan` (on a table) and `stream` (on a query) are lazy — rows are yielded one
|
|
1037
|
+
at a time rather than collected up front. `conditions` / `offset` / `limit`
|
|
1038
|
+
are honored as rows stream; `order` is ignored (sorted output is `records()`
|
|
1039
|
+
/ `collect()`'s job — streaming yields driver key-order). Breaking out early
|
|
1040
|
+
closes the underlying source, and each call returns a fresh iterable:
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1044
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1045
|
+
|
|
1046
|
+
const users = createDatabase({
|
|
1047
|
+
driver: createMemoryDriver(),
|
|
1048
|
+
tables: {
|
|
1049
|
+
users: {
|
|
1050
|
+
id: stringShape(),
|
|
1051
|
+
name: stringShape(),
|
|
1052
|
+
age: integerShape(),
|
|
1053
|
+
role: stringShape(),
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
}).table('users')
|
|
1057
|
+
|
|
1058
|
+
// Table.scan — lazy filtered iteration, no upfront collection.
|
|
1059
|
+
for await (const user of users.scan({
|
|
1060
|
+
conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }],
|
|
1061
|
+
})) {
|
|
1062
|
+
if (user.name === 'Ada') break // closes the source immediately — no more rows read
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Query.stream — the fluent builder's lazy terminal (filters/offset/limit apply, order is ignored).
|
|
1066
|
+
for await (const user of users
|
|
1067
|
+
.query()
|
|
1068
|
+
.condition({ column: 'role', operator: 'equals', values: ['member'], connector: 'and' })
|
|
1069
|
+
.stream()) {
|
|
1070
|
+
console.log(user.name)
|
|
1071
|
+
}
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
### Abort
|
|
1075
|
+
|
|
1076
|
+
Reads, iterations, and point mutations take an optional
|
|
1077
|
+
`OperationOptions.signal`. An already-fired signal throws `ABORTED`; `scan` /
|
|
1078
|
+
`stream` re-check it before each yield, while mutations carry it through the
|
|
1079
|
+
driver to the backend commit point:
|
|
1080
|
+
|
|
1081
|
+
```ts
|
|
1082
|
+
import {
|
|
1083
|
+
checkAbort,
|
|
1084
|
+
createDatabase,
|
|
1085
|
+
createMemoryDriver,
|
|
1086
|
+
isDatabaseError,
|
|
1087
|
+
} from '@orkestrel/database'
|
|
1088
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1089
|
+
|
|
1090
|
+
const users = createDatabase({
|
|
1091
|
+
driver: createMemoryDriver(),
|
|
1092
|
+
tables: {
|
|
1093
|
+
users: { id: stringShape(), name: stringShape(), age: integerShape(), role: stringShape() },
|
|
1094
|
+
},
|
|
1095
|
+
}).table('users')
|
|
1096
|
+
|
|
1097
|
+
// A time-boxed read — abort after 50ms.
|
|
1098
|
+
try {
|
|
1099
|
+
await users.records(undefined, { signal: AbortSignal.timeout(50) })
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
if (isDatabaseError(error) && error.code === 'ABORTED') console.log('too slow', error.context)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// A time-boxed scan — checked before each yielded row.
|
|
1105
|
+
const controller = new AbortController()
|
|
1106
|
+
for await (const user of users.scan(undefined, { signal: controller.signal })) {
|
|
1107
|
+
if (user.id === 'stop-here') controller.abort('caller aborted')
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Every point-mutation primitive carries the signal to its backend commit point.
|
|
1111
|
+
await users.set({ id: 'u2', name: 'Bo', age: 41, role: 'member' }, { signal: controller.signal })
|
|
1112
|
+
await users.add({ id: 'u3', name: 'Cy', age: 29, role: 'member' }, { signal: controller.signal })
|
|
1113
|
+
await users.remove('u2', { signal: controller.signal })
|
|
1114
|
+
|
|
1115
|
+
// The shared gate every abortable boundary calls internally:
|
|
1116
|
+
checkAbort(controller.signal) // throws DatabaseError('ABORTED', …) once aborted
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
Abort is precommit, not a `Promise.race` over an active commit that cannot be aborted
|
|
1120
|
+
write. Memory and SQLite check immediately before their synchronous mutation;
|
|
1121
|
+
IndexedDB aborts its explicit readwrite transaction while active; JSON aborts
|
|
1122
|
+
staging, cleans its temp file, and restores the preimage before rejecting. If
|
|
1123
|
+
the native commit has already been dispatched, the method ignores a late abort
|
|
1124
|
+
and awaits the real success or failure. A batch passes the same signal to each
|
|
1125
|
+
sequential item, so already-committed earlier items remain committed.
|
|
1126
|
+
|
|
1127
|
+
### Batch operations
|
|
1128
|
+
|
|
1129
|
+
The keyed methods batch by overload (see `.claude/rules/patterns.md`
|
|
1130
|
+
§ Batch operations) — one key/row in, one
|
|
1131
|
+
result; an array in, an array of results in the same order. The verb never
|
|
1132
|
+
changes (no `getMany` / `setAll`):
|
|
1133
|
+
|
|
1134
|
+
```ts
|
|
1135
|
+
import type { RowOf } from '@orkestrel/database'
|
|
1136
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1137
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1138
|
+
|
|
1139
|
+
const columns = {
|
|
1140
|
+
id: stringShape(),
|
|
1141
|
+
name: stringShape(),
|
|
1142
|
+
age: integerShape(),
|
|
1143
|
+
role: stringShape(),
|
|
1144
|
+
}
|
|
1145
|
+
const users = createDatabase({
|
|
1146
|
+
driver: createMemoryDriver(),
|
|
1147
|
+
tables: { users: columns },
|
|
1148
|
+
}).table('users')
|
|
1149
|
+
const row1: RowOf<typeof columns> = { id: 'u1', name: 'Ada', age: 36, role: 'admin' }
|
|
1150
|
+
const row2: RowOf<typeof columns> = { id: 'u2', name: 'Bo', age: 41, role: 'member' }
|
|
1151
|
+
const row3: RowOf<typeof columns> = { id: 'u3', name: 'Cy', age: 29, role: 'member' }
|
|
1152
|
+
|
|
1153
|
+
await users.set([row1, row2, row3]) // → readonly Key[]
|
|
1154
|
+
await users.add([row1, row2]) // → readonly Key[] (CONFLICT rejects the batch)
|
|
1155
|
+
await users.get(['u1', 'u2']) // → readonly (Row | undefined)[]
|
|
1156
|
+
await users.resolve(['u1', 'u2']) // → readonly Row[] (NOT_FOUND on any miss)
|
|
1157
|
+
await users.has(['u1', 'u2']) // → readonly boolean[]
|
|
1158
|
+
await users.update(['u1', 'u2'], { role: 'member' }) // same changes to each → readonly boolean[]
|
|
1159
|
+
await users.remove(['u1', 'u2']) // → readonly boolean[]
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
A batch runs as independent sequential operations; wrap it in `transaction`
|
|
1163
|
+
when it must be atomic.
|
|
1164
|
+
|
|
1165
|
+
### Coercion through the contract
|
|
1166
|
+
|
|
1167
|
+
Parses a numeric string against the table's contract, stores the coerced number, and reads it back:
|
|
1168
|
+
|
|
1169
|
+
```ts
|
|
1170
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1171
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1172
|
+
|
|
1173
|
+
const users = createDatabase({
|
|
1174
|
+
driver: createMemoryDriver(),
|
|
1175
|
+
tables: {
|
|
1176
|
+
users: { id: stringShape(), name: stringShape(), age: integerShape(), role: stringShape() },
|
|
1177
|
+
},
|
|
1178
|
+
}).table('users')
|
|
1179
|
+
|
|
1180
|
+
// A numeric column accepts a numeric string and stores the coerced number.
|
|
1181
|
+
const normalized = users.contract.parse({
|
|
1182
|
+
id: 'u2',
|
|
1183
|
+
name: 'Bo',
|
|
1184
|
+
age: '41',
|
|
1185
|
+
role: 'member',
|
|
1186
|
+
})
|
|
1187
|
+
if (normalized === undefined) throw new Error('Expected the row to parse')
|
|
1188
|
+
await users.set(normalized)
|
|
1189
|
+
;(await users.get('u2'))?.age // 41 (a number) — the contract parsed it
|
|
1190
|
+
|
|
1191
|
+
// A row that cannot satisfy the shape throws DatabaseError('VALIDATION').
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
### Fluent queries
|
|
1195
|
+
|
|
1196
|
+
Chains conditions, an order, and a limit through the query builder and collects the matching rows:
|
|
1197
|
+
|
|
1198
|
+
```ts
|
|
1199
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1200
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1201
|
+
|
|
1202
|
+
const users = createDatabase({
|
|
1203
|
+
driver: createMemoryDriver(),
|
|
1204
|
+
tables: {
|
|
1205
|
+
users: { id: stringShape(), name: stringShape(), age: integerShape(), role: stringShape() },
|
|
1206
|
+
},
|
|
1207
|
+
}).table('users')
|
|
1208
|
+
|
|
1209
|
+
await users
|
|
1210
|
+
.query()
|
|
1211
|
+
.condition({ column: 'age', operator: 'from', values: [18], connector: 'and' })
|
|
1212
|
+
.condition({ column: 'role', operator: 'not', values: ['guest'], connector: 'and' })
|
|
1213
|
+
.order({ column: 'age', direction: 'descending' })
|
|
1214
|
+
.limit(10)
|
|
1215
|
+
.collect() // the first ten non-guest adults, oldest first
|
|
1216
|
+
|
|
1217
|
+
await users
|
|
1218
|
+
.query()
|
|
1219
|
+
.condition({ column: 'name', operator: 'starts', values: ['A'], connector: 'and' })
|
|
1220
|
+
.find() // first match or undefined
|
|
1221
|
+
await users
|
|
1222
|
+
.query()
|
|
1223
|
+
.condition({ column: 'role', operator: 'equals', values: ['admin'], connector: 'and' })
|
|
1224
|
+
.count() // number
|
|
1225
|
+
await users
|
|
1226
|
+
.query()
|
|
1227
|
+
.condition({ column: 'role', operator: 'equals', values: ['member'], connector: 'and' })
|
|
1228
|
+
.aggregate('average', 'age') // number | undefined
|
|
1229
|
+
await users
|
|
1230
|
+
.query()
|
|
1231
|
+
.filter((user) => user.name.includes('a'))
|
|
1232
|
+
.collect() // post-fetch JavaScript predicate
|
|
1233
|
+
|
|
1234
|
+
// Ordering, paging, and named aggregation:
|
|
1235
|
+
await users.query().order({ column: 'name', direction: 'ascending' }).offset(10).limit(5).collect() // page 3 of 5, alphabetical
|
|
1236
|
+
await users
|
|
1237
|
+
.query()
|
|
1238
|
+
.condition({ column: 'age', operator: 'above', values: [18], connector: 'and' })
|
|
1239
|
+
.aggregate('sum', 'age')
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
Every condition operator uses the same `condition` method and explicit,
|
|
1243
|
+
serializable input:
|
|
1244
|
+
|
|
1245
|
+
```ts
|
|
1246
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1247
|
+
import { integerShape, optionalShape, stringShape } from '@orkestrel/contract'
|
|
1248
|
+
|
|
1249
|
+
const users = createDatabase({
|
|
1250
|
+
driver: createMemoryDriver(),
|
|
1251
|
+
tables: {
|
|
1252
|
+
users: {
|
|
1253
|
+
id: stringShape(),
|
|
1254
|
+
name: stringShape(),
|
|
1255
|
+
age: integerShape(),
|
|
1256
|
+
role: stringShape(),
|
|
1257
|
+
bio: optionalShape(stringShape()),
|
|
1258
|
+
},
|
|
1259
|
+
},
|
|
1260
|
+
}).table('users')
|
|
1261
|
+
|
|
1262
|
+
await users
|
|
1263
|
+
.query()
|
|
1264
|
+
.condition({ column: 'age', operator: 'equals', values: [36], connector: 'and' })
|
|
1265
|
+
.collect()
|
|
1266
|
+
await users
|
|
1267
|
+
.query()
|
|
1268
|
+
.condition({ column: 'age', operator: 'not', values: [36], connector: 'and' })
|
|
1269
|
+
.collect()
|
|
1270
|
+
await users
|
|
1271
|
+
.query()
|
|
1272
|
+
.condition({ column: 'age', operator: 'above', values: [18], connector: 'and' })
|
|
1273
|
+
.collect()
|
|
1274
|
+
await users
|
|
1275
|
+
.query()
|
|
1276
|
+
.condition({ column: 'age', operator: 'below', values: [65], connector: 'and' })
|
|
1277
|
+
.collect()
|
|
1278
|
+
await users
|
|
1279
|
+
.query()
|
|
1280
|
+
.condition({ column: 'age', operator: 'from', values: [18], connector: 'and' })
|
|
1281
|
+
.collect()
|
|
1282
|
+
await users
|
|
1283
|
+
.query()
|
|
1284
|
+
.condition({ column: 'age', operator: 'to', values: [65], connector: 'and' })
|
|
1285
|
+
.collect()
|
|
1286
|
+
await users
|
|
1287
|
+
.query()
|
|
1288
|
+
.condition({ column: 'age', operator: 'between', values: [18, 65], connector: 'and' })
|
|
1289
|
+
.collect()
|
|
1290
|
+
await users
|
|
1291
|
+
.query()
|
|
1292
|
+
.condition({ column: 'name', operator: 'like', values: ['A%'], connector: 'and' })
|
|
1293
|
+
.collect()
|
|
1294
|
+
await users
|
|
1295
|
+
.query()
|
|
1296
|
+
.condition({ column: 'name', operator: 'glob', values: ['A*'], connector: 'and' })
|
|
1297
|
+
.collect()
|
|
1298
|
+
await users
|
|
1299
|
+
.query()
|
|
1300
|
+
.condition({ column: 'name', operator: 'starts', values: ['A'], connector: 'and' })
|
|
1301
|
+
.collect()
|
|
1302
|
+
await users
|
|
1303
|
+
.query()
|
|
1304
|
+
.condition({ column: 'name', operator: 'ends', values: ['a'], connector: 'and' })
|
|
1305
|
+
.collect()
|
|
1306
|
+
await users
|
|
1307
|
+
.query()
|
|
1308
|
+
.condition({ column: 'role', operator: 'any', values: ['admin', 'member'], connector: 'and' })
|
|
1309
|
+
.collect()
|
|
1310
|
+
await users
|
|
1311
|
+
.query()
|
|
1312
|
+
.condition({ column: 'role', operator: 'none', values: ['guest'], connector: 'and' })
|
|
1313
|
+
.collect()
|
|
1314
|
+
await users
|
|
1315
|
+
.query()
|
|
1316
|
+
.condition({ column: 'bio', operator: 'absent', values: [], connector: 'and' })
|
|
1317
|
+
.collect()
|
|
1318
|
+
await users
|
|
1319
|
+
.query()
|
|
1320
|
+
.condition({ column: 'bio', operator: 'present', values: [], connector: 'and' })
|
|
1321
|
+
.collect()
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
The condition operators map to familiar SQL operators. The engine
|
|
1325
|
+
evaluates every one of them in JS over `scan`; SQLite and IndexedDB push down
|
|
1326
|
+
provably exact candidate work and refine through these same semantics:
|
|
1327
|
+
|
|
1328
|
+
| Operator | SQL |
|
|
1329
|
+
| --------- | ------------- |
|
|
1330
|
+
| `equals` | `=` |
|
|
1331
|
+
| `not` | `!=` |
|
|
1332
|
+
| `above` | `>` |
|
|
1333
|
+
| `below` | `<` |
|
|
1334
|
+
| `from` | `>=` |
|
|
1335
|
+
| `to` | `<=` |
|
|
1336
|
+
| `between` | `BETWEEN` |
|
|
1337
|
+
| `like` | `LIKE` |
|
|
1338
|
+
| `glob` | `GLOB` |
|
|
1339
|
+
| `starts` | `LIKE 'p%'` |
|
|
1340
|
+
| `ends` | `LIKE '%s'` |
|
|
1341
|
+
| `any` | `IN` |
|
|
1342
|
+
| `none` | `NOT IN` |
|
|
1343
|
+
| `absent` | `IS NULL` |
|
|
1344
|
+
| `present` | `IS NOT NULL` |
|
|
1345
|
+
|
|
1346
|
+
### Nested fields
|
|
1347
|
+
|
|
1348
|
+
Every column — in a condition, order, or aggregate — is a
|
|
1349
|
+
[`FieldPath`](contract.md): a **single string is one
|
|
1350
|
+
column** (never split on `.`), while an **array descends** into a nested
|
|
1351
|
+
(object / `json`) value. The _shape_ of the argument says how to read it; the
|
|
1352
|
+
string's _value_ is never parsed — there are no magic strings here.
|
|
1353
|
+
|
|
1354
|
+
```ts
|
|
1355
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1356
|
+
import { numberShape, objectShape, stringShape } from '@orkestrel/contract'
|
|
1357
|
+
|
|
1358
|
+
const db = createDatabase({
|
|
1359
|
+
driver: createMemoryDriver(),
|
|
1360
|
+
tables: {
|
|
1361
|
+
events: {
|
|
1362
|
+
id: stringShape(),
|
|
1363
|
+
payload: objectShape({
|
|
1364
|
+
user: objectShape({ id: stringShape() }),
|
|
1365
|
+
at: stringShape(),
|
|
1366
|
+
}),
|
|
1367
|
+
'payload.id': stringShape(),
|
|
1368
|
+
},
|
|
1369
|
+
orders: {
|
|
1370
|
+
id: stringShape(),
|
|
1371
|
+
totals: objectShape({ amount: numberShape() }),
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
await db
|
|
1377
|
+
.table('events')
|
|
1378
|
+
.query()
|
|
1379
|
+
.condition({
|
|
1380
|
+
column: ['payload', 'user', 'id'],
|
|
1381
|
+
operator: 'equals',
|
|
1382
|
+
values: ['u1'],
|
|
1383
|
+
connector: 'and',
|
|
1384
|
+
})
|
|
1385
|
+
.collect()
|
|
1386
|
+
await db
|
|
1387
|
+
.table('events')
|
|
1388
|
+
.query()
|
|
1389
|
+
.order({ column: ['payload', 'at'], direction: 'descending' })
|
|
1390
|
+
.limit(20)
|
|
1391
|
+
.collect()
|
|
1392
|
+
await db.table('orders').query().aggregate('sum', ['totals', 'amount'])
|
|
1393
|
+
|
|
1394
|
+
// A dotted string is a column literally named 'payload.id', not a path:
|
|
1395
|
+
await db
|
|
1396
|
+
.table('events')
|
|
1397
|
+
.query()
|
|
1398
|
+
.condition({ column: 'payload.id', operator: 'present', values: [], connector: 'and' })
|
|
1399
|
+
.collect()
|
|
1400
|
+
```
|
|
1401
|
+
|
|
1402
|
+
### Cursors
|
|
1403
|
+
|
|
1404
|
+
The concrete cursor implementation is internal. Consumers receive the public
|
|
1405
|
+
`CursorInterface`, whose promise operations execute serially in invocation
|
|
1406
|
+
order. Every call is admitted through its owning transaction ledger before
|
|
1407
|
+
closed-cursor no-op behavior is considered, so a retained cursor still rejects
|
|
1408
|
+
with `CONFLICT` after its transaction settles. One rejected operation does not
|
|
1409
|
+
poison later admitted work.
|
|
1410
|
+
|
|
1411
|
+
```ts
|
|
1412
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1413
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1414
|
+
|
|
1415
|
+
const users = createDatabase({
|
|
1416
|
+
driver: createMemoryDriver(),
|
|
1417
|
+
tables: {
|
|
1418
|
+
users: { id: stringShape(), age: integerShape(), role: stringShape() },
|
|
1419
|
+
},
|
|
1420
|
+
}).table('users')
|
|
1421
|
+
|
|
1422
|
+
const cursor = await users.cursor()
|
|
1423
|
+
while (!cursor.done) {
|
|
1424
|
+
if (cursor.value && cursor.value.age < 18) await cursor.remove()
|
|
1425
|
+
else await cursor.update({ role: 'member' })
|
|
1426
|
+
await cursor.next()
|
|
1427
|
+
}
|
|
1428
|
+
cursor.close()
|
|
1429
|
+
```
|
|
1430
|
+
|
|
1431
|
+
`close()` is the sole synchronous cursor operation. It is terminal and clears
|
|
1432
|
+
`value` immediately. Work queued but not yet dispatched becomes a no-op; a
|
|
1433
|
+
backend mutation already dispatched may settle, but no await continuation can
|
|
1434
|
+
publish cursor state or restore `value` after close.
|
|
1435
|
+
|
|
1436
|
+
### Transactions
|
|
1437
|
+
|
|
1438
|
+
Runs a scoped callback across two tables that commits on success and rolls every table back when the scope throws:
|
|
1439
|
+
|
|
1440
|
+
```ts
|
|
1441
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1442
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1443
|
+
|
|
1444
|
+
const db = createDatabase({
|
|
1445
|
+
driver: createMemoryDriver(),
|
|
1446
|
+
tables: {
|
|
1447
|
+
users: {
|
|
1448
|
+
id: stringShape(),
|
|
1449
|
+
name: stringShape(),
|
|
1450
|
+
age: integerShape(),
|
|
1451
|
+
role: stringShape(),
|
|
1452
|
+
},
|
|
1453
|
+
posts: { slug: stringShape(), title: stringShape() },
|
|
1454
|
+
},
|
|
1455
|
+
primary: { posts: 'slug' },
|
|
1456
|
+
})
|
|
1457
|
+
const somethingWrong = false
|
|
1458
|
+
|
|
1459
|
+
// Commits on success; rolls every table back if the scope throws.
|
|
1460
|
+
await db.transaction(async (transaction) => {
|
|
1461
|
+
await transaction.table('users').set({ id: 'u3', name: 'Cy', age: 29, role: 'member' })
|
|
1462
|
+
await transaction.table('posts').add({ slug: 'intro', title: 'Intro' })
|
|
1463
|
+
if (somethingWrong) throw new Error('abort') // → both writes undone
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
// Every accepted operation drains before settlement, including work not
|
|
1467
|
+
// returned by the callback. Catching an accepted rejection does not rescue
|
|
1468
|
+
// the transaction: the tracker still rolls it back.
|
|
1469
|
+
await db.transaction(async (transaction) => {
|
|
1470
|
+
void transaction.table('users').set({ id: 'u4', name: 'Dee', age: 31, role: 'member' })
|
|
1471
|
+
try {
|
|
1472
|
+
await transaction.table('posts').add({ slug: 'intro', title: 'duplicate' })
|
|
1473
|
+
} catch {
|
|
1474
|
+
// The duplicate remains a tracked transaction failure.
|
|
1475
|
+
}
|
|
1476
|
+
}) // rejects CONFLICT and rolls back u4
|
|
1477
|
+
|
|
1478
|
+
// Iterator continuations are the tracked unit. An in-flight next() drains;
|
|
1479
|
+
// merely creating or pausing an iterator does not hold the transaction open.
|
|
1480
|
+
await db.transaction(async (transaction) => {
|
|
1481
|
+
const rows = transaction.table('users').scan()[Symbol.asyncIterator]()
|
|
1482
|
+
await rows.next()
|
|
1483
|
+
// A rows.next() requested after this callback settles throws CONFLICT.
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
// A pre-aborted signal is checked once at entry, before anything transactional runs:
|
|
1487
|
+
await db.transaction(async () => {}, { signal: AbortSignal.timeout(0) }) // throws ABORTED
|
|
1488
|
+
```
|
|
1489
|
+
|
|
1490
|
+
While the scope is active, root/imported tables, `open`, `close`, `migrate`,
|
|
1491
|
+
and nested transactions reject with `CONFLICT`. The transaction view and every
|
|
1492
|
+
table/query/cursor/stream derived from it are invalid after settlement. When
|
|
1493
|
+
both the callback and a tracked operation reject, the callback rejection takes
|
|
1494
|
+
precedence; otherwise the first tracked rejection becomes the transaction
|
|
1495
|
+
error. `rollback(error)` reports only a completed rollback whose original
|
|
1496
|
+
scope/drain error is still being propagated — a cleanup failure is never
|
|
1497
|
+
reported as a successful rollback.
|
|
1498
|
+
|
|
1499
|
+
Root promise operations enter the shared admission ledger synchronously, before
|
|
1500
|
+
their first `await`. `transaction()` closes root admission before it drains that
|
|
1501
|
+
ledger, so work accepted immediately before the transaction is included and work
|
|
1502
|
+
attempted immediately after the boundary conflicts; there is no unobserved gap where a
|
|
1503
|
+
root write can escape into the transaction. If rollback cleanup fails, the
|
|
1504
|
+
operation rejects `DatabaseError('DRIVER')` with exact evidence
|
|
1505
|
+
`{ cause: rollbackFailure, transaction: originalFailure }` and emits no
|
|
1506
|
+
`rollback` event.
|
|
1507
|
+
|
|
1508
|
+
`AdmissionInterface` is that ledger's published contract — the one shape the
|
|
1509
|
+
root context and a transaction scope both present. No public call returns an
|
|
1510
|
+
instance (every implementor is internal), so read it as the boundary shape a
|
|
1511
|
+
scoped operation is entered into:
|
|
1512
|
+
|
|
1513
|
+
```ts
|
|
1514
|
+
import type { AdmissionInterface } from '@orkestrel/database'
|
|
1515
|
+
|
|
1516
|
+
const boundary: AdmissionInterface = {
|
|
1517
|
+
accepting: true,
|
|
1518
|
+
track: (operation) => operation(),
|
|
1519
|
+
}
|
|
1520
|
+
boundary.accepting // true
|
|
1521
|
+
await boundary.track(async () => 42) // 42
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
### Native transactions
|
|
1525
|
+
|
|
1526
|
+
`transaction` uses a driver's optional native `transaction?(scope)` hook
|
|
1527
|
+
instead of the snapshot floor. The driver passes a `StorageInterface`
|
|
1528
|
+
capability to the callback, commits when it fulfills, rolls back when it
|
|
1529
|
+
rejects, and invalidates the capability after settlement. The database-level
|
|
1530
|
+
`transaction` / `commit` / `rollback` events fire the same either way:
|
|
1531
|
+
|
|
1532
|
+
```ts
|
|
1533
|
+
import type { TableSchema } from '@orkestrel/database'
|
|
1534
|
+
import { createSQLiteDriver } from '@orkestrel/database/server'
|
|
1535
|
+
|
|
1536
|
+
const driver = createSQLiteDriver()
|
|
1537
|
+
const schema: readonly TableSchema[] = [
|
|
1538
|
+
{
|
|
1539
|
+
name: 'users',
|
|
1540
|
+
primary: 'id',
|
|
1541
|
+
columns: [
|
|
1542
|
+
{ name: 'id', storage: 'text', optional: false, nullable: false },
|
|
1543
|
+
{ name: 'name', storage: 'text', optional: false, nullable: false },
|
|
1544
|
+
],
|
|
1545
|
+
indexes: [],
|
|
1546
|
+
},
|
|
1547
|
+
]
|
|
1548
|
+
await driver.open(schema)
|
|
1549
|
+
if (driver.transaction) {
|
|
1550
|
+
await driver.transaction(async (transaction) => {
|
|
1551
|
+
await transaction.write('users', 'u1', { id: 'u1', name: 'Ada' })
|
|
1552
|
+
const row = await transaction.read('users', 'u1')
|
|
1553
|
+
if (row === undefined) throw new Error('missing scoped row')
|
|
1554
|
+
})
|
|
1555
|
+
}
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
A `scope` throw rolls back and preserves the original rejection unless backend
|
|
1559
|
+
cleanup itself fails. A commit failure rejects the callback operation and never
|
|
1560
|
+
publishes candidate state in backends such as `JSONDriver`.
|
|
1561
|
+
|
|
1562
|
+
### Migrations
|
|
1563
|
+
|
|
1564
|
+
Migrations are caller-driven — `planMigration` structurally diffs a
|
|
1565
|
+
deployed and a declared `TableSchema[]` into an ordered `Migration`, which
|
|
1566
|
+
the caller packages as `MigrationInput` for a driver's optional native
|
|
1567
|
+
`migrate?`. The input's schema changes and optional target `metadata`
|
|
1568
|
+
commit or roll back together. `migrateRows` is the pure per-table row transform
|
|
1569
|
+
a driver's `migrate` can lean on. Calling `planMigration` + `driver.migrate?`
|
|
1570
|
+
directly is still the low-level path
|
|
1571
|
+
(useful outside a `Database`, for example against a bare driver):
|
|
1572
|
+
|
|
1573
|
+
```ts
|
|
1574
|
+
import type { TableSchema } from '@orkestrel/database'
|
|
1575
|
+
import { createMemoryDriver, migrateRows, planMigration } from '@orkestrel/database'
|
|
1576
|
+
|
|
1577
|
+
const deployed: readonly TableSchema[] = [
|
|
1578
|
+
{
|
|
1579
|
+
name: 'users',
|
|
1580
|
+
primary: 'id',
|
|
1581
|
+
columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }],
|
|
1582
|
+
indexes: [],
|
|
1583
|
+
},
|
|
1584
|
+
]
|
|
1585
|
+
const declared: readonly TableSchema[] = [
|
|
1586
|
+
{
|
|
1587
|
+
name: 'users',
|
|
1588
|
+
primary: 'id',
|
|
1589
|
+
columns: [
|
|
1590
|
+
{ name: 'id', storage: 'text', optional: false, nullable: false },
|
|
1591
|
+
{ name: 'age', storage: 'integer', optional: true, nullable: true },
|
|
1592
|
+
],
|
|
1593
|
+
indexes: [],
|
|
1594
|
+
},
|
|
1595
|
+
]
|
|
1596
|
+
const plan = planMigration(deployed, declared) // { from: 0, to: 1, steps: [...] }
|
|
1597
|
+
const driver = createMemoryDriver()
|
|
1598
|
+
await driver.open(deployed) // open the physical schema that is actually deployed
|
|
1599
|
+
await driver.migrate?.({ plan }) // atomic schema + row migration
|
|
1600
|
+
|
|
1601
|
+
// The pure row-shape transform a driver's own `migrate` can apply:
|
|
1602
|
+
const rows = [{ id: 'a', name: 'Ada', legacy: true }]
|
|
1603
|
+
migrateRows(rows, [{ operation: 'column.remove', table: 'users', column: 'legacy' }])
|
|
1604
|
+
// => [{ id: 'a', name: 'Ada' }]
|
|
1605
|
+
```
|
|
1606
|
+
|
|
1607
|
+
`Database.migrate(deployed, options?)` wraps that same diff-then-apply
|
|
1608
|
+
orchestration against the database's own declared `tables`, so the caller
|
|
1609
|
+
only has to track what is deployed:
|
|
1610
|
+
|
|
1611
|
+
```ts
|
|
1612
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1613
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1614
|
+
|
|
1615
|
+
const db = createDatabase({
|
|
1616
|
+
driver: createMemoryDriver(),
|
|
1617
|
+
tables: { users: { id: stringShape(), name: stringShape(), age: integerShape() } },
|
|
1618
|
+
})
|
|
1619
|
+
const deployed: readonly import('@orkestrel/database').TableSchema[] = [
|
|
1620
|
+
{
|
|
1621
|
+
name: 'users',
|
|
1622
|
+
primary: 'id',
|
|
1623
|
+
columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }],
|
|
1624
|
+
indexes: [],
|
|
1625
|
+
},
|
|
1626
|
+
]
|
|
1627
|
+
const plan = await db.migrate(deployed) // diffs deployed vs. declared, applies it, emits 'migrate'
|
|
1628
|
+
plan.steps // the applied Migration steps
|
|
1629
|
+
|
|
1630
|
+
db.emitter.on('migrate', (applied) => console.log('migrated to', applied.to))
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
### Versioned auto-migrate on open
|
|
1634
|
+
|
|
1635
|
+
A driver that implements the paired `metadata` / `stamp` can skip the
|
|
1636
|
+
caller-driven `Database.migrate` call entirely: pass
|
|
1637
|
+
`DatabaseOptions.version`, and `open()` reconciles the driver's persisted
|
|
1638
|
+
schema against the declared one for you, migrating and re-stamping as needed.
|
|
1639
|
+
|
|
1640
|
+
A partial capability is deliberately inert: when only `metadata` or only `stamp`
|
|
1641
|
+
exists, `open()` calls neither hook and performs no migration, stamping, or
|
|
1642
|
+
`migrate` event emission. Reconciliation requires `version`, `metadata`, and
|
|
1643
|
+
`stamp` together.
|
|
1644
|
+
|
|
1645
|
+
```ts
|
|
1646
|
+
import { createDatabase } from '@orkestrel/database'
|
|
1647
|
+
import { createJSONDriver } from '@orkestrel/database/server'
|
|
1648
|
+
import { integerShape, optionalShape, stringShape } from '@orkestrel/contract'
|
|
1649
|
+
|
|
1650
|
+
const path = 'data/versioned.json'
|
|
1651
|
+
const db = createDatabase({
|
|
1652
|
+
driver: createJSONDriver(path),
|
|
1653
|
+
tables: { users: { id: stringShape(), name: stringShape(), age: integerShape() } },
|
|
1654
|
+
version: 2, // the declared schema version
|
|
1655
|
+
})
|
|
1656
|
+
|
|
1657
|
+
await db.open() // fresh store → stamps { version: 2, schema } for next time
|
|
1658
|
+
await db.open() // idempotent while this handle remains open
|
|
1659
|
+
db.emitter.on('migrate', (applied) => console.log('auto-migrated to', applied.to))
|
|
1660
|
+
await db.close()
|
|
1661
|
+
|
|
1662
|
+
// close() is terminal for this handle and every imported view. A persistent
|
|
1663
|
+
// reopen uses a fresh driver/database handle over the same store.
|
|
1664
|
+
const same = createDatabase({
|
|
1665
|
+
driver: createJSONDriver(path),
|
|
1666
|
+
tables: { users: { id: stringShape(), name: stringShape(), age: integerShape() } },
|
|
1667
|
+
version: 2,
|
|
1668
|
+
})
|
|
1669
|
+
await same.open() // same version + canonical schema → no migration or metadata rewrite
|
|
1670
|
+
await same.close()
|
|
1671
|
+
|
|
1672
|
+
// Reopen the same store with a higher version and a changed declaration.
|
|
1673
|
+
// The backend first opens DriverMetadata.schema as the deployed physical schema;
|
|
1674
|
+
// Database then diffs deployed → declared and submits one atomic
|
|
1675
|
+
// { plan, metadata: { version, schema } } migration input.
|
|
1676
|
+
const upgraded = createDatabase({
|
|
1677
|
+
driver: createJSONDriver(path),
|
|
1678
|
+
tables: {
|
|
1679
|
+
users: {
|
|
1680
|
+
id: stringShape(),
|
|
1681
|
+
name: stringShape(),
|
|
1682
|
+
age: integerShape(),
|
|
1683
|
+
visits: optionalShape(integerShape()),
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
version: 3,
|
|
1687
|
+
})
|
|
1688
|
+
await upgraded.open() // schema + rows + version-3 metadata publish together
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
### Owning driver metadata
|
|
1692
|
+
|
|
1693
|
+
`DriverMetadata` is exact JSON and crosses one public ownership boundary. A driver
|
|
1694
|
+
snapshots it at every `stamp` / migration ingress and returns a fresh deeply
|
|
1695
|
+
frozen copy from `metadata()`, so neither later mutation of the caller's input nor
|
|
1696
|
+
mutation attempts against a returned value can alter stored version state.
|
|
1697
|
+
`cloneDriverMetadata` provides that boundary to every driver:
|
|
1698
|
+
|
|
1699
|
+
```ts
|
|
1700
|
+
import { cloneDriverMetadata } from '@orkestrel/database'
|
|
1701
|
+
|
|
1702
|
+
const source = {
|
|
1703
|
+
version: 3,
|
|
1704
|
+
schema: [
|
|
1705
|
+
{
|
|
1706
|
+
name: 'users',
|
|
1707
|
+
primary: 'id',
|
|
1708
|
+
columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }],
|
|
1709
|
+
indexes: [],
|
|
1710
|
+
},
|
|
1711
|
+
],
|
|
1712
|
+
}
|
|
1713
|
+
const metadata = cloneDriverMetadata(source)
|
|
1714
|
+
|
|
1715
|
+
Object.isFrozen(metadata) // true
|
|
1716
|
+
Object.isFrozen(metadata.schema[0]) // true
|
|
1717
|
+
metadata !== source // true
|
|
1718
|
+
```
|
|
1719
|
+
|
|
1720
|
+
The total guards inspect untrusted input without throwing, while the cloners
|
|
1721
|
+
establish owned, deeply frozen boundaries for the complete schema or migration.
|
|
1722
|
+
The browser projection consumes the same portable table schema:
|
|
1723
|
+
|
|
1724
|
+
```ts
|
|
1725
|
+
import {
|
|
1726
|
+
cloneDriverSchema,
|
|
1727
|
+
cloneMigrationInput,
|
|
1728
|
+
isColumnSchema,
|
|
1729
|
+
isDriverMetadata,
|
|
1730
|
+
isDriverSchema,
|
|
1731
|
+
isMigration,
|
|
1732
|
+
isMigrationInput,
|
|
1733
|
+
isMigrationStep,
|
|
1734
|
+
isTableSchema,
|
|
1735
|
+
bindRowKey,
|
|
1736
|
+
normalizeDriverSchema,
|
|
1737
|
+
projectMigrationSchema,
|
|
1738
|
+
shapeToColumnSchema,
|
|
1739
|
+
type TableSchema,
|
|
1740
|
+
} from '@orkestrel/database'
|
|
1741
|
+
import { schemaToStore } from '@orkestrel/database/browser'
|
|
1742
|
+
import { optionalShape, stringShape } from '@orkestrel/contract'
|
|
1743
|
+
|
|
1744
|
+
const table: TableSchema = {
|
|
1745
|
+
name: 'users',
|
|
1746
|
+
primary: 'id',
|
|
1747
|
+
columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }],
|
|
1748
|
+
indexes: [],
|
|
1749
|
+
}
|
|
1750
|
+
const plan = { from: 1, to: 2, steps: [{ operation: 'table.add', table }] }
|
|
1751
|
+
const input = { plan, metadata: { version: 2, schema: [table] } }
|
|
1752
|
+
|
|
1753
|
+
isColumnSchema(table.columns[0])
|
|
1754
|
+
isTableSchema(table)
|
|
1755
|
+
isDriverSchema([table])
|
|
1756
|
+
isMigrationStep(plan.steps[0])
|
|
1757
|
+
isMigration(plan)
|
|
1758
|
+
isDriverMetadata(input.metadata)
|
|
1759
|
+
isMigrationInput(input)
|
|
1760
|
+
cloneDriverSchema([table])
|
|
1761
|
+
cloneMigrationInput(input)
|
|
1762
|
+
bindRowKey({ name: 'Ada' }, 'id', 'u1')
|
|
1763
|
+
normalizeDriverSchema([table])
|
|
1764
|
+
shapeToColumnSchema('nickname', optionalShape(stringShape()))
|
|
1765
|
+
projectMigrationSchema([], cloneMigrationInput(input).plan.steps)
|
|
1766
|
+
schemaToStore(table)
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
The helper delegates exact JSON ownership to Contract 0.0.9's
|
|
1770
|
+
`cloneJSONRecord`, then validates the owned output as `DriverMetadata`. A malformed
|
|
1771
|
+
shape, cycle, function, accessor, or hostile/revoked proxy throws
|
|
1772
|
+
`DatabaseError('VALIDATION')` with `context.path === 'metadata'`; clone/traversal
|
|
1773
|
+
failures are retained only as `context.cause`, so no raw Contract or caller
|
|
1774
|
+
error crosses the Database surface. Hostile values are never stringified or
|
|
1775
|
+
embedded in the diagnostic.
|
|
1776
|
+
|
|
1777
|
+
`JSONDriver` applies that ownership rule at every file-backed seam: valid parsed
|
|
1778
|
+
metadata is cloned, while a present malformed metadata value fails the whole
|
|
1779
|
+
open with a payload-safe `DRIVER` error. Root `stamp` / `migrate`
|
|
1780
|
+
and their scoped candidate equivalents clone metadata synchronously before
|
|
1781
|
+
queue admission or another await can yield to caller mutation. Candidate/root
|
|
1782
|
+
publication, serialization, and every `metadata()` copy-out clone again, so the
|
|
1783
|
+
serialized value is an owned validated snapshot and each returned value is
|
|
1784
|
+
distinct and deeply frozen. General rows retain `MemoryDriver`'s native
|
|
1785
|
+
structured-clone behavior and are not forced through the JSON metadata cloner.
|
|
1786
|
+
|
|
1787
|
+
`SQLiteDriver` applies the same boundary at persisted-row ingress, every
|
|
1788
|
+
root/scoped `stamp` and `migrate` ingress, and every `metadata()` copy-out.
|
|
1789
|
+
Root lifecycle and scoped token gates run before hostile metadata traversal;
|
|
1790
|
+
valid migration metadata is cloned before its first DDL statement. Malformed
|
|
1791
|
+
stored metadata fails closed with `DRIVER`, while valid copy-outs are distinct
|
|
1792
|
+
and deeply frozen. General SQLite rows continue through
|
|
1793
|
+
their declared codecs and native `SQLiteValue`s, never the JSON metadata cloner.
|
|
1794
|
+
|
|
1795
|
+
### Driver conformance
|
|
1796
|
+
|
|
1797
|
+
`conformDriver(factory)` runs the same invariant battery every backend must
|
|
1798
|
+
uphold — call it from a new driver's own test suite (or a smoke script) to
|
|
1799
|
+
prove it is a drop-in `DriverInterface`:
|
|
1800
|
+
|
|
1801
|
+
```ts
|
|
1802
|
+
import { conformDriver, createMemoryDriver } from '@orkestrel/database'
|
|
1803
|
+
|
|
1804
|
+
await conformDriver(() => createMemoryDriver()) // resolves once every phase passes
|
|
1805
|
+
// A driver that violates an invariant rejects with DatabaseError('CONFORMANCE', ...)
|
|
1806
|
+
```
|
|
1807
|
+
|
|
1808
|
+
### Auditing a custom driver
|
|
1809
|
+
|
|
1810
|
+
`auditDriver(factory)` drains the full battery instead of failing fast,
|
|
1811
|
+
collecting every violation — useful when developing a new backend and
|
|
1812
|
+
wanting the complete picture in one run rather than fixing one invariant at
|
|
1813
|
+
a time:
|
|
1814
|
+
|
|
1815
|
+
```ts
|
|
1816
|
+
import { auditDriver, createMemoryDriver } from '@orkestrel/database'
|
|
1817
|
+
|
|
1818
|
+
const findings = await auditDriver(() => createMemoryDriver())
|
|
1819
|
+
// [] — a fully conformant driver
|
|
1820
|
+
for (const finding of findings) console.log(`${finding.check}: ${finding.message}`)
|
|
1821
|
+
|
|
1822
|
+
// The lower-level generator these two build on — one phase per yield, lazy:
|
|
1823
|
+
import { scanDriver } from '@orkestrel/database'
|
|
1824
|
+
for await (const finding of scanDriver(() => createMemoryDriver())) {
|
|
1825
|
+
console.log(finding.check, finding.context)
|
|
1826
|
+
}
|
|
1827
|
+
```
|
|
1828
|
+
|
|
1829
|
+
### Key factories
|
|
1830
|
+
|
|
1831
|
+
When the primary column is optional and a write omits it, the table uses
|
|
1832
|
+
global `crypto.randomUUID()` by default. `DatabaseOptions.generator` is an
|
|
1833
|
+
authoritative override; numeric primary columns require one because the
|
|
1834
|
+
default generator returns a string. Explicit primary values never invoke it.
|
|
1835
|
+
Browser consumers relying on the default require a secure context that exposes
|
|
1836
|
+
`crypto.randomUUID()`; otherwise supply a generator or an explicit primary.
|
|
1837
|
+
|
|
1838
|
+
```ts
|
|
1839
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1840
|
+
import { integerShape, optionalShape, stringShape } from '@orkestrel/contract'
|
|
1841
|
+
|
|
1842
|
+
const db = createDatabase({
|
|
1843
|
+
driver: createMemoryDriver(),
|
|
1844
|
+
tables: { posts: { id: optionalShape(stringShape()), title: stringShape() } },
|
|
1845
|
+
})
|
|
1846
|
+
await db.table('posts').set({ title: 'Hello' }) // a fresh UUID
|
|
1847
|
+
|
|
1848
|
+
const numbered = createDatabase({
|
|
1849
|
+
driver: createMemoryDriver(),
|
|
1850
|
+
tables: { events: { id: optionalShape(integerShape()), name: stringShape() } },
|
|
1851
|
+
generator: () => 42,
|
|
1852
|
+
})
|
|
1853
|
+
await numbered.table('events').set({ name: 'opened' }) // 42
|
|
1854
|
+
```
|
|
1855
|
+
|
|
1856
|
+
### Observing
|
|
1857
|
+
|
|
1858
|
+
Both the `Database` and each `Table` expose a typed `emitter` (see
|
|
1859
|
+
`.claude/rules/patterns.md` § Stateful emitters)
|
|
1860
|
+
carrying its lifecycle for fire-and-forget observers — logging, metrics,
|
|
1861
|
+
**cache invalidation, a sync layer**. The vocabulary is split by audience:
|
|
1862
|
+
the **database** carries the connection + transaction moments, each **table**
|
|
1863
|
+
the per-row mutations (key only — no value payload, to keep fan-out lean; a
|
|
1864
|
+
consumer that needs the value re-reads it). Subscribe through
|
|
1865
|
+
`entity.emitter.on(...)`, or wire initial listeners through the reserved
|
|
1866
|
+
`on?` option. **Emitting is observation-only**: every event fires strictly
|
|
1867
|
+
after the relevant transition, so a listener can never change what a write
|
|
1868
|
+
or a transaction does.
|
|
1869
|
+
|
|
1870
|
+
```ts
|
|
1871
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1872
|
+
import { stringShape } from '@orkestrel/contract'
|
|
1873
|
+
|
|
1874
|
+
const db = createDatabase({
|
|
1875
|
+
driver: createMemoryDriver(),
|
|
1876
|
+
tables: { users: { id: stringShape(), name: stringShape() } },
|
|
1877
|
+
on: { commit: () => console.log('transaction committed') },
|
|
1878
|
+
})
|
|
1879
|
+
|
|
1880
|
+
const users = db.table('users') // hold the handle (the documented practice) and observe it
|
|
1881
|
+
users.emitter.on('write', (key) => console.log('invalidate users', key))
|
|
1882
|
+
users.emitter.on('remove', (key) => console.log('invalidate users', key))
|
|
1883
|
+
db.emitter.on('rollback', (error) => console.warn('transaction rolled back', error))
|
|
1884
|
+
```
|
|
1885
|
+
|
|
1886
|
+
The event vocabulary:
|
|
1887
|
+
|
|
1888
|
+
| Entity | Event map | Events |
|
|
1889
|
+
| ---------- | ------------------ | ----------------------------------------------------------------------------------------------- |
|
|
1890
|
+
| `Database` | `DatabaseEventMap` | `open()` · `close()` · `transaction()` · `commit()` · `rollback(error)` · `migrate(migration)` |
|
|
1891
|
+
| `Table` | `TableEventMap` | `write(key)` · `remove(key)` · `clear()` (key only — `set` / `add` / `update` all emit `write`) |
|
|
1892
|
+
|
|
1893
|
+
`open` fires once when the handle's driver connects (an explicit `open()`, or
|
|
1894
|
+
the lazy first-use connect); `close`
|
|
1895
|
+
when the driver is released; `transaction` when a scope begins after its
|
|
1896
|
+
native boundary or fallback snapshot is acquired; `commit` only after a scope
|
|
1897
|
+
succeeds; `rollback` only after a throwing scope's tables are all restored;
|
|
1898
|
+
`migrate` after a migration commits. A `Table` fires `write` after any
|
|
1899
|
+
row put (set / add / update — re-read by key if you need the new value),
|
|
1900
|
+
`remove` after a row is deleted (a delete of an absent key emits nothing),
|
|
1901
|
+
and `clear` after the table is emptied. Reads / queries / counts are **not**
|
|
1902
|
+
emitted — a reader does not mutate, and those paths are too hot. Each
|
|
1903
|
+
`db.table(name)` returns a fresh handle with its own emitter, so subscribe
|
|
1904
|
+
on the handle you hold and operate on that same handle.
|
|
1905
|
+
|
|
1906
|
+
**Listener isolation.** A listener throw never escapes
|
|
1907
|
+
into the engine: the emitter isolates it and routes it to
|
|
1908
|
+
its own `error` handler (the `error` option, surfaced as `(error, event)`),
|
|
1909
|
+
not to a domain event — so a buggy observer is isolated yet not silently
|
|
1910
|
+
lost. The `error` handler runs in its own try/catch, so even a throwing
|
|
1911
|
+
handler can't recurse or escape; with no handler, the throw is swallowed
|
|
1912
|
+
silently. Every throwing listener surfaces (not only the first). Because
|
|
1913
|
+
every emit sits after its transition and is isolated, a buggy observer
|
|
1914
|
+
**cannot corrupt a write or a transaction**: a throwing `commit` observer
|
|
1915
|
+
leaves the committed state intact, a throwing `rollback` observer cannot
|
|
1916
|
+
suppress the propagated transaction error (the original throw still
|
|
1917
|
+
propagates, the tables still roll back), and a throwing `write` observer
|
|
1918
|
+
leaves the written row intact — proven by the per-entity emit-safety tests.
|
|
1919
|
+
(A `Table` reached through the `Database` receives the same `error` handler
|
|
1920
|
+
the `DatabaseOptions.error` option supplies, so a `Table` listener throw
|
|
1921
|
+
routes there; with no `error` handler configured, the throw is swallowed
|
|
1922
|
+
silently.)
|
|
1923
|
+
|
|
1924
|
+
### Importing and exporting schemas
|
|
1925
|
+
|
|
1926
|
+
`import` defines more than one table at once from a shape map (keys are
|
|
1927
|
+
names) and returns a typed view of those tables over the **same** driver.
|
|
1928
|
+
`export` emits a portable definition per table — useful for moving a schema
|
|
1929
|
+
between databases or environments and for diffing migrations.
|
|
1930
|
+
|
|
1931
|
+
```ts
|
|
1932
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1933
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
1934
|
+
|
|
1935
|
+
const db = createDatabase({
|
|
1936
|
+
driver: createMemoryDriver(),
|
|
1937
|
+
tables: { users: { id: stringShape(), name: stringShape() } },
|
|
1938
|
+
})
|
|
1939
|
+
|
|
1940
|
+
// Define more tables at runtime; the returned view is typed and shares storage.
|
|
1941
|
+
// Compose every imported view before the first open/use; every view shares one lifecycle context.
|
|
1942
|
+
const audit = db.import(
|
|
1943
|
+
{
|
|
1944
|
+
logs: { id: stringShape(), message: stringShape(), at: integerShape() },
|
|
1945
|
+
sessions: { id: stringShape(), user: stringShape() },
|
|
1946
|
+
},
|
|
1947
|
+
{ sessions: 'id' },
|
|
1948
|
+
)
|
|
1949
|
+
await audit.table('logs').set({ id: 'l1', message: 'started', at: 1 })
|
|
1950
|
+
|
|
1951
|
+
// Export a portable schema (JSON Schema is environment-agnostic).
|
|
1952
|
+
const portable = db.export()
|
|
1953
|
+
const exported = portable.users
|
|
1954
|
+
if (exported === undefined) throw new Error('Expected the users definition')
|
|
1955
|
+
exported.schema // a JSON Schema document
|
|
1956
|
+
exported.columns // the source column map (re-imports through `import` in a TS environment)
|
|
1957
|
+
exported.primary // 'id'
|
|
1958
|
+
```
|
|
1959
|
+
|
|
1960
|
+
### Introspection & seeding
|
|
1961
|
+
|
|
1962
|
+
Reads a table's contract schema, generates a reproducible seed row, and guards an unknown value against it:
|
|
1963
|
+
|
|
1964
|
+
```ts
|
|
1965
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1966
|
+
import { stringShape } from '@orkestrel/contract'
|
|
1967
|
+
|
|
1968
|
+
const users = createDatabase({
|
|
1969
|
+
driver: createMemoryDriver(),
|
|
1970
|
+
tables: { users: { id: stringShape(), name: stringShape() } },
|
|
1971
|
+
}).table('users')
|
|
1972
|
+
const value: unknown = { id: 'u1', name: 'Ada' }
|
|
1973
|
+
|
|
1974
|
+
users.contract.schema // the table's JSON Schema (from the shape)
|
|
1975
|
+
users.contract.generate() // a valid seed row — reproducible with a seeded RandomFunction
|
|
1976
|
+
users.contract.is(value) // the row guard
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
### Connecting eagerly
|
|
1980
|
+
|
|
1981
|
+
The driver connects lazily on first table use; call `open` to connect
|
|
1982
|
+
eagerly instead (useful to fail fast at startup, before the first request):
|
|
1983
|
+
|
|
1984
|
+
```ts
|
|
1985
|
+
import { createDatabase, createMemoryDriver } from '@orkestrel/database'
|
|
1986
|
+
import { stringShape } from '@orkestrel/contract'
|
|
1987
|
+
|
|
1988
|
+
const db = createDatabase({
|
|
1989
|
+
driver: createMemoryDriver(),
|
|
1990
|
+
tables: { users: { id: stringShape() } },
|
|
1991
|
+
})
|
|
1992
|
+
await db.open() // connects immediately — table() calls after this never wait on it
|
|
1993
|
+
```
|
|
1994
|
+
|
|
1995
|
+
Concurrent eager and lazy callers share one readiness attempt. A physical
|
|
1996
|
+
driver-open failure clears that attempt, so a later `open()` or table operation
|
|
1997
|
+
retries instead of inheriting a permanently rejected promise. When physical
|
|
1998
|
+
open succeeds and version reconciliation then fails, `status` remains `open`
|
|
1999
|
+
and the single `open` event records that physical transition. The shared
|
|
2000
|
+
readiness promise and ordinary table work still reject until a later automatic
|
|
2001
|
+
retry reconciles successfully; recovery reuses the same open physical handle
|
|
2002
|
+
instead of creating a duplicate connection.
|
|
2003
|
+
An explicit migration failure is stricter: ordinary work continues to receive
|
|
2004
|
+
that exact failure until another explicit `migrate(deployed)` succeeds.
|
|
2005
|
+
|
|
2006
|
+
`close()` first stops new root admissions, drains every root operation admitted
|
|
2007
|
+
synchronously before the close/transaction boundary, waits for any shared
|
|
2008
|
+
readiness attempt to settle, and releases the driver. Close is idempotent but
|
|
2009
|
+
terminal: the database handle and every imported view remain `closed`; reopen a
|
|
2010
|
+
persistent store with a fresh driver/database handle.
|
|
2011
|
+
|
|
2012
|
+
### Driver primitives
|
|
2013
|
+
|
|
2014
|
+
A `DriverInterface` is the irreducible storage primitive every backend
|
|
2015
|
+
implements; `Database` / `Table` are the ergonomic layer built on it. Calling
|
|
2016
|
+
it directly (as `Table` does internally) shows the whole required surface:
|
|
2017
|
+
|
|
2018
|
+
```ts
|
|
2019
|
+
import type { TableSchema } from '@orkestrel/database'
|
|
2020
|
+
import { createMemoryDriver } from '@orkestrel/database'
|
|
2021
|
+
|
|
2022
|
+
const driver = createMemoryDriver()
|
|
2023
|
+
const schema: readonly TableSchema[] = [
|
|
2024
|
+
{
|
|
2025
|
+
name: 'users',
|
|
2026
|
+
primary: 'id',
|
|
2027
|
+
columns: [{ name: 'id', storage: 'text', optional: false, nullable: false }],
|
|
2028
|
+
indexes: [],
|
|
2029
|
+
},
|
|
2030
|
+
]
|
|
2031
|
+
await driver.open(schema)
|
|
2032
|
+
await driver.stamp?.({ version: 1, schema })
|
|
2033
|
+
await driver.insert('users', 'u1', { id: 'u1', name: 'Ada' }) // duplicate → CONFLICT
|
|
2034
|
+
await driver.write('users', 'u1', { id: 'u1', name: 'Ada Lovelace' }) // upsert
|
|
2035
|
+
await driver.read('users', 'u1') // { id: 'u1', name: 'Ada' } | undefined
|
|
2036
|
+
for await (const row of driver.scan('users')) row // every row, key order
|
|
2037
|
+
await driver.keys('users') // readonly Key[]
|
|
2038
|
+
const rollback = await driver.snapshot() // capture, then...
|
|
2039
|
+
await driver.delete('users', 'u1') // boolean
|
|
2040
|
+
await rollback() // ...restore the captured state
|
|
2041
|
+
await driver.clear('users')
|
|
2042
|
+
await driver.close()
|
|
2043
|
+
```
|
|
2044
|
+
|
|
2045
|
+
### Query engine helpers
|
|
2046
|
+
|
|
2047
|
+
The pure functions behind `TableInterface` and `QueryInterface` — useful
|
|
2048
|
+
directly when building a new driver's native `records` / `aggregate` hook:
|
|
2049
|
+
|
|
2050
|
+
```ts
|
|
2051
|
+
import type { Condition } from '@orkestrel/database'
|
|
2052
|
+
import { integerShape } from '@orkestrel/contract'
|
|
2053
|
+
import {
|
|
2054
|
+
applyQuery,
|
|
2055
|
+
compareValues,
|
|
2056
|
+
computeAggregate,
|
|
2057
|
+
equalsValue,
|
|
2058
|
+
extractKey,
|
|
2059
|
+
filterRows,
|
|
2060
|
+
matchesGlobPattern,
|
|
2061
|
+
matchesLikePattern,
|
|
2062
|
+
matchesCondition,
|
|
2063
|
+
matchesQuery,
|
|
2064
|
+
shapeToColumnStorage,
|
|
2065
|
+
sortRows,
|
|
2066
|
+
validatePage,
|
|
2067
|
+
matchesWildcardPattern,
|
|
2068
|
+
} from '@orkestrel/database'
|
|
2069
|
+
|
|
2070
|
+
compareValues(1, 2) // -1 — a total order over mixed types
|
|
2071
|
+
matchesWildcardPattern('hello', 'h%o', '%', '_', true) // true — the shared LIKE/GLOB engine
|
|
2072
|
+
matchesLikePattern('hello', 'h%o') // true — case-insensitive
|
|
2073
|
+
matchesGlobPattern('hello', 'h*o') // true — case-sensitive
|
|
2074
|
+
|
|
2075
|
+
const condition: Condition = {
|
|
2076
|
+
column: 'age',
|
|
2077
|
+
operator: 'above',
|
|
2078
|
+
values: [18],
|
|
2079
|
+
connector: 'and',
|
|
2080
|
+
}
|
|
2081
|
+
matchesCondition({ age: 36 }, condition) // true
|
|
2082
|
+
matchesQuery({ age: 36 }, [condition]) // true — folds every condition
|
|
2083
|
+
filterRows([{ age: 36 }, { age: 12 }], [condition]) // [{ age: 36 }] — the count/aggregate basis
|
|
2084
|
+
|
|
2085
|
+
sortRows([{ age: 36 }, { age: 18 }], [{ column: 'age', direction: 'ascending' }])
|
|
2086
|
+
applyQuery([{ age: 36 }, { age: 18 }], { conditions: [condition], limit: 1 })
|
|
2087
|
+
validatePage({ limit: 25, offset: 0 }) // valid; fractions, negatives, NaN, and infinity throw
|
|
2088
|
+
computeAggregate([{ age: 36 }, { age: 18 }], 'average', 'age') // 27
|
|
2089
|
+
|
|
2090
|
+
extractKey({ id: 'u1' }, 'id') // 'u1'
|
|
2091
|
+
shapeToColumnStorage(integerShape()) // 'integer' — the type `open` hands a driver
|
|
2092
|
+
equalsValue({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) // true — structural, not reference, equality
|
|
2093
|
+
```
|
|
2094
|
+
|
|
2095
|
+
### Persistence with the JSON driver
|
|
2096
|
+
|
|
2097
|
+
Opens a database over the JSON driver and writes one row, persisted to the backing file:
|
|
2098
|
+
|
|
2099
|
+
```ts
|
|
2100
|
+
import { createDatabase } from '@orkestrel/database'
|
|
2101
|
+
import { createJSONDriver } from '@orkestrel/database/server'
|
|
2102
|
+
import { stringShape } from '@orkestrel/contract'
|
|
2103
|
+
|
|
2104
|
+
const db = createDatabase({
|
|
2105
|
+
driver: createJSONDriver('data/app.json'),
|
|
2106
|
+
tables: { users: { id: stringShape(), name: stringShape() } },
|
|
2107
|
+
})
|
|
2108
|
+
await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to app.json
|
|
2109
|
+
```
|
|
2110
|
+
|
|
2111
|
+
`JSONDriver` serializes `open` with every mutation. It reads persisted metadata
|
|
2112
|
+
first and, when versioned, adopts `DriverMetadata.schema` as the deployed schema
|
|
2113
|
+
before target reconciliation. Only `ENOENT` proves a fresh store. Every other
|
|
2114
|
+
read failure and every existing invalid document fails closed with a
|
|
2115
|
+
payload-safe `DRIVER` error, leaving prior bytes and in-memory publication
|
|
2116
|
+
unchanged. The accepted document has exactly `tables` and optional `metadata`;
|
|
2117
|
+
its table keys exactly match the selected deployed schema, each table is an
|
|
2118
|
+
array, and every entry is a record with a usable, unique declared primary key.
|
|
2119
|
+
A bare `{ tables }` document remains a valid unstamped legacy file. No corrupt
|
|
2120
|
+
document is rewritten, quarantined, or repaired automatically; external repair
|
|
2121
|
+
followed by `open` on the same driver retries normally. It is a decorator over
|
|
2122
|
+
`MemoryDriver`, but each nontransactional point mutation is one exclusive
|
|
2123
|
+
queue job spanning preimage capture, speculative memory change, temp-file
|
|
2124
|
+
staging, atomic rename, and success or restoration. Reads wait behind that
|
|
2125
|
+
job, so they never observe its speculative state. An abort while queued rejects
|
|
2126
|
+
promptly and the job later exits before touching memory; an abort during
|
|
2127
|
+
staging uses the native file-write signal, removes the temp file, restores the
|
|
2128
|
+
preimage, and only then rejects `ABORTED`. `rename` dispatch is the
|
|
2129
|
+
commit point that cannot be aborted: after dispatch, the driver awaits and reports the
|
|
2130
|
+
real rename result. A failure before or at rename restores memory before the
|
|
2131
|
+
next queued writer starts, so concurrent writers cannot persist an older
|
|
2132
|
+
snapshot over a later mutation. Callback transactions and root
|
|
2133
|
+
`migrate({ plan, metadata })` build an isolated candidate and publish file first:
|
|
2134
|
+
the temp-file replacement must succeed before committed memory, schema, or
|
|
2135
|
+
metadata is swapped. Thus readers never observe state that durable storage did
|
|
2136
|
+
not accept, and a persistence failure discards the entire candidate. After a
|
|
2137
|
+
persistence fault, temporary-file cleanup completes before rejection. Cleanup
|
|
2138
|
+
success preserves the existing precedence: a precommit fired signal rejects
|
|
2139
|
+
`ABORTED`, otherwise the operation rejects `DRIVER` with the native/raw fault
|
|
2140
|
+
only at `context.cause`. If cleanup fails too, the top-level error is `DRIVER`
|
|
2141
|
+
with `context` containing the exact `path`, deterministic sibling `temp`, the
|
|
2142
|
+
original or abort-mapped `cause`, and the native `cleanup` fault. Root memory is
|
|
2143
|
+
restored before that error leaves the exclusive queue, so the next operation
|
|
2144
|
+
can proceed after the temporary obstruction is removed. A successful staging
|
|
2145
|
+
write requests native `flush: true` before the same-directory atomic rename.
|
|
2146
|
+
|
|
2147
|
+
`JSONDriver.snapshot()` captures owned row data together with the table schema
|
|
2148
|
+
needed to decode that capture. Its rollback thunk is repeatable and restores
|
|
2149
|
+
data only into the driver's current schema: current metadata is never rewound,
|
|
2150
|
+
a captured table removed after capture is skipped, and a table added later is
|
|
2151
|
+
preserved. Replaying the same thunk again produces the same row result without
|
|
2152
|
+
replacing the current schema or metadata.
|
|
2153
|
+
|
|
2154
|
+
### Compiling input to SQL
|
|
2155
|
+
|
|
2156
|
+
The server's pure `compilers.ts` turns a core `QueryInput` (the same one
|
|
2157
|
+
`applyQuery` folds) into the `WHERE` / `ORDER BY` / `LIMIT` tail of a
|
|
2158
|
+
`SELECT`, with `?`-bound parameters in clause order — the payload a native SQLite
|
|
2159
|
+
driver's `records` hook runs directly:
|
|
2160
|
+
|
|
2161
|
+
```ts
|
|
2162
|
+
import {
|
|
2163
|
+
compileAggregateSQL,
|
|
2164
|
+
compileColumnSQL,
|
|
2165
|
+
compileFieldSQL,
|
|
2166
|
+
compileQuerySQL,
|
|
2167
|
+
deriveSQLiteIndexName,
|
|
2168
|
+
matchesAggregateExactly,
|
|
2169
|
+
matchesSQLiteAffinity,
|
|
2170
|
+
quoteIdentifier,
|
|
2171
|
+
schemaToIndexes,
|
|
2172
|
+
schemaToTable,
|
|
2173
|
+
stepToSQL,
|
|
2174
|
+
} from '@orkestrel/database/server'
|
|
2175
|
+
import type { TableSchema } from '@orkestrel/database'
|
|
2176
|
+
|
|
2177
|
+
const schema: TableSchema = {
|
|
2178
|
+
name: 'users',
|
|
2179
|
+
primary: 'id',
|
|
2180
|
+
columns: [
|
|
2181
|
+
{ name: 'id', storage: 'text', optional: false, nullable: false },
|
|
2182
|
+
{ name: 'age', storage: 'integer', optional: false, nullable: false },
|
|
2183
|
+
],
|
|
2184
|
+
indexes: [],
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
compileQuerySQL(
|
|
2188
|
+
{ conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }] },
|
|
2189
|
+
schema,
|
|
2190
|
+
) // { sql: 'WHERE "age" >= ? ORDER BY "id"', parameters: [18] }
|
|
2191
|
+
|
|
2192
|
+
quoteIdentifier('order') // '"order"'
|
|
2193
|
+
deriveSQLiteIndexName('users', ['age']) // 'idx_5_users_3_age'
|
|
2194
|
+
compileColumnSQL('integer') // 'INTEGER'
|
|
2195
|
+
compileFieldSQL(['profile', 'score']) // 'json_extract("profile", \'$.score\')'
|
|
2196
|
+
compileAggregateSQL('average', 'age') // 'AVG("age")'
|
|
2197
|
+
matchesAggregateExactly('minimum', 'age', schema) // true
|
|
2198
|
+
matchesSQLiteAffinity('INTEGER', 'integer') // true
|
|
2199
|
+
schemaToTable(schema) // CREATE TABLE IF NOT EXISTS …
|
|
2200
|
+
schemaToIndexes(schema) // []
|
|
2201
|
+
stepToSQL({ operation: 'index.add', table: 'users', index: ['age'] })
|
|
2202
|
+
|
|
2203
|
+
// A parameterized SQLite binding runs it directly:
|
|
2204
|
+
// db.prepare(`SELECT * FROM "users" ${sql}`).all(...parameters)
|
|
2205
|
+
```
|
|
2206
|
+
|
|
2207
|
+
### Exact-or-refine vs. narrow-then-refine native reads
|
|
2208
|
+
|
|
2209
|
+
A native override earns the engine's trust by proving exactness or by
|
|
2210
|
+
narrowing then refining (see `.claude/rules/architecture.md` § System
|
|
2211
|
+
constraints).
|
|
2212
|
+
**Prove-exactness-or-refine** (`SQLiteDriver`): the backend has real typed
|
|
2213
|
+
columns and indexes, so it compiles the `QueryInput` straight to SQL and runs
|
|
2214
|
+
it natively only when `matchesQueryExactly` first proves the SQL and the engine
|
|
2215
|
+
agree on every condition/order term for that schema — otherwise it falls
|
|
2216
|
+
back to a full scan refined through the same core engine (never a "trust
|
|
2217
|
+
blindly" path). **Narrow-then-refine**
|
|
2218
|
+
(`IndexedDBDriver`): the backend can only prove a candidate superset range-exact
|
|
2219
|
+
(a key-range pushdown), so it fetches that superset and hands it to the same
|
|
2220
|
+
core engine every scan-only driver uses (`applyQuery` / `matchesQuery`),
|
|
2221
|
+
which refines it down to the exact result — conformance is earned by "never
|
|
2222
|
+
under-fetch," not by native filtering. Both are indistinguishable from the
|
|
2223
|
+
caller's side: `Table.records` / `count` / `stream` return identical rows
|
|
2224
|
+
either way; only the path to get there differs.
|
|
2225
|
+
|
|
2226
|
+
```ts
|
|
2227
|
+
import type { Condition, TableSchema } from '@orkestrel/database'
|
|
2228
|
+
import {
|
|
2229
|
+
matchesConditionExactly,
|
|
2230
|
+
matchesQueryExactly,
|
|
2231
|
+
matchesOrderExactly,
|
|
2232
|
+
} from '@orkestrel/database/server'
|
|
2233
|
+
|
|
2234
|
+
const schema: TableSchema = {
|
|
2235
|
+
name: 'users',
|
|
2236
|
+
primary: 'id',
|
|
2237
|
+
columns: [
|
|
2238
|
+
{ name: 'id', storage: 'text', optional: false, nullable: false },
|
|
2239
|
+
{ name: 'age', storage: 'integer', optional: false, nullable: false },
|
|
2240
|
+
],
|
|
2241
|
+
indexes: [],
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const exact: Condition = {
|
|
2245
|
+
column: 'age',
|
|
2246
|
+
operator: 'above',
|
|
2247
|
+
values: [18],
|
|
2248
|
+
connector: 'and',
|
|
2249
|
+
}
|
|
2250
|
+
matchesConditionExactly(exact, schema) // true — a plain comparison over a typed column
|
|
2251
|
+
|
|
2252
|
+
const notExact: Condition = {
|
|
2253
|
+
column: 'age',
|
|
2254
|
+
operator: 'above',
|
|
2255
|
+
values: [null],
|
|
2256
|
+
connector: 'and',
|
|
2257
|
+
}
|
|
2258
|
+
matchesConditionExactly(notExact, schema) // false — a null operand refines instead
|
|
2259
|
+
|
|
2260
|
+
matchesOrderExactly({ column: 'age', direction: 'ascending' }, schema) // true — a flat, orderable column
|
|
2261
|
+
|
|
2262
|
+
matchesQueryExactly(
|
|
2263
|
+
{ conditions: [exact], order: [{ column: 'age', direction: 'ascending' }] },
|
|
2264
|
+
schema,
|
|
2265
|
+
) // true
|
|
2266
|
+
```
|
|
2267
|
+
|
|
2268
|
+
### Persistence with the SQLite driver
|
|
2269
|
+
|
|
2270
|
+
Opens a database over the SQLite driver, writes one row, and runs a query compiled to native SQL:
|
|
2271
|
+
|
|
2272
|
+
```ts
|
|
2273
|
+
import { createDatabase } from '@orkestrel/database'
|
|
2274
|
+
import { createSQLiteDriver } from '@orkestrel/database/server'
|
|
2275
|
+
import { integerShape, stringShape } from '@orkestrel/contract'
|
|
2276
|
+
|
|
2277
|
+
const db = createDatabase({
|
|
2278
|
+
driver: createSQLiteDriver({ path: 'data/app.sqlite' }), // or createSQLiteDriver() for ':memory:'
|
|
2279
|
+
tables: { users: { id: stringShape(), name: stringShape(), age: integerShape() } },
|
|
2280
|
+
})
|
|
2281
|
+
await db.table('users').set({ id: 'u1', name: 'Ada', age: 36 }) // persisted to app.sqlite
|
|
2282
|
+
|
|
2283
|
+
// Native querying, paging, and aggregation — compiled to SQL, no engine re-filter:
|
|
2284
|
+
await db
|
|
2285
|
+
.table('users')
|
|
2286
|
+
.query()
|
|
2287
|
+
.condition({ column: 'age', operator: 'from', values: [18], connector: 'and' })
|
|
2288
|
+
.order({ column: 'age', direction: 'descending' })
|
|
2289
|
+
.collect()
|
|
2290
|
+
await db
|
|
2291
|
+
.table('users')
|
|
2292
|
+
.query()
|
|
2293
|
+
.condition({ column: 'age', operator: 'above', values: [18], connector: 'and' })
|
|
2294
|
+
.aggregate('average', 'age')
|
|
2295
|
+
|
|
2296
|
+
// Real transactions and atomic migration ship with it:
|
|
2297
|
+
await db.transaction(async (transaction) => {
|
|
2298
|
+
await transaction.table('users').update('u1', { age: 37 })
|
|
2299
|
+
}) // real BEGIN/COMMIT/ROLLBACK, not the snapshot floor
|
|
2300
|
+
|
|
2301
|
+
// createSQLiteDriver accepts a SQLiteDriverOptions bag:
|
|
2302
|
+
createSQLiteDriver({
|
|
2303
|
+
path: 'data/app.sqlite',
|
|
2304
|
+
timeout: 5000,
|
|
2305
|
+
references: true,
|
|
2306
|
+
pragmas: { journal_mode: 'WAL' }, // applied through pragma() right after connect(), in order
|
|
2307
|
+
})
|
|
2308
|
+
```
|
|
2309
|
+
|
|
2310
|
+
`SQLiteDriver` is the fully-native backend — it implements every optional
|
|
2311
|
+
`DriverInterface` hook (`records?` / `aggregate?` / `transaction?`
|
|
2312
|
+
/ `stream?` / `migrate?` / `metadata?` / `stamp?`). Reopen the same `path` with a
|
|
2313
|
+
higher `DatabaseOptions.version` and it reconciles automatically through its
|
|
2314
|
+
reserved `_metadata` table (`METADATA_TABLE`) — see
|
|
2315
|
+
[Versioned auto-migrate on open](#versioned-auto-migrate-on-open); `open()`
|
|
2316
|
+
throws `DatabaseError('VALIDATION')` if a declared table is literally named
|
|
2317
|
+
`_metadata`, so the collision is caught immediately rather than silently
|
|
2318
|
+
corrupting metadata. `open()` creates only `_metadata` first, reads
|
|
2319
|
+
`DriverMetadata.schema`, and then creates or validates that deployed schema before
|
|
2320
|
+
`Database` reconciles it with the declaration. A root
|
|
2321
|
+
`migrate({ plan, metadata })` uses one native transaction for DDL, row changes, and
|
|
2322
|
+
metadata. The same call inside a callback transaction uses a SQLite savepoint:
|
|
2323
|
+
the wrapper deliberately provides raw `execute`, not a savepoint manager, so the
|
|
2324
|
+
driver owns one guarded fixed internal SQL literal. If caller code catches its
|
|
2325
|
+
failure, the failed inner migration is rolled back to that savepoint while the
|
|
2326
|
+
surrounding transaction remains active and may continue safely. Snapshot
|
|
2327
|
+
capture and replay, plus public `close()`, contain their complete native bodies
|
|
2328
|
+
inside `#guard`; no raw SQLite fault crosses the driver boundary. Candidate
|
|
2329
|
+
schema state becomes live only after the native commit. Point `write` / `insert` /
|
|
2330
|
+
`delete` check `OperationOptions.signal`
|
|
2331
|
+
immediately before the synchronous SQLite call; that call entry is the commit
|
|
2332
|
+
point, so there is no post-check that could relabel a completed commit.
|
|
2333
|
+
|
|
2334
|
+
### Persistence with the IndexedDB driver
|
|
2335
|
+
|
|
2336
|
+
Feature-detects `indexedDB`, opens a database over the IndexedDB driver, writes one row, and runs a query pushed down to a key range:
|
|
2337
|
+
|
|
2338
|
+
```ts
|
|
2339
|
+
import { createDatabase } from '@orkestrel/database'
|
|
2340
|
+
import { createIndexedDBDriver } from '@orkestrel/database/browser'
|
|
2341
|
+
import { stringShape } from '@orkestrel/contract'
|
|
2342
|
+
|
|
2343
|
+
// Feature-detect before reaching for it — IndexedDB is a browser-only global.
|
|
2344
|
+
if (typeof indexedDB !== 'undefined') {
|
|
2345
|
+
const db = createDatabase({
|
|
2346
|
+
driver: createIndexedDBDriver('app'),
|
|
2347
|
+
tables: { users: { id: stringShape(), name: stringShape() } },
|
|
2348
|
+
})
|
|
2349
|
+
await db.table('users').set({ id: 'u1', name: 'Ada' }) // persisted to IndexedDB
|
|
2350
|
+
await db
|
|
2351
|
+
.table('users')
|
|
2352
|
+
.query()
|
|
2353
|
+
.condition({ column: 'id', operator: 'equals', values: ['u1'], connector: 'and' })
|
|
2354
|
+
.collect() // pushed down to a key range
|
|
2355
|
+
}
|
|
2356
|
+
```
|
|
2357
|
+
|
|
2358
|
+
`IndexedDBDriver.open()` first makes a metadata-only bootstrap connection and,
|
|
2359
|
+
inside one readonly transaction, tests whether the `'metadata'` key exists and
|
|
2360
|
+
reads its value. Absence returns `undefined`; a present malformed value
|
|
2361
|
+
(including stored `undefined`) throws a payload-safe `DRIVER` error without
|
|
2362
|
+
publishing schema/identity/connection state or changing the record. The
|
|
2363
|
+
bootstrap closes in every outcome, so external repair or version activity is
|
|
2364
|
+
not blocked and the same driver may retry. It then connects the deployed
|
|
2365
|
+
`DriverMetadata.schema` plus `__metadata__`; it does not create the target declaration
|
|
2366
|
+
before reconciliation. The driver narrows a `QueryInput` to a key-range candidate over the
|
|
2367
|
+
primary key or a single-column secondary index (`selectPlan`), then lets the
|
|
2368
|
+
core engine refine it to the exact result — see
|
|
2369
|
+
[Exact-or-refine vs. narrow-then-refine native reads](#exact-or-refine-vs-narrow-then-refine-native-reads).
|
|
2370
|
+
It implements `records?` / `stream?` / `migrate?` / `metadata?` /
|
|
2371
|
+
`stamp?` (persisted into a reserved `__metadata__` store, `METADATA_STORE` —
|
|
2372
|
+
`open()` throws `DatabaseError('VALIDATION')` if a declared table is
|
|
2373
|
+
literally named `__metadata__`), but omits `transaction?` (the underlying
|
|
2374
|
+
`IDBTransaction` auto-commits the moment control yields to a non-IDB
|
|
2375
|
+
`await`) and `aggregate?` (IndexedDB has no native SUM/AVG/MIN/MAX) by
|
|
2376
|
+
IndexedDB's own nature. A non-empty `migrate({ plan, metadata })` performs DDL,
|
|
2377
|
+
row transformations, and the metadata write in the same versionchange
|
|
2378
|
+
transaction; a metadata-only input uses one ordinary `__metadata__` write
|
|
2379
|
+
transaction. A failed upgrade reconnects the old deployed schema. Each point
|
|
2380
|
+
`write` / `insert` / `delete` still uses one explicit
|
|
2381
|
+
wrapper `database.write(table, scope)` transaction: the signal aborts it only
|
|
2382
|
+
while active, a signal-driven rollback maps to `DatabaseError('ABORTED')`, and
|
|
2383
|
+
native transaction completion is the commit boundary a late abort cannot
|
|
2384
|
+
rewrite.
|
|
2385
|
+
Every public `QueryInput` boundary also calls `validatePage`: `limit` and
|
|
2386
|
+
`offset`, when present, must be finite nonnegative integers. Validation is
|
|
2387
|
+
deterministic (`limit` before `offset`), non-finite diagnostics retain
|
|
2388
|
+
`'NaN'` / `'Infinity'` rather than JSON-coercing to `null`, and zero is
|
|
2389
|
+
legal. Return kind determines timing: `Query.limit` / `Query.offset` and
|
|
2390
|
+
`AsyncIterable` factories (`Table.scan` and every direct driver `stream`)
|
|
2391
|
+
throw synchronously, while Promise terminals (`Table.records` / `count` /
|
|
2392
|
+
`aggregate` and native Promise hooks) return rejected promises. Every path
|
|
2393
|
+
reports identical `VALIDATION` evidence and applies the same page predicate.
|
|
2394
|
+
Failed query-builder validation does not mutate builder state. `count` and
|
|
2395
|
+
`aggregate` validate paging even though valid paging remains intentionally
|
|
2396
|
+
ignored by their unpaged semantics.
|
|
2397
|
+
|
|
2398
|
+
### IndexedDB pushdown planning
|
|
2399
|
+
|
|
2400
|
+
The pure planner behind `IndexedDBDriver`'s native `records?` /
|
|
2401
|
+
`stream?` — useful directly to see what a `QueryInput` pushes down to before it
|
|
2402
|
+
ever touches a browser database:
|
|
2403
|
+
|
|
2404
|
+
```ts
|
|
2405
|
+
import type { Condition, TableSchema } from '@orkestrel/database'
|
|
2406
|
+
import { isKey } from '@orkestrel/database'
|
|
2407
|
+
import { conditionToRange, deriveIndexedDBIndexName, selectPlan } from '@orkestrel/database/browser'
|
|
2408
|
+
|
|
2409
|
+
const schema: TableSchema = {
|
|
2410
|
+
name: 'users',
|
|
2411
|
+
primary: 'id',
|
|
2412
|
+
columns: [
|
|
2413
|
+
{ name: 'id', storage: 'text', optional: false, nullable: false },
|
|
2414
|
+
{ name: 'age', storage: 'integer', optional: false, nullable: false },
|
|
2415
|
+
],
|
|
2416
|
+
indexes: [['age']],
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
isKey('u1') // true — a string is a usable IndexedDB key
|
|
2420
|
+
isKey(true) // false — a boolean is not
|
|
2421
|
+
deriveIndexedDBIndexName(['city', 'age']) // '2#4:city3:age'
|
|
2422
|
+
|
|
2423
|
+
const equalsAge: Condition = {
|
|
2424
|
+
column: 'age',
|
|
2425
|
+
operator: 'equals',
|
|
2426
|
+
values: [30],
|
|
2427
|
+
connector: 'and',
|
|
2428
|
+
}
|
|
2429
|
+
conditionToRange(equalsAge) // an IDBKeyRange.only(30) — an exact comparison operator
|
|
2430
|
+
|
|
2431
|
+
// A full scan (no condition qualifies for pushdown) returns an empty plan —
|
|
2432
|
+
// the driver then reads every row and lets the core engine filter it exactly:
|
|
2433
|
+
selectPlan(undefined, schema, ['age']) // {}
|
|
2434
|
+
|
|
2435
|
+
// A comparison over the indexed `age` column narrows to that index's range:
|
|
2436
|
+
selectPlan(
|
|
2437
|
+
{ conditions: [{ column: 'age', operator: 'from', values: [18], connector: 'and' }] },
|
|
2438
|
+
schema,
|
|
2439
|
+
['age'],
|
|
2440
|
+
) // { index: 'age', range: an IDBKeyRange bounding age >= 18 }
|
|
2441
|
+
```
|
|
2442
|
+
|
|
2443
|
+
### IndexedDB error mapping
|
|
2444
|
+
|
|
2445
|
+
`IndexedDBDriver` never lets a raw backend fault cross its `DriverInterface`
|
|
2446
|
+
surface — every one is mapped to a `DatabaseError`, the original preserved
|
|
2447
|
+
as `context.cause`:
|
|
2448
|
+
|
|
2449
|
+
```ts
|
|
2450
|
+
import type { IndexedDBError } from '@orkestrel/indexeddb'
|
|
2451
|
+
import { mapIndexedDBError, mapMigrationError } from '@orkestrel/database/browser'
|
|
2452
|
+
|
|
2453
|
+
declare const fault: IndexedDBError // a caught backend fault
|
|
2454
|
+
|
|
2455
|
+
mapIndexedDBError(fault) // → DatabaseError('CONFLICT' | 'CLOSED' | 'DRIVER', ...)
|
|
2456
|
+
mapMigrationError(fault) // → the same, but an UPGRADE fault becomes 'MIGRATION'
|
|
2457
|
+
```
|
|
2458
|
+
|
|
2459
|
+
### Practices
|
|
2460
|
+
|
|
2461
|
+
- **Declare tables in `createDatabase({ tables })` and hold the handles** —
|
|
2462
|
+
`const users = db.table('users')`; reuse them rather than re-resolving.
|
|
2463
|
+
- **Writes coerce, reads narrow.** At an unknown-input boundary, normalize
|
|
2464
|
+
loose data (`'41'`) through `table.contract.parse` before a typed write;
|
|
2465
|
+
trust `get` / `records` to return the row type.
|
|
2466
|
+
- **Use `resolve` when absence is an error**, `get` when it is expected —
|
|
2467
|
+
`resolve` throws `NOT_FOUND`, `get` returns `undefined`.
|
|
2468
|
+
- **Reach for `query()` over `records()`** — the builder compiles a portable
|
|
2469
|
+
`QueryInput`; `filter` is the JS escape hatch when an operator won't express
|
|
2470
|
+
it.
|
|
2471
|
+
- **Use `import` to add tables and views to the shared store before its first
|
|
2472
|
+
open/use** and `export` to move a schema across environments. Once opening
|
|
2473
|
+
starts, `import` conflicts; after `close`, it is closed.
|
|
2474
|
+
- **Wrap multi-write invariants in `transaction`** — a throw rolls every
|
|
2475
|
+
table back.
|
|
2476
|
+
- **Observe, don't drive** — subscribe to `db.emitter` (transaction
|
|
2477
|
+
lifecycle) / `table.emitter` (per-row `write` / `remove` / `clear`, key
|
|
2478
|
+
only) for cache invalidation, sync, or metrics (see
|
|
2479
|
+
[Observing](#observing)); emitting is a pure side-channel, so a listener
|
|
2480
|
+
never changes what a write or transaction does (and a throwing one can't
|
|
2481
|
+
corrupt it).
|
|
2482
|
+
- **Use `MemoryDriver` for tests and ephemeral data** — no I/O, and
|
|
2483
|
+
`JSONDriver` swaps in unchanged when writes need to survive a restart.
|
|
2484
|
+
|
|
2485
|
+
## Tests
|
|
2486
|
+
|
|
2487
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ compiler-resolved public-entry bijection across `src/core`, `src/server`, and `src/browser`, including fail-closed temporary-project coverage for barrel resolution and unsupported exports, each interface ↔ implementing-class method bijection, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Create a database` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also compiles every TypeScript fence against the published entry specifiers and runs the flagship fences, asserting the values their comments claim.
|
|
2488
|
+
- [`tests/src/core/cloners.test.ts`](../tests/src/core/cloners.test.ts) — `cloneDriverMetadata` ownership: normalized deeply frozen distinct output, caller-mutation isolation, and `VALIDATION` translation for malformed, cyclic, functional, accessor, and hostile/revoked-proxy inputs without leaking raw Contract or caller errors.
|
|
2489
|
+
- [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — total boundary guards for keys, columns, tables, driver schemas, migrations, inputs, and metadata.
|
|
2490
|
+
- [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — the query engine: `validatePage`'s strict page matrix, deterministic field order, exact non-finite diagnostics, and legal zero; `findColumn`'s flat-column lookup and its `undefined` miss; `resolvePrimary`'s declared-or-default key and `requireColumns`'s typed map lookup with its `NOT_FOUND` throw; `compareValues` total order, every `matchesCondition` operator (the equality family — `equals` / `not` / `any` / `none` — through `equalsValue`, including `NaN`-equals-`NaN`; the range family through `compareValues`), `matchesQuery` folding, `filterRows`, `sortRows`, `applyQuery`, `computeAggregate`, `extractKey`, `shapeToColumnStorage`'s shape → portable-type mapping (scalars, `json` for object/array/union/raw, optional/nullable unwrap, literal-by-values), total `isDriverMetadata` rejection of malformed and hostile getter/proxy input, `equalsValue`'s structural equality, `planMigration`'s `MIGRATION` throw on a shared column's storage/nullability drift, and the `scanDriver` / `conformDriver` / `auditDriver` battery against `MemoryDriver` and a deliberately-broken driver (each check fails with a `CONFORMANCE` `DatabaseError`), including the deepened `write-read` nested-field checks and the `snapshot-nested` phase (a shallow-copying driver fails it).
|
|
2491
|
+
- [`tests/src/core/drivers/MemoryDriver.test.ts`](../tests/src/core/drivers/MemoryDriver.test.ts) — the driver primitive: `open(schema)` readies tables, read/write/atomic-insert/delete/keys/scan/clear + `snapshot` rollback, duplicate-insert `CONFLICT`, non-JSON row isolation through native `structuredClone`, metadata stamp/migrate/copy-out ownership through `cloneDriverMetadata`, strict stream paging, and pre-aborted point mutations rejecting `ABORTED` without changing rows.
|
|
2492
|
+
- [`tests/src/core/Database.test.ts`](../tests/src/core/Database.test.ts) — declared tables, lazy connect, typed CRUD, custom keys, indexes, import/export, and callback transactions: whole accepted-operation drain, synchronous-throw and asynchronous-rejection reason identity, caught-operation rejection still rolling back, callback-over-drain error precedence, root/import/lifecycle/nesting barriers, stale scoped table/query/cursor/stream invalidation, and truthful successful-rollback-only events. It also covers explicit migration and versioned open: deployed-schema-first reconciliation, fresh stamp, same-version no-op, atomic upgrade input, higher-version rejection, paired-hook enforcement (metadata-only and stamp-only are inert), and migrate-event behavior.
|
|
2493
|
+
- [`tests/src/core/ScopedIterator.test.ts`](../tests/src/core/ScopedIterator.test.ts) — direct internal continuation-lifetime coverage: tracked `next` / `return` / `throw`, the readiness thunk running before every advance and its failure preempting the source, synchronous source throws, missing methods, concurrent accepted continuations, idle iterators, late conflicts, and exactly-once rejected cleanup.
|
|
2494
|
+
- [`tests/src/core/DriverIterator.test.ts`](../tests/src/core/DriverIterator.test.ts) — direct root-driver continuation coverage: pre/post-read guards, produced-row discard, terminalization, return races, missing methods, throw delegation, and exactly-once cleanup.
|
|
2495
|
+
- [`tests/src/core/Table.test.ts`](../tests/src/core/Table.test.ts) — `Table`'s keyed CRUD + batch overloads, payload-safe bounded contract diagnostics, strict paging at every read boundary, coercion and error paths, `add` dispatch through atomic `insert` (including concurrent duplicate claims), sequential partial-batch abort semantics, and the emitter's post-commit and no-aborted-event behavior.
|
|
2496
|
+
- [`tests/src/core/Query.test.ts`](../tests/src/core/Query.test.ts) — `Query`'s where / and / or dispatch, ordering, synchronous strict page builders with no failed mutation, legal zero, `filter`, and aggregates.
|
|
2497
|
+
- [`tests/src/core/Cursor.test.ts`](../tests/src/core/Cursor.test.ts) — cursor behavior over a key snapshot: `value` / `index` / `done`, serialized overlapping `next` / `update` / `remove`, rejection recovery, synchronous runner admission, terminal close before queued work and during dispatched reads/mutations, plus transaction-ledger regressions for deleted-key skipping, unawaited normalized updates, validation rollback, and retained closed/active conflicts.
|
|
2498
|
+
- [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — `createDatabase` / `createMemoryDriver` each return a working instance of their interface (a round-trip end to end).
|
|
2499
|
+
- [`tests/src/core/TransactionScope.test.ts`](../tests/src/core/TransactionScope.test.ts) — direct transaction-lifetime coverage: `accepting` and the `check` refusal, synchronous admission through `track`, a synchronous operation throw captured as a rejection, unawaited work contained by `drain`, the drain loop re-reading work another tracked operation admitted, first-failure identity preserved across repeated drains, and `stream`'s per-continuation boundary leaving an idle iterator unpinned.
|
|
2500
|
+
- [`tests/src/core/DatabaseContext.test.ts`](../tests/src/core/DatabaseContext.test.ts) — direct shared-context coverage: `register`'s identical-schema merge, its `VALIDATION` conflict, and its `CONFLICT` / `CLOSED` refusals; idle → open → closed transitions emitted once with one shared readiness promise; root admission, its transaction-time `CONFLICT`, and close-time drain; transaction commit / rollback events and value or error propagation, nested-transaction refusal, entry-only signal checking, and a fresh `TransactionScope` per attempt; explicit migration's missing-hook and post-open refusals; and versioned reconciliation's fresh stamp, newer-store rejection, differing-schema rejection, and same-version no-op.
|
|
2501
|
+
- [`tests/src/core/DatabaseTransaction.test.ts`](../tests/src/core/DatabaseTransaction.test.ts) — direct transaction-view coverage: the typed table `table()` builds over the scoped driver, its writes landing straight on that driver, the scope `CONFLICT` after settlement, the `NOT_FOUND` refusal for an undeclared table, a per-table primary override, a scoped table refusing work started after settlement, and the configured generator minting a key for a keyless write.
|
|
2502
|
+
- [`tests/src/server/factories.test.ts`](../tests/src/server/factories.test.ts) — `createJSONDriver` / `createSQLiteDriver` each return a working `DriverInterface` instance (a round-trip end to end), drive the core `createDatabase` stack, and persist across a reopen; `createSQLiteDriver` defaults to an in-memory database when its options bag is omitted.
|
|
2503
|
+
- [`tests/src/server/drivers/JSONDriver.test.ts`](../tests/src/server/drivers/JSONDriver.test.ts) — `JSONDriver` persistence plus atomic insert and the exclusive point-mutation queue: queued abort/no late start, active staging restoration, cleanup-success error precedence, deterministic real-filesystem persistence-plus-cleanup dual failure with exact evidence and queue recovery, read isolation, concurrent-writer ordering, fail-closed real-filesystem coverage for non-absence reads, invalid syntax/documents/table sets/containers/rows/metadata, byte preservation, no partial publication, application-invalid row retention, payload-safe rejection, strict stream paging, and same-instance external-repair retry; synchronous root/scoped stamp and migration ownership, deeply frozen distinct copy-out, deployed-schema-first open, isolated callback/root-migration candidates whose rows/schema/metadata publish only after file replacement succeeds, transaction-time root scan/stream continuation conflicts with terminal cleanup, and post-native-rollback rejection replacement.
|
|
2504
|
+
- [`tests/src/server/drivers/SQLiteDriver.test.ts`](../tests/src/server/drivers/SQLiteDriver.test.ts) — `SQLiteDriver`'s native surface: deployed-metadata-first open and reconciliation; fail-closed malformed metadata, physical schema disagreement, and persisted-table loss before DDL, including deterministic first-loss evidence, physical non-recreation, external repair, and same-driver retry; root/scoped stamp/migration ownership and distinct deeply frozen copy-out; atomic insert/duplicate `CONFLICT`; point-mutation abort boundaries; strict direct records/aggregate/stream paging; native exact-or-refine query and aggregate paths; repeatable schema-aware snapshot capture/replay plus real dropped-table and exclusive-lock failure containment/recovery; atomic `MigrationInput` schema/rows/metadata at the root; fixed-literal savepoint containment for a caught migration failure while its callback transaction remains active; candidate-schema publication only after commit; callback transaction barriers/invalidation including root continuation cleanup; payload-safe rejection; post-native-rollback rejection replacement; backend-fault mapping; and engine parity.
|
|
2505
|
+
- [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) — the SQLite bridge: `quoteIdentifier`, codecs, row extraction, `deriveSQLiteIndexName` exact bytes, and the `matchesConditionExactly` / `matchesOrderExactly` / `matchesQueryExactly` / `matchesAggregateExactly` / `matchesSQLiteAffinity` predicates.
|
|
2506
|
+
- [`tests/src/server/compilers.test.ts`](../tests/src/server/compilers.test.ts) — the coherent SQL-emitter cluster: column/field/aggregate compilation, table/index/migration DDL, and the strict-page `QueryInput` → SQL pipeline with exact statements and parameters.
|
|
2507
|
+
- [`tests/src/server/inferers.test.ts`](../tests/src/server/inferers.test.ts) — `inferValueStorage`'s nested-operand storage inference: the `ColumnStorage` a `json_extract` operand must encode as, over booleans, integral and fractional numbers, bigints, objects and arrays, strings, `null`, and `undefined`.
|
|
2508
|
+
- [`tests/src/server/integration.test.ts`](../tests/src/server/integration.test.ts) — cross-backend behavioral parity: the same `QueryInput` set run against `MemoryDriver` and `SQLiteDriver` (both exact-path and refine-path queries) produce identical rows/counts/aggregates.
|
|
2509
|
+
- [`tests/src/browser/drivers/IndexedDBDriver.test.ts`](../tests/src/browser/drivers/IndexedDBDriver.test.ts) — `IndexedDBDriver` against real IndexedDB: metadata-only bootstrap and deployed-schema-first reopen, one-transaction `has`/`get` absence discrimination, fail-closed malformed-metadata preservation (including stored `undefined`) and persisted-store loss with deterministic first-loss evidence, physical non-recreation, extra-store retention, external repair, and same-driver retry; bootstrap-lifetime store/version capture and a competing versionchange proving the persisted final open is pinned and retryable; payload-safe errors and table rejection, closed failed state, reserved-store validation, atomic `add` insertion and duplicate `CONFLICT`, active/pre-dispatch/late abort boundaries, strict direct records/stream paging, native narrow-then-refine queries, snapshot capture-replay, schema/rows/metadata in one versionchange migration, metadata-only migration input, failed-upgrade reconnection to the old schema, metadata persistence, backend-fault mapping, and confirmation that callback `transaction` / native `aggregate` are absent.
|
|
2510
|
+
- [`tests/src/browser/helpers.test.ts`](../tests/src/browser/helpers.test.ts) — the pushdown planner: `conditionToRange` over every comparison operator, `selectPlan` index/primary selection and lossless fallbacks, backend-error mapping, and exact `deriveIndexedDBIndexName` bytes.
|
|
2511
|
+
- [`tests/src/browser/factories.test.ts`](../tests/src/browser/factories.test.ts) — `createIndexedDBDriver` returns a working `DriverInterface` instance (a round-trip end to end).
|
|
2512
|
+
- [`tests/src/browser/integration.test.ts`](../tests/src/browser/integration.test.ts) — cross-backend behavioral parity: the same `QueryInput` set run against `MemoryDriver` and `IndexedDBDriver` produce identical rows/counts, including pushdown edge cases (`below`/`to` on a secondary-indexed column, a reversed `between`).
|
|
2513
|
+
|
|
2514
|
+
## See also
|
|
2515
|
+
|
|
2516
|
+
- [`contract.md`](contract.md) — the shape DSL and `createContract` a table is built on.
|
|
2517
|
+
- [`AGENTS.md`](../AGENTS.md) — the rules; see § Documentation contract.
|
|
2518
|
+
- [`README.md`](README.md) — the guides index.
|