@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/dist/db.js
ADDED
|
@@ -0,0 +1,1016 @@
|
|
|
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
|
+
import * as path from "node:path";
|
|
33
|
+
import * as errors from "@superbuilders/errors";
|
|
34
|
+
import { phantom } from "#brand.ts";
|
|
35
|
+
import { exhumeStore } from "#exhume.ts";
|
|
36
|
+
import { lower } from "#lower.ts";
|
|
37
|
+
import { factOf, isMintedFresh, keyRowOf, recordOf, rowOf } from "#marshal.ts";
|
|
38
|
+
import { native } from "#native.ts";
|
|
39
|
+
import { lowerQuery } from "#query/lower.ts";
|
|
40
|
+
import { decodeAnswers, wireParams } from "#query/run.ts";
|
|
41
|
+
/**
|
|
42
|
+
* The runtime discriminant of {@link Abandon} values — a property probe is
|
|
43
|
+
* how `writeWitnessed` distinguishes "abort without committing" from an
|
|
44
|
+
* ordinary callback result, never a guess about the host's own value shapes.
|
|
45
|
+
*/
|
|
46
|
+
const abandonMark = Symbol("bumbledb.abandon");
|
|
47
|
+
/**
|
|
48
|
+
* Wraps a payload in the {@link Abandon} sentinel — the one way a
|
|
49
|
+
* `writeWitnessed` callback declines to commit: `return abandon(payload)`
|
|
50
|
+
* aborts the delta (nothing is committed, not even an empty commit) and the
|
|
51
|
+
* write resolves to `{ ok: false, abandoned: payload }`.
|
|
52
|
+
*/
|
|
53
|
+
function abandon(payload) {
|
|
54
|
+
return Object.freeze({ [abandonMark]: true, payload });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Narrows a `writeWitnessed` callback result to the abandon sentinel. The
|
|
58
|
+
* probe is the private {@link abandonMark} symbol only {@link abandon} sets,
|
|
59
|
+
* and `R`'s `Abandon` arm is the only way a sentinel can flow out of the
|
|
60
|
+
* callback — so the narrowed payload type is sound by construction.
|
|
61
|
+
*/
|
|
62
|
+
function isAbandon(value) {
|
|
63
|
+
return typeof value === "object" && value !== null && abandonMark in value;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The bridge guard: runs one native call and wraps anything it throws —
|
|
67
|
+
* marshal-shape refusals and handle-lifecycle refusals cross as genuine
|
|
68
|
+
* typed failures, never bare foreign errors.
|
|
69
|
+
*/
|
|
70
|
+
function bridged(context, run) {
|
|
71
|
+
const result = errors.trySync(run);
|
|
72
|
+
if (result.error) {
|
|
73
|
+
throw errors.wrap(result.error, context);
|
|
74
|
+
}
|
|
75
|
+
return result.data;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Mirrors the engine's materialized statement order
|
|
79
|
+
* (`SchemaDescriptor::materialized_statements`, pinned by the fingerprint):
|
|
80
|
+
* one auto-key per fresh field (relation declaration order, then field
|
|
81
|
+
* order), one closed auto-key per closed relation (declaration order),
|
|
82
|
+
* then the declared statements in declaration order — a `mirrors`
|
|
83
|
+
* statement occupying TWO adjacent slots (the engine lowers `==` to two
|
|
84
|
+
* containments, `source <= target` first), both owned by the one SDK
|
|
85
|
+
* value. This positional match is how statement ids resolve back to SDK
|
|
86
|
+
* statement values without the engine ever learning a wire format.
|
|
87
|
+
*/
|
|
88
|
+
function materializedEntries(theory) {
|
|
89
|
+
const entries = impliedKeyEntries(theory);
|
|
90
|
+
for (const statement of theory.statements) {
|
|
91
|
+
entries.push(...declaredEntries(statement));
|
|
92
|
+
}
|
|
93
|
+
return entries;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The engine-materialized implied keys, in the engine's pinned order: one
|
|
97
|
+
* auto-key per fresh field (relation declaration order, then field order),
|
|
98
|
+
* then one closed auto-key `R(id) -> R` per closed relation (declaration
|
|
99
|
+
* order). These slots carry no SDK statement value — the engine owns them
|
|
100
|
+
* (`schema()` rejects an explicit duplicate).
|
|
101
|
+
*/
|
|
102
|
+
function impliedKeyEntries(theory) {
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const member of Object.values(theory.relations)) {
|
|
105
|
+
if ("handles" in member.data) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const declared of member.data.fields) {
|
|
109
|
+
if (declared.field.minted) {
|
|
110
|
+
entries.push({
|
|
111
|
+
kind: "functionality",
|
|
112
|
+
statement: undefined,
|
|
113
|
+
key: { owner: member.name, projection: [declared.name] }
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const member of Object.values(theory.relations)) {
|
|
119
|
+
if ("handles" in member.data) {
|
|
120
|
+
entries.push({
|
|
121
|
+
kind: "functionality",
|
|
122
|
+
statement: undefined,
|
|
123
|
+
key: { owner: member.name, projection: ["id"] }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return entries;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* One declared statement's materialized slots: a key or window occupies
|
|
131
|
+
* one, a `mirrors` occupies two adjacent slots (the engine lowers `==` to
|
|
132
|
+
* two containments, `source <= target` first), both owned by the one SDK
|
|
133
|
+
* value.
|
|
134
|
+
*/
|
|
135
|
+
function declaredEntries(statement) {
|
|
136
|
+
const data = statement.data;
|
|
137
|
+
switch (data.kind) {
|
|
138
|
+
case "key": {
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
kind: "functionality",
|
|
142
|
+
statement,
|
|
143
|
+
key: { owner: data.owner.name, projection: data.projection }
|
|
144
|
+
}
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
case "containment": {
|
|
148
|
+
if (data.bidirectional) {
|
|
149
|
+
return [
|
|
150
|
+
{ kind: "containment", statement, key: undefined, reversed: false },
|
|
151
|
+
{ kind: "containment", statement, key: undefined, reversed: true }
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
return [{ kind: "containment", statement, key: undefined }];
|
|
155
|
+
}
|
|
156
|
+
case "window": {
|
|
157
|
+
return [{ kind: "cardinality", statement, key: undefined }];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Narrows a callback result to a thenable — the async-callback probe both
|
|
163
|
+
* commit sites share: an `async` build callback typechecks (`Promise<void>`
|
|
164
|
+
* is assignable where a `void` return is expected), so the refusal has to
|
|
165
|
+
* be a runtime probe on the returned value.
|
|
166
|
+
*/
|
|
167
|
+
function isThenable(value) {
|
|
168
|
+
return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Narrows a keyed-get middle argument to a statement value (vs a key
|
|
172
|
+
* object): only statement values carry `data.kind` — no fact cell shape
|
|
173
|
+
* (bool, bigint, string, bytes, `{ start, end }`) ever does, so the probe
|
|
174
|
+
* never misreads a key object whose relation declares a field named `data`.
|
|
175
|
+
*/
|
|
176
|
+
function isStatementValue(value) {
|
|
177
|
+
if (typeof value !== "object" || !("data" in value)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const data = value.data;
|
|
181
|
+
return typeof data === "object" && data !== null && "kind" in data;
|
|
182
|
+
}
|
|
183
|
+
/** Maps a slot's reversal flag to the violation's `orientation` payload. */
|
|
184
|
+
function orientationOf(reversed) {
|
|
185
|
+
if (reversed === undefined) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
if (reversed) {
|
|
189
|
+
return "mirrored";
|
|
190
|
+
}
|
|
191
|
+
return "written";
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Builds the id-resolution tables from the manifest, verifying the SDK's
|
|
195
|
+
* positional mirror against the engine's reported order — any drift
|
|
196
|
+
* (count, kind, id, or membership) is a construction-time failure, never a
|
|
197
|
+
* silent misattribution of a violation to the wrong statement value.
|
|
198
|
+
*/
|
|
199
|
+
function tablesOf(theory, manifest) {
|
|
200
|
+
const entries = materializedEntries(theory);
|
|
201
|
+
if (entries.length !== manifest.statements.length) {
|
|
202
|
+
throw errors.new(`bumbledb manifest drift: the SDK lowering yields ${entries.length} materialized statements, the engine reports ${manifest.statements.length}`);
|
|
203
|
+
}
|
|
204
|
+
manifest.statements.forEach(function verifySlot(statement, index) {
|
|
205
|
+
const entry = entries[index];
|
|
206
|
+
if (entry === undefined || statement.id !== index || entry.kind !== statement.kind) {
|
|
207
|
+
throw errors.new(`bumbledb manifest drift: statement ${statement.id} is ${statement.kind}, the SDK mirror at ${index} expected ${entry?.kind}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
const relations = new Map();
|
|
211
|
+
for (const relation of manifest.relations) {
|
|
212
|
+
const member = theory.relations[relation.name];
|
|
213
|
+
if (member === undefined) {
|
|
214
|
+
throw errors.new(`bumbledb manifest drift: relation ${relation.name} is not in schema ${theory.name}`);
|
|
215
|
+
}
|
|
216
|
+
const fieldIds = new Map();
|
|
217
|
+
for (const field of relation.fields) {
|
|
218
|
+
fieldIds.set(field.name, field.id);
|
|
219
|
+
}
|
|
220
|
+
let primaryKey;
|
|
221
|
+
entries.forEach(function firstOwnedKey(entry, index) {
|
|
222
|
+
if (primaryKey === undefined && entry.key !== undefined && entry.key.owner === relation.name) {
|
|
223
|
+
primaryKey = Object.freeze({ statementId: index, projection: entry.key.projection });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
relations.set(relation.name, Object.freeze({ id: relation.id, member, fieldIds, primaryKey }));
|
|
227
|
+
}
|
|
228
|
+
for (const name of Object.keys(theory.relations)) {
|
|
229
|
+
if (!relations.has(name)) {
|
|
230
|
+
throw errors.new(`bumbledb manifest drift: schema relation ${name} is not in the manifest`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return Object.freeze({ relations, statements: Object.freeze(entries) });
|
|
234
|
+
}
|
|
235
|
+
/** The private lifetime records of this module's read scopes. */
|
|
236
|
+
const scopeStates = new WeakMap();
|
|
237
|
+
/** The private engine halves of this module's prepared values. */
|
|
238
|
+
const preparedPlans = new WeakMap();
|
|
239
|
+
/**
|
|
240
|
+
* Reclaims the engine-side plan of a garbage-collected {@link Prepared}
|
|
241
|
+
* value. RECLAMATION ONLY, never correctness: a plan the collector never
|
|
242
|
+
* visits is idle engine memory until process exit, and a failure to close
|
|
243
|
+
* is swallowed (there is no one left to care — the owning value is gone).
|
|
244
|
+
*/
|
|
245
|
+
const planReclaimer = new FinalizationRegistry(function reclaimPlan(handle) {
|
|
246
|
+
const closed = errors.trySync(function closePlan() {
|
|
247
|
+
native.preparedClose(handle);
|
|
248
|
+
});
|
|
249
|
+
if (closed.error) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
/**
|
|
254
|
+
* The internal retry signal a lazily-witnessed transaction throws when the
|
|
255
|
+
* engine reports a moved generation at begin: `writeWitnessed` catches it
|
|
256
|
+
* by identity (through cause chains, via `errors.is`) and reruns the whole
|
|
257
|
+
* callback on a fresh snapshot. It never escapes the SDK.
|
|
258
|
+
*/
|
|
259
|
+
const generationMovedSignal = errors.new("bumbledb witnessed generation moved");
|
|
260
|
+
/**
|
|
261
|
+
* Fills one insert's omitted fresh cells through the engine's
|
|
262
|
+
* alloc-then-insert dyn lane (there is no insert-with-omitted-fields wire
|
|
263
|
+
* spelling) and collects every fresh cell — minted or resupplied — for the
|
|
264
|
+
* insert's return. Mutates `values` in place with the minted cells.
|
|
265
|
+
*/
|
|
266
|
+
function mintFreshCells(txHandle, entry, relation, values) {
|
|
267
|
+
const fresh = {};
|
|
268
|
+
for (const declared of relation.data.fields) {
|
|
269
|
+
if (!declared.field.minted) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
let cell = values[declared.name];
|
|
273
|
+
if (cell === undefined) {
|
|
274
|
+
const fieldId = entry.fieldIds.get(declared.name);
|
|
275
|
+
if (fieldId === undefined) {
|
|
276
|
+
throw errors.new(`bumbledb manifest drift: relation ${relation.name} has no field id for ${declared.name}`);
|
|
277
|
+
}
|
|
278
|
+
cell = bridged("bumbledb tx alloc", function mint() {
|
|
279
|
+
return native.txAlloc(txHandle, entry.id, fieldId);
|
|
280
|
+
});
|
|
281
|
+
values[declared.name] = cell;
|
|
282
|
+
}
|
|
283
|
+
if (typeof cell !== "bigint") {
|
|
284
|
+
throw errors.new(`relation ${relation.name} field ${declared.name}: a fresh cell is a u64 bigint, got ${typeof cell}`);
|
|
285
|
+
}
|
|
286
|
+
fresh[declared.name] = cell;
|
|
287
|
+
}
|
|
288
|
+
return fresh;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Constructs one open `Db` over an already-admitted handle: builds the
|
|
292
|
+
* id-resolution tables once and closes over them — the `Db` owns handle
|
|
293
|
+
* and tables and nothing else. Handle lifetime is the process's: the store
|
|
294
|
+
* cache holds the environment handle until the exit hook closes it.
|
|
295
|
+
*/
|
|
296
|
+
function openDb(handle, theory, manifest) {
|
|
297
|
+
const tables = tablesOf(theory, manifest);
|
|
298
|
+
/** This store's identity token: read scopes and prepared values carry it, so cross-store use is a typed refusal. */
|
|
299
|
+
const owner = Object.freeze({});
|
|
300
|
+
function isMemberName(name) {
|
|
301
|
+
return tables.relations.has(name);
|
|
302
|
+
}
|
|
303
|
+
function resolveOrdinary(relation) {
|
|
304
|
+
const entry = tables.relations.get(relation.name);
|
|
305
|
+
if (entry === undefined || entry.member !== relation) {
|
|
306
|
+
throw errors.new(`relation ${relation.name} is not a member of schema ${theory.name}`);
|
|
307
|
+
}
|
|
308
|
+
if ("handles" in relation.data) {
|
|
309
|
+
throw errors.new(`relation ${relation.name} is closed — its extension is schema data (axioms), never scanned or written`);
|
|
310
|
+
}
|
|
311
|
+
return entry;
|
|
312
|
+
}
|
|
313
|
+
function offendingFactOf(fact) {
|
|
314
|
+
if (!isMemberName(fact.relation)) {
|
|
315
|
+
throw errors.new(`bumbledb violation cites unknown relation ${fact.relation}`);
|
|
316
|
+
}
|
|
317
|
+
const decoded = {};
|
|
318
|
+
for (const cell of fact.fields) {
|
|
319
|
+
decoded[cell.name] = cell.value;
|
|
320
|
+
}
|
|
321
|
+
return Object.freeze({ relation: fact.relation, fact: Object.freeze(decoded) });
|
|
322
|
+
}
|
|
323
|
+
function violationOf(wire) {
|
|
324
|
+
const entry = tables.statements[wire.statementId];
|
|
325
|
+
if (entry === undefined) {
|
|
326
|
+
throw errors.new(`bumbledb violation cites unknown statement id ${wire.statementId}`);
|
|
327
|
+
}
|
|
328
|
+
return Object.freeze({
|
|
329
|
+
statement: entry.statement,
|
|
330
|
+
kind: wire.kind,
|
|
331
|
+
canonical: wire.canonical,
|
|
332
|
+
direction: wire.direction,
|
|
333
|
+
orientation: orientationOf(entry.reversed),
|
|
334
|
+
count: wire.count,
|
|
335
|
+
facts: Object.freeze(wire.facts.map(offendingFactOf))
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Resolves a key-statement-selected read: the statement must be the
|
|
340
|
+
* IDENTICAL `key()` value this schema declared (identity is the
|
|
341
|
+
* membership rule) and must key `relation` — its materialized statement
|
|
342
|
+
* id comes from the positional mirror, so the engine point-reads through
|
|
343
|
+
* exactly the declared projection.
|
|
344
|
+
*/
|
|
345
|
+
function declaredKeyOf(relation, statement) {
|
|
346
|
+
const statementId = tables.statements.findIndex(function byIdentity(candidate) {
|
|
347
|
+
return candidate.statement === statement;
|
|
348
|
+
});
|
|
349
|
+
const entry = tables.statements[statementId];
|
|
350
|
+
if (entry === undefined) {
|
|
351
|
+
throw errors.new(`keyed get statement is not a declared statement of schema ${theory.name} — statement identity is the membership rule`);
|
|
352
|
+
}
|
|
353
|
+
if (entry.kind !== "functionality" || entry.key === undefined) {
|
|
354
|
+
throw errors.new("keyed get takes a key() statement — containments and windows key nothing");
|
|
355
|
+
}
|
|
356
|
+
if (entry.key.owner !== relation.name) {
|
|
357
|
+
throw errors.new(`keyed get statement keys ${entry.key.owner}, not ${relation.name} — the statement must be a declared key of the relation it reads`);
|
|
358
|
+
}
|
|
359
|
+
return Object.freeze({ statementId, projection: entry.key.projection });
|
|
360
|
+
}
|
|
361
|
+
function pointReadsOf(assertLive, reads) {
|
|
362
|
+
function contains(relation, fact) {
|
|
363
|
+
assertLive();
|
|
364
|
+
const entry = resolveOrdinary(relation);
|
|
365
|
+
return reads.contains(entry.id, rowOf(relation.data, recordOf(fact)));
|
|
366
|
+
}
|
|
367
|
+
/** One keyed point read through an already-resolved key, decoded to a fact (`undefined` on a miss). */
|
|
368
|
+
function readThroughKey(relation, entry, selected, key) {
|
|
369
|
+
const row = reads.get(entry.id, selected.statementId, keyRowOf(relation.data, selected.projection, key));
|
|
370
|
+
if (row === null) {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
return factOf(relation, row);
|
|
374
|
+
}
|
|
375
|
+
function get(relation, keyOrStatement, declaredKey) {
|
|
376
|
+
assertLive();
|
|
377
|
+
const entry = resolveOrdinary(relation);
|
|
378
|
+
if (declaredKey !== undefined) {
|
|
379
|
+
if (!isStatementValue(keyOrStatement)) {
|
|
380
|
+
throw errors.new("keyed get takes a key() statement value as its second argument");
|
|
381
|
+
}
|
|
382
|
+
const selected = declaredKeyOf(relation, keyOrStatement);
|
|
383
|
+
return readThroughKey(relation, entry, selected, recordOf(declaredKey));
|
|
384
|
+
}
|
|
385
|
+
if (isStatementValue(keyOrStatement)) {
|
|
386
|
+
throw errors.new("keyed get with a statement selector also takes the key object — get(relation, keyStatement, key)");
|
|
387
|
+
}
|
|
388
|
+
const primaryKey = entry.primaryKey;
|
|
389
|
+
if (primaryKey === undefined) {
|
|
390
|
+
throw errors.new(`relation ${relation.name} has no candidate key — keyed get requires a fresh field or a declared key statement`);
|
|
391
|
+
}
|
|
392
|
+
return readThroughKey(relation, entry, primaryKey, recordOf(keyOrStatement));
|
|
393
|
+
}
|
|
394
|
+
return { contains, get };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Resolves a prepared value's private plan, refusing foreign objects
|
|
398
|
+
* and prepared values of other stores as typed errors.
|
|
399
|
+
*/
|
|
400
|
+
function planOf(prepared) {
|
|
401
|
+
const plan = preparedPlans.get(prepared);
|
|
402
|
+
if (plan === undefined) {
|
|
403
|
+
throw errors.new("bumbledb execute target is not a prepared value of this SDK");
|
|
404
|
+
}
|
|
405
|
+
if (plan.owner !== owner) {
|
|
406
|
+
throw errors.new(`bumbledb prepared value was prepared by a different store than this one (schema ${theory.name})`);
|
|
407
|
+
}
|
|
408
|
+
return plan;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Builds one {@link ReadScope} over a live scope state. Every verb
|
|
412
|
+
* asserts liveness first: the owning call flips `state.live` the moment
|
|
413
|
+
* its callback returns, so a leaked scope is a typed refusal forever
|
|
414
|
+
* after.
|
|
415
|
+
*/
|
|
416
|
+
function makeScope(state, generation) {
|
|
417
|
+
function assertLive() {
|
|
418
|
+
if (!state.live) {
|
|
419
|
+
throw errors.new("bumbledb read scope is invalidated — its owning read callback already returned");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const reads = pointReadsOf(assertLive, {
|
|
423
|
+
contains(relationId, row) {
|
|
424
|
+
return bridged("bumbledb snapshot contains", function readContains() {
|
|
425
|
+
return native.snapshotContains(state.handle, relationId, row);
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
get(relationId, statementId, key) {
|
|
429
|
+
return bridged("bumbledb snapshot get", function readGet() {
|
|
430
|
+
return native.snapshotGet(state.handle, relationId, statementId, key);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
function scan(relation) {
|
|
435
|
+
assertLive();
|
|
436
|
+
const entry = resolveOrdinary(relation);
|
|
437
|
+
const rows = bridged("bumbledb snapshot scan", function readScan() {
|
|
438
|
+
return native.snapshotScan(state.handle, entry.id);
|
|
439
|
+
});
|
|
440
|
+
return rows.map(function decodeRow(row) {
|
|
441
|
+
return factOf(relation, row);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
function execute(prepared, params) {
|
|
445
|
+
assertLive();
|
|
446
|
+
const plan = planOf(prepared);
|
|
447
|
+
const wire = wireParams(plan.params, recordOf(params));
|
|
448
|
+
const rows = bridged("execute bumbledb prepared query", function callExecute() {
|
|
449
|
+
return native.preparedExecute(plan.handle, state.handle, wire);
|
|
450
|
+
});
|
|
451
|
+
return decodeAnswers(plan.select, rows);
|
|
452
|
+
}
|
|
453
|
+
const scope = Object.freeze({
|
|
454
|
+
generation,
|
|
455
|
+
scan,
|
|
456
|
+
get: reads.get,
|
|
457
|
+
contains: reads.contains,
|
|
458
|
+
execute
|
|
459
|
+
});
|
|
460
|
+
scopeStates.set(scope, state);
|
|
461
|
+
return scope;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Live-handle accounting (diagnostic law, prod EINVAL 2026-07-17): every
|
|
465
|
+
* snapshot open/close is counted so a write-begin failure can report how
|
|
466
|
+
* many read handles were live at the fault — a leaked scope is invisible
|
|
467
|
+
* until the exact moment it matters, so the failure carries the census.
|
|
468
|
+
*/
|
|
469
|
+
let liveSnapshots = 0;
|
|
470
|
+
/** Opens one snapshot and its scope state (live until the owner flips it). */
|
|
471
|
+
function openScopeState() {
|
|
472
|
+
const snapHandle = bridged("open bumbledb snapshot", function openSnapshot() {
|
|
473
|
+
return native.dbSnapshot(handle);
|
|
474
|
+
});
|
|
475
|
+
liveSnapshots += 1;
|
|
476
|
+
return { handle: snapHandle, live: true, owner };
|
|
477
|
+
}
|
|
478
|
+
/** Closes a scope's snapshot after the owner invalidated it. */
|
|
479
|
+
function closeScopeState(state) {
|
|
480
|
+
bridged("close bumbledb snapshot", function closeSnapshot() {
|
|
481
|
+
native.snapshotClose(state.handle);
|
|
482
|
+
});
|
|
483
|
+
liveSnapshots -= 1;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Reads the committed generation for a just-opened scope, closing the
|
|
487
|
+
* scope's snapshot when the read faults: `dbGeneration` opens a transient
|
|
488
|
+
* engine read txn, so reader-table exhaustion is precisely the state in
|
|
489
|
+
* which it throws — with one snapshot already open. An unpaired fault
|
|
490
|
+
* here would park a snapshot worker and consume one of the engine's
|
|
491
|
+
* reader slots FOREVER (and undercount the liveSnapshots census), each
|
|
492
|
+
* fault ratcheting toward ReadersFull-for-the-process's-lifetime.
|
|
493
|
+
*/
|
|
494
|
+
function generationForScope(state) {
|
|
495
|
+
const generation = errors.trySync(function readGeneration() {
|
|
496
|
+
return bridged("read bumbledb generation", function callGeneration() {
|
|
497
|
+
return native.dbGeneration(handle);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
if (generation.error) {
|
|
501
|
+
state.live = false;
|
|
502
|
+
closeScopeState(state);
|
|
503
|
+
throw generation.error;
|
|
504
|
+
}
|
|
505
|
+
return generation.data;
|
|
506
|
+
}
|
|
507
|
+
function read(fn) {
|
|
508
|
+
const state = openScopeState();
|
|
509
|
+
const generation = generationForScope(state);
|
|
510
|
+
const scope = makeScope(state, generation);
|
|
511
|
+
const result = errors.trySync(function runRead() {
|
|
512
|
+
return fn(scope);
|
|
513
|
+
});
|
|
514
|
+
state.live = false;
|
|
515
|
+
closeScopeState(state);
|
|
516
|
+
if (result.error) {
|
|
517
|
+
throw errors.wrap(result.error, "bumbledb read");
|
|
518
|
+
}
|
|
519
|
+
return result.data;
|
|
520
|
+
}
|
|
521
|
+
function scan(relation) {
|
|
522
|
+
return read(function scanInScope(snap) {
|
|
523
|
+
return snap.scan(relation);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
function get(relation, keyOrStatement, declaredKey) {
|
|
527
|
+
return read(function getInScope(snap) {
|
|
528
|
+
if (declaredKey !== undefined) {
|
|
529
|
+
if (!isStatementValue(keyOrStatement)) {
|
|
530
|
+
throw errors.new("keyed get takes a key() statement value as its second argument");
|
|
531
|
+
}
|
|
532
|
+
return snap.get(relation, keyOrStatement, declaredKey);
|
|
533
|
+
}
|
|
534
|
+
if (isStatementValue(keyOrStatement)) {
|
|
535
|
+
throw errors.new("keyed get with a statement selector also takes the key object — get(relation, keyStatement, key)");
|
|
536
|
+
}
|
|
537
|
+
return snap.get(relation, keyOrStatement);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function contains(relation, fact) {
|
|
541
|
+
return read(function containsInScope(snap) {
|
|
542
|
+
return snap.contains(relation, fact);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
function execute(prepared, params) {
|
|
546
|
+
return read(function executeInScope(snap) {
|
|
547
|
+
return snap.execute(prepared, params);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Builds one {@link Tx} over a transaction-handle thunk: `write` passes
|
|
552
|
+
* an already-begun handle; `writeWitnessed` passes a LAZY thunk that
|
|
553
|
+
* begins the witnessed transaction on the first delta verb (so premise
|
|
554
|
+
* reads and the host's own interleaved writes can precede it) and
|
|
555
|
+
* throws {@link generationMovedSignal} when the witness is stale.
|
|
556
|
+
*/
|
|
557
|
+
function makeTx(resolveTx) {
|
|
558
|
+
const txState = { spent: false };
|
|
559
|
+
function assertLive() {
|
|
560
|
+
if (txState.spent) {
|
|
561
|
+
throw errors.new("bumbledb write transaction is spent");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const reads = pointReadsOf(assertLive, {
|
|
565
|
+
contains(relationId, row) {
|
|
566
|
+
const txHandle = resolveTx();
|
|
567
|
+
return bridged("bumbledb tx contains", function readContains() {
|
|
568
|
+
return native.txContains(txHandle, relationId, row);
|
|
569
|
+
});
|
|
570
|
+
},
|
|
571
|
+
get(relationId, statementId, key) {
|
|
572
|
+
const txHandle = resolveTx();
|
|
573
|
+
return bridged("bumbledb tx get", function readGet() {
|
|
574
|
+
return native.txGet(txHandle, relationId, statementId, key);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
function insert(relation, fact) {
|
|
579
|
+
assertLive();
|
|
580
|
+
const entry = resolveOrdinary(relation);
|
|
581
|
+
const txHandle = resolveTx();
|
|
582
|
+
const values = recordOf(fact);
|
|
583
|
+
const fresh = mintFreshCells(txHandle, entry, relation, values);
|
|
584
|
+
const row = rowOf(relation.data, values);
|
|
585
|
+
bridged("bumbledb tx insert", function record() {
|
|
586
|
+
native.txInsert(txHandle, entry.id, row);
|
|
587
|
+
});
|
|
588
|
+
Object.freeze(fresh);
|
|
589
|
+
if (!isMintedFresh(relation, fresh)) {
|
|
590
|
+
throw errors.new(`relation ${relation.name}: minted fresh record is incomplete`);
|
|
591
|
+
}
|
|
592
|
+
return fresh;
|
|
593
|
+
}
|
|
594
|
+
function remove(relation, fact) {
|
|
595
|
+
assertLive();
|
|
596
|
+
const entry = resolveOrdinary(relation);
|
|
597
|
+
const txHandle = resolveTx();
|
|
598
|
+
const row = rowOf(relation.data, recordOf(fact));
|
|
599
|
+
return bridged("bumbledb tx delete", function record() {
|
|
600
|
+
return native.txDelete(txHandle, entry.id, row);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
const tx = Object.freeze({
|
|
604
|
+
insert,
|
|
605
|
+
delete: remove,
|
|
606
|
+
contains: reads.contains,
|
|
607
|
+
get: reads.get
|
|
608
|
+
});
|
|
609
|
+
function spend() {
|
|
610
|
+
txState.spent = true;
|
|
611
|
+
}
|
|
612
|
+
return { tx, spend };
|
|
613
|
+
}
|
|
614
|
+
function runDelta(txHandle, fn) {
|
|
615
|
+
const made = makeTx(function resolveTx() {
|
|
616
|
+
return txHandle;
|
|
617
|
+
});
|
|
618
|
+
const built = errors.trySync(function buildDelta() {
|
|
619
|
+
return fn(made.tx);
|
|
620
|
+
});
|
|
621
|
+
made.spend();
|
|
622
|
+
if (built.error) {
|
|
623
|
+
bridged("abort bumbledb write transaction", function abort() {
|
|
624
|
+
native.txAbort(txHandle);
|
|
625
|
+
});
|
|
626
|
+
throw errors.wrap(built.error, "build write delta");
|
|
627
|
+
}
|
|
628
|
+
if (isThenable(built.data)) {
|
|
629
|
+
/**
|
|
630
|
+
* An `async` callback TYPECHECKS (Promise<void> is assignable where
|
|
631
|
+
* a `void` return is expected) but its body runs after the tx is
|
|
632
|
+
* spent: committing here would be a silent EMPTY commit reported
|
|
633
|
+
* ok while the callback's real inserts throw "spent" as unhandled
|
|
634
|
+
* rejections. Refused typed instead — abort, nothing committed
|
|
635
|
+
* (the same one-writer law as the thrown-callback path).
|
|
636
|
+
*/
|
|
637
|
+
bridged("abort bumbledb write transaction", function abort() {
|
|
638
|
+
native.txAbort(txHandle);
|
|
639
|
+
});
|
|
640
|
+
throw errors.new("bumbledb write callback returned a thenable — the delta build is synchronous; an async callback is refused, nothing was committed");
|
|
641
|
+
}
|
|
642
|
+
const committed = errors.trySync(function commitDelta() {
|
|
643
|
+
return bridged("commit bumbledb write transaction", function commit() {
|
|
644
|
+
return native.txCommit(txHandle);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
if (committed.error) {
|
|
648
|
+
/**
|
|
649
|
+
* A THROWN commit (engine I/O failure, bridge fault) must never
|
|
650
|
+
* leave the write transaction live: LMDB holds one writer per
|
|
651
|
+
* environment, and a leaked handle turns every later begin into
|
|
652
|
+
* EINVAL for the process's lifetime. The abort is best-effort —
|
|
653
|
+
* the native side may already have consumed the handle.
|
|
654
|
+
*/
|
|
655
|
+
const aborted = errors.trySync(function abortAfterFailedCommit() {
|
|
656
|
+
native.txAbort(txHandle);
|
|
657
|
+
});
|
|
658
|
+
if (aborted.error) {
|
|
659
|
+
}
|
|
660
|
+
throw errors.wrap(committed.error, "commit bumbledb write transaction");
|
|
661
|
+
}
|
|
662
|
+
const outcome = committed.data;
|
|
663
|
+
if (outcome.ok) {
|
|
664
|
+
return Object.freeze({ ok: true, generation: outcome.generation });
|
|
665
|
+
}
|
|
666
|
+
return Object.freeze({
|
|
667
|
+
ok: false,
|
|
668
|
+
violations: Object.freeze(outcome.violations.map(violationOf))
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
function write(fn) {
|
|
672
|
+
const begun = errors.trySync(function beginDelta() {
|
|
673
|
+
return bridged("begin bumbledb write transaction", function begin() {
|
|
674
|
+
return native.dbWriteBegin(handle);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
if (begun.error) {
|
|
678
|
+
throw errors.wrap(begun.error, `begin bumbledb write transaction (live snapshots at fault: ${liveSnapshots})`);
|
|
679
|
+
}
|
|
680
|
+
return runDelta(begun.data, fn);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Commits an already-begun witnessed transaction and closes the
|
|
684
|
+
* attempt's snapshot: the committed generation, or the engine's
|
|
685
|
+
* complete violation set as data.
|
|
686
|
+
*/
|
|
687
|
+
function commitWitnessed(state, txHandle) {
|
|
688
|
+
const committed = errors.trySync(function commitWitnessedDelta() {
|
|
689
|
+
return bridged("commit bumbledb witnessed write transaction", function commit() {
|
|
690
|
+
return native.txCommit(txHandle);
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
if (committed.error) {
|
|
694
|
+
/** Same one-writer law as `runDelta`: a thrown commit aborts before rethrowing. */
|
|
695
|
+
const aborted = errors.trySync(function abortAfterFailedCommit() {
|
|
696
|
+
native.txAbort(txHandle);
|
|
697
|
+
});
|
|
698
|
+
if (aborted.error) {
|
|
699
|
+
}
|
|
700
|
+
closeScopeState(state);
|
|
701
|
+
throw errors.wrap(committed.error, "commit bumbledb witnessed write transaction");
|
|
702
|
+
}
|
|
703
|
+
const outcome = committed.data;
|
|
704
|
+
closeScopeState(state);
|
|
705
|
+
if (outcome.ok) {
|
|
706
|
+
return Object.freeze({ ok: true, generation: outcome.generation });
|
|
707
|
+
}
|
|
708
|
+
return Object.freeze({
|
|
709
|
+
ok: false,
|
|
710
|
+
violations: Object.freeze(outcome.violations.map(violationOf))
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* One attempt of the witnessed loop: fresh snapshot, the callback over
|
|
715
|
+
* its scope and a LAZILY-begun witnessed transaction (the first delta
|
|
716
|
+
* verb begins it, so premise reads and the host's own interleaved
|
|
717
|
+
* writes can precede the witness check), then the witnessed commit —
|
|
718
|
+
* or the abandon abort, which never issues a commit. Returns
|
|
719
|
+
* `undefined` exactly when the generation moved and the whole callback
|
|
720
|
+
* must rerun on a fresh snapshot.
|
|
721
|
+
*/
|
|
722
|
+
function witnessedAttempt(fn) {
|
|
723
|
+
const state = openScopeState();
|
|
724
|
+
const generation = generationForScope(state);
|
|
725
|
+
const scope = makeScope(state, generation);
|
|
726
|
+
const pending = { tx: undefined };
|
|
727
|
+
function beginWitnessed() {
|
|
728
|
+
const witnessed = bridged("begin witnessed bumbledb write transaction", function begin() {
|
|
729
|
+
return native.dbWriteFrom(handle, state.handle);
|
|
730
|
+
});
|
|
731
|
+
if (!witnessed.ok) {
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
return witnessed.tx;
|
|
735
|
+
}
|
|
736
|
+
const made = makeTx(function resolveWitnessedTx() {
|
|
737
|
+
if (pending.tx === undefined) {
|
|
738
|
+
const begun = beginWitnessed();
|
|
739
|
+
if (begun === undefined) {
|
|
740
|
+
throw generationMovedSignal;
|
|
741
|
+
}
|
|
742
|
+
pending.tx = begun;
|
|
743
|
+
}
|
|
744
|
+
return pending.tx;
|
|
745
|
+
});
|
|
746
|
+
const built = errors.trySync(function computeWitnessed() {
|
|
747
|
+
return fn(scope, made.tx);
|
|
748
|
+
});
|
|
749
|
+
made.spend();
|
|
750
|
+
state.live = false;
|
|
751
|
+
/**
|
|
752
|
+
* Aborts the pending transaction if one was begun. A faulted abort
|
|
753
|
+
* still closes the attempt's snapshot BEFORE rethrowing — every
|
|
754
|
+
* openScopeState is paired with closeScopeState on every exit, or a
|
|
755
|
+
* reader slot and its snapshot worker leak for the process's lifetime.
|
|
756
|
+
*/
|
|
757
|
+
function abortPending() {
|
|
758
|
+
const txHandle = pending.tx;
|
|
759
|
+
if (txHandle === undefined) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const aborted = errors.trySync(function abort() {
|
|
763
|
+
native.txAbort(txHandle);
|
|
764
|
+
});
|
|
765
|
+
if (aborted.error) {
|
|
766
|
+
closeScopeState(state);
|
|
767
|
+
throw errors.wrap(aborted.error, "abort bumbledb witnessed write transaction");
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (built.error) {
|
|
771
|
+
abortPending();
|
|
772
|
+
closeScopeState(state);
|
|
773
|
+
if (errors.is(built.error, generationMovedSignal)) {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
throw errors.wrap(built.error, "build witnessed write delta");
|
|
777
|
+
}
|
|
778
|
+
if (isThenable(built.data)) {
|
|
779
|
+
/** The same async-callback refusal as `runDelta` — a thenable means the real delta build races the commit; nothing is committed. */
|
|
780
|
+
abortPending();
|
|
781
|
+
closeScopeState(state);
|
|
782
|
+
throw errors.new("bumbledb writeWitnessed callback returned a thenable — the delta build is synchronous; an async callback is refused, nothing was committed");
|
|
783
|
+
}
|
|
784
|
+
if (isAbandon(built.data)) {
|
|
785
|
+
abortPending();
|
|
786
|
+
closeScopeState(state);
|
|
787
|
+
return Object.freeze({ ok: false, abandoned: built.data.payload });
|
|
788
|
+
}
|
|
789
|
+
const late = errors.trySync(function resolveCommitTx() {
|
|
790
|
+
if (pending.tx === undefined) {
|
|
791
|
+
return beginWitnessed();
|
|
792
|
+
}
|
|
793
|
+
return pending.tx;
|
|
794
|
+
});
|
|
795
|
+
if (late.error) {
|
|
796
|
+
/** A faulted late begin must not leak the attempt's snapshot either. */
|
|
797
|
+
closeScopeState(state);
|
|
798
|
+
throw late.error;
|
|
799
|
+
}
|
|
800
|
+
const txHandle = late.data;
|
|
801
|
+
if (txHandle === undefined) {
|
|
802
|
+
closeScopeState(state);
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
return commitWitnessed(state, txHandle);
|
|
806
|
+
}
|
|
807
|
+
function writeWitnessed(fn) {
|
|
808
|
+
for (;;) {
|
|
809
|
+
const attempt = witnessedAttempt(fn);
|
|
810
|
+
if (attempt !== undefined) {
|
|
811
|
+
return attempt;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Verifies the declaration-ordinal law the query lowering leans on
|
|
817
|
+
* against the live manifest tables: relation ids and sealed field ids
|
|
818
|
+
* both equal declaration order (`dbManifest` is the engine's own
|
|
819
|
+
* pinning of it). Any drift is a construction-time failure here, never
|
|
820
|
+
* a silently misaddressed query.
|
|
821
|
+
*/
|
|
822
|
+
function assertOrdinalAlignment() {
|
|
823
|
+
Object.keys(theory.relations).forEach(function verifyRelation(name, ordinal) {
|
|
824
|
+
const entry = tables.relations.get(name);
|
|
825
|
+
if (entry === undefined || entry.id !== ordinal) {
|
|
826
|
+
throw errors.new(`bumbledb manifest drift: relation ${name} has engine id ${entry?.id}, its declaration ordinal is ${ordinal} — query lowering depends on declaration order = ids`);
|
|
827
|
+
}
|
|
828
|
+
const member = theory.relations[name];
|
|
829
|
+
if (member === undefined) {
|
|
830
|
+
throw errors.new(`bumbledb manifest drift: schema ${theory.name} lost relation ${name}`);
|
|
831
|
+
}
|
|
832
|
+
const sealed = "handles" in member.data
|
|
833
|
+
? [
|
|
834
|
+
"id",
|
|
835
|
+
...member.data.columns.map(function columnName(column) {
|
|
836
|
+
return column.name;
|
|
837
|
+
})
|
|
838
|
+
]
|
|
839
|
+
: member.data.fields.map(function fieldName(declared) {
|
|
840
|
+
return declared.name;
|
|
841
|
+
});
|
|
842
|
+
sealed.forEach(function verifyField(fieldName, fieldOrdinal) {
|
|
843
|
+
if (entry.fieldIds.get(fieldName) !== fieldOrdinal) {
|
|
844
|
+
throw errors.new(`bumbledb manifest drift: ${name}.${fieldName} has engine field id ${entry.fieldIds.get(fieldName)}, its sealed ordinal is ${fieldOrdinal}`);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function prepare(q) {
|
|
850
|
+
if (q.schema !== theory) {
|
|
851
|
+
throw errors.new(`query was built against schema ${q.schema.name}, not the identical schema value this store opened with — schema identity is the membership rule`);
|
|
852
|
+
}
|
|
853
|
+
assertOrdinalAlignment();
|
|
854
|
+
const program = lowerQuery(q);
|
|
855
|
+
const outcome = bridged("prepare bumbledb program", function callPrepare() {
|
|
856
|
+
return native.dbPrepare(handle, program);
|
|
857
|
+
});
|
|
858
|
+
if (!outcome.ok) {
|
|
859
|
+
throw errors.new(`bumbledb ${outcome.kind} (prepare): ${outcome.message}`);
|
|
860
|
+
}
|
|
861
|
+
const preparedHandle = outcome.prepared;
|
|
862
|
+
function staleness(snap) {
|
|
863
|
+
const snapState = scopeStates.get(snap);
|
|
864
|
+
if (snapState === undefined) {
|
|
865
|
+
throw errors.new("bumbledb staleness witness is not a read scope of this SDK");
|
|
866
|
+
}
|
|
867
|
+
if (snapState.owner !== owner) {
|
|
868
|
+
throw errors.new(`bumbledb read scope belongs to a different store than this prepared value (schema ${theory.name})`);
|
|
869
|
+
}
|
|
870
|
+
if (!snapState.live) {
|
|
871
|
+
throw errors.new("bumbledb read scope is invalidated — its owning read callback already returned");
|
|
872
|
+
}
|
|
873
|
+
return bridged("read bumbledb prepared staleness", function callStaleness() {
|
|
874
|
+
return native.preparedStaleness(preparedHandle, snapState.handle);
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
const prepared = Object.freeze({ staleness });
|
|
878
|
+
preparedPlans.set(prepared, Object.freeze({
|
|
879
|
+
handle: preparedHandle,
|
|
880
|
+
owner,
|
|
881
|
+
params: q.data.registry.params,
|
|
882
|
+
select: q.data.select
|
|
883
|
+
}));
|
|
884
|
+
planReclaimer.register(prepared, preparedHandle);
|
|
885
|
+
return prepared;
|
|
886
|
+
}
|
|
887
|
+
return Object.freeze({
|
|
888
|
+
schema: theory,
|
|
889
|
+
read,
|
|
890
|
+
scan,
|
|
891
|
+
get,
|
|
892
|
+
contains,
|
|
893
|
+
execute,
|
|
894
|
+
write,
|
|
895
|
+
writeWitnessed,
|
|
896
|
+
prepare
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* The per-process store cache, keyed by canonical path
|
|
901
|
+
* (`node:path.resolve` — absolute and normalized). Symlink aliasing is
|
|
902
|
+
* deliberately not resolved here: an aliased spelling misses the cache and
|
|
903
|
+
* reaches the engine, whose exclusive lock refuses a second live handle on
|
|
904
|
+
* the same store — the backstop that keeps "one store, one handle" true.
|
|
905
|
+
*/
|
|
906
|
+
const openStores = new Map();
|
|
907
|
+
/**
|
|
908
|
+
* The in-process fingerprint check and its typing proof in one probe:
|
|
909
|
+
* theory identity (`===`) implies `Rels` identity, because a cache entry's
|
|
910
|
+
* `db` was constructed from that very theory value — so a hit narrows the
|
|
911
|
+
* entry's `db` to `Db<Rels>` with no assertion anywhere.
|
|
912
|
+
*/
|
|
913
|
+
function holdsTheory(entry, theory) {
|
|
914
|
+
return entry.theory === theory;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* The best-effort exit hook: closes every cached environment so LMDB
|
|
918
|
+
* releases its locks tidily on a clean exit. CORRECTNESS NEVER RESTS HERE —
|
|
919
|
+
* the engine fsyncs every commit, so a process killed before (or during)
|
|
920
|
+
* this hook loses nothing that was committed.
|
|
921
|
+
*/
|
|
922
|
+
process.once("exit", function closeCachedStores() {
|
|
923
|
+
for (const cached of openStores.values()) {
|
|
924
|
+
const closed = errors.trySync(function closeEnvironment() {
|
|
925
|
+
native.dbClose(cached.handle);
|
|
926
|
+
});
|
|
927
|
+
if (closed.error) {
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
/**
|
|
932
|
+
* The one admission path both verbs share: canonical-path cache lookup
|
|
933
|
+
* first (a hit returns the SAME `Db` value for the identical theory, a
|
|
934
|
+
* typed fingerprint error for a different one, and a typed refusal for
|
|
935
|
+
* `create` — the store a cache entry proves initialized is exactly what
|
|
936
|
+
* create refuses). On a miss: lower the theory, run one bridge call, and
|
|
937
|
+
* wrap the two domain refusals — `schemaError` (spec resolution + schema
|
|
938
|
+
* validation, every issue in one message) and `fingerprintMismatch` (a
|
|
939
|
+
* different theory cannot open the store) — into typed errors carrying the
|
|
940
|
+
* engine's message intact.
|
|
941
|
+
*/
|
|
942
|
+
function admit(verb, storePath, theory) {
|
|
943
|
+
const canonical = path.resolve(storePath);
|
|
944
|
+
const cached = openStores.get(canonical);
|
|
945
|
+
if (cached !== undefined) {
|
|
946
|
+
if (verb === "create") {
|
|
947
|
+
throw errors.new(`create bumbledb store at ${canonical}: the store is already open in this process — create refuses an already-initialized directory`);
|
|
948
|
+
}
|
|
949
|
+
if (!holdsTheory(cached, theory)) {
|
|
950
|
+
throw errors.new(`bumbledb fingerprintMismatch (open ${canonical}): the cached store was opened with schema ${cached.theory.name}, not this theory value — schema identity is the membership rule`);
|
|
951
|
+
}
|
|
952
|
+
return cached.db;
|
|
953
|
+
}
|
|
954
|
+
const spec = lower(theory);
|
|
955
|
+
const opened = bridged(`${verb} bumbledb store at ${canonical}`, function callBridge() {
|
|
956
|
+
if (verb === "create") {
|
|
957
|
+
return native.dbCreate(canonical, spec);
|
|
958
|
+
}
|
|
959
|
+
return native.dbOpen(canonical, spec);
|
|
960
|
+
});
|
|
961
|
+
if (!opened.ok) {
|
|
962
|
+
throw errors.new(`bumbledb ${opened.kind} (${verb} ${canonical}): ${opened.message}`);
|
|
963
|
+
}
|
|
964
|
+
const manifest = bridged("fetch bumbledb manifest", function fetchManifest() {
|
|
965
|
+
return native.dbManifest(opened.db);
|
|
966
|
+
});
|
|
967
|
+
const db = openDb(opened.db, theory, manifest);
|
|
968
|
+
openStores.set(canonical, Object.freeze({ theory, db, handle: opened.db }));
|
|
969
|
+
return db;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* The store lifecycle — `Db.create(path, schema)` / `Db.open(path, schema)`.
|
|
973
|
+
* Create refuses an already-initialized directory; open verifies format
|
|
974
|
+
* version, store kind, and the schema fingerprint. Both return values
|
|
975
|
+
* CACHED per canonical path: a second open of the same path with the
|
|
976
|
+
* identical theory value returns the SAME `Db`, and a different theory on
|
|
977
|
+
* a cached path is a typed fingerprint error. There is no close anywhere:
|
|
978
|
+
* the process owns every cached environment until exit (a best-effort exit
|
|
979
|
+
* hook closes them; durability is the engine's per-commit fsync). One
|
|
980
|
+
* store kind exists: durable — resume = reopen, meaning this process's
|
|
981
|
+
* cached value or a fresh process's open.
|
|
982
|
+
*/
|
|
983
|
+
const Db = Object.freeze({
|
|
984
|
+
/** Creates a fresh durable store at `path` from the schema; the value is cached for every later open. */
|
|
985
|
+
async create(path, theory) {
|
|
986
|
+
return admit("create", path, theory);
|
|
987
|
+
},
|
|
988
|
+
/**
|
|
989
|
+
* Opens an existing durable store at `path` with the same theory — the
|
|
990
|
+
* cached value when this process already holds it. A fingerprint-matching
|
|
991
|
+
* open also BACK-FILLS the store's persisted schema descriptor when it is
|
|
992
|
+
* absent (self-describing stores, engine 50-storage.md § the `_meta`
|
|
993
|
+
* block), so a legacy store becomes exhumable after one ordinary open —
|
|
994
|
+
* adoption is automatic, never a separate verb.
|
|
995
|
+
*/
|
|
996
|
+
async open(path, theory) {
|
|
997
|
+
return admit("open", path, theory);
|
|
998
|
+
},
|
|
999
|
+
/**
|
|
1000
|
+
* Opens a store READ-ONLY from its own persisted descriptor — the SDK's
|
|
1001
|
+
* one schema-independent read path (no theory, no fingerprint check; the
|
|
1002
|
+
* store rebirth tool's entry). Lives beside `open`/`create` so the path
|
|
1003
|
+
* law stays in one place: the same `node:path.resolve` canonicalization,
|
|
1004
|
+
* applied here. The value is NOT cached and carries no close: the
|
|
1005
|
+
* engine-side handle (and the store's exclusive lock) is reclaimed by GC
|
|
1006
|
+
* — reclamation only, never correctness. A store not yet adopted rejects
|
|
1007
|
+
* with the typed `ErrExhumeNoDescriptor` (the remedy: one
|
|
1008
|
+
* fingerprint-matching `Db.open` under the creating schema back-fills
|
|
1009
|
+
* the descriptor).
|
|
1010
|
+
*/
|
|
1011
|
+
async exhume(storePath) {
|
|
1012
|
+
return exhumeStore(path.resolve(storePath));
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
export { abandon, Db };
|
|
1016
|
+
//# sourceMappingURL=db.js.map
|