@bjornpagen/bumbledb 0.10.0 → 0.12.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 (69) hide show
  1. package/COOKBOOK.md +155 -136
  2. package/README.md +5 -9
  3. package/dist/db.d.ts +77 -109
  4. package/dist/db.d.ts.map +1 -1
  5. package/dist/db.js +121 -339
  6. package/dist/db.js.map +1 -1
  7. package/dist/index.d.ts +12 -17
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +8 -11
  10. package/dist/index.js.map +1 -1
  11. package/dist/lower.d.ts.map +1 -1
  12. package/dist/lower.js +8 -1
  13. package/dist/lower.js.map +1 -1
  14. package/dist/native.d.ts +77 -51
  15. package/dist/native.d.ts.map +1 -1
  16. package/dist/native.js.map +1 -1
  17. package/dist/query/atom.d.ts +94 -112
  18. package/dist/query/atom.d.ts.map +1 -1
  19. package/dist/query/atom.js +8 -17
  20. package/dist/query/atom.js.map +1 -1
  21. package/dist/query/find.d.ts +18 -35
  22. package/dist/query/find.d.ts.map +1 -1
  23. package/dist/query/find.js +13 -32
  24. package/dist/query/find.js.map +1 -1
  25. package/dist/query/lower.d.ts +128 -124
  26. package/dist/query/lower.d.ts.map +1 -1
  27. package/dist/query/lower.js +366 -258
  28. package/dist/query/lower.js.map +1 -1
  29. package/dist/query/parse-ir.d.ts +12 -0
  30. package/dist/query/parse-ir.d.ts.map +1 -0
  31. package/dist/query/parse-ir.js +71 -0
  32. package/dist/query/parse-ir.js.map +1 -0
  33. package/dist/query/run.d.ts +2 -2
  34. package/dist/query/run.d.ts.map +1 -1
  35. package/dist/query/run.js +2 -13
  36. package/dist/query/run.js.map +1 -1
  37. package/dist/query/scope.d.ts +4 -16
  38. package/dist/query/scope.d.ts.map +1 -1
  39. package/dist/query/scope.js +1 -6
  40. package/dist/query/scope.js.map +1 -1
  41. package/dist/schema.js +2 -2
  42. package/dist/schema.js.map +1 -1
  43. package/dist/statements.d.ts +8 -4
  44. package/dist/statements.d.ts.map +1 -1
  45. package/dist/statements.js +7 -9
  46. package/dist/statements.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/db.ts +182 -443
  49. package/src/index.ts +11 -24
  50. package/src/lower.ts +8 -1
  51. package/src/native.ts +79 -43
  52. package/src/query/atom.ts +105 -138
  53. package/src/query/find.ts +39 -80
  54. package/src/query/lower.ts +626 -434
  55. package/src/query/parse-ir.ts +82 -0
  56. package/src/query/run.ts +2 -14
  57. package/src/query/scope.ts +3 -21
  58. package/src/schema.ts +2 -2
  59. package/src/statements.ts +17 -13
  60. package/dist/order.d.ts +0 -87
  61. package/dist/order.d.ts.map +0 -1
  62. package/dist/order.js +0 -153
  63. package/dist/order.js.map +0 -1
  64. package/dist/query/predicate.d.ts +0 -91
  65. package/dist/query/predicate.d.ts.map +0 -1
  66. package/dist/query/predicate.js +0 -156
  67. package/dist/query/predicate.js.map +0 -1
  68. package/src/order.ts +0 -234
  69. package/src/query/predicate.ts +0 -269
package/COOKBOOK.md CHANGED
@@ -18,7 +18,8 @@ recipes: each schema is constructed through the public surface, admitted
18
18
  on a real store (the engine's schema validation is the acceptance
19
19
  judgment), its fingerprint asserted stable across a reopen AND equal to
20
20
  the per-recipe cross-host golden the Rust cookbook suite also pins
21
- (`test/fixtures/cookbook-fingerprints.txt` the two cookbooks teach one
21
+ (`fixtures/cookbook-fingerprints.txt` at the repository root the two
22
+ cookbooks teach one
22
23
  theory per recipe number, provably), and every query snippet lowered
23
24
  through `db.prepare` (the engine's own IR validation).
24
25
 
@@ -37,12 +38,10 @@ import {
37
38
  abandon,
38
39
  allen,
39
40
  bool,
40
- by,
41
41
  bytes,
42
42
  capacity,
43
43
  closed,
44
44
  contained,
45
- desc,
46
45
  duration,
47
46
  eq,
48
47
  i64,
@@ -53,7 +52,6 @@ import {
53
52
  not,
54
53
  on,
55
54
  pointIn,
56
- program,
57
55
  query,
58
56
  ref,
59
57
  relation,
@@ -179,7 +177,8 @@ const Grading = schema("Grading", { Kind, Task, DeterministicGrading, CustomOper
179
177
  mirrors(on(Task.where({ kind: "CustomOperator" }), "id"), on(CustomOperatorGrading, "task"))
180
178
  // Exclusivity is a theorem, not a statement: one id in two arms would
181
179
  // force `kind` to equal two handles against the fresh key on id.
182
- // The executor spends the same theorem again recipe 22's free lunch.
180
+ // The executor spends the same theorem again as a diagnostic witness
181
+ // (recipe 22); multi-rule execution still keeps a spanning seen-set.
183
182
  ])
184
183
 
185
184
  // Host dispatch over the discriminator is native `switch` narrowing over
@@ -512,13 +511,32 @@ const playingAt = query(Playlists).rule((r) => {
512
511
  .find({ track })
513
512
  })
514
513
 
515
- // Answers are SETS — the host sorts them, and the SDK ships the comparator:
516
- // sort keys as data, a bare name ascending, `desc(...)` the flip; intervals
517
- // order by (start, end). Limit is the language's own `.slice(0, n)`.
514
+ // Answers are SETS — the host sorts them. Limit is the language's own
515
+ // `.slice(0, n)`.
518
516
  const inPlayOrder = [
519
517
  { slot: { start: 1n, end: 2n }, track: "b" },
520
518
  { slot: { start: 0n, end: 1n }, track: "a" }
521
- ].sort(by("slot", "track"))
519
+ ].sort(function bySlotThenTrack(left, right) {
520
+ if (left.slot.start < right.slot.start) {
521
+ return -1
522
+ }
523
+ if (left.slot.start > right.slot.start) {
524
+ return 1
525
+ }
526
+ if (left.slot.end < right.slot.end) {
527
+ return -1
528
+ }
529
+ if (left.slot.end > right.slot.end) {
530
+ return 1
531
+ }
532
+ if (left.track < right.track) {
533
+ return -1
534
+ }
535
+ if (left.track > right.track) {
536
+ return 1
537
+ }
538
+ return 0
539
+ })
522
540
  ```
523
541
 
524
542
  Middle insert is honest about its cost: making room at position `k` shifts
@@ -928,8 +946,7 @@ const claimed = query(FreeTime).rule((r) => {
928
946
  })
929
947
  // Coalesced totals = the two-query composition (pack, then a host fold) —
930
948
  // aggregates never nest; free time (gaps) is the two-line host walk over
931
- // sorted packed answers (`rows.sort(by("person", "span"))`the
932
- // keys-as-data comparator; limit is the language's own `.slice`) — both
949
+ // sorted packed answers (host sorts by person then span start/end) — both
933
950
  // refusals recorded in the ledger.
934
951
  ```
935
952
 
@@ -982,14 +999,12 @@ discipline — snapshot-derived writes detect movement
982
999
  final-state point reads need no earlier witness.
983
1000
 
984
1001
  The generation witness: read the model, propose a delta, commit iff the model
985
- you read is still the model. In the SDK the whole loop is `db.writeWitnessed`
986
- retry on movement is built in (every generation move is self-inflicted by
987
- the host's own interleaved writes), capped at 64 attempts: a callback that
988
- itself issues a plain `db.write` each try re-moves the generation it is about
989
- to witness, and past the cap that interleave hazard throws the typed
990
- `ErrWitnessedLivelock` instead of spinning forever. `abandon(payload)`
1002
+ you read is still the model. The SDK ships one-shot `writeFrom` (must run
1003
+ inside the read callback that owns the snapshot). Retry on
1004
+ `ErrGenerationMoved` is host policy a short loop around `db.read` +
1005
+ `writeFrom` if the host wants it. `abandon(payload)`
991
1006
  declines to commit without issuing anything — from `db.write` and
992
- `db.writeWitnessed` alike (the sentinel's contract is unconditional, and
1007
+ `db.writeFrom` alike (the sentinel's contract is unconditional, and
993
1008
  the outcome arm is in the result type exactly when the callback can
994
1009
  abandon).
995
1010
 
@@ -1015,22 +1030,25 @@ const stillQueued = query(Jobs).rule((r) => {
1015
1030
  const db = await Db.create("./jobs.db", Jobs)
1016
1031
  const prepared = db.prepare(stillQueued)
1017
1032
 
1018
- // The witnessed loop: premise reads via `snap`, the delta via `tx`; on a
1019
- // moved generation the WHOLE callback reruns on a fresh snapshot. The other
1020
- // two idioms: insert-select is the same shape (query source answers, insert
1021
- // the derived facts); key-shaped read-modify-write uses `tx.get`/`tx.contains`
1022
- // — final-state point reads need no earlier witness.
1023
- const outcome = db.writeWitnessed(function updateWhere(snap, tx) {
1024
- const queued = snap.execute(prepared, {})
1025
- if (queued.length === 0) {
1026
- return abandon("nothing queued")
1027
- }
1028
- for (const row of queued) {
1029
- tx.delete(Job, { id: row.id, state: "Queued", payload: row.payload })
1030
- tx.insert(Job, { id: row.id, state: "Running", payload: row.payload })
1031
- tx.insert(Lease, { job: row.id, worker: 7n, until: 60n })
1032
- }
1033
- return undefined
1033
+ // The witnessed write: premise reads via `snap`, the delta via `tx`. On a
1034
+ // moved generation `writeFrom` throws `ErrGenerationMoved` retry is host
1035
+ // policy. The other two idioms: insert-select is the same shape (query
1036
+ // source answers, insert the derived facts); key-shaped read-modify-write
1037
+ // uses `tx.get`/`tx.contains` — final-state point reads need no earlier
1038
+ // witness.
1039
+ const outcome = db.read(function attempt(snap) {
1040
+ return db.writeFrom(snap, function updateWhere(tx) {
1041
+ const queued = snap.execute(prepared, {})
1042
+ if (queued.length === 0) {
1043
+ return abandon("nothing queued")
1044
+ }
1045
+ for (const row of queued) {
1046
+ tx.delete(Job, { id: row.id, state: "Queued", payload: row.payload })
1047
+ tx.insert(Job, { id: row.id, state: "Running", payload: row.payload })
1048
+ tx.insert(Lease, { job: row.id, worker: 7n, until: 60n })
1049
+ }
1050
+ return undefined
1051
+ })
1034
1052
  })
1035
1053
  ```
1036
1054
 
@@ -1074,8 +1092,10 @@ const deriving = query(Rollup).rule((r) => {
1074
1092
 
1075
1093
  Guarantee: Lean theorem + represented planner/runtime premise — rule union is
1076
1094
  set-idempotent (`lean/Bumbledb/Query/Denotation.lean: union_idempotent`);
1077
- key-backed DU arms justify the disjointness optimization
1078
- (`lean/Bumbledb/Exec/Dedup.lean: disjoint_witness_licence`).
1095
+ key-backed DU arms justify a disjointness *witness*
1096
+ (`lean/Bumbledb/Exec/Dedup.lean: disjoint_witness_licence`) spent diagnostically
1097
+ only — execution always keeps one spanning seen-set
1098
+ (`docs/architecture/40-execution.md` § set semantics).
1079
1099
 
1080
1100
  The whole-DU read is a set of rules: one head, one rule per arm — disjunction
1081
1101
  is data at the top, never an execution node.
@@ -1096,7 +1116,8 @@ const Payments = schema("Payments", { Kind, Payment, Card, Ach }, [
1096
1116
 
1097
1117
  // One query, two rules (set union). The exclusivity theorem (recipe 2) is
1098
1118
  // spent a third time here: rules selecting different `kind` handles are
1099
- // provably disjoint, so the executor elides cross-rule dedup — the free lunch.
1119
+ // provably disjoint. Plan introspection retains the witness; execution still
1120
+ // probes one spanning seen-set — the measured refutation deleted the elision.
1100
1121
  const wholeDu = query(Payments)
1101
1122
  .rule((r) => {
1102
1123
  const { id } = v(Payment)
@@ -1157,14 +1178,15 @@ const Gravestones = schema("Gravestones", { Step, Score, ActiveRun, Usage, Event
1157
1178
 
1158
1179
  Guarantee: host discipline for the loop — the finite `seen` set proves
1159
1180
  termination for the host run; the engine-native form beside it executes
1160
- whole under the fixpoint driver, budget-bounded
1161
- (`lean/Bumbledb/Exec/Fixpoint.lean: program_eval_sound`).
1181
+ whole under the linear reach driver, budget-bounded
1182
+ (`lean/Bumbledb/Exec/Reach.lean: evalLinearReach_eq_lfp` /
1183
+ `evalQuery_sound`).
1162
1184
 
1163
1185
  Reachability, in two dialects. The host-loop idiom remains the depth-bounded
1164
1186
  answer: the loop runs depth-many rounds and each round is one ∈-set query —
1165
1187
  an `inSet` probe, microsecond-class. The frontier discipline below *is*
1166
1188
  semi-naive evaluation's Δ, spent where a loop is a loop: the host. The
1167
- engine-native form is the same closure as one stratified `program()`.
1189
+ engine-native form is the same closure as one query with `.reach()`.
1168
1190
 
1169
1191
  ```ts
1170
1192
  const Node = relation("Node", { id: u64.fresh, name: str })
@@ -1215,68 +1237,65 @@ for (;;) {
1215
1237
  Termination is the host's theorem: `seen` grows strictly or the loop breaks,
1216
1238
  inside a finite node set. When the idiom's costs bite — **unbounded or large
1217
1239
  depth**, or **closure composed into a larger plan** — write the engine-native
1218
- form instead: `?root` seeds the predicate, and the output is the finished
1219
- set's own identity projection (an `idb` atom is a positive occurrence, so it
1240
+ form instead: `?root` seeds the rec, and the main rule is the finished
1241
+ set's own identity projection (an interior atom is a positive occurrence, so it
1220
1242
  grounds its variables — no re-grounding join over a domain relation exists):
1221
1243
 
1222
1244
  ```ts
1223
- const reach = program(Closure, (p) => {
1224
- const rec = p.rec("reach")
1225
- const seeded = rec
1226
- .rule((r) => {
1227
- const { id: c } = v(Node)
1228
- return r
1229
- .match(Node, { id: c })
1230
- .where(eq(c, r.param("root")))
1231
- .find({ c })
1232
- })
1233
- .rule((r) => {
1234
- const { child: c, parent } = v(Parent)
1235
- return r
1236
- .match(Parent, { child: c, parent })
1237
- .idb(rec, { c: parent })
1238
- .find({ c })
1239
- })
1240
- return p.output((r) => {
1245
+ const reach = query(Closure)
1246
+ .reach("reach", {
1247
+ base: [
1248
+ (r) => {
1249
+ const { id: c } = v(Node)
1250
+ return r
1251
+ .match(Node, { id: c })
1252
+ .where(eq(c, r.param("root")))
1253
+ .find({ c })
1254
+ }
1255
+ ],
1256
+ rec: [
1257
+ (r) => {
1258
+ const { child: c, parent } = v(Parent)
1259
+ return r.match(Parent, { child: c, parent }).interior("reach", { c: parent }).find({ c })
1260
+ }
1261
+ ]
1262
+ })
1263
+ .rule((r) => {
1241
1264
  const { id: c } = v(Node)
1242
- return r.idb(seeded, { c }).find({ c })
1265
+ return r.interior("reach", { c }).find({ c })
1243
1266
  })
1244
- })
1245
1267
  const reachPrepared = db.prepare(reach)
1246
1268
  ```
1247
1269
 
1248
- The complement is one `r.not` away — negation **of** a finished stratum is
1249
- engine-legal (the strata judge refuses only negation *through* a cycle), so
1270
+ The complement is one `r.not` away — negation **of** a finished rec is
1271
+ engine-legal (negation in rec is refused), so
1250
1272
  "every node the closure never reached" runs in-plan through the engine's
1251
- anti-probe, never as a host-side set difference — the same program with the
1252
- output rule:
1273
+ anti-probe, never as a host-side set difference — the same query with the
1274
+ main rule:
1253
1275
 
1254
1276
  ```ts
1255
- const unreached = program(Closure, (p) => {
1256
- const rec = p.rec("reach")
1257
- const seeded = rec
1258
- .rule((r) => {
1259
- const { id: c } = v(Node)
1260
- return r
1261
- .match(Node, { id: c })
1262
- .where(eq(c, r.param("root")))
1263
- .find({ c })
1264
- })
1265
- .rule((r) => {
1266
- const { child: c, parent } = v(Parent)
1267
- return r
1268
- .match(Parent, { child: c, parent })
1269
- .idb(rec, { c: parent })
1270
- .find({ c })
1271
- })
1272
- return p.output((r) => {
1277
+ const unreached = query(Closure)
1278
+ .reach("reach", {
1279
+ base: [
1280
+ (r) => {
1281
+ const { id: c } = v(Node)
1282
+ return r
1283
+ .match(Node, { id: c })
1284
+ .where(eq(c, r.param("root")))
1285
+ .find({ c })
1286
+ }
1287
+ ],
1288
+ rec: [
1289
+ (r) => {
1290
+ const { child: c, parent } = v(Parent)
1291
+ return r.match(Parent, { child: c, parent }).interior("reach", { c: parent }).find({ c })
1292
+ }
1293
+ ]
1294
+ })
1295
+ .rule((r) => {
1273
1296
  const { id: c } = v(Node)
1274
- return r
1275
- .match(Node, { id: c })
1276
- .where(r.not(seeded, { c }))
1277
- .find({ c })
1297
+ return r.match(Node, { id: c }).where(r.not("reach", { c })).find({ c })
1278
1298
  })
1279
- })
1280
1299
  ```
1281
1300
 
1282
1301
  (the test drives both dialects and asserts the same reachable sets, root for
@@ -1289,16 +1308,16 @@ composition has no engine form.
1289
1308
 
1290
1309
  Guarantee: host discipline + runtime aggregate semantics — the host computes
1291
1310
  closure, then one checked `sum` (`lean/Bumbledb/Query/Aggregates.lean:
1292
- checkedSum_sound`); the engine-native form folds over a *finished* lower
1293
- stratum, the one aggregation shape the strata roster admits.
1311
+ checkedSum_sound`); the engine-native form folds over a finished rec,
1312
+ the one aggregation shape the rec roster admits.
1294
1313
 
1295
1314
  The ledger workload's real recursion case, in the same two dialects: a
1296
1315
  hierarchical chart of accounts and a subtree rollup. The host composition —
1297
1316
  recipe 24's loop accumulates the subtree's ∈-set, then **one `sum` query over
1298
1317
  the accumulated set** folds the postings. The engine aggregates, the host
1299
- composes (aggregates never nest). The engine-native form is one program:
1300
- aggregation *through* a cycle is refused, but a fold over a recursive
1301
- predicate from a **higher stratum** reads a finished set and is ordinary.
1318
+ composes (aggregates never nest). The engine-native form is one query:
1319
+ aggregation *through* a cycle is refused, but a fold over a finished rec
1320
+ from main reads a finished set and is ordinary.
1302
1321
 
1303
1322
  ```ts
1304
1323
  const Account = relation("Account", { id: u64.fresh, name: str })
@@ -1324,33 +1343,33 @@ const subtreeRollup = query(Accounts).rule((r) => {
1324
1343
  const { id, minor } = v(Posting)
1325
1344
  return r.match(Posting, { id, account: r.inSet("subtree"), minor }).find({ total: r.sum(minor) })
1326
1345
  })
1327
- // The engine-native form: the closure stratum converges first, then the
1328
- // output's fold runs once over the finished subtree.
1329
- const nativeRollup = program(Accounts, (p) => {
1330
- const sub = p.rec("sub")
1331
- const seeded = sub
1332
- .rule((r) => {
1333
- const { id: a } = v(Account)
1334
- return r
1335
- .match(Account, { id: a })
1336
- .where(eq(a, r.param("root")))
1337
- .find({ a })
1338
- })
1339
- .rule((r) => {
1340
- const { child: a, parent } = v(AccountParent)
1341
- return r
1342
- .match(AccountParent, { child: a, parent })
1343
- .idb(sub, { a: parent })
1344
- .find({ a })
1345
- })
1346
- return p.output((r) => {
1346
+ // The engine-native form: the rec converges first, then the
1347
+ // main fold runs once over the finished subtree.
1348
+ const nativeRollup = query(Accounts)
1349
+ .reach("sub", {
1350
+ base: [
1351
+ (r) => {
1352
+ const { id: a } = v(Account)
1353
+ return r
1354
+ .match(Account, { id: a })
1355
+ .where(eq(a, r.param("root")))
1356
+ .find({ a })
1357
+ }
1358
+ ],
1359
+ rec: [
1360
+ (r) => {
1361
+ const { child: a, parent } = v(AccountParent)
1362
+ return r.match(AccountParent, { child: a, parent }).interior("sub", { a: parent }).find({ a })
1363
+ }
1364
+ ]
1365
+ })
1366
+ .rule((r) => {
1347
1367
  const { id, account: a, minor } = v(Posting)
1348
1368
  return r
1349
1369
  .match(Posting, { id, account: a, minor })
1350
- .idb(seeded, { a })
1370
+ .interior("sub", { a })
1351
1371
  .find({ total: r.sum(minor) })
1352
1372
  })
1353
- })
1354
1373
  ```
1355
1374
 
1356
1375
  ## 26. Exact partition
@@ -1419,9 +1438,9 @@ const deriving = query(MaintainedRollup).rule((r) => {
1419
1438
  })
1420
1439
  ```
1421
1440
 
1422
- The host loop is `db.writeWitnessed`: derive on the attempt's snapshot, diff,
1423
- build the delta — recipe 20's third idiom. On a moved generation the SDK
1424
- throws away the attempt and reruns the whole callback on a fresh snapshot; it
1441
+ The host loop is `db.read` + `writeFrom`: derive on the attempt's snapshot, diff,
1442
+ build the delta — recipe 20's third idiom. On a moved generation the host
1443
+ throws away the attempt and retries on a fresh snapshot; it
1425
1444
  never retries a stale diff. Dependencies prove every surviving stored span
1426
1445
  sound, while the witness proves which source state the derivation saw;
1427
1446
  neither mechanism proves completeness. The engine's compiled copy
@@ -1549,20 +1568,20 @@ keyed point read answers exactly that fact or nothing, on every scope
1549
1568
  `crates/bumbledb/tests/keyed_get.rs`).
1550
1569
 
1551
1570
  The key is a **law**, and the read surface is that law made callable. The
1552
- schema says `key(Program, ["grp"])` — one program per group — so "the
1553
- program of a group" is a well-posed question with at most one answer, and
1571
+ schema says `key(Course, ["grp"])` — one course per group — so "the
1572
+ course of a group" is a well-posed question with at most one answer, and
1554
1573
  the store already enforces that on every commit. Hold the statement VALUE:
1555
1574
  it is the read's selector below (statement identity is the membership rule).
1556
1575
 
1557
1576
  ```ts
1558
1577
  const Grp = relation("Grp", { id: u64.fresh, label: str })
1559
- const Program = relation("Program", { id: u64.fresh, grp: u64, title: str })
1560
- // The law: one program per group — the callable key.
1561
- const programGrpKey = key(Program, ["grp"])
1578
+ const Course = relation("Course", { id: u64.fresh, grp: u64, title: str })
1579
+ // The law: one course per group — the callable key.
1580
+ const courseGrpKey = key(Course, ["grp"])
1562
1581
 
1563
- const KeyedRead = schema("KeyedRead", { Grp, Program }, [
1564
- contained(on(Program, "grp"), on(Grp, "id")),
1565
- programGrpKey
1582
+ const KeyedRead = schema("KeyedRead", { Grp, Course }, [
1583
+ contained(on(Course, "grp"), on(Grp, "id")),
1584
+ courseGrpKey
1566
1585
  ])
1567
1586
  ```
1568
1587
 
@@ -1576,38 +1595,38 @@ runtime shape check. The primary 2-arg form needs no statement: the fresh
1576
1595
  field IS the primary key.
1577
1596
 
1578
1597
  ```ts
1579
- const db = await Db.create("./programs.db", KeyedRead)
1598
+ const db = await Db.create("./courses.db", KeyedRead)
1580
1599
 
1581
1600
  const minted: { grp?: bigint } = {}
1582
1601
  db.write((tx) => {
1583
1602
  const g = tx.insert(Grp, { label: "algebra" })
1584
- tx.insert(Program, { grp: g.id, title: "linear equations" })
1603
+ tx.insert(Course, { grp: g.id, title: "linear equations" })
1585
1604
  minted.grp = g.id
1586
1605
  })
1587
1606
  const grp = minted.grp ?? 0n
1588
1607
 
1589
1608
  // db.get — the standalone keyed read through the declared law:
1590
- const byGroup = db.get(Program, programGrpKey, { grp })
1609
+ const byGroup = db.get(Course, courseGrpKey, { grp })
1591
1610
 
1592
1611
  // snap.get — the same spelling inside a read scope:
1593
- const viaSnap = db.read((snap) => snap.get(Program, programGrpKey, { grp }))
1612
+ const viaSnap = db.read((snap) => snap.get(Course, courseGrpKey, { grp }))
1594
1613
 
1595
1614
  // tx.get — key-shaped read-modify-write, final-state (recipe 20's third
1596
1615
  // idiom): per-fact premises need no earlier snapshot witness.
1597
1616
  db.write((tx) => {
1598
- const current = tx.get(Program, programGrpKey, { grp })
1617
+ const current = tx.get(Course, courseGrpKey, { grp })
1599
1618
  if (current !== undefined) {
1600
- tx.delete(Program, current)
1601
- tx.insert(Program, { id: current.id, grp: current.grp, title: "linear equations II" })
1619
+ tx.delete(Course, current)
1620
+ tx.insert(Course, { id: current.id, grp: current.grp, title: "linear equations II" })
1602
1621
  }
1603
1622
  })
1604
1623
 
1605
1624
  // The primary 2-arg form — the fresh field is the primary key:
1606
- const byId = byGroup === undefined ? undefined : db.get(Program, { id: byGroup.id })
1625
+ const byId = byGroup === undefined ? undefined : db.get(Course, { id: byGroup.id })
1607
1626
  ```
1608
1627
 
1609
1628
  The anti-pattern this recipe retires: a scan-and-find where a key law
1610
- exists — `snap.scan(Program).find((row) => row.grp === grp)` — re-derives
1629
+ exists — `snap.scan(Course).find((row) => row.grp === grp)` — re-derives
1611
1630
  in the host what the store already enforces. The uniqueness the fold
1612
1631
  quietly assumes IS the declared key statement; spell the law and the point
1613
1632
  read comes with it.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Type-theoretic TypeScript SDK for the [bumbledb](https://github.com/bjornpagen/bumbledb) embedded relational engine.
4
4
 
5
- bumbledb models data as relations judged by statements (functionality, containment, capacity) and queried with Datalog expressed as plain values — no SQL, no query-string parser. The SDK is a thin, fully typed surface over an in-process native engine (LMDB storage, MVCC snapshots, a single-writer witnessed write loop).
5
+ bumbledb models data as relations judged by statements (functionality, containment, capacity) and queried as typed IR values — no SQL, no query-string parser. The SDK is a thin, fully typed surface over an in-process native engine (LMDB storage, MVCC snapshots, one-shot `write` / `writeFrom`).
6
6
 
7
7
  The surface is structural to the bone. Relation declarations are pure structure — kind, width, element, fresh, nothing else — and domains are never declared anywhere: **the laws type the columns**. `schema()` computes every field's equivalence class from the statement list itself, so the containments and mirrors you already write ARE the typing, at compile time and again at construction. Values stay bare (`bigint`, `string`, …); identity lives in the class the laws compute, not in a wrapper.
8
8
 
@@ -21,7 +21,7 @@ pnpm add @bjornpagen/bumbledb
21
21
  ## Quick start
22
22
 
23
23
  Declare relations as pure structure, let the statement list type every column,
24
- write facts through a transaction, and query with Datalog as values.
24
+ write facts through a transaction, and query with the typed builder.
25
25
  Everything is typed end to end — bare structural values in law-computed
26
26
  classes, inferred query rows, and rejections that arrive as data rather than
27
27
  exceptions.
@@ -75,7 +75,7 @@ if (!result.ok) {
75
75
  }
76
76
  }
77
77
 
78
- // Query: Datalog as values. v(R) mints a fresh variable per column, typed by
78
+ // Query: v(R) mints a fresh variable per column, typed by
79
79
  // the column's law-class; reusing one by object reference IS the join, and
80
80
  // rows are typed from the find keys. Params are typed by use.
81
81
  // `gt` is one of the free comparison exports.
@@ -102,10 +102,6 @@ console.log(rows)
102
102
  console.log(snap.generation, snap.execute(prepared, { floor: 15n }))
103
103
  }
104
104
 
105
- // explain(): the plan as data — what the engine did with the query, plan
106
- // sections and counters as plain values (a diagnostic surface, unfrozen).
107
- console.log(db.explain(prepared, { floor: 15n }).emits)
108
-
109
105
  // Host dispatch over the sealed roster is native `switch` narrowing over
110
106
  // the handle union ("DirectPass" | "JudgedPass" | "Failed") — exhaustive
111
107
  // via `satisfies never`; the sealed axioms read back typed.
@@ -132,8 +128,8 @@ The drizzle law governs this surface: the SDK's job at the host boundary is tran
132
128
 
133
129
  - The structural type kernel — fields as pure structure (`bool`, `bytes`, `i64`, `u64`, `str`, `interval`, `span`), `relation()`, and `closed()` sealed rosters with typed axiom payloads. A closed reference's value type IS the handle union (`Infer` speaks it); dispatch is native `switch` narrowing with `satisfies never` exhaustiveness. Domains are never declared: `schema()` computes every field's class from the statement list.
134
130
  - The statement algebra — `schema()`, `key`, `contained`, `mirrors`, `capacity`; faces via `on` (set membership is a plain array in `.where`); windows via `within` (`within(n)` exact, `within(lo, hi)` range, `within(lo, "*")` floor), measures via `weigh` (`weigh("f")` a u64 field, `weigh(duration("f"))` an interval's measure), dependent bounds via `ref`/`duration` read from the target row; ψ-selection via `.where` on relations and closed rosters.
135
- - The `Db` runtime — `Db.create`/`Db.open`, path-cached stores, transactions, typed violations, scoped snapshot reads (`db.read(fn)`, or `using snap = db.read()` — lifetimes are disposables, never `close()`), the write verbs with `abandon` (returning `abandon(payload)` from `write` or `writeWitnessed` rolls the transaction back; the outcome arm is in the result type), and `db.explain` — the prepared plan as data.
136
- - The query surface — Datalog as values, `query(S).rule(r => ...)`: `v(R)`-minted vars (identity is the object reference — reusing one across binding positions IS the join), `find({...})` named result heads (renames are real), params typed by use unchanged, negation, aggregates, and the free comparison/connective exports (`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`, `allen`/`ALLEN`, `pointIn`); set membership at a closed field is a plain array in the match record (`r.match(Ticket, { priority: ["Normal", "Urgent"] })` — closed-only there: an ordinary field's membership is a bound `r.inSet` param); stratified recursion via `program()`; `db.prepare` as a plain value.
131
+ - The `Db` runtime — `Db.create`/`Db.open` (exclusive-lock stores; a second open of the same path is `EnvironmentLocked`), transactions, typed violations, scoped snapshot reads (`db.read(fn)`, or `using snap = db.read()` — lifetimes are disposables, never `close()`), the write verbs with `abandon` (returning `abandon(payload)` from `write` or `writeFrom` rolls the transaction back; the outcome arm is in the result type).
132
+ - The query surface — `query(S).rule(r => ...)`: `v(R)`-minted vars (identity is the object reference — reusing one across binding positions IS the join), `find({...})` named result heads (renames are real), params typed by use unchanged, negation, aggregates, and the free comparison/connective exports (`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`, `allen`/`ALLEN`, `pointIn`); set membership at a closed field is a plain array in the match record (`r.match(Ticket, { priority: ["Normal", "Urgent"] })` — closed-only there: an ordinary field's membership is a bound `r.inSet` param); named interiors and one linear rec via `q.interior` / `q.reach`; `db.prepare` as a plain value.
137
133
  - The exhume surface — `Db.exhume`, the schema-independent read path: a store's self-described shapes and raw facts by name, with typed refusals (`ErrExhumeNoDescriptor`, `ErrExhumeFormatMismatch`, `ErrExhumeCorruption`). A disposable lifetime: `using exhumed = await Db.exhume(path)` releases the store's exclusive lock at scope exit.
138
134
 
139
135
  ## Cookbook