@bjornpagen/bumbledb 0.1.0
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/LICENSE +12 -0
- package/README.md +84 -0
- package/dist/brand.d.ts +59 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +47 -0
- package/dist/brand.js.map +1 -0
- package/dist/closed.d.ts +97 -0
- package/dist/closed.d.ts.map +1 -0
- package/dist/closed.js +107 -0
- package/dist/closed.js.map +1 -0
- package/dist/count.d.ts +55 -0
- package/dist/count.d.ts.map +1 -0
- package/dist/count.js +92 -0
- package/dist/count.js.map +1 -0
- package/dist/db.d.ts +341 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +1016 -0
- package/dist/db.js.map +1 -0
- package/dist/exhume.d.ts +130 -0
- package/dist/exhume.d.ts.map +1 -0
- package/dist/exhume.js +145 -0
- package/dist/exhume.js.map +1 -0
- package/dist/face.d.ts +91 -0
- package/dist/face.d.ts.map +1 -0
- package/dist/face.js +69 -0
- package/dist/face.js.map +1 -0
- package/dist/fields.d.ts +187 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +204 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/lower.d.ts +34 -0
- package/dist/lower.d.ts.map +1 -0
- package/dist/lower.js +105 -0
- package/dist/lower.js.map +1 -0
- package/dist/marshal.d.ts +79 -0
- package/dist/marshal.d.ts.map +1 -0
- package/dist/marshal.js +167 -0
- package/dist/marshal.js.map +1 -0
- package/dist/native.d.ts +532 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +64 -0
- package/dist/native.js.map +1 -0
- package/dist/query/atom.d.ts +285 -0
- package/dist/query/atom.d.ts.map +1 -0
- package/dist/query/atom.js +281 -0
- package/dist/query/atom.js.map +1 -0
- package/dist/query/lower.d.ts +145 -0
- package/dist/query/lower.d.ts.map +1 -0
- package/dist/query/lower.js +604 -0
- package/dist/query/lower.js.map +1 -0
- package/dist/query/predicate.d.ts +101 -0
- package/dist/query/predicate.d.ts.map +1 -0
- package/dist/query/predicate.js +85 -0
- package/dist/query/predicate.js.map +1 -0
- package/dist/query/run.d.ts +30 -0
- package/dist/query/run.d.ts.map +1 -0
- package/dist/query/run.js +94 -0
- package/dist/query/run.js.map +1 -0
- package/dist/query/scope.d.ts +168 -0
- package/dist/query/scope.d.ts.map +1 -0
- package/dist/query/scope.js +134 -0
- package/dist/query/scope.js.map +1 -0
- package/dist/query/select.d.ts +106 -0
- package/dist/query/select.d.ts.map +1 -0
- package/dist/query/select.js +69 -0
- package/dist/query/select.js.map +1 -0
- package/dist/relation.d.ts +120 -0
- package/dist/relation.d.ts.map +1 -0
- package/dist/relation.js +108 -0
- package/dist/relation.js.map +1 -0
- package/dist/schema.d.ts +50 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +235 -0
- package/dist/schema.js.map +1 -0
- package/dist/spec.d.ts +218 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +154 -0
- package/dist/spec.js.map +1 -0
- package/dist/statements.d.ts +91 -0
- package/dist/statements.d.ts.map +1 -0
- package/dist/statements.js +101 -0
- package/dist/statements.js.map +1 -0
- package/package.json +66 -0
- package/src/brand.ts +82 -0
- package/src/closed.ts +228 -0
- package/src/count.ts +117 -0
- package/src/db.ts +1519 -0
- package/src/exhume.ts +243 -0
- package/src/face.ts +161 -0
- package/src/fields.ts +385 -0
- package/src/index.ts +185 -0
- package/src/lower.ts +118 -0
- package/src/marshal.ts +220 -0
- package/src/native.ts +576 -0
- package/src/query/atom.ts +556 -0
- package/src/query/lower.ts +855 -0
- package/src/query/predicate.ts +195 -0
- package/src/query/run.ts +106 -0
- package/src/query/scope.ts +301 -0
- package/src/query/select.ts +140 -0
- package/src/relation.ts +252 -0
- package/src/schema.ts +297 -0
- package/src/spec.ts +325 -0
- package/src/statements.ts +148 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,1519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Db` — the living half of the SDK (PRD-07): open/create a store from a
|
|
3
|
+
* `Schema`, write typed facts through delta transactions with race-free
|
|
4
|
+
* final-state point reads, receive rejections as typed violation VALUES
|
|
5
|
+
* keyed to statements, read through scoped snapshots, and run the witnessed
|
|
6
|
+
* read-compute-write loop — all typed by the schema's relations record.
|
|
7
|
+
*
|
|
8
|
+
* ZERO CLOSABLES: no value this module returns carries a close, dispose, or
|
|
9
|
+
* release spelling. `Db` values are CACHED per canonical path for the life
|
|
10
|
+
* of the process (a best-effort exit hook closes the cached environments;
|
|
11
|
+
* correctness never depends on it — the engine fsyncs every commit, so a
|
|
12
|
+
* process that dies without the hook loses nothing that was committed).
|
|
13
|
+
* Snapshots are internal: `read(fn)` opens one before `fn` and closes it
|
|
14
|
+
* after unconditionally, and the {@link ReadScope} handed to `fn` is
|
|
15
|
+
* invalidated the moment `fn` returns. Prepared plans are plain values whose
|
|
16
|
+
* engine-side half is reclaimed by a GC finalizer — reclamation only, never
|
|
17
|
+
* correctness.
|
|
18
|
+
*
|
|
19
|
+
* PROCESS MODEL: one process, one exclusive-lock handle per store. The
|
|
20
|
+
* cached `Db` value owns the LMDB environment's exclusive lock until
|
|
21
|
+
* process exit; a second engine-level open of the same store (an aliased
|
|
22
|
+
* path spelling, or another process) is refused by the engine. The
|
|
23
|
+
* run-store process model (PRD-16) depends on this being true: resume =
|
|
24
|
+
* reopen, which is either this process's cached value or a fresh process's
|
|
25
|
+
* open.
|
|
26
|
+
*
|
|
27
|
+
* REJECTION IS DATA: a rejected commit is a domain outcome (it becomes the
|
|
28
|
+
* LLM repair prompt downstream), returned as a {@link WriteResult} carrying
|
|
29
|
+
* {@link Violation} values. Genuine failures — I/O, used-after-scope,
|
|
30
|
+
* marshal shape — throw `@superbuilders/errors` wrapped errors instead.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as path from "node:path"
|
|
34
|
+
import * as errors from "@superbuilders/errors"
|
|
35
|
+
import { phantom } from "#brand.ts"
|
|
36
|
+
import type { Exhumed } from "#exhume.ts"
|
|
37
|
+
import { exhumeStore } from "#exhume.ts"
|
|
38
|
+
import { lower } from "#lower.ts"
|
|
39
|
+
import { factOf, isMintedFresh, type KeyFact, keyRowOf, type Minted, recordOf, rowOf } from "#marshal.ts"
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
DbHandle,
|
|
43
|
+
FactValue,
|
|
44
|
+
Manifest,
|
|
45
|
+
PreparedHandle,
|
|
46
|
+
SnapshotHandle,
|
|
47
|
+
Staleness,
|
|
48
|
+
StatementKindTag,
|
|
49
|
+
TxHandle,
|
|
50
|
+
Violation as WireViolation,
|
|
51
|
+
ViolationFact as WireViolationFact
|
|
52
|
+
} from "#native.ts"
|
|
53
|
+
import { native } from "#native.ts"
|
|
54
|
+
import type { Query, SelectColumn } from "#query/lower.ts"
|
|
55
|
+
import { lowerQuery } from "#query/lower.ts"
|
|
56
|
+
import { decodeAnswers, wireParams } from "#query/run.ts"
|
|
57
|
+
import type { ParamEntry, ParamsRecord } from "#query/scope.ts"
|
|
58
|
+
import type { AnyRelation, Fact, InsertFact } from "#relation.ts"
|
|
59
|
+
import type { AnySchema, Schema, SchemaRelation, SchemaRelations } from "#schema.ts"
|
|
60
|
+
import type { KeyStatement, Statement } from "#statements.ts"
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The ordinary (writable, scannable) relations of a schema's record — the
|
|
64
|
+
* only values the runtime methods accept: closed relations lack the
|
|
65
|
+
* relation shape entirely, so passing one is a type error.
|
|
66
|
+
*/
|
|
67
|
+
type MemberRelation<Rels extends SchemaRelations> = Extract<Rels[keyof Rels], AnyRelation>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The key object of a key-statement-selected `get`: exactly the selected
|
|
71
|
+
* `key()` statement's projection fields, each carrying the relation's own
|
|
72
|
+
* branded value type — the {@link KeyFact} rule generalized from the
|
|
73
|
+
* primary key to ANY declared key statement.
|
|
74
|
+
*/
|
|
75
|
+
type DeclaredKeyFact<R extends AnyRelation, Projection extends readonly string[]> = {
|
|
76
|
+
readonly [K in Projection[number] & keyof Fact<R>]: Fact<R>[K]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One offending fact of a violation: the cited relation's name (a member
|
|
81
|
+
* of the schema's record) and the fact decoded to a named natural-value
|
|
82
|
+
* object — partial exactly as the engine cites it.
|
|
83
|
+
*/
|
|
84
|
+
interface OffendingFact<Rels extends SchemaRelations> {
|
|
85
|
+
readonly relation: keyof Rels & string
|
|
86
|
+
readonly fact: Readonly<Record<string, FactValue>>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One violated statement of a rejected commit, as a typed value.
|
|
91
|
+
* `statement` is the IDENTICAL SDK statement value the schema declared —
|
|
92
|
+
* consumers `===`-match it against their own constants; it is `undefined`
|
|
93
|
+
* exactly for the engine-materialized fresh-implied and closed auto-keys,
|
|
94
|
+
* which have no declared spelling (`schema()` rejects an explicit
|
|
95
|
+
* duplicate of them). `canonical` is the ENGINE's rendering of the
|
|
96
|
+
* violated materialized statement — for a `mirrors` statement BOTH
|
|
97
|
+
* materialized slots render as the one `==` utterance in the written
|
|
98
|
+
* orientation (identical strings; the engine's `render.rs` renders each
|
|
99
|
+
* partner of a mirrored pair as the `==` spelling, never a bare `<=`
|
|
100
|
+
* direction). `direction` (`sourceUnsatisfied` | `targetRequired`) and
|
|
101
|
+
* `count` are the containment/window form payloads, passed through from
|
|
102
|
+
* the engine VERBATIM — `direction` is relative to the violated SLOT's
|
|
103
|
+
* own orientation, so for a `mirrors` statement it alone cannot say which
|
|
104
|
+
* side of the `==` was violated: the slot identity is carried by
|
|
105
|
+
* `orientation`, present exactly for `mirrors` slots — `written` is the
|
|
106
|
+
* `source <= target` slot as the statement was spelled, `mirrored` the
|
|
107
|
+
* engine-materialized `target <= source` partner.
|
|
108
|
+
*/
|
|
109
|
+
interface Violation<Rels extends SchemaRelations> {
|
|
110
|
+
readonly statement: Statement | undefined
|
|
111
|
+
readonly kind: StatementKindTag
|
|
112
|
+
readonly canonical: string
|
|
113
|
+
readonly direction?: "sourceUnsatisfied" | "targetRequired"
|
|
114
|
+
readonly orientation?: "written" | "mirrored"
|
|
115
|
+
readonly count?: bigint
|
|
116
|
+
readonly facts: readonly OffendingFact<Rels>[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A commit's domain outcome: the committed generation, or the COMPLETE
|
|
121
|
+
* violation set (every violated statement cited once, per direction for a
|
|
122
|
+
* containment, in materialized statement order). Narrows on `.ok`.
|
|
123
|
+
*/
|
|
124
|
+
type WriteResult<Rels extends SchemaRelations> =
|
|
125
|
+
| { readonly ok: true; readonly generation: bigint }
|
|
126
|
+
| { readonly ok: false; readonly violations: readonly Violation<Rels>[] }
|
|
127
|
+
|
|
128
|
+
/** The delta-building callback of a write: runs synchronously against the live transaction. */
|
|
129
|
+
type DeltaBuild<Rels extends SchemaRelations> = (tx: Tx<Rels>) => void
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The runtime discriminant of {@link Abandon} values — a property probe is
|
|
133
|
+
* how `writeWitnessed` distinguishes "abort without committing" from an
|
|
134
|
+
* ordinary callback result, never a guess about the host's own value shapes.
|
|
135
|
+
*/
|
|
136
|
+
const abandonMark: unique symbol = Symbol("bumbledb.abandon")
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The abandon sentinel {@link abandon} builds: returning one from a
|
|
140
|
+
* `writeWitnessed` callback aborts the attempt WITHOUT committing (no empty
|
|
141
|
+
* commit is ever issued) and surfaces the payload as
|
|
142
|
+
* `{ ok: false, abandoned: payload }`.
|
|
143
|
+
*/
|
|
144
|
+
interface Abandon<P> {
|
|
145
|
+
readonly [abandonMark]: true
|
|
146
|
+
readonly payload: P
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wraps a payload in the {@link Abandon} sentinel — the one way a
|
|
151
|
+
* `writeWitnessed` callback declines to commit: `return abandon(payload)`
|
|
152
|
+
* aborts the delta (nothing is committed, not even an empty commit) and the
|
|
153
|
+
* write resolves to `{ ok: false, abandoned: payload }`.
|
|
154
|
+
*/
|
|
155
|
+
function abandon<P>(payload: P): Abandon<P> {
|
|
156
|
+
return Object.freeze({ [abandonMark]: true as const, payload })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The abandon payload type a `writeWitnessed` callback's return type
|
|
161
|
+
* implies: the payload of its `Abandon` arm, `never` when the callback can
|
|
162
|
+
* never abandon (the `abandoned` outcome is then statically unreachable).
|
|
163
|
+
*/
|
|
164
|
+
type AbandonedPayload<R> = R extends Abandon<infer P> ? P : never
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Narrows a `writeWitnessed` callback result to the abandon sentinel. The
|
|
168
|
+
* probe is the private {@link abandonMark} symbol only {@link abandon} sets,
|
|
169
|
+
* and `R`'s `Abandon` arm is the only way a sentinel can flow out of the
|
|
170
|
+
* callback — so the narrowed payload type is sound by construction.
|
|
171
|
+
*/
|
|
172
|
+
function isAbandon<R>(value: R): value is R & Abandon<AbandonedPayload<R>> {
|
|
173
|
+
return typeof value === "object" && value !== null && abandonMark in value
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `writeWitnessed`'s domain outcome: the committed generation, the COMPLETE
|
|
178
|
+
* engine violation set (rejection-as-data, exactly {@link WriteResult}'s
|
|
179
|
+
* false arm), or the callback's own abandon payload. Narrows on `.ok`, then
|
|
180
|
+
* on `"violations" in result`.
|
|
181
|
+
*/
|
|
182
|
+
type WitnessedWriteResult<Rels extends SchemaRelations, R> =
|
|
183
|
+
| { readonly ok: true; readonly generation: bigint }
|
|
184
|
+
| { readonly ok: false; readonly violations: readonly Violation<Rels>[] }
|
|
185
|
+
| { readonly ok: false; readonly abandoned: AbandonedPayload<R> }
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* One live write transaction: the submitted delta with the engine's
|
|
189
|
+
* FINAL-STATE point-read view (base + pending delta — the exact state the
|
|
190
|
+
* commit judgment judges, so check-then-act is race-free by construction).
|
|
191
|
+
* Spent when its owning `write`/`writeWitnessed` call resolves the attempt;
|
|
192
|
+
* any later use throws.
|
|
193
|
+
*/
|
|
194
|
+
interface Tx<Rels extends SchemaRelations> {
|
|
195
|
+
/**
|
|
196
|
+
* Records one insert. Omitted fresh fields are MINTED through the
|
|
197
|
+
* engine's alloc lane and returned branded; supplying them instead
|
|
198
|
+
* preserves identity (the resupply idiom). Returns the relation's
|
|
199
|
+
* fresh cells, minted or resupplied.
|
|
200
|
+
*/
|
|
201
|
+
insert<R extends MemberRelation<Rels>>(relation: R, fact: InsertFact<R>): Minted<R>
|
|
202
|
+
/** Records one delete; `true` iff the final state changed. */
|
|
203
|
+
delete<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
|
|
204
|
+
/** Final-state membership of one complete fact. */
|
|
205
|
+
contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
|
|
206
|
+
/**
|
|
207
|
+
* Final-state point lookup through the relation's primary key (the
|
|
208
|
+
* {@link KeyFact} rule); `undefined` on a miss.
|
|
209
|
+
*/
|
|
210
|
+
get<R extends MemberRelation<Rels>>(relation: R, key: KeyFact<R>): Fact<R> | undefined
|
|
211
|
+
/**
|
|
212
|
+
* Final-state point lookup through a DECLARED `key()` statement of this
|
|
213
|
+
* schema — the key object is typed by the statement's own projection;
|
|
214
|
+
* `undefined` on a miss.
|
|
215
|
+
*/
|
|
216
|
+
get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
217
|
+
relation: R,
|
|
218
|
+
keyStatement: KeyStatement<R, P>,
|
|
219
|
+
key: DeclaredKeyFact<R, P>
|
|
220
|
+
): Fact<R> | undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The read view one `db.read(fn)` call scopes: an MVCC snapshot pinned at
|
|
225
|
+
* its generation, valid EXACTLY for the synchronous extent of `fn`. The
|
|
226
|
+
* value is invalidated when `fn` returns — every later verb call throws a
|
|
227
|
+
* typed used-after-scope error; the underlying snapshot (and its LMDB
|
|
228
|
+
* reader slot) is already closed. No close spelling exists here because
|
|
229
|
+
* there is nothing the host could ever need to close.
|
|
230
|
+
*/
|
|
231
|
+
interface ReadScope<Rels extends SchemaRelations> {
|
|
232
|
+
/**
|
|
233
|
+
* The committed generation this scope witnessed — captured atomically
|
|
234
|
+
* with the snapshot: writes are synchronous and this process holds the
|
|
235
|
+
* store's only write handle, so nothing can commit between the snapshot
|
|
236
|
+
* open and the generation read.
|
|
237
|
+
*/
|
|
238
|
+
readonly generation: bigint
|
|
239
|
+
/** Full-relation export in row-id order, decoded to branded facts. */
|
|
240
|
+
scan<R extends MemberRelation<Rels>>(relation: R): Fact<R>[]
|
|
241
|
+
/**
|
|
242
|
+
* Committed-state point lookup through the relation's primary key
|
|
243
|
+
* (the {@link KeyFact} rule); `undefined` on a miss.
|
|
244
|
+
*/
|
|
245
|
+
get<R extends MemberRelation<Rels>>(relation: R, key: KeyFact<R>): Fact<R> | undefined
|
|
246
|
+
/**
|
|
247
|
+
* Committed-state point lookup through a DECLARED `key()` statement of
|
|
248
|
+
* this schema — the key object is typed by the statement's own
|
|
249
|
+
* projection; `undefined` on a miss.
|
|
250
|
+
*/
|
|
251
|
+
get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
252
|
+
relation: R,
|
|
253
|
+
keyStatement: KeyStatement<R, P>,
|
|
254
|
+
key: DeclaredKeyFact<R, P>
|
|
255
|
+
): Fact<R> | undefined
|
|
256
|
+
/** Committed-state membership of one complete fact. */
|
|
257
|
+
contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
|
|
258
|
+
/**
|
|
259
|
+
* Executes a prepared query against this scope's snapshot with the
|
|
260
|
+
* typed params object; returns the answer SET as plain rows with
|
|
261
|
+
* branded values (no order — the host sorts). This is the ONE
|
|
262
|
+
* execution spelling ({@link Prepared} carries no `execute`).
|
|
263
|
+
*/
|
|
264
|
+
execute<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Row[]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* One prepared query as a plain VALUE: explicit visible compilation
|
|
269
|
+
* (`db.prepare(q)` lowers, pins the plan, and surfaces every engine roster
|
|
270
|
+
* refusal), no lifecycle. Execution happens ONLY through
|
|
271
|
+
* `snap.execute(prepared, params)` / `db.execute(prepared, params)` — the
|
|
272
|
+
* symmetry rule's one spelling. The engine-side plan is reclaimed by a GC
|
|
273
|
+
* finalizer when this value becomes unreachable (reclamation only, never
|
|
274
|
+
* correctness — an unreclaimed plan is idle memory, and process exit frees
|
|
275
|
+
* everything).
|
|
276
|
+
*/
|
|
277
|
+
interface Prepared<Rels extends SchemaRelations, Row, Params extends ParamsRecord> {
|
|
278
|
+
/**
|
|
279
|
+
* The pull-based plan-drift report against a read scope's snapshot —
|
|
280
|
+
* engine-policy-free: no threshold exists engine-side; the host owns
|
|
281
|
+
* re-prepare.
|
|
282
|
+
*/
|
|
283
|
+
staleness(snap: ReadScope<Rels>): Staleness
|
|
284
|
+
readonly [phantom]?: { readonly row: Row; readonly params: Params }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* An open store, cached per canonical path for the life of the process.
|
|
289
|
+
* There is no close: read through `read`/the read sugar, write through
|
|
290
|
+
* `write`/`writeWitnessed`, and let the process own the environment's
|
|
291
|
+
* lifetime (the engine fsyncs every commit, so durability never waits on a
|
|
292
|
+
* close).
|
|
293
|
+
*/
|
|
294
|
+
interface Db<Rels extends SchemaRelations> {
|
|
295
|
+
/** The theory this store was opened with (fingerprint-verified by the engine). */
|
|
296
|
+
readonly schema: Schema<Rels>
|
|
297
|
+
/**
|
|
298
|
+
* One scoped snapshot read: opens an MVCC snapshot, runs `fn`
|
|
299
|
+
* SYNCHRONOUSLY against it, and closes the snapshot unconditionally
|
|
300
|
+
* before returning `fn`'s result. The {@link ReadScope} is invalidated
|
|
301
|
+
* when `fn` returns — a used-after-scope call throws a typed error.
|
|
302
|
+
*/
|
|
303
|
+
read<T>(fn: (snap: ReadScope<Rels>) => T): T
|
|
304
|
+
/** `db.scan(r)` === `db.read(snap => snap.scan(r))` — the symmetry rule. */
|
|
305
|
+
scan<R extends MemberRelation<Rels>>(relation: R): Fact<R>[]
|
|
306
|
+
/** `db.get(r, k)` === `db.read(snap => snap.get(r, k))` — the symmetry rule. */
|
|
307
|
+
get<R extends MemberRelation<Rels>>(relation: R, key: KeyFact<R>): Fact<R> | undefined
|
|
308
|
+
/** `db.get(r, s, k)` === `db.read(snap => snap.get(r, s, k))` — the symmetry rule, keyed form. */
|
|
309
|
+
get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
310
|
+
relation: R,
|
|
311
|
+
keyStatement: KeyStatement<R, P>,
|
|
312
|
+
key: DeclaredKeyFact<R, P>
|
|
313
|
+
): Fact<R> | undefined
|
|
314
|
+
/** `db.contains(r, f)` === `db.read(snap => snap.contains(r, f))` — the symmetry rule. */
|
|
315
|
+
contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
|
|
316
|
+
/** `db.execute(p, params)` === `db.read(snap => snap.execute(p, params))` — the symmetry rule. */
|
|
317
|
+
execute<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Row[]
|
|
318
|
+
/**
|
|
319
|
+
* One delta transaction: builds the delta synchronously through `fn`,
|
|
320
|
+
* commits, and returns the domain outcome. A throw from `fn` aborts
|
|
321
|
+
* the delta (LMDB untouched) and rethrows wrapped.
|
|
322
|
+
*/
|
|
323
|
+
write(fn: DeltaBuild<Rels>): WriteResult<Rels>
|
|
324
|
+
/**
|
|
325
|
+
* The ONE witnessed-write form: snapshot → `fn` (premise reads via
|
|
326
|
+
* `snap`, delta via `tx`) → witnessed commit, which lands only if no
|
|
327
|
+
* state-changing commit intervened since the snapshot. On a moved
|
|
328
|
+
* generation the WHOLE `fn` reruns on a fresh snapshot, unbounded: this
|
|
329
|
+
* process holds the store's only write handle, so every generation move
|
|
330
|
+
* is self-inflicted by the host's own interleaved writes — contention
|
|
331
|
+
* is bounded by the host's own progress, and any retry cap would be an
|
|
332
|
+
* invented limit (the house no-limits law). `fn` may decline to commit
|
|
333
|
+
* by returning {@link abandon}`(payload)` — the outcome is then
|
|
334
|
+
* `{ ok: false, abandoned: payload }` and NO commit (not even an empty
|
|
335
|
+
* one) is issued.
|
|
336
|
+
*/
|
|
337
|
+
writeWitnessed<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WitnessedWriteResult<Rels, R>
|
|
338
|
+
/**
|
|
339
|
+
* Prepares a query value built against THIS schema (identity is the
|
|
340
|
+
* membership rule): lowers it to the engine IR, pins the plan, and
|
|
341
|
+
* returns the typed {@link Prepared} value. Every IR roster refusal —
|
|
342
|
+
* rule caps, strata legality, type rules — is the ENGINE's typed
|
|
343
|
+
* judgment and throws here carrying its message intact.
|
|
344
|
+
*/
|
|
345
|
+
prepare<Row, Params extends ParamsRecord>(q: Query<Rels, Row, Params>): Prepared<Rels, Row, Params>
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The bridge guard: runs one native call and wraps anything it throws —
|
|
350
|
+
* marshal-shape refusals and handle-lifecycle refusals cross as genuine
|
|
351
|
+
* typed failures, never bare foreign errors.
|
|
352
|
+
*/
|
|
353
|
+
function bridged<T>(context: string, run: () => T): T {
|
|
354
|
+
const result = errors.trySync(run)
|
|
355
|
+
if (result.error) {
|
|
356
|
+
throw errors.wrap(result.error, context)
|
|
357
|
+
}
|
|
358
|
+
return result.data
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** One relation's runtime tables: engine id, the identical schema member, field ids, primary key. */
|
|
362
|
+
interface RelationEntry {
|
|
363
|
+
readonly id: number
|
|
364
|
+
readonly member: SchemaRelation
|
|
365
|
+
readonly fieldIds: ReadonlyMap<string, number>
|
|
366
|
+
readonly primaryKey: PrimaryKey | undefined
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** One relation's primary candidate key: its materialized statement id and projection. */
|
|
370
|
+
interface PrimaryKey {
|
|
371
|
+
readonly statementId: number
|
|
372
|
+
readonly projection: readonly string[]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* One materialized-statement slot as the SDK mirrors it: the form tag, the
|
|
377
|
+
* SDK statement value that lowered to it (`undefined` for the
|
|
378
|
+
* engine-materialized implied keys), and — for functionality forms — the
|
|
379
|
+
* key's owner and projection (what keyed point reads resolve through).
|
|
380
|
+
*/
|
|
381
|
+
interface StatementEntry {
|
|
382
|
+
readonly kind: StatementKindTag
|
|
383
|
+
readonly statement: Statement | undefined
|
|
384
|
+
readonly key: { readonly owner: string; readonly projection: readonly string[] } | undefined
|
|
385
|
+
/**
|
|
386
|
+
* The slot's orientation relative to the written statement — set exactly
|
|
387
|
+
* for the two slots of a `mirrors` (`false` = the written `source <=
|
|
388
|
+
* target`, `true` = the materialized `target <= source` partner), so a
|
|
389
|
+
* violation can say WHICH side of the `==` was violated.
|
|
390
|
+
*/
|
|
391
|
+
readonly reversed?: boolean
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Mirrors the engine's materialized statement order
|
|
396
|
+
* (`SchemaDescriptor::materialized_statements`, pinned by the fingerprint):
|
|
397
|
+
* one auto-key per fresh field (relation declaration order, then field
|
|
398
|
+
* order), one closed auto-key per closed relation (declaration order),
|
|
399
|
+
* then the declared statements in declaration order — a `mirrors`
|
|
400
|
+
* statement occupying TWO adjacent slots (the engine lowers `==` to two
|
|
401
|
+
* containments, `source <= target` first), both owned by the one SDK
|
|
402
|
+
* value. This positional match is how statement ids resolve back to SDK
|
|
403
|
+
* statement values without the engine ever learning a wire format.
|
|
404
|
+
*/
|
|
405
|
+
function materializedEntries(theory: AnySchema): StatementEntry[] {
|
|
406
|
+
const entries = impliedKeyEntries(theory)
|
|
407
|
+
for (const statement of theory.statements) {
|
|
408
|
+
entries.push(...declaredEntries(statement))
|
|
409
|
+
}
|
|
410
|
+
return entries
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The engine-materialized implied keys, in the engine's pinned order: one
|
|
415
|
+
* auto-key per fresh field (relation declaration order, then field order),
|
|
416
|
+
* then one closed auto-key `R(id) -> R` per closed relation (declaration
|
|
417
|
+
* order). These slots carry no SDK statement value — the engine owns them
|
|
418
|
+
* (`schema()` rejects an explicit duplicate).
|
|
419
|
+
*/
|
|
420
|
+
function impliedKeyEntries(theory: AnySchema): StatementEntry[] {
|
|
421
|
+
const entries: StatementEntry[] = []
|
|
422
|
+
for (const member of Object.values(theory.relations)) {
|
|
423
|
+
if ("handles" in member.data) {
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
for (const declared of member.data.fields) {
|
|
427
|
+
if (declared.field.minted) {
|
|
428
|
+
entries.push({
|
|
429
|
+
kind: "functionality",
|
|
430
|
+
statement: undefined,
|
|
431
|
+
key: { owner: member.name, projection: [declared.name] }
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const member of Object.values(theory.relations)) {
|
|
437
|
+
if ("handles" in member.data) {
|
|
438
|
+
entries.push({
|
|
439
|
+
kind: "functionality",
|
|
440
|
+
statement: undefined,
|
|
441
|
+
key: { owner: member.name, projection: ["id"] }
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return entries
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* One declared statement's materialized slots: a key or window occupies
|
|
450
|
+
* one, a `mirrors` occupies two adjacent slots (the engine lowers `==` to
|
|
451
|
+
* two containments, `source <= target` first), both owned by the one SDK
|
|
452
|
+
* value.
|
|
453
|
+
*/
|
|
454
|
+
function declaredEntries(statement: Statement): StatementEntry[] {
|
|
455
|
+
const data = statement.data
|
|
456
|
+
switch (data.kind) {
|
|
457
|
+
case "key": {
|
|
458
|
+
return [
|
|
459
|
+
{
|
|
460
|
+
kind: "functionality",
|
|
461
|
+
statement,
|
|
462
|
+
key: { owner: data.owner.name, projection: data.projection }
|
|
463
|
+
}
|
|
464
|
+
]
|
|
465
|
+
}
|
|
466
|
+
case "containment": {
|
|
467
|
+
if (data.bidirectional) {
|
|
468
|
+
return [
|
|
469
|
+
{ kind: "containment", statement, key: undefined, reversed: false },
|
|
470
|
+
{ kind: "containment", statement, key: undefined, reversed: true }
|
|
471
|
+
]
|
|
472
|
+
}
|
|
473
|
+
return [{ kind: "containment", statement, key: undefined }]
|
|
474
|
+
}
|
|
475
|
+
case "window": {
|
|
476
|
+
return [{ kind: "cardinality", statement, key: undefined }]
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Narrows a callback result to a thenable — the async-callback probe both
|
|
483
|
+
* commit sites share: an `async` build callback typechecks (`Promise<void>`
|
|
484
|
+
* is assignable where a `void` return is expected), so the refusal has to
|
|
485
|
+
* be a runtime probe on the returned value.
|
|
486
|
+
*/
|
|
487
|
+
function isThenable(value: unknown): boolean {
|
|
488
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function"
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Narrows a keyed-get middle argument to a statement value (vs a key
|
|
493
|
+
* object): only statement values carry `data.kind` — no fact cell shape
|
|
494
|
+
* (bool, bigint, string, bytes, `{ start, end }`) ever does, so the probe
|
|
495
|
+
* never misreads a key object whose relation declares a field named `data`.
|
|
496
|
+
*/
|
|
497
|
+
function isStatementValue<R extends AnyRelation, P extends readonly string[]>(
|
|
498
|
+
value: KeyFact<R> | KeyStatement<R, P>
|
|
499
|
+
): value is KeyStatement<R, P> {
|
|
500
|
+
if (typeof value !== "object" || !("data" in value)) {
|
|
501
|
+
return false
|
|
502
|
+
}
|
|
503
|
+
const data: unknown = value.data
|
|
504
|
+
return typeof data === "object" && data !== null && "kind" in data
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Maps a slot's reversal flag to the violation's `orientation` payload. */
|
|
508
|
+
function orientationOf(reversed: boolean | undefined): "written" | "mirrored" | undefined {
|
|
509
|
+
if (reversed === undefined) {
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
512
|
+
if (reversed) {
|
|
513
|
+
return "mirrored"
|
|
514
|
+
}
|
|
515
|
+
return "written"
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The id-resolution tables one open builds: relation entries by name, statement slots by id. */
|
|
519
|
+
interface Tables {
|
|
520
|
+
readonly relations: ReadonlyMap<string, RelationEntry>
|
|
521
|
+
readonly statements: readonly StatementEntry[]
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Builds the id-resolution tables from the manifest, verifying the SDK's
|
|
526
|
+
* positional mirror against the engine's reported order — any drift
|
|
527
|
+
* (count, kind, id, or membership) is a construction-time failure, never a
|
|
528
|
+
* silent misattribution of a violation to the wrong statement value.
|
|
529
|
+
*/
|
|
530
|
+
function tablesOf(theory: AnySchema, manifest: Manifest): Tables {
|
|
531
|
+
const entries = materializedEntries(theory)
|
|
532
|
+
if (entries.length !== manifest.statements.length) {
|
|
533
|
+
throw errors.new(
|
|
534
|
+
`bumbledb manifest drift: the SDK lowering yields ${entries.length} materialized statements, the engine reports ${manifest.statements.length}`
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
manifest.statements.forEach(function verifySlot(statement, index) {
|
|
538
|
+
const entry = entries[index]
|
|
539
|
+
if (entry === undefined || statement.id !== index || entry.kind !== statement.kind) {
|
|
540
|
+
throw errors.new(
|
|
541
|
+
`bumbledb manifest drift: statement ${statement.id} is ${statement.kind}, the SDK mirror at ${index} expected ${entry?.kind}`
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
const relations = new Map<string, RelationEntry>()
|
|
546
|
+
for (const relation of manifest.relations) {
|
|
547
|
+
const member = theory.relations[relation.name]
|
|
548
|
+
if (member === undefined) {
|
|
549
|
+
throw errors.new(`bumbledb manifest drift: relation ${relation.name} is not in schema ${theory.name}`)
|
|
550
|
+
}
|
|
551
|
+
const fieldIds = new Map<string, number>()
|
|
552
|
+
for (const field of relation.fields) {
|
|
553
|
+
fieldIds.set(field.name, field.id)
|
|
554
|
+
}
|
|
555
|
+
let primaryKey: PrimaryKey | undefined
|
|
556
|
+
entries.forEach(function firstOwnedKey(entry, index) {
|
|
557
|
+
if (primaryKey === undefined && entry.key !== undefined && entry.key.owner === relation.name) {
|
|
558
|
+
primaryKey = Object.freeze({ statementId: index, projection: entry.key.projection })
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
relations.set(relation.name, Object.freeze({ id: relation.id, member, fieldIds, primaryKey }))
|
|
562
|
+
}
|
|
563
|
+
for (const name of Object.keys(theory.relations)) {
|
|
564
|
+
if (!relations.has(name)) {
|
|
565
|
+
throw errors.new(`bumbledb manifest drift: schema relation ${name} is not in the manifest`)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return Object.freeze({ relations, statements: Object.freeze(entries) })
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The point-read half a transaction and a read scope share, over their own handle. */
|
|
572
|
+
interface PointReads {
|
|
573
|
+
contains(relationId: number, row: readonly FactValue[]): boolean
|
|
574
|
+
get(relationId: number, statementId: number, key: readonly FactValue[]): FactValue[] | null
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* One read scope's PRIVATE lifetime record: its live snapshot handle, its
|
|
579
|
+
* liveness flag (flipped exactly when the owning `read`/`writeWitnessed`
|
|
580
|
+
* callback returns), and its owning store's identity token. Held in
|
|
581
|
+
* {@link scopeStates} — the snapshot handle is never a public value.
|
|
582
|
+
*/
|
|
583
|
+
interface ScopeState {
|
|
584
|
+
readonly handle: SnapshotHandle
|
|
585
|
+
live: boolean
|
|
586
|
+
readonly owner: object
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** The private lifetime records of this module's read scopes. */
|
|
590
|
+
const scopeStates = new WeakMap<object, ScopeState>()
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* One prepared value's PRIVATE engine half: the pinned plan handle, the
|
|
594
|
+
* owning store's identity token, and the query's marshaling tables (params
|
|
595
|
+
* in declaration order, select columns in head order). Held in
|
|
596
|
+
* {@link preparedPlans} — the plan handle is never a public value.
|
|
597
|
+
*/
|
|
598
|
+
interface PreparedPlan {
|
|
599
|
+
readonly handle: PreparedHandle
|
|
600
|
+
readonly owner: object
|
|
601
|
+
readonly params: readonly ParamEntry[]
|
|
602
|
+
readonly select: readonly SelectColumn[]
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The private engine halves of this module's prepared values. */
|
|
606
|
+
const preparedPlans = new WeakMap<object, PreparedPlan>()
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Reclaims the engine-side plan of a garbage-collected {@link Prepared}
|
|
610
|
+
* value. RECLAMATION ONLY, never correctness: a plan the collector never
|
|
611
|
+
* visits is idle engine memory until process exit, and a failure to close
|
|
612
|
+
* is swallowed (there is no one left to care — the owning value is gone).
|
|
613
|
+
*/
|
|
614
|
+
const planReclaimer = new FinalizationRegistry<PreparedHandle>(function reclaimPlan(handle) {
|
|
615
|
+
const closed = errors.trySync(function closePlan() {
|
|
616
|
+
native.preparedClose(handle)
|
|
617
|
+
})
|
|
618
|
+
if (closed.error) {
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The internal retry signal a lazily-witnessed transaction throws when the
|
|
625
|
+
* engine reports a moved generation at begin: `writeWitnessed` catches it
|
|
626
|
+
* by identity (through cause chains, via `errors.is`) and reruns the whole
|
|
627
|
+
* callback on a fresh snapshot. It never escapes the SDK.
|
|
628
|
+
*/
|
|
629
|
+
const generationMovedSignal = errors.new("bumbledb witnessed generation moved")
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Fills one insert's omitted fresh cells through the engine's
|
|
633
|
+
* alloc-then-insert dyn lane (there is no insert-with-omitted-fields wire
|
|
634
|
+
* spelling) and collects every fresh cell — minted or resupplied — for the
|
|
635
|
+
* insert's return. Mutates `values` in place with the minted cells.
|
|
636
|
+
*/
|
|
637
|
+
function mintFreshCells(
|
|
638
|
+
txHandle: TxHandle,
|
|
639
|
+
entry: RelationEntry,
|
|
640
|
+
relation: AnyRelation,
|
|
641
|
+
values: Record<string, unknown>
|
|
642
|
+
): Record<string, FactValue> {
|
|
643
|
+
const fresh: Record<string, FactValue> = {}
|
|
644
|
+
for (const declared of relation.data.fields) {
|
|
645
|
+
if (!declared.field.minted) {
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
let cell = values[declared.name]
|
|
649
|
+
if (cell === undefined) {
|
|
650
|
+
const fieldId = entry.fieldIds.get(declared.name)
|
|
651
|
+
if (fieldId === undefined) {
|
|
652
|
+
throw errors.new(`bumbledb manifest drift: relation ${relation.name} has no field id for ${declared.name}`)
|
|
653
|
+
}
|
|
654
|
+
cell = bridged("bumbledb tx alloc", function mint() {
|
|
655
|
+
return native.txAlloc(txHandle, entry.id, fieldId)
|
|
656
|
+
})
|
|
657
|
+
values[declared.name] = cell
|
|
658
|
+
}
|
|
659
|
+
if (typeof cell !== "bigint") {
|
|
660
|
+
throw errors.new(
|
|
661
|
+
`relation ${relation.name} field ${declared.name}: a fresh cell is a u64 bigint, got ${typeof cell}`
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
fresh[declared.name] = cell
|
|
665
|
+
}
|
|
666
|
+
return fresh
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Constructs one open `Db` over an already-admitted handle: builds the
|
|
671
|
+
* id-resolution tables once and closes over them — the `Db` owns handle
|
|
672
|
+
* and tables and nothing else. Handle lifetime is the process's: the store
|
|
673
|
+
* cache holds the environment handle until the exit hook closes it.
|
|
674
|
+
*/
|
|
675
|
+
function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<Rels>, manifest: Manifest): Db<Rels> {
|
|
676
|
+
const tables = tablesOf(theory, manifest)
|
|
677
|
+
/** This store's identity token: read scopes and prepared values carry it, so cross-store use is a typed refusal. */
|
|
678
|
+
const owner = Object.freeze({})
|
|
679
|
+
|
|
680
|
+
function isMemberName(name: string): name is keyof Rels & string {
|
|
681
|
+
return tables.relations.has(name)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function resolveOrdinary(relation: AnyRelation): RelationEntry {
|
|
685
|
+
const entry = tables.relations.get(relation.name)
|
|
686
|
+
if (entry === undefined || entry.member !== relation) {
|
|
687
|
+
throw errors.new(`relation ${relation.name} is not a member of schema ${theory.name}`)
|
|
688
|
+
}
|
|
689
|
+
if ("handles" in relation.data) {
|
|
690
|
+
throw errors.new(
|
|
691
|
+
`relation ${relation.name} is closed — its extension is schema data (axioms), never scanned or written`
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
return entry
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function offendingFactOf(fact: WireViolationFact): OffendingFact<Rels> {
|
|
698
|
+
if (!isMemberName(fact.relation)) {
|
|
699
|
+
throw errors.new(`bumbledb violation cites unknown relation ${fact.relation}`)
|
|
700
|
+
}
|
|
701
|
+
const decoded: Record<string, FactValue> = {}
|
|
702
|
+
for (const cell of fact.fields) {
|
|
703
|
+
decoded[cell.name] = cell.value
|
|
704
|
+
}
|
|
705
|
+
return Object.freeze({ relation: fact.relation, fact: Object.freeze(decoded) })
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function violationOf(wire: WireViolation): Violation<Rels> {
|
|
709
|
+
const entry = tables.statements[wire.statementId]
|
|
710
|
+
if (entry === undefined) {
|
|
711
|
+
throw errors.new(`bumbledb violation cites unknown statement id ${wire.statementId}`)
|
|
712
|
+
}
|
|
713
|
+
return Object.freeze({
|
|
714
|
+
statement: entry.statement,
|
|
715
|
+
kind: wire.kind,
|
|
716
|
+
canonical: wire.canonical,
|
|
717
|
+
direction: wire.direction,
|
|
718
|
+
orientation: orientationOf(entry.reversed),
|
|
719
|
+
count: wire.count,
|
|
720
|
+
facts: Object.freeze(wire.facts.map(offendingFactOf))
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Resolves a key-statement-selected read: the statement must be the
|
|
726
|
+
* IDENTICAL `key()` value this schema declared (identity is the
|
|
727
|
+
* membership rule) and must key `relation` — its materialized statement
|
|
728
|
+
* id comes from the positional mirror, so the engine point-reads through
|
|
729
|
+
* exactly the declared projection.
|
|
730
|
+
*/
|
|
731
|
+
function declaredKeyOf(relation: AnyRelation, statement: Statement): PrimaryKey {
|
|
732
|
+
const statementId = tables.statements.findIndex(function byIdentity(candidate) {
|
|
733
|
+
return candidate.statement === statement
|
|
734
|
+
})
|
|
735
|
+
const entry = tables.statements[statementId]
|
|
736
|
+
if (entry === undefined) {
|
|
737
|
+
throw errors.new(
|
|
738
|
+
`keyed get statement is not a declared statement of schema ${theory.name} — statement identity is the membership rule`
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
if (entry.kind !== "functionality" || entry.key === undefined) {
|
|
742
|
+
throw errors.new("keyed get takes a key() statement — containments and windows key nothing")
|
|
743
|
+
}
|
|
744
|
+
if (entry.key.owner !== relation.name) {
|
|
745
|
+
throw errors.new(
|
|
746
|
+
`keyed get statement keys ${entry.key.owner}, not ${relation.name} — the statement must be a declared key of the relation it reads`
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
return Object.freeze({ statementId, projection: entry.key.projection })
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function pointReadsOf(assertLive: () => void, reads: PointReads) {
|
|
753
|
+
function contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean {
|
|
754
|
+
assertLive()
|
|
755
|
+
const entry = resolveOrdinary(relation)
|
|
756
|
+
return reads.contains(entry.id, rowOf(relation.data, recordOf(fact)))
|
|
757
|
+
}
|
|
758
|
+
/** One keyed point read through an already-resolved key, decoded to a fact (`undefined` on a miss). */
|
|
759
|
+
function readThroughKey<R extends MemberRelation<Rels>>(
|
|
760
|
+
relation: R,
|
|
761
|
+
entry: RelationEntry,
|
|
762
|
+
selected: PrimaryKey,
|
|
763
|
+
key: Readonly<Record<string, unknown>>
|
|
764
|
+
): Fact<R> | undefined {
|
|
765
|
+
const row = reads.get(entry.id, selected.statementId, keyRowOf(relation.data, selected.projection, key))
|
|
766
|
+
if (row === null) {
|
|
767
|
+
return undefined
|
|
768
|
+
}
|
|
769
|
+
return factOf(relation, row)
|
|
770
|
+
}
|
|
771
|
+
function get<R extends MemberRelation<Rels>>(relation: R, key: KeyFact<R>): Fact<R> | undefined
|
|
772
|
+
function get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
773
|
+
relation: R,
|
|
774
|
+
keyStatement: KeyStatement<R, P>,
|
|
775
|
+
key: DeclaredKeyFact<R, P>
|
|
776
|
+
): Fact<R> | undefined
|
|
777
|
+
function get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
778
|
+
relation: R,
|
|
779
|
+
keyOrStatement: KeyFact<R> | KeyStatement<R, P>,
|
|
780
|
+
declaredKey?: DeclaredKeyFact<R, P>
|
|
781
|
+
): Fact<R> | undefined {
|
|
782
|
+
assertLive()
|
|
783
|
+
const entry = resolveOrdinary(relation)
|
|
784
|
+
if (declaredKey !== undefined) {
|
|
785
|
+
if (!isStatementValue(keyOrStatement)) {
|
|
786
|
+
throw errors.new("keyed get takes a key() statement value as its second argument")
|
|
787
|
+
}
|
|
788
|
+
const selected = declaredKeyOf(relation, keyOrStatement)
|
|
789
|
+
return readThroughKey(relation, entry, selected, recordOf(declaredKey))
|
|
790
|
+
}
|
|
791
|
+
if (isStatementValue(keyOrStatement)) {
|
|
792
|
+
throw errors.new(
|
|
793
|
+
"keyed get with a statement selector also takes the key object — get(relation, keyStatement, key)"
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
const primaryKey = entry.primaryKey
|
|
797
|
+
if (primaryKey === undefined) {
|
|
798
|
+
throw errors.new(
|
|
799
|
+
`relation ${relation.name} has no candidate key — keyed get requires a fresh field or a declared key statement`
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
return readThroughKey(relation, entry, primaryKey, recordOf(keyOrStatement))
|
|
803
|
+
}
|
|
804
|
+
return { contains, get }
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Resolves a prepared value's private plan, refusing foreign objects
|
|
809
|
+
* and prepared values of other stores as typed errors.
|
|
810
|
+
*/
|
|
811
|
+
function planOf(prepared: object): PreparedPlan {
|
|
812
|
+
const plan = preparedPlans.get(prepared)
|
|
813
|
+
if (plan === undefined) {
|
|
814
|
+
throw errors.new("bumbledb execute target is not a prepared value of this SDK")
|
|
815
|
+
}
|
|
816
|
+
if (plan.owner !== owner) {
|
|
817
|
+
throw errors.new(
|
|
818
|
+
`bumbledb prepared value was prepared by a different store than this one (schema ${theory.name})`
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
return plan
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Builds one {@link ReadScope} over a live scope state. Every verb
|
|
826
|
+
* asserts liveness first: the owning call flips `state.live` the moment
|
|
827
|
+
* its callback returns, so a leaked scope is a typed refusal forever
|
|
828
|
+
* after.
|
|
829
|
+
*/
|
|
830
|
+
function makeScope(state: ScopeState, generation: bigint): ReadScope<Rels> {
|
|
831
|
+
function assertLive(): void {
|
|
832
|
+
if (!state.live) {
|
|
833
|
+
throw errors.new("bumbledb read scope is invalidated — its owning read callback already returned")
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const reads = pointReadsOf(assertLive, {
|
|
837
|
+
contains(relationId, row) {
|
|
838
|
+
return bridged("bumbledb snapshot contains", function readContains() {
|
|
839
|
+
return native.snapshotContains(state.handle, relationId, row)
|
|
840
|
+
})
|
|
841
|
+
},
|
|
842
|
+
get(relationId, statementId, key) {
|
|
843
|
+
return bridged("bumbledb snapshot get", function readGet() {
|
|
844
|
+
return native.snapshotGet(state.handle, relationId, statementId, key)
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
function scan<R extends MemberRelation<Rels>>(relation: R): Fact<R>[] {
|
|
849
|
+
assertLive()
|
|
850
|
+
const entry = resolveOrdinary(relation)
|
|
851
|
+
const rows = bridged("bumbledb snapshot scan", function readScan() {
|
|
852
|
+
return native.snapshotScan(state.handle, entry.id)
|
|
853
|
+
})
|
|
854
|
+
return rows.map(function decodeRow(row) {
|
|
855
|
+
return factOf(relation, row)
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
function execute<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Row[] {
|
|
859
|
+
assertLive()
|
|
860
|
+
const plan = planOf(prepared)
|
|
861
|
+
const wire = wireParams(plan.params, recordOf(params))
|
|
862
|
+
const rows = bridged("execute bumbledb prepared query", function callExecute() {
|
|
863
|
+
return native.preparedExecute(plan.handle, state.handle, wire)
|
|
864
|
+
})
|
|
865
|
+
return decodeAnswers<Row>(plan.select, rows)
|
|
866
|
+
}
|
|
867
|
+
const scope: ReadScope<Rels> = Object.freeze({
|
|
868
|
+
generation,
|
|
869
|
+
scan,
|
|
870
|
+
get: reads.get,
|
|
871
|
+
contains: reads.contains,
|
|
872
|
+
execute
|
|
873
|
+
})
|
|
874
|
+
scopeStates.set(scope, state)
|
|
875
|
+
return scope
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Live-handle accounting (diagnostic law, prod EINVAL 2026-07-17): every
|
|
880
|
+
* snapshot open/close is counted so a write-begin failure can report how
|
|
881
|
+
* many read handles were live at the fault — a leaked scope is invisible
|
|
882
|
+
* until the exact moment it matters, so the failure carries the census.
|
|
883
|
+
*/
|
|
884
|
+
let liveSnapshots = 0
|
|
885
|
+
|
|
886
|
+
/** Opens one snapshot and its scope state (live until the owner flips it). */
|
|
887
|
+
function openScopeState(): ScopeState {
|
|
888
|
+
const snapHandle = bridged("open bumbledb snapshot", function openSnapshot() {
|
|
889
|
+
return native.dbSnapshot(handle)
|
|
890
|
+
})
|
|
891
|
+
liveSnapshots += 1
|
|
892
|
+
return { handle: snapHandle, live: true, owner }
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Closes a scope's snapshot after the owner invalidated it. */
|
|
896
|
+
function closeScopeState(state: ScopeState): void {
|
|
897
|
+
bridged("close bumbledb snapshot", function closeSnapshot() {
|
|
898
|
+
native.snapshotClose(state.handle)
|
|
899
|
+
})
|
|
900
|
+
liveSnapshots -= 1
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Reads the committed generation for a just-opened scope, closing the
|
|
905
|
+
* scope's snapshot when the read faults: `dbGeneration` opens a transient
|
|
906
|
+
* engine read txn, so reader-table exhaustion is precisely the state in
|
|
907
|
+
* which it throws — with one snapshot already open. An unpaired fault
|
|
908
|
+
* here would park a snapshot worker and consume one of the engine's
|
|
909
|
+
* reader slots FOREVER (and undercount the liveSnapshots census), each
|
|
910
|
+
* fault ratcheting toward ReadersFull-for-the-process's-lifetime.
|
|
911
|
+
*/
|
|
912
|
+
function generationForScope(state: ScopeState): bigint {
|
|
913
|
+
const generation = errors.trySync(function readGeneration() {
|
|
914
|
+
return bridged("read bumbledb generation", function callGeneration() {
|
|
915
|
+
return native.dbGeneration(handle)
|
|
916
|
+
})
|
|
917
|
+
})
|
|
918
|
+
if (generation.error) {
|
|
919
|
+
state.live = false
|
|
920
|
+
closeScopeState(state)
|
|
921
|
+
throw generation.error
|
|
922
|
+
}
|
|
923
|
+
return generation.data
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function read<T>(fn: (snap: ReadScope<Rels>) => T): T {
|
|
927
|
+
const state = openScopeState()
|
|
928
|
+
const generation = generationForScope(state)
|
|
929
|
+
const scope = makeScope(state, generation)
|
|
930
|
+
const result = errors.trySync(function runRead() {
|
|
931
|
+
return fn(scope)
|
|
932
|
+
})
|
|
933
|
+
state.live = false
|
|
934
|
+
closeScopeState(state)
|
|
935
|
+
if (result.error) {
|
|
936
|
+
throw errors.wrap(result.error, "bumbledb read")
|
|
937
|
+
}
|
|
938
|
+
return result.data
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function scan<R extends MemberRelation<Rels>>(relation: R): Fact<R>[] {
|
|
942
|
+
return read(function scanInScope(snap) {
|
|
943
|
+
return snap.scan(relation)
|
|
944
|
+
})
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function get<R extends MemberRelation<Rels>>(relation: R, key: KeyFact<R>): Fact<R> | undefined
|
|
948
|
+
function get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
949
|
+
relation: R,
|
|
950
|
+
keyStatement: KeyStatement<R, P>,
|
|
951
|
+
key: DeclaredKeyFact<R, P>
|
|
952
|
+
): Fact<R> | undefined
|
|
953
|
+
function get<R extends MemberRelation<Rels>, const P extends readonly string[]>(
|
|
954
|
+
relation: R,
|
|
955
|
+
keyOrStatement: KeyFact<R> | KeyStatement<R, P>,
|
|
956
|
+
declaredKey?: DeclaredKeyFact<R, P>
|
|
957
|
+
): Fact<R> | undefined {
|
|
958
|
+
return read(function getInScope(snap) {
|
|
959
|
+
if (declaredKey !== undefined) {
|
|
960
|
+
if (!isStatementValue(keyOrStatement)) {
|
|
961
|
+
throw errors.new("keyed get takes a key() statement value as its second argument")
|
|
962
|
+
}
|
|
963
|
+
return snap.get(relation, keyOrStatement, declaredKey)
|
|
964
|
+
}
|
|
965
|
+
if (isStatementValue(keyOrStatement)) {
|
|
966
|
+
throw errors.new(
|
|
967
|
+
"keyed get with a statement selector also takes the key object — get(relation, keyStatement, key)"
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
return snap.get(relation, keyOrStatement)
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean {
|
|
975
|
+
return read(function containsInScope(snap) {
|
|
976
|
+
return snap.contains(relation, fact)
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function execute<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Row[] {
|
|
981
|
+
return read(function executeInScope(snap) {
|
|
982
|
+
return snap.execute(prepared, params)
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Builds one {@link Tx} over a transaction-handle thunk: `write` passes
|
|
988
|
+
* an already-begun handle; `writeWitnessed` passes a LAZY thunk that
|
|
989
|
+
* begins the witnessed transaction on the first delta verb (so premise
|
|
990
|
+
* reads and the host's own interleaved writes can precede it) and
|
|
991
|
+
* throws {@link generationMovedSignal} when the witness is stale.
|
|
992
|
+
*/
|
|
993
|
+
function makeTx(resolveTx: () => TxHandle): { readonly tx: Tx<Rels>; spend(): void } {
|
|
994
|
+
const txState = { spent: false }
|
|
995
|
+
function assertLive(): void {
|
|
996
|
+
if (txState.spent) {
|
|
997
|
+
throw errors.new("bumbledb write transaction is spent")
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const reads = pointReadsOf(assertLive, {
|
|
1001
|
+
contains(relationId, row) {
|
|
1002
|
+
const txHandle = resolveTx()
|
|
1003
|
+
return bridged("bumbledb tx contains", function readContains() {
|
|
1004
|
+
return native.txContains(txHandle, relationId, row)
|
|
1005
|
+
})
|
|
1006
|
+
},
|
|
1007
|
+
get(relationId, statementId, key) {
|
|
1008
|
+
const txHandle = resolveTx()
|
|
1009
|
+
return bridged("bumbledb tx get", function readGet() {
|
|
1010
|
+
return native.txGet(txHandle, relationId, statementId, key)
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
})
|
|
1014
|
+
function insert<R extends MemberRelation<Rels>>(relation: R, fact: InsertFact<R>): Minted<R> {
|
|
1015
|
+
assertLive()
|
|
1016
|
+
const entry = resolveOrdinary(relation)
|
|
1017
|
+
const txHandle = resolveTx()
|
|
1018
|
+
const values = recordOf(fact)
|
|
1019
|
+
const fresh = mintFreshCells(txHandle, entry, relation, values)
|
|
1020
|
+
const row = rowOf(relation.data, values)
|
|
1021
|
+
bridged("bumbledb tx insert", function record() {
|
|
1022
|
+
native.txInsert(txHandle, entry.id, row)
|
|
1023
|
+
})
|
|
1024
|
+
Object.freeze(fresh)
|
|
1025
|
+
if (!isMintedFresh(relation, fresh)) {
|
|
1026
|
+
throw errors.new(`relation ${relation.name}: minted fresh record is incomplete`)
|
|
1027
|
+
}
|
|
1028
|
+
return fresh
|
|
1029
|
+
}
|
|
1030
|
+
function remove<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean {
|
|
1031
|
+
assertLive()
|
|
1032
|
+
const entry = resolveOrdinary(relation)
|
|
1033
|
+
const txHandle = resolveTx()
|
|
1034
|
+
const row = rowOf(relation.data, recordOf(fact))
|
|
1035
|
+
return bridged("bumbledb tx delete", function record() {
|
|
1036
|
+
return native.txDelete(txHandle, entry.id, row)
|
|
1037
|
+
})
|
|
1038
|
+
}
|
|
1039
|
+
const tx: Tx<Rels> = Object.freeze({
|
|
1040
|
+
insert,
|
|
1041
|
+
delete: remove,
|
|
1042
|
+
contains: reads.contains,
|
|
1043
|
+
get: reads.get
|
|
1044
|
+
})
|
|
1045
|
+
function spend(): void {
|
|
1046
|
+
txState.spent = true
|
|
1047
|
+
}
|
|
1048
|
+
return { tx, spend }
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function runDelta(txHandle: TxHandle, fn: DeltaBuild<Rels>): WriteResult<Rels> {
|
|
1052
|
+
const made = makeTx(function resolveTx() {
|
|
1053
|
+
return txHandle
|
|
1054
|
+
})
|
|
1055
|
+
const built = errors.trySync(function buildDelta() {
|
|
1056
|
+
return fn(made.tx)
|
|
1057
|
+
})
|
|
1058
|
+
made.spend()
|
|
1059
|
+
if (built.error) {
|
|
1060
|
+
bridged("abort bumbledb write transaction", function abort() {
|
|
1061
|
+
native.txAbort(txHandle)
|
|
1062
|
+
})
|
|
1063
|
+
throw errors.wrap(built.error, "build write delta")
|
|
1064
|
+
}
|
|
1065
|
+
if (isThenable(built.data)) {
|
|
1066
|
+
/**
|
|
1067
|
+
* An `async` callback TYPECHECKS (Promise<void> is assignable where
|
|
1068
|
+
* a `void` return is expected) but its body runs after the tx is
|
|
1069
|
+
* spent: committing here would be a silent EMPTY commit reported
|
|
1070
|
+
* ok while the callback's real inserts throw "spent" as unhandled
|
|
1071
|
+
* rejections. Refused typed instead — abort, nothing committed
|
|
1072
|
+
* (the same one-writer law as the thrown-callback path).
|
|
1073
|
+
*/
|
|
1074
|
+
bridged("abort bumbledb write transaction", function abort() {
|
|
1075
|
+
native.txAbort(txHandle)
|
|
1076
|
+
})
|
|
1077
|
+
throw errors.new(
|
|
1078
|
+
"bumbledb write callback returned a thenable — the delta build is synchronous; an async callback is refused, nothing was committed"
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
const committed = errors.trySync(function commitDelta() {
|
|
1082
|
+
return bridged("commit bumbledb write transaction", function commit() {
|
|
1083
|
+
return native.txCommit(txHandle)
|
|
1084
|
+
})
|
|
1085
|
+
})
|
|
1086
|
+
if (committed.error) {
|
|
1087
|
+
/**
|
|
1088
|
+
* A THROWN commit (engine I/O failure, bridge fault) must never
|
|
1089
|
+
* leave the write transaction live: LMDB holds one writer per
|
|
1090
|
+
* environment, and a leaked handle turns every later begin into
|
|
1091
|
+
* EINVAL for the process's lifetime. The abort is best-effort —
|
|
1092
|
+
* the native side may already have consumed the handle.
|
|
1093
|
+
*/
|
|
1094
|
+
const aborted = errors.trySync(function abortAfterFailedCommit() {
|
|
1095
|
+
native.txAbort(txHandle)
|
|
1096
|
+
})
|
|
1097
|
+
if (aborted.error) {
|
|
1098
|
+
}
|
|
1099
|
+
throw errors.wrap(committed.error, "commit bumbledb write transaction")
|
|
1100
|
+
}
|
|
1101
|
+
const outcome = committed.data
|
|
1102
|
+
if (outcome.ok) {
|
|
1103
|
+
return Object.freeze({ ok: true, generation: outcome.generation })
|
|
1104
|
+
}
|
|
1105
|
+
return Object.freeze({
|
|
1106
|
+
ok: false,
|
|
1107
|
+
violations: Object.freeze(outcome.violations.map(violationOf))
|
|
1108
|
+
})
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function write(fn: DeltaBuild<Rels>): WriteResult<Rels> {
|
|
1112
|
+
const begun = errors.trySync(function beginDelta() {
|
|
1113
|
+
return bridged("begin bumbledb write transaction", function begin() {
|
|
1114
|
+
return native.dbWriteBegin(handle)
|
|
1115
|
+
})
|
|
1116
|
+
})
|
|
1117
|
+
if (begun.error) {
|
|
1118
|
+
throw errors.wrap(begun.error, `begin bumbledb write transaction (live snapshots at fault: ${liveSnapshots})`)
|
|
1119
|
+
}
|
|
1120
|
+
return runDelta(begun.data, fn)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Commits an already-begun witnessed transaction and closes the
|
|
1125
|
+
* attempt's snapshot: the committed generation, or the engine's
|
|
1126
|
+
* complete violation set as data.
|
|
1127
|
+
*/
|
|
1128
|
+
function commitWitnessed<R>(state: ScopeState, txHandle: TxHandle): WitnessedWriteResult<Rels, R> {
|
|
1129
|
+
const committed = errors.trySync(function commitWitnessedDelta() {
|
|
1130
|
+
return bridged("commit bumbledb witnessed write transaction", function commit() {
|
|
1131
|
+
return native.txCommit(txHandle)
|
|
1132
|
+
})
|
|
1133
|
+
})
|
|
1134
|
+
if (committed.error) {
|
|
1135
|
+
/** Same one-writer law as `runDelta`: a thrown commit aborts before rethrowing. */
|
|
1136
|
+
const aborted = errors.trySync(function abortAfterFailedCommit() {
|
|
1137
|
+
native.txAbort(txHandle)
|
|
1138
|
+
})
|
|
1139
|
+
if (aborted.error) {
|
|
1140
|
+
}
|
|
1141
|
+
closeScopeState(state)
|
|
1142
|
+
throw errors.wrap(committed.error, "commit bumbledb witnessed write transaction")
|
|
1143
|
+
}
|
|
1144
|
+
const outcome = committed.data
|
|
1145
|
+
closeScopeState(state)
|
|
1146
|
+
if (outcome.ok) {
|
|
1147
|
+
return Object.freeze({ ok: true, generation: outcome.generation })
|
|
1148
|
+
}
|
|
1149
|
+
return Object.freeze({
|
|
1150
|
+
ok: false,
|
|
1151
|
+
violations: Object.freeze(outcome.violations.map(violationOf))
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* One attempt of the witnessed loop: fresh snapshot, the callback over
|
|
1157
|
+
* its scope and a LAZILY-begun witnessed transaction (the first delta
|
|
1158
|
+
* verb begins it, so premise reads and the host's own interleaved
|
|
1159
|
+
* writes can precede the witness check), then the witnessed commit —
|
|
1160
|
+
* or the abandon abort, which never issues a commit. Returns
|
|
1161
|
+
* `undefined` exactly when the generation moved and the whole callback
|
|
1162
|
+
* must rerun on a fresh snapshot.
|
|
1163
|
+
*/
|
|
1164
|
+
function witnessedAttempt<R>(
|
|
1165
|
+
fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R
|
|
1166
|
+
): WitnessedWriteResult<Rels, R> | undefined {
|
|
1167
|
+
const state = openScopeState()
|
|
1168
|
+
const generation = generationForScope(state)
|
|
1169
|
+
const scope = makeScope(state, generation)
|
|
1170
|
+
const pending: { tx: TxHandle | undefined } = { tx: undefined }
|
|
1171
|
+
function beginWitnessed(): TxHandle | undefined {
|
|
1172
|
+
const witnessed = bridged("begin witnessed bumbledb write transaction", function begin() {
|
|
1173
|
+
return native.dbWriteFrom(handle, state.handle)
|
|
1174
|
+
})
|
|
1175
|
+
if (!witnessed.ok) {
|
|
1176
|
+
return undefined
|
|
1177
|
+
}
|
|
1178
|
+
return witnessed.tx
|
|
1179
|
+
}
|
|
1180
|
+
const made = makeTx(function resolveWitnessedTx() {
|
|
1181
|
+
if (pending.tx === undefined) {
|
|
1182
|
+
const begun = beginWitnessed()
|
|
1183
|
+
if (begun === undefined) {
|
|
1184
|
+
throw generationMovedSignal
|
|
1185
|
+
}
|
|
1186
|
+
pending.tx = begun
|
|
1187
|
+
}
|
|
1188
|
+
return pending.tx
|
|
1189
|
+
})
|
|
1190
|
+
const built = errors.trySync(function computeWitnessed() {
|
|
1191
|
+
return fn(scope, made.tx)
|
|
1192
|
+
})
|
|
1193
|
+
made.spend()
|
|
1194
|
+
state.live = false
|
|
1195
|
+
/**
|
|
1196
|
+
* Aborts the pending transaction if one was begun. A faulted abort
|
|
1197
|
+
* still closes the attempt's snapshot BEFORE rethrowing — every
|
|
1198
|
+
* openScopeState is paired with closeScopeState on every exit, or a
|
|
1199
|
+
* reader slot and its snapshot worker leak for the process's lifetime.
|
|
1200
|
+
*/
|
|
1201
|
+
function abortPending(): void {
|
|
1202
|
+
const txHandle = pending.tx
|
|
1203
|
+
if (txHandle === undefined) {
|
|
1204
|
+
return
|
|
1205
|
+
}
|
|
1206
|
+
const aborted = errors.trySync(function abort() {
|
|
1207
|
+
native.txAbort(txHandle)
|
|
1208
|
+
})
|
|
1209
|
+
if (aborted.error) {
|
|
1210
|
+
closeScopeState(state)
|
|
1211
|
+
throw errors.wrap(aborted.error, "abort bumbledb witnessed write transaction")
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (built.error) {
|
|
1215
|
+
abortPending()
|
|
1216
|
+
closeScopeState(state)
|
|
1217
|
+
if (errors.is(built.error, generationMovedSignal)) {
|
|
1218
|
+
return undefined
|
|
1219
|
+
}
|
|
1220
|
+
throw errors.wrap(built.error, "build witnessed write delta")
|
|
1221
|
+
}
|
|
1222
|
+
if (isThenable(built.data)) {
|
|
1223
|
+
/** The same async-callback refusal as `runDelta` — a thenable means the real delta build races the commit; nothing is committed. */
|
|
1224
|
+
abortPending()
|
|
1225
|
+
closeScopeState(state)
|
|
1226
|
+
throw errors.new(
|
|
1227
|
+
"bumbledb writeWitnessed callback returned a thenable — the delta build is synchronous; an async callback is refused, nothing was committed"
|
|
1228
|
+
)
|
|
1229
|
+
}
|
|
1230
|
+
if (isAbandon(built.data)) {
|
|
1231
|
+
abortPending()
|
|
1232
|
+
closeScopeState(state)
|
|
1233
|
+
return Object.freeze({ ok: false, abandoned: built.data.payload })
|
|
1234
|
+
}
|
|
1235
|
+
const late = errors.trySync(function resolveCommitTx() {
|
|
1236
|
+
if (pending.tx === undefined) {
|
|
1237
|
+
return beginWitnessed()
|
|
1238
|
+
}
|
|
1239
|
+
return pending.tx
|
|
1240
|
+
})
|
|
1241
|
+
if (late.error) {
|
|
1242
|
+
/** A faulted late begin must not leak the attempt's snapshot either. */
|
|
1243
|
+
closeScopeState(state)
|
|
1244
|
+
throw late.error
|
|
1245
|
+
}
|
|
1246
|
+
const txHandle = late.data
|
|
1247
|
+
if (txHandle === undefined) {
|
|
1248
|
+
closeScopeState(state)
|
|
1249
|
+
return undefined
|
|
1250
|
+
}
|
|
1251
|
+
return commitWitnessed(state, txHandle)
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function writeWitnessed<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WitnessedWriteResult<Rels, R> {
|
|
1255
|
+
for (;;) {
|
|
1256
|
+
const attempt = witnessedAttempt(fn)
|
|
1257
|
+
if (attempt !== undefined) {
|
|
1258
|
+
return attempt
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Verifies the declaration-ordinal law the query lowering leans on
|
|
1265
|
+
* against the live manifest tables: relation ids and sealed field ids
|
|
1266
|
+
* both equal declaration order (`dbManifest` is the engine's own
|
|
1267
|
+
* pinning of it). Any drift is a construction-time failure here, never
|
|
1268
|
+
* a silently misaddressed query.
|
|
1269
|
+
*/
|
|
1270
|
+
function assertOrdinalAlignment(): void {
|
|
1271
|
+
Object.keys(theory.relations).forEach(function verifyRelation(name, ordinal) {
|
|
1272
|
+
const entry = tables.relations.get(name)
|
|
1273
|
+
if (entry === undefined || entry.id !== ordinal) {
|
|
1274
|
+
throw errors.new(
|
|
1275
|
+
`bumbledb manifest drift: relation ${name} has engine id ${entry?.id}, its declaration ordinal is ${ordinal} — query lowering depends on declaration order = ids`
|
|
1276
|
+
)
|
|
1277
|
+
}
|
|
1278
|
+
const member = theory.relations[name]
|
|
1279
|
+
if (member === undefined) {
|
|
1280
|
+
throw errors.new(`bumbledb manifest drift: schema ${theory.name} lost relation ${name}`)
|
|
1281
|
+
}
|
|
1282
|
+
const sealed =
|
|
1283
|
+
"handles" in member.data
|
|
1284
|
+
? [
|
|
1285
|
+
"id",
|
|
1286
|
+
...member.data.columns.map(function columnName(column) {
|
|
1287
|
+
return column.name
|
|
1288
|
+
})
|
|
1289
|
+
]
|
|
1290
|
+
: member.data.fields.map(function fieldName(declared) {
|
|
1291
|
+
return declared.name
|
|
1292
|
+
})
|
|
1293
|
+
sealed.forEach(function verifyField(fieldName, fieldOrdinal) {
|
|
1294
|
+
if (entry.fieldIds.get(fieldName) !== fieldOrdinal) {
|
|
1295
|
+
throw errors.new(
|
|
1296
|
+
`bumbledb manifest drift: ${name}.${fieldName} has engine field id ${entry.fieldIds.get(fieldName)}, its sealed ordinal is ${fieldOrdinal}`
|
|
1297
|
+
)
|
|
1298
|
+
}
|
|
1299
|
+
})
|
|
1300
|
+
})
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function prepare<Row, Params extends ParamsRecord>(q: Query<Rels, Row, Params>): Prepared<Rels, Row, Params> {
|
|
1304
|
+
if (q.schema !== theory) {
|
|
1305
|
+
throw errors.new(
|
|
1306
|
+
`query was built against schema ${q.schema.name}, not the identical schema value this store opened with — schema identity is the membership rule`
|
|
1307
|
+
)
|
|
1308
|
+
}
|
|
1309
|
+
assertOrdinalAlignment()
|
|
1310
|
+
const program = lowerQuery(q)
|
|
1311
|
+
const outcome = bridged("prepare bumbledb program", function callPrepare() {
|
|
1312
|
+
return native.dbPrepare(handle, program)
|
|
1313
|
+
})
|
|
1314
|
+
if (!outcome.ok) {
|
|
1315
|
+
throw errors.new(`bumbledb ${outcome.kind} (prepare): ${outcome.message}`)
|
|
1316
|
+
}
|
|
1317
|
+
const preparedHandle = outcome.prepared
|
|
1318
|
+
function staleness(snap: ReadScope<Rels>): Staleness {
|
|
1319
|
+
const snapState = scopeStates.get(snap)
|
|
1320
|
+
if (snapState === undefined) {
|
|
1321
|
+
throw errors.new("bumbledb staleness witness is not a read scope of this SDK")
|
|
1322
|
+
}
|
|
1323
|
+
if (snapState.owner !== owner) {
|
|
1324
|
+
throw errors.new(
|
|
1325
|
+
`bumbledb read scope belongs to a different store than this prepared value (schema ${theory.name})`
|
|
1326
|
+
)
|
|
1327
|
+
}
|
|
1328
|
+
if (!snapState.live) {
|
|
1329
|
+
throw errors.new("bumbledb read scope is invalidated — its owning read callback already returned")
|
|
1330
|
+
}
|
|
1331
|
+
return bridged("read bumbledb prepared staleness", function callStaleness() {
|
|
1332
|
+
return native.preparedStaleness(preparedHandle, snapState.handle)
|
|
1333
|
+
})
|
|
1334
|
+
}
|
|
1335
|
+
const prepared: Prepared<Rels, Row, Params> = Object.freeze({ staleness })
|
|
1336
|
+
preparedPlans.set(
|
|
1337
|
+
prepared,
|
|
1338
|
+
Object.freeze({
|
|
1339
|
+
handle: preparedHandle,
|
|
1340
|
+
owner,
|
|
1341
|
+
params: q.data.registry.params,
|
|
1342
|
+
select: q.data.select
|
|
1343
|
+
})
|
|
1344
|
+
)
|
|
1345
|
+
planReclaimer.register(prepared, preparedHandle)
|
|
1346
|
+
return prepared
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
return Object.freeze({
|
|
1350
|
+
schema: theory,
|
|
1351
|
+
read,
|
|
1352
|
+
scan,
|
|
1353
|
+
get,
|
|
1354
|
+
contains,
|
|
1355
|
+
execute,
|
|
1356
|
+
write,
|
|
1357
|
+
writeWitnessed,
|
|
1358
|
+
prepare
|
|
1359
|
+
})
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* One cached open store: the theory VALUE it was admitted with (identity is
|
|
1364
|
+
* the membership rule — the fingerprint check against a cached path is a
|
|
1365
|
+
* `===` on this), the `Db` value every same-path open returns, and the
|
|
1366
|
+
* environment handle the exit hook closes.
|
|
1367
|
+
*/
|
|
1368
|
+
interface CachedStore {
|
|
1369
|
+
readonly theory: AnySchema
|
|
1370
|
+
readonly db: unknown
|
|
1371
|
+
readonly handle: DbHandle
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* The per-process store cache, keyed by canonical path
|
|
1376
|
+
* (`node:path.resolve` — absolute and normalized). Symlink aliasing is
|
|
1377
|
+
* deliberately not resolved here: an aliased spelling misses the cache and
|
|
1378
|
+
* reaches the engine, whose exclusive lock refuses a second live handle on
|
|
1379
|
+
* the same store — the backstop that keeps "one store, one handle" true.
|
|
1380
|
+
*/
|
|
1381
|
+
const openStores = new Map<string, CachedStore>()
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* The in-process fingerprint check and its typing proof in one probe:
|
|
1385
|
+
* theory identity (`===`) implies `Rels` identity, because a cache entry's
|
|
1386
|
+
* `db` was constructed from that very theory value — so a hit narrows the
|
|
1387
|
+
* entry's `db` to `Db<Rels>` with no assertion anywhere.
|
|
1388
|
+
*/
|
|
1389
|
+
function holdsTheory<Rels extends SchemaRelations>(
|
|
1390
|
+
entry: CachedStore,
|
|
1391
|
+
theory: Schema<Rels>
|
|
1392
|
+
): entry is CachedStore & { readonly db: Db<Rels> } {
|
|
1393
|
+
return entry.theory === theory
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* The best-effort exit hook: closes every cached environment so LMDB
|
|
1398
|
+
* releases its locks tidily on a clean exit. CORRECTNESS NEVER RESTS HERE —
|
|
1399
|
+
* the engine fsyncs every commit, so a process killed before (or during)
|
|
1400
|
+
* this hook loses nothing that was committed.
|
|
1401
|
+
*/
|
|
1402
|
+
process.once("exit", function closeCachedStores() {
|
|
1403
|
+
for (const cached of openStores.values()) {
|
|
1404
|
+
const closed = errors.trySync(function closeEnvironment() {
|
|
1405
|
+
native.dbClose(cached.handle)
|
|
1406
|
+
})
|
|
1407
|
+
if (closed.error) {
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
})
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* The one admission path both verbs share: canonical-path cache lookup
|
|
1414
|
+
* first (a hit returns the SAME `Db` value for the identical theory, a
|
|
1415
|
+
* typed fingerprint error for a different one, and a typed refusal for
|
|
1416
|
+
* `create` — the store a cache entry proves initialized is exactly what
|
|
1417
|
+
* create refuses). On a miss: lower the theory, run one bridge call, and
|
|
1418
|
+
* wrap the two domain refusals — `schemaError` (spec resolution + schema
|
|
1419
|
+
* validation, every issue in one message) and `fingerprintMismatch` (a
|
|
1420
|
+
* different theory cannot open the store) — into typed errors carrying the
|
|
1421
|
+
* engine's message intact.
|
|
1422
|
+
*/
|
|
1423
|
+
function admit<Rels extends SchemaRelations>(
|
|
1424
|
+
verb: "create" | "open",
|
|
1425
|
+
storePath: string,
|
|
1426
|
+
theory: Schema<Rels>
|
|
1427
|
+
): Db<Rels> {
|
|
1428
|
+
const canonical = path.resolve(storePath)
|
|
1429
|
+
const cached = openStores.get(canonical)
|
|
1430
|
+
if (cached !== undefined) {
|
|
1431
|
+
if (verb === "create") {
|
|
1432
|
+
throw errors.new(
|
|
1433
|
+
`create bumbledb store at ${canonical}: the store is already open in this process — create refuses an already-initialized directory`
|
|
1434
|
+
)
|
|
1435
|
+
}
|
|
1436
|
+
if (!holdsTheory(cached, theory)) {
|
|
1437
|
+
throw errors.new(
|
|
1438
|
+
`bumbledb fingerprintMismatch (open ${canonical}): the cached store was opened with schema ${cached.theory.name}, not this theory value — schema identity is the membership rule`
|
|
1439
|
+
)
|
|
1440
|
+
}
|
|
1441
|
+
return cached.db
|
|
1442
|
+
}
|
|
1443
|
+
const spec = lower(theory)
|
|
1444
|
+
const opened = bridged(`${verb} bumbledb store at ${canonical}`, function callBridge() {
|
|
1445
|
+
if (verb === "create") {
|
|
1446
|
+
return native.dbCreate(canonical, spec)
|
|
1447
|
+
}
|
|
1448
|
+
return native.dbOpen(canonical, spec)
|
|
1449
|
+
})
|
|
1450
|
+
if (!opened.ok) {
|
|
1451
|
+
throw errors.new(`bumbledb ${opened.kind} (${verb} ${canonical}): ${opened.message}`)
|
|
1452
|
+
}
|
|
1453
|
+
const manifest = bridged("fetch bumbledb manifest", function fetchManifest() {
|
|
1454
|
+
return native.dbManifest(opened.db)
|
|
1455
|
+
})
|
|
1456
|
+
const db = openDb(opened.db, theory, manifest)
|
|
1457
|
+
openStores.set(canonical, Object.freeze({ theory, db, handle: opened.db }))
|
|
1458
|
+
return db
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* The store lifecycle — `Db.create(path, schema)` / `Db.open(path, schema)`.
|
|
1463
|
+
* Create refuses an already-initialized directory; open verifies format
|
|
1464
|
+
* version, store kind, and the schema fingerprint. Both return values
|
|
1465
|
+
* CACHED per canonical path: a second open of the same path with the
|
|
1466
|
+
* identical theory value returns the SAME `Db`, and a different theory on
|
|
1467
|
+
* a cached path is a typed fingerprint error. There is no close anywhere:
|
|
1468
|
+
* the process owns every cached environment until exit (a best-effort exit
|
|
1469
|
+
* hook closes them; durability is the engine's per-commit fsync). One
|
|
1470
|
+
* store kind exists: durable — resume = reopen, meaning this process's
|
|
1471
|
+
* cached value or a fresh process's open.
|
|
1472
|
+
*/
|
|
1473
|
+
const Db = Object.freeze({
|
|
1474
|
+
/** Creates a fresh durable store at `path` from the schema; the value is cached for every later open. */
|
|
1475
|
+
async create<Rels extends SchemaRelations>(path: string, theory: Schema<Rels>): Promise<Db<Rels>> {
|
|
1476
|
+
return admit("create", path, theory)
|
|
1477
|
+
},
|
|
1478
|
+
/**
|
|
1479
|
+
* Opens an existing durable store at `path` with the same theory — the
|
|
1480
|
+
* cached value when this process already holds it. A fingerprint-matching
|
|
1481
|
+
* open also BACK-FILLS the store's persisted schema descriptor when it is
|
|
1482
|
+
* absent (self-describing stores, engine 50-storage.md § the `_meta`
|
|
1483
|
+
* block), so a legacy store becomes exhumable after one ordinary open —
|
|
1484
|
+
* adoption is automatic, never a separate verb.
|
|
1485
|
+
*/
|
|
1486
|
+
async open<Rels extends SchemaRelations>(path: string, theory: Schema<Rels>): Promise<Db<Rels>> {
|
|
1487
|
+
return admit("open", path, theory)
|
|
1488
|
+
},
|
|
1489
|
+
/**
|
|
1490
|
+
* Opens a store READ-ONLY from its own persisted descriptor — the SDK's
|
|
1491
|
+
* one schema-independent read path (no theory, no fingerprint check; the
|
|
1492
|
+
* store rebirth tool's entry). Lives beside `open`/`create` so the path
|
|
1493
|
+
* law stays in one place: the same `node:path.resolve` canonicalization,
|
|
1494
|
+
* applied here. The value is NOT cached and carries no close: the
|
|
1495
|
+
* engine-side handle (and the store's exclusive lock) is reclaimed by GC
|
|
1496
|
+
* — reclamation only, never correctness. A store not yet adopted rejects
|
|
1497
|
+
* with the typed `ErrExhumeNoDescriptor` (the remedy: one
|
|
1498
|
+
* fingerprint-matching `Db.open` under the creating schema back-fills
|
|
1499
|
+
* the descriptor).
|
|
1500
|
+
*/
|
|
1501
|
+
async exhume(storePath: string): Promise<Exhumed> {
|
|
1502
|
+
return exhumeStore(path.resolve(storePath))
|
|
1503
|
+
}
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
export type {
|
|
1507
|
+
Abandon,
|
|
1508
|
+
DeclaredKeyFact,
|
|
1509
|
+
DeltaBuild,
|
|
1510
|
+
MemberRelation,
|
|
1511
|
+
OffendingFact,
|
|
1512
|
+
Prepared,
|
|
1513
|
+
ReadScope,
|
|
1514
|
+
Tx,
|
|
1515
|
+
Violation,
|
|
1516
|
+
WitnessedWriteResult,
|
|
1517
|
+
WriteResult
|
|
1518
|
+
}
|
|
1519
|
+
export { abandon, Db }
|