@bjornpagen/bumbledb 0.4.0 → 0.6.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 (103) hide show
  1. package/COOKBOOK.md +191 -92
  2. package/README.md +9 -7
  3. package/dist/closed.d.ts +30 -2
  4. package/dist/closed.d.ts.map +1 -1
  5. package/dist/closed.js +66 -20
  6. package/dist/closed.js.map +1 -1
  7. package/dist/db.d.ts +33 -6
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +89 -72
  10. package/dist/db.js.map +1 -1
  11. package/dist/exhume.d.ts.map +1 -1
  12. package/dist/exhume.js +1 -14
  13. package/dist/exhume.js.map +1 -1
  14. package/dist/face.d.ts +1 -1
  15. package/dist/face.d.ts.map +1 -1
  16. package/dist/face.js +2 -1
  17. package/dist/face.js.map +1 -1
  18. package/dist/fields.d.ts +18 -1
  19. package/dist/fields.d.ts.map +1 -1
  20. package/dist/fields.js +44 -16
  21. package/dist/fields.js.map +1 -1
  22. package/dist/index.d.ts +18 -10
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +13 -6
  25. package/dist/index.js.map +1 -1
  26. package/dist/law.d.ts +2 -1
  27. package/dist/law.d.ts.map +1 -1
  28. package/dist/law.js +15 -14
  29. package/dist/law.js.map +1 -1
  30. package/dist/lower.d.ts.map +1 -1
  31. package/dist/lower.js +1 -7
  32. package/dist/lower.js.map +1 -1
  33. package/dist/marshal.d.ts.map +1 -1
  34. package/dist/marshal.js +15 -27
  35. package/dist/marshal.js.map +1 -1
  36. package/dist/native.d.ts +21 -2
  37. package/dist/native.d.ts.map +1 -1
  38. package/dist/native.js +20 -3
  39. package/dist/native.js.map +1 -1
  40. package/dist/order.d.ts +36 -0
  41. package/dist/order.d.ts.map +1 -0
  42. package/dist/order.js +135 -0
  43. package/dist/order.js.map +1 -0
  44. package/dist/query/atom.d.ts +140 -206
  45. package/dist/query/atom.d.ts.map +1 -1
  46. package/dist/query/atom.js +33 -52
  47. package/dist/query/atom.js.map +1 -1
  48. package/dist/query/find.d.ts +116 -0
  49. package/dist/query/find.d.ts.map +1 -0
  50. package/dist/query/{select.js → find.js} +22 -22
  51. package/dist/query/find.js.map +1 -0
  52. package/dist/query/lower.d.ts +124 -162
  53. package/dist/query/lower.d.ts.map +1 -1
  54. package/dist/query/lower.js +462 -507
  55. package/dist/query/lower.js.map +1 -1
  56. package/dist/query/predicate.d.ts +22 -14
  57. package/dist/query/predicate.d.ts.map +1 -1
  58. package/dist/query/predicate.js +45 -20
  59. package/dist/query/predicate.js.map +1 -1
  60. package/dist/query/run.d.ts +3 -3
  61. package/dist/query/run.d.ts.map +1 -1
  62. package/dist/query/run.js +10 -11
  63. package/dist/query/run.js.map +1 -1
  64. package/dist/query/scope.d.ts +124 -83
  65. package/dist/query/scope.d.ts.map +1 -1
  66. package/dist/query/scope.js +77 -70
  67. package/dist/query/scope.js.map +1 -1
  68. package/dist/relation.d.ts +9 -22
  69. package/dist/relation.d.ts.map +1 -1
  70. package/dist/relation.js +9 -28
  71. package/dist/relation.js.map +1 -1
  72. package/dist/schema.d.ts.map +1 -1
  73. package/dist/schema.js +7 -31
  74. package/dist/schema.js.map +1 -1
  75. package/dist/statements.d.ts +5 -2
  76. package/dist/statements.d.ts.map +1 -1
  77. package/dist/statements.js +30 -35
  78. package/dist/statements.js.map +1 -1
  79. package/package.json +2 -5
  80. package/src/closed.ts +73 -28
  81. package/src/db.ts +126 -88
  82. package/src/exhume.ts +1 -15
  83. package/src/face.ts +4 -3
  84. package/src/fields.ts +58 -16
  85. package/src/index.ts +19 -13
  86. package/src/law.ts +15 -14
  87. package/src/lower.ts +2 -9
  88. package/src/marshal.ts +15 -31
  89. package/src/native.ts +22 -4
  90. package/src/order.ts +156 -0
  91. package/src/query/atom.ts +186 -276
  92. package/src/query/find.ts +212 -0
  93. package/src/query/lower.ts +614 -749
  94. package/src/query/predicate.ts +47 -20
  95. package/src/query/run.ts +11 -13
  96. package/src/query/scope.ts +165 -132
  97. package/src/relation.ts +9 -51
  98. package/src/schema.ts +7 -33
  99. package/src/statements.ts +33 -38
  100. package/dist/query/select.d.ts +0 -128
  101. package/dist/query/select.d.ts.map +0 -1
  102. package/dist/query/select.js.map +0 -1
  103. package/src/query/select.ts +0 -215
package/COOKBOOK.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The cookbook — modeling intuition as schemas, in TypeScript
2
2
 
3
- The bumbledb engine's 29 cookbook recipes (`bumbledb/docs/cookbook.md`),
3
+ The bumbledb engine's 30 cookbook recipes (`bumbledb/docs/cookbook.md`),
4
4
  translated to this SDK's structural API. **This document is illustrative,
5
5
  never normative**: where a recipe and an engine architecture chapter disagree,
6
6
  the chapter wins (`docs/architecture/README.md` rule 5) — the SDK is the same
@@ -37,9 +37,11 @@ import {
37
37
  abandon,
38
38
  allen,
39
39
  bool,
40
+ by,
40
41
  bytes,
41
42
  closed,
42
43
  contained,
44
+ desc,
43
45
  eq,
44
46
  i64,
45
47
  interval,
@@ -54,7 +56,8 @@ import {
54
56
  relation,
55
57
  schema,
56
58
  str,
57
- u64
59
+ u64,
60
+ v
58
61
  } from "@bjornpagen/bumbledb"
59
62
  ```
60
63
 
@@ -116,27 +119,27 @@ const Uptime = schema("Uptime", { Service, Outage }, [
116
119
  key(Outage, ["service", "window"])
117
120
  ])
118
121
 
119
- // down at instant t — `r.vars` mints the rule's whole variable scope at
120
- // once, and shorthand punning binds same-named columns:
122
+ // down at instant t — v(Outage) mints a fresh variable per column (typed by
123
+ // that column's law-class); destructure what you bind, reuse to join:
121
124
  const downAt = query(Uptime).rule((r) => {
122
- const { service, window } = r.vars("service", "window")
125
+ const { service, window } = v(Outage)
123
126
  return r
124
127
  .match(Outage, { service, window })
125
128
  .where(pointIn(r.param("t"), window))
126
- .select("service")
129
+ .find({ service })
127
130
  })
128
131
  // overlapping an incident window (one Allen mask, no operator zoo):
129
132
  const overlapping = query(Uptime).rule((r) => {
130
- const { service, window } = r.vars("service", "window")
133
+ const { service, window } = v(Outage)
131
134
  return r
132
135
  .match(Outage, { service, window })
133
136
  .where(allen(window, ALLEN.intersects, r.param("incident")))
134
- .select("service", "window")
137
+ .find({ service, window })
135
138
  })
136
139
  // total downtime per service (the denotation's one arithmetic):
137
140
  const downtime = query(Uptime).rule((r) => {
138
- const { service, window } = r.vars("service", "window")
139
- return r.match(Outage, { service, window }).select("service", r.sum(r.duration("window")))
141
+ const { service, window } = v(Outage)
142
+ return r.match(Outage, { service, window }).find({ service, downtime: r.sum(r.duration(window)) })
140
143
  })
141
144
  ```
142
145
 
@@ -215,11 +218,11 @@ const Optionality = schema("Optionality", { Business, MailingAddress }, [
215
218
 
216
219
  // Negation is plain anti-join (no null branch exists in any operator):
217
220
  const unaddressed = query(Optionality).rule((r) => {
218
- const { b } = r.vars("b")
221
+ const { id: b } = v(Business)
219
222
  return r
220
223
  .match(Business, { id: b })
221
224
  .where(not(MailingAddress, { business: b }))
222
- .select("b")
225
+ .find({ b })
223
226
  })
224
227
  ```
225
228
 
@@ -255,8 +258,8 @@ const Money = schema("Money", { Currency, Account, Posting }, [
255
258
  // silently. Bind the fresh id: set semantics would collapse two equal
256
259
  // (account, currency, minor) postings without it.
257
260
  const totals = query(Money).rule((r) => {
258
- const { id, account, currency, minor } = r.vars("id", "account", "currency", "minor")
259
- return r.match(Posting, { id, account, currency, minor }).select("account", "currency", r.sum("minor"))
261
+ const { id, account, currency, minor } = v(Posting)
262
+ return r.match(Posting, { id, account, currency, minor }).find({ account, currency, total: r.sum(minor) })
260
263
  })
261
264
  ```
262
265
 
@@ -292,8 +295,8 @@ const Content = schema("Content", { Region, Document, Replica }, [
292
295
 
293
296
  // a bytes param self-encodes (Uint8Array by inference):
294
297
  const byDigest = query(Content).rule((r) => {
295
- const { id } = r.vars("id")
296
- return r.match(Document, { id, payload: r.param("digest") }).select("id")
298
+ const { id } = v(Document)
299
+ return r.match(Document, { id, payload: r.param("digest") }).find({ id })
297
300
  })
298
301
  ```
299
302
 
@@ -334,8 +337,8 @@ const Tickets = schema("Tickets", { Priority, Ticket }, [
334
337
  // that drifts without a rebuild is an ordinary relation — a vocabulary is
335
338
  // never written, only declared.
336
339
  const urgent = query(Tickets).rule((r) => {
337
- const { t } = r.vars("t")
338
- return r.match(Ticket, { id: t, priority: "Urgent" }).select("t")
340
+ const { id: t } = v(Ticket)
341
+ return r.match(Ticket, { id: t, priority: "Urgent" }).find({ t })
339
342
  })
340
343
 
341
344
  // Set membership is a plain array — the drizzle law's spelling, closed-only
@@ -343,8 +346,8 @@ const urgent = query(Tickets).rule((r) => {
343
346
  // ∈-set param, `r.inSet`); the array folds to the same wire set the param
344
347
  // spelling crosses. In `.where()` selections arrays work at EVERY field kind.
345
348
  const actionable = query(Tickets).rule((r) => {
346
- const { t } = r.vars("t")
347
- return r.match(Ticket, { id: t, priority: ["Normal", "Urgent"] }).select("t")
349
+ const { id: t } = v(Ticket)
350
+ return r.match(Ticket, { id: t, priority: ["Normal", "Urgent"] }).find({ t })
348
351
  })
349
352
  ```
350
353
 
@@ -388,11 +391,11 @@ const Review = schema("Review", { Kind, Attempt, Certificate }, [
388
391
  // exactly like an ordinary one, and the atom folds at prepare into a
389
392
  // plan-constant handle set on its sibling.
390
393
  const masteredAttempts = query(Review).rule((r) => {
391
- const { a, k } = r.vars("a", "k")
394
+ const { id: a, kind: k } = v(Attempt)
392
395
  return r
393
396
  .match(Attempt, { id: a, kind: k })
394
397
  .match(Kind, { id: k, mastered: true })
395
- .select("a")
398
+ .find({ a })
396
399
  })
397
400
 
398
401
  // Host dispatch on the payload tier is the record-table idiom — a `Record`
@@ -451,11 +454,11 @@ const Oncall = schema("Oncall", { Severity, Incident, Escalation }, [
451
454
 
452
455
  // who is being paged — the same ψ, on the read side:
453
456
  const paged = query(Oncall).rule((r) => {
454
- const { i, s } = r.vars("i", "s")
457
+ const { incident: i, severity: s } = v(Escalation)
455
458
  return r
456
459
  .match(Escalation, { incident: i, severity: s })
457
460
  .match(Severity, { id: s, pages: true })
458
- .select("i")
461
+ .find({ i })
459
462
  })
460
463
  ```
461
464
 
@@ -468,7 +471,8 @@ plus pointwise keys realizes exact partition
468
471
  (`lean/Bumbledb/Dependencies.lean: exact_partition_iff`), and the mixed-width
469
472
  interval positions type by element domain
470
473
  (`lean/Bumbledb/Schema.lean: Value.points_one_tag_u64`); ordering the result
471
- remains a host presentation step.
474
+ remains a host presentation step — the SDK ships the comparator (`by`/`desc`,
475
+ keys as data); the engine never orders.
472
476
 
473
477
  The linked-list verdict: successor pointers are control flow smuggled into
474
478
  data. Order is a value. The idiomatic ordered collection is an interval
@@ -496,12 +500,20 @@ const Playlists = schema("Playlists", { Playlist, Extent, Slot }, [
496
500
 
497
501
  // Positional access is membership — "what plays at position ?pos":
498
502
  const playingAt = query(Playlists).rule((r) => {
499
- const { slot, track } = r.vars("slot", "track")
503
+ const { slot, track } = v(Slot)
500
504
  return r
501
505
  .match(Slot, { playlist: r.param("list"), slot, track })
502
506
  .where(pointIn(r.param("pos"), slot))
503
- .select("track")
507
+ .find({ track })
504
508
  })
509
+
510
+ // Answers are SETS — the host sorts them, and the SDK ships the comparator:
511
+ // sort keys as data, a bare name ascending, `desc(...)` the flip; intervals
512
+ // order by (start, end). Limit is the language's own `.slice(0, n)`.
513
+ const inPlayOrder = [
514
+ { slot: { start: 1n, end: 2n }, track: "b" },
515
+ { slot: { start: 0n, end: 1n }, track: "a" }
516
+ ].sort(by("slot", "track"))
505
517
  ```
506
518
 
507
519
  Middle insert is honest about its cost: making room at position `k` shifts
@@ -548,11 +560,12 @@ const Ast = schema("Ast", { Kind, Node, Lit, Add, Parent }, [
548
560
  ])
549
561
 
550
562
  const lhsLiteral = query(Ast).rule((r) => {
551
- const { l, v } = r.vars("l", "v")
563
+ const { lhs: l } = v(Add)
564
+ const { value } = v(Lit)
552
565
  return r
553
566
  .match(Add, { node: r.param("n"), lhs: l })
554
- .match(Lit, { node: l, value: v })
555
- .select("v")
567
+ .match(Lit, { node: l, value })
568
+ .find({ value })
556
569
  })
557
570
  ```
558
571
 
@@ -583,12 +596,12 @@ const Graph = schema("Graph", { Person, Repo, Follows, Maintains }, [
583
596
  // live in the "Person.id" class, so the reuse is lawful); `lt` keeps each
584
597
  // pair once:
585
598
  const mutual = query(Graph).rule((r) => {
586
- const { a, b } = r.vars("a", "b")
599
+ const { follower: a, followee: b } = v(Follows)
587
600
  return r
588
601
  .match(Follows, { follower: a, followee: b })
589
602
  .match(Follows, { follower: b, followee: a })
590
603
  .where(lt(a, b))
591
- .select("a", "b")
604
+ .find({ a, b })
592
605
  })
593
606
  ```
594
607
 
@@ -621,11 +634,12 @@ const Ecs = schema("Ecs", { Entity, Transform, Velocity, Renderable }, [
621
634
 
622
635
  // The physics join is the component intersection:
623
636
  const physics = query(Ecs).rule((r) => {
624
- const { entity, x, y, dx, dy } = r.vars("entity", "x", "y", "dx", "dy")
637
+ const { entity, x, y } = v(Transform)
638
+ const { dx, dy } = v(Velocity)
625
639
  return r
626
640
  .match(Transform, { entity, x, y })
627
641
  .match(Velocity, { entity, dx, dy })
628
- .select("entity", "x", "y", "dx", "dy")
642
+ .find({ entity, x, y, dx, dy })
629
643
  })
630
644
  ```
631
645
 
@@ -661,11 +675,12 @@ const Orders = schema("Orders", { State, Order, Placement, Shipment }, [
661
675
  ])
662
676
 
663
677
  const shipped = query(Orders).rule((r) => {
664
- const { id, carrier } = r.vars("id", "carrier")
678
+ const { id } = v(Order)
679
+ const { carrier } = v(Shipment)
665
680
  return r
666
681
  .match(Order, { id, state: "Shipped" })
667
682
  .match(Shipment, { order: id, carrier })
668
- .select("id", "carrier")
683
+ .find({ id, carrier })
669
684
  })
670
685
  ```
671
686
 
@@ -731,18 +746,18 @@ const Calendar = schema("Calendar", { Rsvp, Arm, Person, Room, Event, Attendance
731
746
  ])
732
747
 
733
748
  const roomConflicts = query(Calendar).rule((r) => {
734
- const { room, span } = r.vars("room", "span")
749
+ const { room, span } = v(Booking)
735
750
  return r
736
751
  .match(Booking, { room, span })
737
752
  .where(allen(span, ALLEN.intersects, r.param("want")))
738
- .select("room", "span")
753
+ .find({ room, span })
739
754
  })
740
755
  const personLoad = query(Calendar).rule((r) => {
741
- const { person, span } = r.vars("person", "span")
756
+ const { person, span } = v(Claim)
742
757
  return r
743
758
  .match(Claim, { person, span })
744
759
  .where(allen(span, ALLEN.intersects, r.param("window")))
745
- .select("person", "span")
760
+ .find({ person, span })
746
761
  })
747
762
  ```
748
763
 
@@ -774,20 +789,21 @@ const Pricing = schema("Pricing", { Policy, Version }, [
774
789
 
775
790
  // in force on date t — one membership probe:
776
791
  const inForce = query(Pricing).rule((r) => {
777
- const { rate_bps, valid } = r.vars("rate_bps", "valid")
792
+ const { rate_bps, valid } = v(Version)
778
793
  return r
779
794
  .match(Version, { policy: r.param("p"), rate_bps, valid })
780
795
  .where(pointIn(r.param("t"), valid))
781
- .select("rate_bps")
796
+ .find({ rate_bps })
782
797
  })
783
798
  // clean successions (half-open makes MEETS exact, no ±1 fudge):
784
799
  const successions = query(Pricing).rule((r) => {
785
- const { p, a, b } = r.vars("p", "a", "b")
800
+ const { policy: p, valid: a } = v(Version)
801
+ const { valid: b } = v(Version)
786
802
  return r
787
803
  .match(Version, { policy: p, valid: a })
788
804
  .match(Version, { policy: p, valid: b })
789
805
  .where(allen(a, ALLEN.meets, b))
790
- .select("a", "b")
806
+ .find({ a, b })
791
807
  })
792
808
  ```
793
809
 
@@ -816,11 +832,11 @@ const Payroll = schema("Payroll", { FiscalYear, PayPeriod }, [
816
832
 
817
833
  // the period holding date t:
818
834
  const holding = query(Payroll).rule((r) => {
819
- const { seq, span } = r.vars("seq", "span")
835
+ const { seq, span } = v(PayPeriod)
820
836
  return r
821
837
  .match(PayPeriod, { year: r.param("y"), seq, span })
822
838
  .where(pointIn(r.param("t"), span))
823
- .select("seq")
839
+ .find({ seq })
824
840
  })
825
841
  ```
826
842
 
@@ -864,12 +880,13 @@ const Tax = schema("Tax", { Status, Regime, Bracket, Residency, Earned }, [
864
880
  // owed is host arithmetic over the bracket walk — arithmetic beyond the
865
881
  // measure is refused (the ledger).
866
882
  const marginal = query(Tax).rule((r) => {
867
- const { reg, b, rate_bps } = r.vars("reg", "b", "rate_bps")
883
+ const { id: reg } = v(Regime)
884
+ const { income: b, rate_bps } = v(Bracket)
868
885
  return r
869
886
  .match(Regime, { id: reg, year: r.param("y"), status: r.param("s") })
870
887
  .match(Bracket, { regime: reg, income: b, rate_bps })
871
888
  .where(pointIn(r.param("taxable"), b))
872
- .select("rate_bps")
889
+ .find({ rate_bps })
873
890
  })
874
891
  ```
875
892
 
@@ -896,17 +913,19 @@ const FreeTime = schema("FreeTime", { Person, Claim }, [
896
913
 
897
914
  // busy time, coalesced (adjacent segments merge — the half-open law):
898
915
  const busy = query(FreeTime).rule((r) => {
899
- const { person, span } = r.vars("person", "span")
900
- return r.match(Claim, { person, span }).select("person", r.pack("span"))
916
+ const { person, span } = v(Claim)
917
+ return r.match(Claim, { person, span }).find({ person, packed: r.pack(span) })
901
918
  })
902
919
  // raw claimed time (overlaps double-count — often the wrong question):
903
920
  const claimed = query(FreeTime).rule((r) => {
904
- const { person, span } = r.vars("person", "span")
905
- return r.match(Claim, { person, span }).select("person", r.sum(r.duration("span")))
921
+ const { person, span } = v(Claim)
922
+ return r.match(Claim, { person, span }).find({ person, claimed: r.sum(r.duration(span)) })
906
923
  })
907
924
  // Coalesced totals = the two-query composition (pack, then a host fold) —
908
925
  // aggregates never nest; free time (gaps) is the two-line host walk over
909
- // sorted packed answers both refusals recorded in the ledger.
926
+ // sorted packed answers (`rows.sort(by("person", "span"))` the
927
+ // keys-as-data comparator; limit is the language's own `.slice`) — both
928
+ // refusals recorded in the ledger.
910
929
  ```
911
930
 
912
931
  ## The write side
@@ -940,13 +959,13 @@ const Ledger = schema("Ledger", { Account, JournalEntry, Posting }, [
940
959
 
941
960
  // balances (bind the fresh id — set semantics collapses duplicates):
942
961
  const balances = query(Ledger).rule((r) => {
943
- const { id, account, minor } = r.vars("id", "account", "minor")
944
- return r.match(Posting, { id, account, minor }).select("account", r.sum("minor"))
962
+ const { id, account, minor } = v(Posting)
963
+ return r.match(Posting, { id, account, minor }).find({ account, balance: r.sum(minor) })
945
964
  })
946
965
  // double-entry audit (host asserts every total is 0 — discipline, not schema):
947
966
  const doubleEntry = query(Ledger).rule((r) => {
948
- const { id, entry, minor } = r.vars("id", "entry", "minor")
949
- return r.match(Posting, { id, entry, minor }).select("entry", r.sum("minor"))
967
+ const { id, entry, minor } = v(Posting)
968
+ return r.match(Posting, { id, entry, minor }).find({ entry, balance: r.sum(minor) })
950
969
  })
951
970
  ```
952
971
 
@@ -960,8 +979,11 @@ final-state point reads need no earlier witness.
960
979
  The generation witness: read the model, propose a delta, commit iff the model
961
980
  you read is still the model. In the SDK the whole loop is `db.writeWitnessed`
962
981
  — retry on movement is built in (every generation move is self-inflicted by
963
- the host's own interleaved writes), and `abandon(payload)` declines to commit
964
- without issuing anything.
982
+ the host's own interleaved writes), capped at 64 attempts: a callback that
983
+ itself issues a plain `db.write` each try re-moves the generation it is about
984
+ to witness, and past the cap that interleave hazard throws the typed
985
+ `ErrWitnessedLivelock` instead of spinning forever. `abandon(payload)`
986
+ declines to commit without issuing anything.
965
987
 
966
988
  ```ts
967
989
  const State = closed("State", ["Queued", "Running", "Done"])
@@ -978,8 +1000,8 @@ const Jobs = schema("Jobs", { State, Job, Lease }, [
978
1000
 
979
1001
  // update-where's premise — "still Queued" is the witness:
980
1002
  const stillQueued = query(Jobs).rule((r) => {
981
- const { id, payload } = r.vars("id", "payload")
982
- return r.match(Job, { id, state: "Queued", payload }).select("id", "payload")
1003
+ const { id, payload } = v(Job)
1004
+ return r.match(Job, { id, state: "Queued", payload }).find({ id, payload })
983
1005
  })
984
1006
 
985
1007
  const db = await Db.create("./jobs.db", Jobs)
@@ -1035,8 +1057,8 @@ const Rollup = schema("Rollup", { Arm, Claim, BusySpan }, [
1035
1057
  // against sources it didn't actually read. The deriving query (pack IS the
1036
1058
  // coalesce):
1037
1059
  const deriving = query(Rollup).rule((r) => {
1038
- const { person, span } = r.vars("person", "span")
1039
- return r.match(Claim, { person, span, arm: "Busy" }).select("person", r.pack("span"))
1060
+ const { person, span } = v(Claim)
1061
+ return r.match(Claim, { person, span, arm: "Busy" }).find({ person, packed: r.pack(span) })
1040
1062
  })
1041
1063
  ```
1042
1064
 
@@ -1069,18 +1091,20 @@ const Payments = schema("Payments", { Kind, Payment, Card, Ach }, [
1069
1091
  // provably disjoint, so the executor elides cross-rule dedup — the free lunch.
1070
1092
  const wholeDu = query(Payments)
1071
1093
  .rule((r) => {
1072
- const { id, n } = r.vars("id", "n")
1094
+ const { id } = v(Payment)
1095
+ const { last4: n } = v(Card)
1073
1096
  return r
1074
1097
  .match(Payment, { id, kind: "Card" })
1075
1098
  .match(Card, { payment: id, last4: n })
1076
- .select("id", "n")
1099
+ .find({ id, n })
1077
1100
  })
1078
1101
  .rule((r) => {
1079
- const { id, n } = r.vars("id", "n")
1102
+ const { id } = v(Payment)
1103
+ const { routing: n } = v(Ach)
1080
1104
  return r
1081
1105
  .match(Payment, { id, kind: "Ach" })
1082
1106
  .match(Ach, { payment: id, routing: n })
1083
- .select("id", "n")
1107
+ .find({ id, n })
1084
1108
  })
1085
1109
  ```
1086
1110
 
@@ -1148,8 +1172,8 @@ const Closure = schema("Closure", { Node, Parent }, [
1148
1172
 
1149
1173
  // The loop's one query — the frontier's children, one ∈-set probe:
1150
1174
  const step = query(Closure).rule((r) => {
1151
- const { c } = r.vars("c")
1152
- return r.match(Parent, { child: c, parent: r.inSet("frontier") }).select("c")
1175
+ const { child: c } = v(Parent)
1176
+ return r.match(Parent, { child: c, parent: r.inSet("frontier") }).find({ c })
1153
1177
  })
1154
1178
  ```
1155
1179
 
@@ -1192,22 +1216,22 @@ const reach = program(Closure, (p) => {
1192
1216
  const rec = p.rec("reach")
1193
1217
  const seeded = rec
1194
1218
  .rule((r) => {
1195
- const { c } = r.vars("c")
1219
+ const { id: c } = v(Node)
1196
1220
  return r
1197
1221
  .match(Node, { id: c })
1198
1222
  .where(eq(c, r.param("root")))
1199
- .select("c")
1223
+ .find({ c })
1200
1224
  })
1201
1225
  .rule((r) => {
1202
- const { c, parent } = r.vars("c", "parent")
1226
+ const { child: c, parent } = v(Parent)
1203
1227
  return r
1204
1228
  .match(Parent, { child: c, parent })
1205
- .idb(rec, parent)
1206
- .select("c")
1229
+ .idb(rec, { c: parent })
1230
+ .find({ c })
1207
1231
  })
1208
1232
  return p.output((r) => {
1209
- const { c } = r.vars("c")
1210
- return r.match(Node, { id: c }).idb(seeded, c).select("c")
1233
+ const { id: c } = v(Node)
1234
+ return r.match(Node, { id: c }).idb(seeded, { c }).find({ c })
1211
1235
  })
1212
1236
  })
1213
1237
  const reachPrepared = db.prepare(reach)
@@ -1249,14 +1273,14 @@ const Accounts = schema("Accounts", { Account, AccountParent, Posting }, [
1249
1273
  // The two queries the host rollup composes:
1250
1274
  // the frontier step (recipe 24's loop, verbatim):
1251
1275
  const frontierStep = query(Accounts).rule((r) => {
1252
- const { c } = r.vars("c")
1253
- return r.match(AccountParent, { child: c, parent: r.inSet("frontier") }).select("c")
1276
+ const { child: c } = v(AccountParent)
1277
+ return r.match(AccountParent, { child: c, parent: r.inSet("frontier") }).find({ c })
1254
1278
  })
1255
1279
  // the rollup over the accumulated subtree (bind the fresh id — recipe
1256
1280
  // 19's discipline, spent again; equal postings to one account both count):
1257
1281
  const subtreeRollup = query(Accounts).rule((r) => {
1258
- const { id, minor } = r.vars("id", "minor")
1259
- return r.match(Posting, { id, account: r.inSet("subtree"), minor }).select(r.sum("minor"))
1282
+ const { id, minor } = v(Posting)
1283
+ return r.match(Posting, { id, account: r.inSet("subtree"), minor }).find({ total: r.sum(minor) })
1260
1284
  })
1261
1285
  // The engine-native form: the closure stratum converges first, then the
1262
1286
  // output's fold runs once over the finished subtree.
@@ -1264,25 +1288,25 @@ const nativeRollup = program(Accounts, (p) => {
1264
1288
  const sub = p.rec("sub")
1265
1289
  const seeded = sub
1266
1290
  .rule((r) => {
1267
- const { a } = r.vars("a")
1291
+ const { id: a } = v(Account)
1268
1292
  return r
1269
1293
  .match(Account, { id: a })
1270
1294
  .where(eq(a, r.param("root")))
1271
- .select("a")
1295
+ .find({ a })
1272
1296
  })
1273
1297
  .rule((r) => {
1274
- const { a, parent } = r.vars("a", "parent")
1298
+ const { child: a, parent } = v(AccountParent)
1275
1299
  return r
1276
1300
  .match(AccountParent, { child: a, parent })
1277
- .idb(sub, parent)
1278
- .select("a")
1301
+ .idb(sub, { a: parent })
1302
+ .find({ a })
1279
1303
  })
1280
1304
  return p.output((r) => {
1281
- const { id, a, minor } = r.vars("id", "a", "minor")
1305
+ const { id, account: a, minor } = v(Posting)
1282
1306
  return r
1283
1307
  .match(Posting, { id, account: a, minor })
1284
- .idb(seeded, a)
1285
- .select(r.sum("minor"))
1308
+ .idb(seeded, { a })
1309
+ .find({ total: r.sum(minor) })
1286
1310
  })
1287
1311
  })
1288
1312
  ```
@@ -1348,8 +1372,8 @@ const MaintainedRollup = schema("MaintainedRollup", { Arm, Claim, BusySpan }, [
1348
1372
 
1349
1373
  // Derive the desired rollup on the maintenance snapshot:
1350
1374
  const deriving = query(MaintainedRollup).rule((r) => {
1351
- const { source, person, span } = r.vars("source", "person", "span")
1352
- return r.match(Claim, { source, person, arm: "Busy", span }).select("person", r.pack("span"))
1375
+ const { source, person, span } = v(Claim)
1376
+ return r.match(Claim, { source, person, arm: "Busy", span }).find({ person, packed: r.pack(span) })
1353
1377
  })
1354
1378
  ```
1355
1379
 
@@ -1402,12 +1426,13 @@ const Payroll = schema("Payroll", { Employee, Salary }, [
1402
1426
 
1403
1427
  // The post-migration read — salaries in force at an instant:
1404
1428
  const inForceAt = query(Payroll).rule((r) => {
1405
- const { e, name, amount, w } = r.vars("e", "name", "amount", "w")
1429
+ const { id: e, name } = v(Employee)
1430
+ const { amount, applies: w } = v(Salary)
1406
1431
  return r
1407
1432
  .match(Employee, { id: e, name })
1408
1433
  .match(Salary, { employee: e, amount, applies: w })
1409
1434
  .where(pointIn(r.param("at"), w))
1410
- .select("name", "amount")
1435
+ .find({ name, amount })
1411
1436
  })
1412
1437
  ```
1413
1438
 
@@ -1468,3 +1493,77 @@ unit slots `[4,5)`, `[5,6)` satisfies both directions, because nothing forces
1468
1493
  the witness rows to mirror the sidecar's segmentation — only its points. If
1469
1494
  per-row correspondence matters, the host writes zones at slot granularity;
1470
1495
  the schema proves disjointness and coverage either way.
1496
+
1497
+ ## Point reads
1498
+
1499
+ ## 30. The keyed read
1500
+
1501
+ Guarantee: validator/runtime premises — a declared key FD admits at most one
1502
+ fact per determinant tuple (the key phase of the commit judgment), and every
1503
+ keyed point read answers exactly that fact or nothing, on every scope
1504
+ (`ts/test/keyed-get.test.ts`; the engine half is
1505
+ `crates/bumbledb/tests/keyed_get.rs`).
1506
+
1507
+ The key is a **law**, and the read surface is that law made callable. The
1508
+ schema says `key(Program, ["grp"])` — one program per group — so "the
1509
+ program of a group" is a well-posed question with at most one answer, and
1510
+ the store already enforces that on every commit. Hold the statement VALUE:
1511
+ it is the read's selector below (statement identity is the membership rule).
1512
+
1513
+ ```ts
1514
+ const Grp = relation("Grp", { id: u64.fresh, label: str })
1515
+ const Program = relation("Program", { id: u64.fresh, grp: u64, title: str })
1516
+ // The law: one program per group — the callable key.
1517
+ const programGrpKey = key(Program, ["grp"])
1518
+
1519
+ const KeyedRead = schema("KeyedRead", { Grp, Program }, [
1520
+ contained(on(Program, "grp"), on(Grp, "id")),
1521
+ programGrpKey
1522
+ ])
1523
+ ```
1524
+
1525
+ The point read is the statement handed back to `get` — one spelling on
1526
+ every scope (the symmetry rule): `db.get` standalone, `snap.get` inside a
1527
+ read scope, `tx.get` inside a write transaction, where the transaction side
1528
+ answers the FINAL state (base plus pending delta: read-your-writes, a
1529
+ pending delete answers `undefined`). The key object is typed by the
1530
+ statement's own projection — a wrong field name is a compile error, never a
1531
+ runtime shape check. The primary 2-arg form needs no statement: the fresh
1532
+ field IS the primary key.
1533
+
1534
+ ```ts
1535
+ const db = await Db.create("./programs.db", KeyedRead)
1536
+
1537
+ const minted: { grp?: bigint } = {}
1538
+ db.write((tx) => {
1539
+ const g = tx.insert(Grp, { label: "algebra" })
1540
+ tx.insert(Program, { grp: g.id, title: "linear equations" })
1541
+ minted.grp = g.id
1542
+ })
1543
+ const grp = minted.grp ?? 0n
1544
+
1545
+ // db.get — the standalone keyed read through the declared law:
1546
+ const byGroup = db.get(Program, programGrpKey, { grp })
1547
+
1548
+ // snap.get — the same spelling inside a read scope:
1549
+ const viaSnap = db.read((snap) => snap.get(Program, programGrpKey, { grp }))
1550
+
1551
+ // tx.get — key-shaped read-modify-write, final-state (recipe 20's third
1552
+ // idiom): per-fact premises need no earlier snapshot witness.
1553
+ db.write((tx) => {
1554
+ const current = tx.get(Program, programGrpKey, { grp })
1555
+ if (current !== undefined) {
1556
+ tx.delete(Program, current)
1557
+ tx.insert(Program, { id: current.id, grp: current.grp, title: "linear equations II" })
1558
+ }
1559
+ })
1560
+
1561
+ // The primary 2-arg form — the fresh field is the primary key:
1562
+ const byId = byGroup === undefined ? undefined : db.get(Program, { id: byGroup.id })
1563
+ ```
1564
+
1565
+ The anti-pattern this recipe retires: a scan-and-find where a key law
1566
+ exists — `snap.scan(Program).find((row) => row.grp === grp)` — re-derives
1567
+ in the host what the store already enforces. The uniqueness the fold
1568
+ quietly assumes IS the declared key statement; spell the law and the point
1569
+ read comes with it.
package/README.md CHANGED
@@ -27,7 +27,7 @@ classes, inferred query rows, and rejections that arrive as data rather than
27
27
  exceptions.
28
28
 
29
29
  ```ts
30
- import { bool, closed, contained, Db, gt, type Infer, key, on, query, relation, schema, u64 } from "@bjornpagen/bumbledb"
30
+ import { bool, closed, contained, Db, gt, type Infer, key, on, query, relation, schema, u64, v } from "@bjornpagen/bumbledb"
31
31
 
32
32
  // A closed relation: a sealed roster of axioms with typed payload columns.
33
33
  // At the host surface a handle is its NAME — the string literal "DirectPass"
@@ -75,16 +75,18 @@ if (!result.ok) {
75
75
  }
76
76
  }
77
77
 
78
- // Query: Datalog as values. Vars are named and typed by the class of their
79
- // first binding; params are typed by use; rows are typed from the select.
78
+ // Query: Datalog as values. v(R) mints a fresh variable per column, typed by
79
+ // the column's law-class; reusing one by object reference IS the join, and
80
+ // rows are typed from the find keys. Params are typed by use.
80
81
  // `gt` is one of the free comparison exports.
81
82
  const certifiedAbove = query(Review).rule((r) => {
82
- const { a, k, rank } = r.vars("a", "k", "rank")
83
+ const { attempt: a, kind: k } = v(Certificate)
84
+ const { rank } = v(Kind)
83
85
  return r
84
86
  .match(Certificate, { attempt: a, kind: k })
85
- .match(Kind, { id: k, mastered: true, rank }) // ψ on the read side too
87
+ .match(Kind, { id: k, mastered: true, rank }) // k reused at Kind.id — that reuse is the join; ψ on the read side too
86
88
  .where(gt(rank, r.param("floor")))
87
- .select("a", "rank")
89
+ .find({ a, rank })
88
90
  })
89
91
 
90
92
  const prepared = db.prepare(certifiedAbove)
@@ -118,7 +120,7 @@ The drizzle law governs this surface: the SDK's job at the host boundary is tran
118
120
  - 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.
119
121
  - The statement algebra — `schema()`, `key`, `contained`, `mirrors`, `window`; faces via `on` (set membership is a plain array in `.where`); counts via `exactly`, `atLeast`, `atMost`, `between`, `none`; ψ-selection via `.where` on relations and closed rosters.
120
122
  - The `Db` runtime — `Db.create`/`Db.open`, path-cached stores, transactions, typed violations, scoped snapshot reads, the witnessed write loop with `abandon`.
121
- - The query surface — Datalog as values, `query(S).rule(r => ...)`: named vars, params typed by use, negation, aggregates, and the free comparison/connective exports (`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`, `allen`/`ALLEN`, `pointIn`, `covers`); 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.
123
+ - 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.
122
124
  - 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`).
123
125
 
124
126
  ## Cookbook