@bjornpagen/bumbledb 0.6.0 → 0.7.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.
Files changed (83) hide show
  1. package/COOKBOOK.md +45 -6
  2. package/README.md +15 -2
  3. package/dist/closed.d.ts +7 -5
  4. package/dist/closed.d.ts.map +1 -1
  5. package/dist/closed.js +4 -1
  6. package/dist/closed.js.map +1 -1
  7. package/dist/count.js +1 -1
  8. package/dist/count.js.map +1 -1
  9. package/dist/db.d.ts +110 -73
  10. package/dist/db.d.ts.map +1 -1
  11. package/dist/db.js +170 -114
  12. package/dist/db.js.map +1 -1
  13. package/dist/exhume.d.ts +22 -10
  14. package/dist/exhume.d.ts.map +1 -1
  15. package/dist/exhume.js +42 -9
  16. package/dist/exhume.js.map +1 -1
  17. package/dist/face.d.ts +10 -6
  18. package/dist/face.d.ts.map +1 -1
  19. package/dist/face.js.map +1 -1
  20. package/dist/fields.d.ts +38 -16
  21. package/dist/fields.d.ts.map +1 -1
  22. package/dist/fields.js +33 -8
  23. package/dist/fields.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/lower.d.ts +9 -9
  27. package/dist/lower.d.ts.map +1 -1
  28. package/dist/lower.js +17 -12
  29. package/dist/lower.js.map +1 -1
  30. package/dist/marshal.d.ts +17 -7
  31. package/dist/marshal.d.ts.map +1 -1
  32. package/dist/marshal.js +32 -10
  33. package/dist/marshal.js.map +1 -1
  34. package/dist/native.d.ts +60 -9
  35. package/dist/native.d.ts.map +1 -1
  36. package/dist/native.js.map +1 -1
  37. package/dist/query/atom.d.ts +72 -7
  38. package/dist/query/atom.d.ts.map +1 -1
  39. package/dist/query/atom.js +12 -9
  40. package/dist/query/atom.js.map +1 -1
  41. package/dist/query/lower.d.ts +8 -0
  42. package/dist/query/lower.d.ts.map +1 -1
  43. package/dist/query/lower.js +80 -13
  44. package/dist/query/lower.js.map +1 -1
  45. package/dist/query/run.d.ts +5 -5
  46. package/dist/query/run.d.ts.map +1 -1
  47. package/dist/query/run.js +7 -12
  48. package/dist/query/run.js.map +1 -1
  49. package/dist/query/scope.d.ts +15 -5
  50. package/dist/query/scope.d.ts.map +1 -1
  51. package/dist/query/scope.js.map +1 -1
  52. package/dist/relation.d.ts.map +1 -1
  53. package/dist/relation.js +3 -1
  54. package/dist/relation.js.map +1 -1
  55. package/dist/schema.d.ts.map +1 -1
  56. package/dist/schema.js +12 -2
  57. package/dist/schema.js.map +1 -1
  58. package/dist/spec.d.ts +20 -10
  59. package/dist/spec.d.ts.map +1 -1
  60. package/dist/spec.js.map +1 -1
  61. package/dist/statements.d.ts +22 -2
  62. package/dist/statements.d.ts.map +1 -1
  63. package/dist/statements.js +26 -5
  64. package/dist/statements.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/closed.ts +13 -7
  67. package/src/count.ts +1 -1
  68. package/src/db.ts +289 -173
  69. package/src/exhume.ts +48 -11
  70. package/src/face.ts +13 -6
  71. package/src/fields.ts +51 -15
  72. package/src/index.ts +1 -1
  73. package/src/lower.ts +17 -12
  74. package/src/marshal.ts +38 -13
  75. package/src/native.ts +59 -8
  76. package/src/query/atom.ts +119 -15
  77. package/src/query/lower.ts +117 -15
  78. package/src/query/run.ts +7 -12
  79. package/src/query/scope.ts +15 -6
  80. package/src/relation.ts +3 -1
  81. package/src/schema.ts +14 -2
  82. package/src/spec.ts +21 -9
  83. package/src/statements.ts +30 -6
package/src/db.ts CHANGED
@@ -5,16 +5,19 @@
5
5
  * keyed to statements, read through scoped snapshots, and run the witnessed
6
6
  * read-compute-write loop — all typed by the schema's relations record.
7
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.
8
+ * LIFETIMES ARE DISPOSABLES, never `close()` (ruled 2026-07-23, R12): no
9
+ * value this module returns carries a close spelling release is
10
+ * deterministic and scope-shaped in the language's own syntax. `Db` values
11
+ * are CACHED per canonical path for the life of the process (a best-effort
12
+ * exit hook closes the cached environments; correctness never depends on it
13
+ * the engine fsyncs every commit, so a process that dies without the hook
14
+ * loses nothing that was committed). Snapshots are scope-shaped both ways:
15
+ * `read(fn)` opens one before `fn` and closes it after unconditionally
16
+ * (the {@link ReadScope} handed to `fn` is invalidated the moment `fn`
17
+ * returns), and `using snap = db.read()` hands the caller the lifetime,
18
+ * released by the scope's own `Symbol.dispose` at scope exit. Prepared
19
+ * plans are plain values whose engine-side half is reclaimed by a GC
20
+ * finalizer — reclamation only, never correctness.
18
21
  *
19
22
  * PROCESS MODEL: one process, one exclusive-lock handle per store. The
20
23
  * cached `Db` value owns the LMDB environment's exclusive lock until
@@ -41,7 +44,7 @@ import {
41
44
  factOf,
42
45
  handleOf,
43
46
  isFreshField,
44
- isMintedFresh,
47
+ isInserted,
45
48
  type KeyFact,
46
49
  keyRowOf,
47
50
  type Minted,
@@ -51,6 +54,7 @@ import {
51
54
 
52
55
  import type {
53
56
  DbHandle,
57
+ Explain,
54
58
  FactValue,
55
59
  Manifest,
56
60
  PreparedHandle,
@@ -69,7 +73,7 @@ import { decodeAnswers, wireParams } from "#query/run.ts"
69
73
  import type { ParamEntry, ParamsRecord } from "#query/scope.ts"
70
74
  import type { AnyRelation, Fact, InsertFact } from "#relation.ts"
71
75
  import type { AnySchema, Schema, SchemaRelation, SchemaRelations } from "#schema.ts"
72
- import type { KeyStatement, Statement } from "#statements.ts"
76
+ import { isStatement, type KeyStatement, type Statement } from "#statements.ts"
73
77
 
74
78
  /**
75
79
  * The ordinary (writable, scannable) relations of a schema's record — the
@@ -132,16 +136,35 @@ interface Violation<Rels extends SchemaRelations> {
132
136
  }
133
137
 
134
138
  /**
135
- * A commit's domain outcome: the committed generation, or the COMPLETE
136
- * violation set (every violated statement cited once, per direction for a
137
- * containment, in materialized statement order). Narrows on `.ok`.
139
+ * The abandoned arm of a write result (ruled 2026-07-23, R10): present in
140
+ * the type EXACTLY when the callback can abandon the conditional
141
+ * distributes over `R`, so a callback with no `Abandon` arm contributes
142
+ * `never` and the arm vanishes from the sum. The outcome is in the type;
143
+ * a dead arm is never handled.
138
144
  */
139
- type WriteResult<Rels extends SchemaRelations> =
145
+ type AbandonedArm<R> = R extends Abandon<infer P> ? { readonly ok: false; readonly abandoned: P } : never
146
+
147
+ /**
148
+ * A write's domain outcome, one sum for BOTH verbs (ruled 2026-07-23,
149
+ * R10): the committed generation; the COMPLETE violation set (every
150
+ * violated statement cited once, per direction for a containment, in
151
+ * materialized statement order); or the callback's own abandon payload —
152
+ * commit-vs-abandon is in the type, so a caller's explicit decline to
153
+ * commit can never be silently discarded. Narrows on `.ok`, then (when the
154
+ * callback can abandon) on `"violations" in result`.
155
+ */
156
+ type WriteResult<Rels extends SchemaRelations, R = void> =
140
157
  | { readonly ok: true; readonly generation: bigint }
141
158
  | { readonly ok: false; readonly violations: readonly Violation<Rels>[] }
159
+ | AbandonedArm<R>
142
160
 
143
- /** The delta-building callback of a write: runs synchronously against the live transaction. */
144
- type DeltaBuild<Rels extends SchemaRelations> = (tx: Tx<Rels>) => void
161
+ /**
162
+ * The delta-building callback of a write: runs synchronously against the
163
+ * live transaction. Returning {@link abandon}`(payload)` rolls the
164
+ * transaction back (R10) — the result type carries the payload arm exactly
165
+ * then.
166
+ */
167
+ type DeltaBuild<Rels extends SchemaRelations, R = void> = (tx: Tx<Rels>) => R
145
168
 
146
169
  /**
147
170
  * The runtime discriminant of {@link Abandon} values — a property probe is
@@ -151,10 +174,11 @@ type DeltaBuild<Rels extends SchemaRelations> = (tx: Tx<Rels>) => void
151
174
  const abandonMark: unique symbol = Symbol("bumbledb.abandon")
152
175
 
153
176
  /**
154
- * The abandon sentinel {@link abandon} builds: returning one from a
155
- * `writeWitnessed` callback aborts the attempt WITHOUT committing (no empty
156
- * commit is ever issued) and surfaces the payload as
157
- * `{ ok: false, abandoned: payload }`.
177
+ * The abandon sentinel {@link abandon} builds: returning one from a `write`
178
+ * or `writeWitnessed` callback rolls the transaction back WITHOUT
179
+ * committing (no empty commit is ever issued) and surfaces the payload as
180
+ * `{ ok: false, abandoned: payload }` (ruled 2026-07-23, R10 — the
181
+ * sentinel's contract is unconditional, whichever write verb received it).
158
182
  */
159
183
  interface Abandon<P> {
160
184
  readonly [abandonMark]: true
@@ -162,26 +186,28 @@ interface Abandon<P> {
162
186
  }
163
187
 
164
188
  /**
165
- * Wraps a payload in the {@link Abandon} sentinel — the one way a
166
- * `writeWitnessed` callback declines to commit: `return abandon(payload)`
167
- * aborts the delta (nothing is committed, not even an empty commit) and the
168
- * write resolves to `{ ok: false, abandoned: payload }`.
189
+ * Wraps a payload in the {@link Abandon} sentinel — the one way a write
190
+ * callback declines to commit: `return abandon(payload)` aborts the delta
191
+ * (nothing is committed, not even an empty commit) and the write resolves
192
+ * to `{ ok: false, abandoned: payload }`, from `write` and `writeWitnessed`
193
+ * alike (R10).
169
194
  */
170
195
  function abandon<P>(payload: P): Abandon<P> {
171
196
  return Object.freeze({ [abandonMark]: true as const, payload })
172
197
  }
173
198
 
174
199
  /**
175
- * The abandon payload type a `writeWitnessed` callback's return type
176
- * implies: the payload of its `Abandon` arm, `never` when the callback can
177
- * never abandon (the `abandoned` outcome is then statically unreachable).
200
+ * The abandon payload type a write callback's return type implies: the
201
+ * payload of its `Abandon` arm, `never` when the callback can never
202
+ * abandon (the `abandoned` outcome is then statically unreachable and
203
+ * {@link AbandonedArm} erases it from the sum).
178
204
  */
179
205
  type AbandonedPayload<R> = R extends Abandon<infer P> ? P : never
180
206
 
181
207
  /**
182
- * Narrows a `writeWitnessed` callback result to the abandon sentinel. The
183
- * probe is the private {@link abandonMark} symbol only {@link abandon} sets,
184
- * and `R`'s `Abandon` arm is the only way a sentinel can flow out of the
208
+ * Narrows a write callback result to the abandon sentinel. The probe is
209
+ * the private {@link abandonMark} symbol only {@link abandon} sets, and
210
+ * `R`'s `Abandon` arm is the only way a sentinel can flow out of the
185
211
  * callback — so the narrowed payload type is sound by construction.
186
212
  */
187
213
  function isAbandon<R>(value: R): value is R & Abandon<AbandonedPayload<R>> {
@@ -189,15 +215,30 @@ function isAbandon<R>(value: R): value is R & Abandon<AbandonedPayload<R>> {
189
215
  }
190
216
 
191
217
  /**
192
- * `writeWitnessed`'s domain outcome: the committed generation, the COMPLETE
193
- * engine violation set (rejection-as-data, exactly {@link WriteResult}'s
194
- * false arm), or the callback's own abandon payload. Narrows on `.ok`, then
195
- * on `"violations" in result`.
218
+ * The abandon outcome's trusted admission seam: the value's shape is the
219
+ * checkable half (the sentinel mark only {@link abandon} mints, and the
220
+ * outcome carrying that sentinel's own payload), and the sentinel's
221
+ * existence IS the proof `R` carries an `Abandon` arm — so the outcome is
222
+ * admitted at the conditional {@link AbandonedArm} face the type tier
223
+ * cannot resolve over an open `R`.
196
224
  */
197
- type WitnessedWriteResult<Rels extends SchemaRelations, R> =
198
- | { readonly ok: true; readonly generation: bigint }
199
- | { readonly ok: false; readonly violations: readonly Violation<Rels>[] }
200
- | { readonly ok: false; readonly abandoned: AbandonedPayload<R> }
225
+ function isAbandonedOutcome<Rels extends SchemaRelations, R>(
226
+ outcome: { readonly ok: false; readonly abandoned: AbandonedPayload<R> },
227
+ sentinel: Abandon<AbandonedPayload<R>>
228
+ ): outcome is { readonly ok: false; readonly abandoned: AbandonedPayload<R> } & WriteResult<Rels, R> {
229
+ return isAbandon(sentinel) && outcome.abandoned === sentinel.payload
230
+ }
231
+
232
+ /** Builds the abandoned write outcome from the callback's own sentinel (the R10 arm's one mint). */
233
+ function abandonedOutcome<Rels extends SchemaRelations, R>(
234
+ sentinel: Abandon<AbandonedPayload<R>>
235
+ ): WriteResult<Rels, R> {
236
+ const outcome = Object.freeze({ ok: false as const, abandoned: sentinel.payload })
237
+ if (!isAbandonedOutcome<Rels, R>(outcome, sentinel)) {
238
+ throw errors.new("bumbledb abandon outcome construction incomplete")
239
+ }
240
+ return outcome
241
+ }
201
242
 
202
243
  /**
203
244
  * One live write transaction: the submitted delta with the engine's
@@ -210,10 +251,17 @@ interface Tx<Rels extends SchemaRelations> {
210
251
  /**
211
252
  * Records one insert. Omitted fresh fields are MINTED through the
212
253
  * engine's alloc lane and returned as bare bigints; supplying them instead
213
- * preserves identity (the resupply idiom). Returns the relation's
214
- * fresh cells, minted or resupplied.
254
+ * preserves identity (the resupply idiom). Returns `{ changed, ...fresh }`
255
+ * (ruled 2026-07-23, R11): the engine's changed-state report — the Rust
256
+ * surface's `insert(&fact) -> bool` bijection `delete` always honored —
257
+ * beside the relation's fresh cells, minted or resupplied. The
258
+ * idempotent-replay lane reads the bit from the insert itself; no extra
259
+ * `contains` round trip exists. The flattened shape cannot carry a FRESH
260
+ * cell literally named `changed` beside the report, so admission refuses
261
+ * that one spelling ({@link refuseShadowedChanged}) — never a silent
262
+ * shadow here.
215
263
  */
216
- insert<R extends MemberRelation<Rels>>(relation: R, fact: InsertFact<R>): Minted<R>
264
+ insert<R extends MemberRelation<Rels>>(relation: R, fact: InsertFact<R>): { readonly changed: boolean } & Minted<R>
217
265
  /** Records one delete; `true` iff the final state changed. */
218
266
  delete<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
219
267
  /** Final-state membership of one complete fact. */
@@ -236,19 +284,20 @@ interface Tx<Rels extends SchemaRelations> {
236
284
  }
237
285
 
238
286
  /**
239
- * The read view one `db.read(fn)` call scopes: an MVCC snapshot pinned at
240
- * its generation, valid EXACTLY for the synchronous extent of `fn`. The
241
- * value is invalidated when `fn` returns every later verb call throws a
242
- * typed used-after-scope error; the underlying snapshot (and its LMDB
243
- * reader slot) is already closed. No close spelling exists here because
244
- * there is nothing the host could ever need to close.
287
+ * The read view one `db.read(fn)` call scopes or one `using snap =
288
+ * db.read()` acquisition owns (ruled 2026-07-23, R12: lifetimes are
289
+ * disposables, never `close()`). The callback form invalidates the value
290
+ * when `fn` returns; the `using` form releases it at scope exit through
291
+ * `Symbol.dispose` either way the release is deterministic and
292
+ * scope-shaped, every later verb call throws a typed used-after-scope
293
+ * error, and the underlying snapshot (with its LMDB reader slot) is
294
+ * already closed.
245
295
  */
246
- interface ReadScope<Rels extends SchemaRelations> {
296
+ interface ReadScope<Rels extends SchemaRelations> extends Disposable {
247
297
  /**
248
- * The committed generation this scope witnessed — captured atomically
249
- * with the snapshot: writes are synchronous and this process holds the
250
- * store's only write handle, so nothing can commit between the snapshot
251
- * open and the generation read.
298
+ * The committed generation this scope witnessed — carried by the
299
+ * snapshot open itself (one crossing, inside the snapshot's own
300
+ * transaction), so it is atomic with the snapshot by construction.
252
301
  */
253
302
  readonly generation: bigint
254
303
  /** Full-relation export in row-id order, decoded to bare structural facts. */
@@ -277,6 +326,16 @@ interface ReadScope<Rels extends SchemaRelations> {
277
326
  * execution spelling ({@link Prepared} carries no `execute`).
278
327
  */
279
328
  execute<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Row[]
329
+ /**
330
+ * Plan introspection as data (ruled 2026-07-23, R13): runs the prepared
331
+ * query against this scope's snapshot with counting instrumentation
332
+ * (the engine's ANALYZE semantics) and returns the structured
333
+ * {@link Explain} report — plan sections and counters as plain values,
334
+ * so the host reads what the engine did with its query without a
335
+ * second toolchain. A diagnostic surface, EXPLICITLY UNFROZEN: the
336
+ * shape follows the plan representation wherever it goes.
337
+ */
338
+ explain<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Explain
280
339
  }
281
340
 
282
341
  /**
@@ -326,6 +385,14 @@ interface Db<Rels extends SchemaRelations> {
326
385
  * when `fn` returns — a used-after-scope call throws a typed error.
327
386
  */
328
387
  read<T>(fn: (snap: ReadScope<Rels>) => T): T
388
+ /**
389
+ * The `using` acquisition (ruled 2026-07-23, R12): `using snap =
390
+ * db.read()` — the caller owns the scope's lifetime, and the scope's
391
+ * `Symbol.dispose` releases the snapshot deterministically at scope
392
+ * exit, in the language's own syntax. Lifetimes are disposables, never
393
+ * `close()`.
394
+ */
395
+ read(): ReadScope<Rels>
329
396
  /** `db.scan(r)` === `db.read(snap => snap.scan(r))` — the symmetry rule. */
330
397
  scan<R extends MemberRelation<Rels>>(relation: R): Fact<R>[]
331
398
  /** `db.get(r, k)` === `db.read(snap => snap.get(r, k))` — the symmetry rule. */
@@ -340,12 +407,18 @@ interface Db<Rels extends SchemaRelations> {
340
407
  contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
341
408
  /** `db.execute(p, params)` === `db.read(snap => snap.execute(p, params))` — the symmetry rule. */
342
409
  execute<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Row[]
410
+ /** `db.explain(p, params)` === `db.read(snap => snap.explain(p, params))` — the symmetry rule (R13). */
411
+ explain<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Explain
343
412
  /**
344
413
  * One delta transaction: builds the delta synchronously through `fn`,
345
414
  * commits, and returns the domain outcome. A throw from `fn` aborts
346
- * the delta (LMDB untouched) and rethrows wrapped.
415
+ * the delta (LMDB untouched) and rethrows wrapped. `fn` may decline to
416
+ * commit by returning {@link abandon}`(payload)` (ruled 2026-07-23,
417
+ * R10): the transaction rolls back — nothing is committed, not even an
418
+ * empty commit — and the outcome is `{ ok: false, abandoned: payload }`,
419
+ * an arm the result type carries exactly when the callback can abandon.
347
420
  */
348
- write(fn: DeltaBuild<Rels>): WriteResult<Rels>
421
+ write<R = void>(fn: DeltaBuild<Rels, R>): WriteResult<Rels, R>
349
422
  /**
350
423
  * The ONE witnessed-write form: snapshot → `fn` (premise reads via
351
424
  * `snap`, delta via `tx`) → witnessed commit, which lands only if no
@@ -364,7 +437,7 @@ interface Db<Rels extends SchemaRelations> {
364
437
  * `{ ok: false, abandoned: payload }` and NO commit (not even an empty
365
438
  * one) is issued.
366
439
  */
367
- writeWitnessed<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WitnessedWriteResult<Rels, R>
440
+ writeWitnessed<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WriteResult<Rels, R>
368
441
  /**
369
442
  * Prepares a query value built against THIS schema (identity is the
370
443
  * membership rule): lowers it to the engine IR, pins the plan, and
@@ -507,18 +580,16 @@ function isThenable(value: unknown): boolean {
507
580
 
508
581
  /**
509
582
  * Narrows a keyed-get middle argument to a statement value (vs a key
510
- * object): only statement values carry `data.kind`no fact cell shape
511
- * (bool, bigint, string, bytes, `{ start, end }`) ever does, so the probe
512
- * never misreads a key object whose relation declares a field named `data`.
583
+ * object) through the statement module's admission branda
584
+ * REPRESENTATION, never a shape probe: fact cell shapes are structurally
585
+ * OPEN (an interval value carrying an excess `kind` property is a legal
586
+ * cell), so no property probe could ever be sound here, but no host-built
587
+ * key object can spell the module-private brand symbol.
513
588
  */
514
589
  function isStatementValue<R extends AnyRelation, P extends readonly string[]>(
515
590
  value: KeyFact<R> | KeyStatement<R, P>
516
591
  ): value is KeyStatement<R, P> {
517
- if (typeof value !== "object" || !("data" in value)) {
518
- return false
519
- }
520
- const data: unknown = value.data
521
- return typeof data === "object" && data !== null && "kind" in data
592
+ return isStatement(value)
522
593
  }
523
594
 
524
595
  /**
@@ -569,7 +640,11 @@ interface Tables {
569
640
  * Builds the id-resolution tables from the manifest, verifying the SDK's
570
641
  * positional mirror against the engine's reported order — any drift
571
642
  * (count, kind, id, or membership) is a construction-time failure, never a
572
- * silent misattribution of a violation to the wrong statement value.
643
+ * silent misattribution of a violation to the wrong statement value. The
644
+ * declaration-ordinal law the query lowering leans on is verified in the
645
+ * same walks: relation ids and sealed field ids both equal declaration
646
+ * order, so a constructed `Tables` IS the proof and `prepare` inherits it
647
+ * structurally — never a silently misaddressed query.
573
648
  */
574
649
  function tablesOf(theory: AnySchema, manifest: Manifest): Tables {
575
650
  const entries = materializedEntries(theory)
@@ -596,6 +671,13 @@ function tablesOf(theory: AnySchema, manifest: Manifest): Tables {
596
671
  for (const field of relation.fields) {
597
672
  fieldIds.set(field.name, field.id)
598
673
  }
674
+ sealedFieldsOf(member).forEach(function verifyField(declared, fieldOrdinal) {
675
+ if (fieldIds.get(declared.name) !== fieldOrdinal) {
676
+ throw errors.new(
677
+ `bumbledb manifest drift: ${relation.name}.${declared.name} has engine field id ${fieldIds.get(declared.name)}, its sealed ordinal is ${fieldOrdinal}`
678
+ )
679
+ }
680
+ })
599
681
  let primaryKey: PrimaryKey | undefined
600
682
  entries.forEach(function firstOwnedKey(entry, index) {
601
683
  if (primaryKey === undefined && entry.key !== undefined && entry.key.owner === relation.name) {
@@ -604,11 +686,17 @@ function tablesOf(theory: AnySchema, manifest: Manifest): Tables {
604
686
  })
605
687
  relations.set(relation.name, Object.freeze({ id: relation.id, member, fieldIds, primaryKey }))
606
688
  }
607
- for (const name of Object.keys(theory.relations)) {
608
- if (!relations.has(name)) {
689
+ Object.keys(theory.relations).forEach(function verifyRelation(name, ordinal) {
690
+ const entry = relations.get(name)
691
+ if (entry === undefined) {
609
692
  throw errors.new(`bumbledb manifest drift: schema relation ${name} is not in the manifest`)
610
693
  }
611
- }
694
+ if (entry.id !== ordinal) {
695
+ throw errors.new(
696
+ `bumbledb manifest drift: relation ${name} has engine id ${entry.id}, its declaration ordinal is ${ordinal} — query lowering depends on declaration order = ids`
697
+ )
698
+ }
699
+ })
612
700
  return Object.freeze({ relations, statements: Object.freeze(entries) })
613
701
  }
614
702
 
@@ -619,14 +707,20 @@ interface PointReads {
619
707
  }
620
708
 
621
709
  /**
622
- * One read scope's PRIVATE lifetime record: its live snapshot handle, its
623
- * liveness flag (flipped exactly when the owning `read`/`writeWitnessed`
624
- * callback returns), and its owning store's identity token. Held in
625
- * {@link scopeStates} the snapshot handle is never a public value.
710
+ * One read scope's PRIVATE lifetime record: its live snapshot handle, the
711
+ * generation it witnessed (carried by the snapshot open itself — one
712
+ * crossing, finding 016), its liveness flag (flipped when the owning
713
+ * `read`/`writeWitnessed` callback returns, or by the scope's own
714
+ * `Symbol.dispose`), its close latch (`closed` — the snapshot closes
715
+ * exactly once, whichever of the owner and the dispose gets there first),
716
+ * and its owning store's identity token. Held in {@link scopeStates} —
717
+ * the snapshot handle is never a public value.
626
718
  */
627
719
  interface ScopeState {
628
720
  readonly handle: SnapshotHandle
721
+ readonly generation: bigint
629
722
  live: boolean
723
+ closed: boolean
630
724
  readonly owner: object
631
725
  }
632
726
 
@@ -900,10 +994,11 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
900
994
  /**
901
995
  * Builds one {@link ReadScope} over a live scope state. Every verb
902
996
  * asserts liveness first: the owning call flips `state.live` the moment
903
- * its callback returns, so a leaked scope is a typed refusal forever
997
+ * its callback returns (or the scope's own `Symbol.dispose` does, for a
998
+ * `using`-acquired scope), so a leaked scope is a typed refusal forever
904
999
  * after.
905
1000
  */
906
- function makeScope(state: ScopeState, generation: bigint): ReadScope<Rels> {
1001
+ function makeScope(state: ScopeState): ReadScope<Rels> {
907
1002
  function assertLive(): void {
908
1003
  if (!state.live) {
909
1004
  throw errors.new("bumbledb read scope is invalidated — its owning read callback already returned")
@@ -940,12 +1035,27 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
940
1035
  })
941
1036
  return decodeAnswers<Row>(plan.finds, rows)
942
1037
  }
1038
+ function explain<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Explain {
1039
+ assertLive()
1040
+ const plan = planOf(prepared)
1041
+ const wire = wireParams(plan.params, recordOf(params))
1042
+ return bridged("explain bumbledb prepared query", function callExplain() {
1043
+ return native.preparedExplain(plan.handle, state.handle, wire)
1044
+ })
1045
+ }
1046
+ /** The R12 teardown: invalidate, then close — idempotent through the state's close latch. */
1047
+ function dispose(): void {
1048
+ state.live = false
1049
+ closeScopeState(state)
1050
+ }
943
1051
  const scope: ReadScope<Rels> = Object.freeze({
944
- generation,
1052
+ generation: state.generation,
945
1053
  scan,
946
1054
  get: reads.get,
947
1055
  contains: reads.contains,
948
- execute
1056
+ execute,
1057
+ explain,
1058
+ [Symbol.dispose]: dispose
949
1059
  })
950
1060
  scopeStates.set(scope, state)
951
1061
  return scope
@@ -959,50 +1069,52 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
959
1069
  */
960
1070
  let liveSnapshots = 0
961
1071
 
962
- /** Opens one snapshot and its scope state (live until the owner flips it). */
1072
+ /**
1073
+ * Opens one snapshot and its scope state (live until the owner flips
1074
+ * it). The witnessed generation rides the snapshot open itself — one
1075
+ * crossing carries both (finding 016), so the fault-pairing close
1076
+ * branch a second `dbGeneration` call needed is structurally gone.
1077
+ */
963
1078
  function openScopeState(): ScopeState {
964
- const snapHandle = bridged("open bumbledb snapshot", function openSnapshot() {
1079
+ const opened = bridged("open bumbledb snapshot", function openSnapshot() {
965
1080
  return native.dbSnapshot(handle)
966
1081
  })
967
1082
  liveSnapshots += 1
968
- return { handle: snapHandle, live: true, owner }
1083
+ return { handle: opened.snapshot, generation: opened.generation, live: true, closed: false, owner }
969
1084
  }
970
1085
 
971
- /** Closes a scope's snapshot after the owner invalidated it. */
1086
+ /**
1087
+ * Closes a scope's snapshot after the owner invalidated it — a LATCH:
1088
+ * the snapshot closes exactly once, whichever of the owning call and
1089
+ * the scope's own `Symbol.dispose` gets there first, so an early
1090
+ * in-callback disposal never double-closes (and never double-counts
1091
+ * the census).
1092
+ */
972
1093
  function closeScopeState(state: ScopeState): void {
1094
+ if (state.closed) {
1095
+ return
1096
+ }
1097
+ state.closed = true
973
1098
  bridged("close bumbledb snapshot", function closeSnapshot() {
974
1099
  native.snapshotClose(state.handle)
975
1100
  })
976
1101
  liveSnapshots -= 1
977
1102
  }
978
1103
 
979
- /**
980
- * Reads the committed generation for a just-opened scope, closing the
981
- * scope's snapshot when the read faults: `dbGeneration` opens a transient
982
- * engine read txn, so reader-table exhaustion is precisely the state in
983
- * which it throws — with one snapshot already open. An unpaired fault
984
- * here would park a snapshot worker and consume one of the engine's
985
- * reader slots FOREVER (and undercount the liveSnapshots census), each
986
- * fault ratcheting toward ReadersFull-for-the-process's-lifetime.
987
- */
988
- function generationForScope(state: ScopeState): bigint {
989
- const generation = errors.trySync(function readGeneration() {
990
- return bridged("read bumbledb generation", function callGeneration() {
991
- return native.dbGeneration(handle)
992
- })
993
- })
994
- if (generation.error) {
995
- state.live = false
996
- closeScopeState(state)
997
- throw generation.error
998
- }
999
- return generation.data
1000
- }
1001
-
1002
- function read<T>(fn: (snap: ReadScope<Rels>) => T): T {
1104
+ function read<T>(fn: (snap: ReadScope<Rels>) => T): T
1105
+ function read(): ReadScope<Rels>
1106
+ function read<T>(fn?: (snap: ReadScope<Rels>) => T): T | ReadScope<Rels> {
1003
1107
  const state = openScopeState()
1004
- const generation = generationForScope(state)
1005
- const scope = makeScope(state, generation)
1108
+ const scope = makeScope(state)
1109
+ if (fn === undefined) {
1110
+ /**
1111
+ * The `using` acquisition (R12): the caller owns the lifetime —
1112
+ * `using snap = db.read()` — and the scope's `Symbol.dispose`
1113
+ * is the deterministic release, scope-shaped in the language's
1114
+ * own syntax.
1115
+ */
1116
+ return scope
1117
+ }
1006
1118
  const result = errors.trySync(function runRead() {
1007
1119
  return fn(scope)
1008
1120
  })
@@ -1057,6 +1169,12 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1057
1169
  })
1058
1170
  }
1059
1171
 
1172
+ function explain<Row, Params extends ParamsRecord>(prepared: Prepared<Rels, Row, Params>, params: Params): Explain {
1173
+ return read(function explainInScope(snap) {
1174
+ return snap.explain(prepared, params)
1175
+ })
1176
+ }
1177
+
1060
1178
  /**
1061
1179
  * Builds one {@link Tx} over a transaction-handle thunk: `write` passes
1062
1180
  * an already-begun handle; `writeWitnessed` passes a LAZY thunk that
@@ -1085,21 +1203,25 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1085
1203
  })
1086
1204
  }
1087
1205
  })
1088
- function insert<R extends MemberRelation<Rels>>(relation: R, fact: InsertFact<R>): Minted<R> {
1206
+ function insert<R extends MemberRelation<Rels>>(
1207
+ relation: R,
1208
+ fact: InsertFact<R>
1209
+ ): { readonly changed: boolean } & Minted<R> {
1089
1210
  assertLive()
1090
1211
  const entry = resolveOrdinary(relation)
1091
1212
  const txHandle = resolveTx()
1092
- const values = recordOf(fact)
1213
+ /** The one spread copy of the write path: `mintFreshCells` writes minted cells in place, and they must never land in the caller's own fact object. */
1214
+ const values: Record<string, unknown> = { ...recordOf(fact) }
1093
1215
  const fresh = mintFreshCells(txHandle, entry, relation, values)
1094
1216
  const row = rowOf(relation.data, values)
1095
- bridged("bumbledb tx insert", function record() {
1096
- native.txInsert(txHandle, entry.id, row)
1217
+ const changed = bridged("bumbledb tx insert", function record() {
1218
+ return native.txInsert(txHandle, entry.id, row)
1097
1219
  })
1098
- Object.freeze(fresh)
1099
- if (!isMintedFresh(relation, fresh)) {
1100
- throw errors.new(`relation ${relation.name}: minted fresh record is incomplete`)
1220
+ const inserted: Readonly<Record<string, FactValue | boolean>> = Object.freeze({ changed, ...fresh })
1221
+ if (!isInserted(relation, inserted)) {
1222
+ throw errors.new(`relation ${relation.name}: insert return record is incomplete`)
1101
1223
  }
1102
- return fresh
1224
+ return inserted
1103
1225
  }
1104
1226
  function remove<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean {
1105
1227
  assertLive()
@@ -1122,7 +1244,7 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1122
1244
  return { tx, spend }
1123
1245
  }
1124
1246
 
1125
- function runDelta(txHandle: TxHandle, fn: DeltaBuild<Rels>): WriteResult<Rels> {
1247
+ function runDelta<R>(txHandle: TxHandle, fn: DeltaBuild<Rels, R>): WriteResult<Rels, R> {
1126
1248
  const made = makeTx(function resolveTx() {
1127
1249
  return txHandle
1128
1250
  })
@@ -1152,6 +1274,18 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1152
1274
  "bumbledb write callback returned a thenable — the delta build is synchronous; an async callback is refused, nothing was committed"
1153
1275
  )
1154
1276
  }
1277
+ if (isAbandon(built.data)) {
1278
+ /**
1279
+ * The caller's explicit decline to commit (R10): the sentinel's
1280
+ * contract is unconditional — roll back, nothing committed, not
1281
+ * even an empty commit; commit is unreachable for a sentinel
1282
+ * result.
1283
+ */
1284
+ bridged("abort bumbledb write transaction", function abort() {
1285
+ native.txAbort(txHandle)
1286
+ })
1287
+ return abandonedOutcome<Rels, R>(built.data)
1288
+ }
1155
1289
  const committed = errors.trySync(function commitDelta() {
1156
1290
  return bridged("commit bumbledb write transaction", function commit() {
1157
1291
  return native.txCommit(txHandle)
@@ -1182,16 +1316,11 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1182
1316
  })
1183
1317
  }
1184
1318
 
1185
- function write(fn: DeltaBuild<Rels>): WriteResult<Rels> {
1186
- const begun = errors.trySync(function beginDelta() {
1187
- return bridged("begin bumbledb write transaction", function begin() {
1188
- return native.dbWriteBegin(handle)
1189
- })
1319
+ function write<R = void>(fn: DeltaBuild<Rels, R>): WriteResult<Rels, R> {
1320
+ const txHandle = bridged(`begin bumbledb write transaction (live snapshots: ${liveSnapshots})`, function begin() {
1321
+ return native.dbWriteBegin(handle)
1190
1322
  })
1191
- if (begun.error) {
1192
- throw errors.wrap(begun.error, `begin bumbledb write transaction (live snapshots at fault: ${liveSnapshots})`)
1193
- }
1194
- return runDelta(begun.data, fn)
1323
+ return runDelta(txHandle, fn)
1195
1324
  }
1196
1325
 
1197
1326
  /**
@@ -1199,7 +1328,7 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1199
1328
  * attempt's snapshot: the committed generation, or the engine's
1200
1329
  * complete violation set as data.
1201
1330
  */
1202
- function commitWitnessed<R>(state: ScopeState, txHandle: TxHandle): WitnessedWriteResult<Rels, R> {
1331
+ function commitWitnessed<R>(state: ScopeState, txHandle: TxHandle): WriteResult<Rels, R> {
1203
1332
  const committed = errors.trySync(function commitWitnessedDelta() {
1204
1333
  return bridged("commit bumbledb witnessed write transaction", function commit() {
1205
1334
  return native.txCommit(txHandle)
@@ -1235,12 +1364,9 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1235
1364
  * `undefined` exactly when the generation moved and the whole callback
1236
1365
  * must rerun on a fresh snapshot.
1237
1366
  */
1238
- function witnessedAttempt<R>(
1239
- fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R
1240
- ): WitnessedWriteResult<Rels, R> | undefined {
1367
+ function witnessedAttempt<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WriteResult<Rels, R> | undefined {
1241
1368
  const state = openScopeState()
1242
- const generation = generationForScope(state)
1243
- const scope = makeScope(state, generation)
1369
+ const scope = makeScope(state)
1244
1370
  const pending: { tx: TxHandle | undefined } = { tx: undefined }
1245
1371
  function beginWitnessed(): TxHandle | undefined {
1246
1372
  const witnessed = bridged("begin witnessed bumbledb write transaction", function begin() {
@@ -1304,7 +1430,7 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1304
1430
  if (isAbandon(built.data)) {
1305
1431
  abortPending()
1306
1432
  closeScopeState(state)
1307
- return Object.freeze({ ok: false, abandoned: built.data.payload })
1433
+ return abandonedOutcome<Rels, R>(built.data)
1308
1434
  }
1309
1435
  const late = errors.trySync(function resolveCommitTx() {
1310
1436
  if (pending.tx === undefined) {
@@ -1340,7 +1466,7 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1340
1466
  * error, never a loop — retry is host policy, and this is the host
1341
1467
  * policy's own honesty bound).
1342
1468
  */
1343
- function writeWitnessed<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WitnessedWriteResult<Rels, R> {
1469
+ function writeWitnessed<R>(fn: (snap: ReadScope<Rels>, tx: Tx<Rels>) => R): WriteResult<Rels, R> {
1344
1470
  for (let attempts = 0; attempts < WITNESSED_ATTEMPT_CAP; attempts += 1) {
1345
1471
  const attempt = witnessedAttempt(fn)
1346
1472
  if (attempt !== undefined) {
@@ -1353,45 +1479,12 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1353
1479
  )
1354
1480
  }
1355
1481
 
1356
- /**
1357
- * Verifies the declaration-ordinal law the query lowering leans on
1358
- * against the live manifest tables: relation ids and sealed field ids
1359
- * both equal declaration order (`dbManifest` is the engine's own
1360
- * pinning of it). Any drift is a construction-time failure here, never
1361
- * a silently misaddressed query.
1362
- */
1363
- function assertOrdinalAlignment(): void {
1364
- Object.keys(theory.relations).forEach(function verifyRelation(name, ordinal) {
1365
- const entry = tables.relations.get(name)
1366
- if (entry === undefined || entry.id !== ordinal) {
1367
- throw errors.new(
1368
- `bumbledb manifest drift: relation ${name} has engine id ${entry?.id}, its declaration ordinal is ${ordinal} — query lowering depends on declaration order = ids`
1369
- )
1370
- }
1371
- const member = theory.relations[name]
1372
- if (member === undefined) {
1373
- throw errors.new(`bumbledb manifest drift: schema ${theory.name} lost relation ${name}`)
1374
- }
1375
- const sealed = sealedFieldsOf(member).map(function fieldName(declared) {
1376
- return declared.name
1377
- })
1378
- sealed.forEach(function verifyField(fieldName, fieldOrdinal) {
1379
- if (entry.fieldIds.get(fieldName) !== fieldOrdinal) {
1380
- throw errors.new(
1381
- `bumbledb manifest drift: ${name}.${fieldName} has engine field id ${entry.fieldIds.get(fieldName)}, its sealed ordinal is ${fieldOrdinal}`
1382
- )
1383
- }
1384
- })
1385
- })
1386
- }
1387
-
1388
1482
  function prepare<Row, Params extends ParamsRecord>(q: Query<Rels, Row, Params>): Prepared<Rels, Row, Params> {
1389
1483
  if (q.schema !== theory) {
1390
1484
  throw errors.new(
1391
1485
  `query was built against schema ${q.schema.name}, not the identical schema value this store opened with — schema identity is the membership rule`
1392
1486
  )
1393
1487
  }
1394
- assertOrdinalAlignment()
1395
1488
  const program = lowerQuery(q)
1396
1489
  const outcome = bridged("prepare bumbledb program", function callPrepare() {
1397
1490
  return native.dbPrepare(handle, program)
@@ -1438,6 +1531,7 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1438
1531
  get,
1439
1532
  contains,
1440
1533
  execute,
1534
+ explain,
1441
1535
  write,
1442
1536
  writeWitnessed,
1443
1537
  prepare
@@ -1508,6 +1602,27 @@ const ErrNewtypeMismatch = errors.new(
1508
1602
  "bumbledb newtypeMismatch: a statement pairs faces whose newtypes disagree — the faces of a dependency agree on their newtype, or neither carries one"
1509
1603
  )
1510
1604
 
1605
+ /**
1606
+ * `Tx.insert` returns the flattened `{ changed, ...fresh }` record (R11),
1607
+ * where the spread wins: a FRESH field literally named `changed` would
1608
+ * shadow the engine's changed-state report on every insert of its
1609
+ * relation. No field name is reserved SILENTLY — the one unspeakable
1610
+ * spelling is refused here at admission, before any store is touched.
1611
+ * Supplied (non-fresh) fields named `changed` never enter the return
1612
+ * record and stay legal.
1613
+ */
1614
+ function refuseShadowedChanged(theory: AnySchema): void {
1615
+ for (const [name, member] of Object.entries(theory.relations)) {
1616
+ for (const declared of sealedFieldsOf(member)) {
1617
+ if (declared.name === "changed" && isFreshField(declared.field)) {
1618
+ throw errors.new(
1619
+ `relation ${name}: a fresh field named "changed" would shadow tx.insert's changed-state report in its { changed, ...fresh } return (R11) — rename the fresh field; a supplied field named "changed" stays legal (only fresh cells ride the return)`
1620
+ )
1621
+ }
1622
+ }
1623
+ }
1624
+ }
1625
+
1511
1626
  /**
1512
1627
  * The one admission path both verbs share: canonical-path cache lookup
1513
1628
  * first (a hit returns the SAME `Db` value for the identical theory, a
@@ -1525,6 +1640,7 @@ function admit<Rels extends SchemaRelations>(
1525
1640
  storePath: string,
1526
1641
  theory: Schema<Rels>
1527
1642
  ): Db<Rels> {
1643
+ refuseShadowedChanged(theory)
1528
1644
  const canonical = path.resolve(storePath)
1529
1645
  const cached = openStores.get(canonical)
1530
1646
  if (cached !== undefined) {
@@ -1594,12 +1710,12 @@ const Db = Object.freeze({
1594
1710
  * one schema-independent read path (no theory, no fingerprint check; the
1595
1711
  * store rebirth tool's entry). Lives beside `open`/`create` so the path
1596
1712
  * law stays in one place: the same `node:path.resolve` canonicalization,
1597
- * applied here. The value is NOT cached and carries no close: the
1598
- * engine-side handle (and the store's exclusive lock) is reclaimed by GC
1599
- * reclamation only, never correctness. A store not yet adopted rejects
1600
- * with the typed `ErrExhumeNoDescriptor` (the remedy: one
1601
- * fingerprint-matching `Db.open` under the creating schema back-fills
1602
- * the descriptor).
1713
+ * applied here. The value is NOT cached and is a DISPOSABLE lifetime
1714
+ * (R12) `using exhumed = await Db.exhume(path)` releases the engine
1715
+ * handle and the store's exclusive lock at scope exit, so a same-path
1716
+ * reopen never waits on GC. A store not yet adopted rejects with the
1717
+ * typed `ErrExhumeNoDescriptor` (the remedy: one fingerprint-matching
1718
+ * `Db.open` under the creating schema back-fills the descriptor).
1603
1719
  */
1604
1720
  async exhume(storePath: string): Promise<Exhumed> {
1605
1721
  return exhumeStore(path.resolve(storePath))
@@ -1608,6 +1724,7 @@ const Db = Object.freeze({
1608
1724
 
1609
1725
  export type {
1610
1726
  Abandon,
1727
+ AbandonedArm,
1611
1728
  DeclaredKeyFact,
1612
1729
  DeltaBuild,
1613
1730
  MemberRelation,
@@ -1616,7 +1733,6 @@ export type {
1616
1733
  ReadScope,
1617
1734
  Tx,
1618
1735
  Violation,
1619
- WitnessedWriteResult,
1620
1736
  WriteResult
1621
1737
  }
1622
1738
  export { abandon, Db, ErrNewtypeMismatch, ErrWitnessedLivelock, WITNESSED_ATTEMPT_CAP }