@bjornpagen/bumbledb 0.3.0 → 0.4.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 (66) hide show
  1. package/COOKBOOK.md +66 -46
  2. package/README.md +28 -15
  3. package/dist/closed.d.ts +50 -73
  4. package/dist/closed.d.ts.map +1 -1
  5. package/dist/closed.js +38 -109
  6. package/dist/closed.js.map +1 -1
  7. package/dist/db.d.ts +4 -1
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +26 -3
  10. package/dist/db.js.map +1 -1
  11. package/dist/face.d.ts +39 -39
  12. package/dist/face.d.ts.map +1 -1
  13. package/dist/face.js +7 -16
  14. package/dist/face.js.map +1 -1
  15. package/dist/fields.d.ts +28 -14
  16. package/dist/fields.d.ts.map +1 -1
  17. package/dist/fields.js +15 -14
  18. package/dist/fields.js.map +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/marshal.d.ts +33 -6
  24. package/dist/marshal.d.ts.map +1 -1
  25. package/dist/marshal.js +67 -6
  26. package/dist/marshal.js.map +1 -1
  27. package/dist/query/atom.d.ts +59 -14
  28. package/dist/query/atom.d.ts.map +1 -1
  29. package/dist/query/atom.js +3 -0
  30. package/dist/query/atom.js.map +1 -1
  31. package/dist/query/lower.d.ts.map +1 -1
  32. package/dist/query/lower.js +251 -26
  33. package/dist/query/lower.js.map +1 -1
  34. package/dist/query/run.d.ts +15 -5
  35. package/dist/query/run.d.ts.map +1 -1
  36. package/dist/query/run.js +26 -6
  37. package/dist/query/run.js.map +1 -1
  38. package/dist/query/scope.d.ts +35 -13
  39. package/dist/query/scope.d.ts.map +1 -1
  40. package/dist/query/scope.js +16 -4
  41. package/dist/query/scope.js.map +1 -1
  42. package/dist/relation.d.ts +8 -7
  43. package/dist/relation.d.ts.map +1 -1
  44. package/dist/relation.js +32 -10
  45. package/dist/relation.js.map +1 -1
  46. package/dist/spec.d.ts +3 -2
  47. package/dist/spec.d.ts.map +1 -1
  48. package/dist/spec.js.map +1 -1
  49. package/dist/statements.d.ts +10 -4
  50. package/dist/statements.d.ts.map +1 -1
  51. package/dist/statements.js +79 -7
  52. package/dist/statements.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/closed.ts +72 -179
  55. package/src/db.ts +42 -5
  56. package/src/face.ts +34 -45
  57. package/src/fields.ts +46 -34
  58. package/src/index.ts +1 -2
  59. package/src/marshal.ts +74 -7
  60. package/src/query/atom.ts +58 -15
  61. package/src/query/lower.ts +301 -28
  62. package/src/query/run.ts +26 -6
  63. package/src/query/scope.ts +49 -15
  64. package/src/relation.ts +45 -17
  65. package/src/spec.ts +3 -2
  66. package/src/statements.ts +86 -7
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import * as errors from "@superbuilders/errors"
29
- import type { AnyField } from "#fields.ts"
29
+ import type { AnyField, ClosedRoster } from "#fields.ts"
30
30
  import { assertDeclarationOrderKey } from "#fields.ts"
31
31
  import type { ClassRecordOf, SchemaClasses } from "#law.ts"
32
32
  import type {
@@ -495,6 +495,56 @@ function matchFieldsOf(owner: MatchOwner): readonly RelationField[] {
495
495
  return owner.data.fields
496
496
  }
497
497
 
498
+ /**
499
+ * Judges one membership ARRAY at a binding position — legal exactly at a
500
+ * CLOSED-reference field (the owner ruling: ordinary u64/str membership is
501
+ * spelled through `r.inSet` params; literal arrays are the closed
502
+ * vocabulary's spelling), holding ≥ 2 DISTINCT handle names (the
503
+ * degenerate sets are refusals: empty selects nothing, one element is the
504
+ * bare literal respelled, and a duplicate member is the same respelling in
505
+ * disguise — write each member once). The returned name is
506
+ * CONTENT-ADDRESSED (vocabulary + the member SET — the key sorts a copy,
507
+ * so two spellings of one set, reordered or not, share one dense
508
+ * `ParamId`); the members are shape-checked strings here and
509
+ * roster-verified at the one verification point (`taggedHandleId`) when
510
+ * the SDK supplies the set at execute — the same moment a bound `r.inSet`
511
+ * param's members are judged.
512
+ */
513
+ function membershipSet(
514
+ context: string,
515
+ field: AnyField,
516
+ value: readonly unknown[]
517
+ ): { readonly name: string; readonly members: readonly string[] } {
518
+ if (!("closed" in field)) {
519
+ throw errors.new(
520
+ `${context}: a membership array is the closed-reference spelling — ordinary field membership is a bound ∈-set param (r.inSet)`
521
+ )
522
+ }
523
+ if (value.length === 0) {
524
+ throw errors.new(`${context}: an empty membership array selects nothing — write the query you mean`)
525
+ }
526
+ if (value.length === 1) {
527
+ throw errors.new(
528
+ `${context}: a one-element membership array is the bare literal respelled — write the literal (the canonical-utterance law: one meaning, one spelling)`
529
+ )
530
+ }
531
+ const seen = new Set<string>()
532
+ const members = value.map(function memberName(member) {
533
+ if (typeof member !== "string") {
534
+ throw literalShapeError(context, `a ${field.closed.name} handle name (string)`, member)
535
+ }
536
+ if (seen.has(member)) {
537
+ throw errors.new(
538
+ `${context}: the membership array spells ${member} twice — write it once (the canonical-utterance law: one meaning, one spelling)`
539
+ )
540
+ }
541
+ seen.add(member)
542
+ return member
543
+ })
544
+ const key = [...members].sort()
545
+ return { name: `∈ ${field.closed.name} ${JSON.stringify(key)}`, members: Object.freeze(members) }
546
+ }
547
+
498
548
  /**
499
549
  * Resolves a bindings record against an atom owner's matchable fields (a
500
550
  * relation's declared fields; a closed relation's sealed id + columns), in
@@ -539,14 +589,26 @@ function resolveBindings(
539
589
  case "param": {
540
590
  bound = Object.freeze({ kind: "param" as const, name: value.name })
541
591
  uses.push(
542
- Object.freeze({ name: value.name, shape: "value" as const, anchor: declared.field, op: "binding" as const })
592
+ Object.freeze({
593
+ name: value.name,
594
+ shape: "value" as const,
595
+ anchor: declared.field,
596
+ op: "binding" as const,
597
+ members: undefined
598
+ })
543
599
  )
544
600
  break
545
601
  }
546
602
  case "setParam": {
547
603
  bound = Object.freeze({ kind: "setParam" as const, name: value.name })
548
604
  uses.push(
549
- Object.freeze({ name: value.name, shape: "set" as const, anchor: declared.field, op: "binding" as const })
605
+ Object.freeze({
606
+ name: value.name,
607
+ shape: "set" as const,
608
+ anchor: declared.field,
609
+ op: "binding" as const,
610
+ members: undefined
611
+ })
550
612
  )
551
613
  break
552
614
  }
@@ -559,6 +621,18 @@ function resolveBindings(
559
621
  `${context}.${fieldName}: the measure is not a field-typed value — it lives in comparisons and select entries`
560
622
  )
561
623
  }
624
+ } else if (Array.isArray(value)) {
625
+ const set = membershipSet(`${context}.${fieldName}`, declared.field, value)
626
+ bound = Object.freeze({ kind: "literalSet" as const, name: set.name, members: set.members })
627
+ uses.push(
628
+ Object.freeze({
629
+ name: set.name,
630
+ shape: "set" as const,
631
+ anchor: declared.field,
632
+ op: "binding" as const,
633
+ members: set.members
634
+ })
635
+ )
562
636
  } else {
563
637
  bound = Object.freeze({ kind: "literal" as const, value })
564
638
  }
@@ -651,7 +725,8 @@ function sideUses(
651
725
  name: side.name,
652
726
  shape: side.kind === "param" ? ("value" as const) : ("set" as const),
653
727
  anchor,
654
- op
728
+ op,
729
+ members: undefined
655
730
  })
656
731
  )
657
732
  }
@@ -671,7 +746,13 @@ function condDataOf(cond: AnyCond, varFields: Readonly<Record<string, ClassedFie
671
746
  } else if (isTerm(maskValue) && maskValue[term] === "maskParam") {
672
747
  mask = Object.freeze({ kind: "param" as const, name: maskValue.name })
673
748
  uses.push(
674
- Object.freeze({ name: maskValue.name, shape: "mask" as const, anchor: undefined, op: "allen" as const })
749
+ Object.freeze({
750
+ name: maskValue.name,
751
+ shape: "mask" as const,
752
+ anchor: undefined,
753
+ op: "allen" as const,
754
+ members: undefined
755
+ })
675
756
  )
676
757
  } else {
677
758
  throw errors.new("allen: the mask position takes a 13-bit mask number or a maskParam")
@@ -742,14 +823,27 @@ function isAggregateEntry(
742
823
  return typeof value === "object" && value !== null && "agg" in value
743
824
  }
744
825
 
745
- /** Classifies one select entry into its named answer column. */
826
+ /**
827
+ * Classifies one select entry into its named answer column. The `closed`
828
+ * slice is resolved LATER, at rule completion (`completeRule`), where the
829
+ * rule's `varFields` are in hand — until then every column is provisionally
830
+ * bare.
831
+ */
746
832
  function selectColumnOf(entry: unknown): SelectColumn {
747
833
  if (typeof entry === "string") {
748
- return Object.freeze({ name: entry, entry: Object.freeze({ kind: "var" as const, over: entry }) })
834
+ return Object.freeze({
835
+ name: entry,
836
+ entry: Object.freeze({ kind: "var" as const, over: entry }),
837
+ closed: undefined
838
+ })
749
839
  }
750
840
  if (isTerm(entry)) {
751
841
  if (entry[term] === "duration") {
752
- return Object.freeze({ name: entry.name, entry: Object.freeze({ kind: "measure" as const, over: entry.name }) })
842
+ return Object.freeze({
843
+ name: entry.name,
844
+ entry: Object.freeze({ kind: "measure" as const, over: entry.name }),
845
+ closed: undefined
846
+ })
753
847
  }
754
848
  throw errors.new(
755
849
  `query select: a ${entry[term]} is not projectable — select takes variable names, duration(v), or aggregates`
@@ -768,7 +862,11 @@ function aggregateColumnOf(entry: {
768
862
  readonly key: unknown
769
863
  }): SelectColumn {
770
864
  function column(name: string, agg: AggData): SelectColumn {
771
- return Object.freeze({ name, entry: Object.freeze({ kind: "aggregate" as const, agg: Object.freeze(agg) }) })
865
+ return Object.freeze({
866
+ name,
867
+ entry: Object.freeze({ kind: "aggregate" as const, agg: Object.freeze(agg) }),
868
+ closed: undefined
869
+ })
772
870
  }
773
871
  const over = entry.over
774
872
  switch (entry.agg) {
@@ -809,6 +907,26 @@ function aggregateColumnOf(entry: {
809
907
  }
810
908
  }
811
909
 
910
+ /**
911
+ * The orderable ban's pointed refusal (`docs/architecture/10-data-model.md`
912
+ * § orderability): a closed reference is equality-and-membership only —
913
+ * its declaration-id order is an encoding accident, so every
914
+ * order-comparison and fold position refuses it. The construction-time
915
+ * twin of the type tier's `OrderVarOk` exclusion, so the wall holds for
916
+ * untyped callers too (the engine cannot backstop this one: the wire IR
917
+ * carries plain u64s, no rosters).
918
+ */
919
+ function closedOrderError(context: string, position: string, vocabulary: string): Error {
920
+ return errors.new(
921
+ `${context}: ${position} is a ${vocabulary} reference — declaration order is an accident, not semantics: vocabularies do not order (docs/architecture/10-data-model.md; equality, membership, and counting remain)`
922
+ )
923
+ }
924
+
925
+ /** The comparison ops the orderable ban covers (order roster + point membership — every order-comparison position). */
926
+ function isOrderOp(op: CmpKind | "binding"): op is "lt" | "le" | "gt" | "ge" | "pointIn" {
927
+ return op === "lt" || op === "le" || op === "gt" || op === "ge" || op === "pointIn"
928
+ }
929
+
812
930
  /** Requires a var name to be bound by a relation atom of the rule. */
813
931
  function assertBound(context: string, varFields: Readonly<Record<string, ClassedField>>, name: string): ClassedField {
814
932
  const slot = varFields[name]
@@ -841,7 +959,10 @@ function validateCond(context: string, varFields: Readonly<Record<string, Classe
841
959
  if (cond.kind === "cmp") {
842
960
  for (const side of [cond.lhs, cond.rhs]) {
843
961
  if (side.kind === "var") {
844
- assertBound(context, varFields, side.name)
962
+ const slot = assertBound(context, varFields, side.name)
963
+ if (isOrderOp(cond.op) && "closed" in slot.field) {
964
+ throw closedOrderError(context, `the ${cond.op} side ${side.name}`, slot.field.closed.name)
965
+ }
845
966
  }
846
967
  if (side.kind === "measure") {
847
968
  assertIntervalBound(context, varFields, side.name)
@@ -887,22 +1008,69 @@ function validateColumn(
887
1008
  return
888
1009
  case "fold": {
889
1010
  if (typeof agg.over === "string") {
890
- assertBound(`${context} select ${column.name}`, varFields, agg.over)
1011
+ const slot = assertBound(`${context} select ${column.name}`, varFields, agg.over)
1012
+ if ("closed" in slot.field) {
1013
+ throw closedOrderError(
1014
+ `${context} select ${column.name}`,
1015
+ `the ${agg.fold} input ${agg.over}`,
1016
+ slot.field.closed.name
1017
+ )
1018
+ }
891
1019
  return
892
1020
  }
893
1021
  assertIntervalBound(`${context} select ${column.name}`, varFields, agg.over.duration)
894
1022
  return
895
1023
  }
896
- case "arg":
1024
+ case "arg": {
897
1025
  assertBound(`${context} select ${column.name}`, varFields, agg.over)
898
- assertBound(`${context} select ${column.name}`, varFields, agg.key)
1026
+ const key = assertBound(`${context} select ${column.name}`, varFields, agg.key)
1027
+ if ("closed" in key.field) {
1028
+ throw closedOrderError(
1029
+ `${context} select ${column.name}`,
1030
+ `the ${agg.direction} key ${agg.key}`,
1031
+ key.field.closed.name
1032
+ )
1033
+ }
899
1034
  return
1035
+ }
900
1036
  case "pack":
901
1037
  assertIntervalBound(`${context} select ${column.name}`, varFields, agg.over)
902
1038
  return
903
1039
  }
904
1040
  }
905
1041
 
1042
+ /**
1043
+ * Resolves the roster one select column decodes through: a projected var,
1044
+ * or an Arg-carried payload, bound at a closed-referencing field carries
1045
+ * that field's roster (read off `varFields` — the same slot the domain
1046
+ * machinery reads), and `decodeAnswers` lifts the column's row ids back to
1047
+ * handle NAMES through it — the runtime twin of the row type's `Infer`
1048
+ * claim. Every other entry decodes bare: counts are counts, the measure
1049
+ * and `pack` are never closed, and a closed FOLD is banned outright
1050
+ * ({@link closedOrderError}) before this resolution runs.
1051
+ */
1052
+ function selectClosedOf(
1053
+ varFields: Readonly<Record<string, ClassedField>>,
1054
+ entry: SelectEntryData
1055
+ ): ClosedRoster | undefined {
1056
+ let over: string | undefined
1057
+ if (entry.kind === "var") {
1058
+ over = entry.over
1059
+ } else if (entry.kind === "aggregate" && entry.agg.op === "arg") {
1060
+ over = entry.agg.over
1061
+ } else {
1062
+ over = undefined
1063
+ }
1064
+ if (over === undefined) {
1065
+ return undefined
1066
+ }
1067
+ const field = varFields[over]?.field
1068
+ if (field !== undefined && "closed" in field) {
1069
+ return field.closed
1070
+ }
1071
+ return undefined
1072
+ }
1073
+
906
1074
  /**
907
1075
  * Completes one rule: classifies the select record (written order = answer
908
1076
  * column order, names must be declaration-order-safe keys), and validates
@@ -969,7 +1137,15 @@ function completeRule(context: string, state: RuleBuildState, columns: readonly
969
1137
  }
970
1138
  return Object.freeze({
971
1139
  items: state.items,
972
- select: Object.freeze([...columns]),
1140
+ select: Object.freeze(
1141
+ columns.map(function enrichColumn(column): SelectColumn {
1142
+ return Object.freeze({
1143
+ name: column.name,
1144
+ entry: column.entry,
1145
+ closed: selectClosedOf(state.varFields, column.entry)
1146
+ })
1147
+ })
1148
+ ),
973
1149
  varFields: state.varFields,
974
1150
  paramUses: state.paramUses
975
1151
  })
@@ -1132,6 +1308,11 @@ interface ProgramState {
1132
1308
  sealed: boolean
1133
1309
  }
1134
1310
 
1311
+ /** Renders one head column's closed slice for the rule-alignment check's diagnostics. */
1312
+ function renderClosedSlice(closed: ClosedRoster | undefined): string {
1313
+ return closed === undefined ? "a bare value" : `a ${closed.name} reference`
1314
+ }
1315
+
1135
1316
  /** Renders one head column's signature for the rule-alignment check. */
1136
1317
  function headSignature(column: SelectColumn): string {
1137
1318
  const entry = column.entry
@@ -1148,32 +1329,91 @@ function headSignature(column: SelectColumn): string {
1148
1329
  return `${column.name}:${agg.op}`
1149
1330
  }
1150
1331
 
1332
+ /** The roster a param anchor carries: present exactly on a closed-reference field anchor. */
1333
+ function anchorRosterOf(anchor: AnyField | "measure" | undefined): ClosedRoster | undefined {
1334
+ if (anchor === undefined || anchor === "measure") {
1335
+ return undefined
1336
+ }
1337
+ if ("closed" in anchor) {
1338
+ return anchor.closed
1339
+ }
1340
+ return undefined
1341
+ }
1342
+
1343
+ /** Renders one param anchor's closedness for the registry's coherence diagnostics. */
1344
+ function renderParamAnchor(roster: ClosedRoster | undefined): string {
1345
+ return roster === undefined ? "a non-closed position" : `a ${roster.name} reference`
1346
+ }
1347
+
1151
1348
  /**
1152
1349
  * Folds every rule's param uses (recs in declaration order first, output
1153
1350
  * rules last — exactly the lowering walk) into the query's registry:
1154
1351
  * first use mints the dense `ParamId`, the first FIELD-ANCHORED use types
1155
- * the wire, and one name must keep one shape.
1352
+ * the wire, and one name must keep one shape AND one closedness — every
1353
+ * anchored use of one name must agree on the roster (value identity), so a
1354
+ * param anchored at a closed reference is GUARANTEED to ride the one
1355
+ * roster-verification point (`taggedHandleId`) at execute; a name anchored
1356
+ * both at a closed reference and at a non-closed position (or at two
1357
+ * vocabularies) is refused here, because the wire would translate only the
1358
+ * first anchor's reading (the type tier intersects the uses to `never`;
1359
+ * this is its runtime twin for untyped callers). A param whose anchor is a
1360
+ * CLOSED reference must never sit in an order-comparison position — the
1361
+ * anchor types its value a handle name and the engine would order the
1362
+ * translated row ids, so the pairing is refused here too (the registry is
1363
+ * the one place a name's every use and its anchoring field meet).
1156
1364
  */
1157
1365
  function paramRegistryOf(recs: readonly RecData[], rules: readonly RuleData[]): readonly ParamEntry[] {
1158
1366
  const order: string[] = []
1159
- const byName = new Map<string, { shape: ParamEntry["shape"]; anchor: ParamEntry["anchor"]; op: ParamEntry["op"] }>()
1367
+ const byName = new Map<
1368
+ string,
1369
+ {
1370
+ shape: ParamEntry["shape"]
1371
+ anchor: ParamEntry["anchor"]
1372
+ op: ParamEntry["op"]
1373
+ members: ParamEntry["members"]
1374
+ orderOp: "lt" | "le" | "gt" | "ge" | "pointIn" | undefined
1375
+ }
1376
+ >()
1160
1377
  function fold(uses: readonly ParamUse[]): void {
1161
1378
  for (const use of uses) {
1162
1379
  const existing = byName.get(use.name)
1163
1380
  if (existing === undefined) {
1164
1381
  order.push(use.name)
1165
- byName.set(use.name, { shape: use.shape, anchor: use.anchor, op: use.op })
1382
+ byName.set(use.name, {
1383
+ shape: use.shape,
1384
+ anchor: use.anchor,
1385
+ op: use.op,
1386
+ members: use.members,
1387
+ orderOp: isOrderOp(use.op) ? use.op : undefined
1388
+ })
1166
1389
  continue
1167
1390
  }
1391
+ if ((existing.members === undefined) !== (use.members === undefined)) {
1392
+ throw errors.new(
1393
+ `query param ${use.name} collides with a membership array's registry entry — name the param differently`
1394
+ )
1395
+ }
1168
1396
  if (existing.shape !== use.shape) {
1169
1397
  throw errors.new(
1170
1398
  `query param ${use.name} is used both as a ${existing.shape} param and a ${use.shape} param — one name, one shape`
1171
1399
  )
1172
1400
  }
1401
+ if (existing.anchor !== undefined && use.anchor !== undefined) {
1402
+ const registered = anchorRosterOf(existing.anchor)
1403
+ const anchored = anchorRosterOf(use.anchor)
1404
+ if (registered !== anchored) {
1405
+ throw errors.new(
1406
+ `query param ${use.name} is anchored at ${renderParamAnchor(registered)} and at ${renderParamAnchor(anchored)} — a closed-anchored param translates handle names through ONE roster (one name, one domain); name the params differently`
1407
+ )
1408
+ }
1409
+ }
1173
1410
  if (existing.anchor === undefined && use.anchor !== undefined) {
1174
1411
  existing.anchor = use.anchor
1175
1412
  existing.op = use.op
1176
1413
  }
1414
+ if (existing.orderOp === undefined && isOrderOp(use.op)) {
1415
+ existing.orderOp = use.op
1416
+ }
1177
1417
  }
1178
1418
  }
1179
1419
  for (const rec of recs) {
@@ -1190,7 +1430,15 @@ function paramRegistryOf(recs: readonly RecData[], rules: readonly RuleData[]):
1190
1430
  if (entry === undefined) {
1191
1431
  throw errors.new(`query param ${name} lost its registry entry`)
1192
1432
  }
1193
- return Object.freeze({ name, shape: entry.shape, anchor: entry.anchor, op: entry.op })
1433
+ if (
1434
+ entry.orderOp !== undefined &&
1435
+ entry.anchor !== undefined &&
1436
+ entry.anchor !== "measure" &&
1437
+ "closed" in entry.anchor
1438
+ ) {
1439
+ throw closedOrderError(`query param ${name}`, `its ${entry.orderOp} use's anchor`, entry.anchor.closed.name)
1440
+ }
1441
+ return Object.freeze({ name, shape: entry.shape, anchor: entry.anchor, op: entry.op, members: entry.members })
1194
1442
  })
1195
1443
  )
1196
1444
  }
@@ -1221,6 +1469,19 @@ function makeRawQuery(theory: AnySchema, recs: readonly RecData[], rules: readon
1221
1469
  `every rule of a query derives the same head — rule 0 selects (${signature}), rule ${index} selects (${candidate})`
1222
1470
  )
1223
1471
  }
1472
+ // The closed slice is part of the head too: one answer column decodes
1473
+ // through one roster, so a union whose rules bind a column at
1474
+ // different vocabularies (or one closed, one bare — the ids would
1475
+ // mistranslate silently) is refused pointed. Vocabulary identity is
1476
+ // value identity, the SDK's membership rule everywhere.
1477
+ rule.select.forEach(function verifyClosedSlice(column, position) {
1478
+ const lead = first.select[position]
1479
+ if (lead !== undefined && column.closed !== lead.closed) {
1480
+ throw errors.new(
1481
+ `every rule of a query derives the same head — the answer column ${lead.name} is ${renderClosedSlice(lead.closed)} in rule 0 but ${renderClosedSlice(column.closed)} in rule ${index} (one column decodes through one roster)`
1482
+ )
1483
+ }
1484
+ })
1224
1485
  })
1225
1486
  const data: QueryData = Object.freeze({
1226
1487
  recs: Object.freeze([...recs]),
@@ -1305,25 +1566,30 @@ function isIntervalShaped(value: unknown): value is { readonly start: bigint; re
1305
1566
  }
1306
1567
 
1307
1568
  /**
1308
- * Tags one closed-reference literal: the bare handle id, verified against
1309
- * the roster (the belt the type level cannot provide — structural values
1310
- * make any bigint spellable here) and tagged u64 — queries cross ids,
1311
- * never handle names.
1569
+ * Tags one closed-reference literal: the handle NAME, verified against the
1570
+ * roster (the belt the wide fallback type cannot provide — structural
1571
+ * values make any string spellable here) and translated to its
1572
+ * declaration-order row id, tagged u64 — queries cross ids, never handle
1573
+ * names; the wire is untouched. THE single roster-verification point of
1574
+ * the query surface: atom-binding literals, comparison literals,
1575
+ * execute-time params, and membership-array members all reach it (never
1576
+ * duplicate the check per call site).
1312
1577
  */
1313
1578
  function taggedHandleId(
1314
1579
  context: string,
1315
1580
  closed: { readonly name: string; readonly handles: readonly string[] },
1316
1581
  value: unknown
1317
1582
  ): TaggedValue {
1318
- if (typeof value !== "bigint") {
1319
- throw literalShapeError(context, `a ${closed.name} handle id (bigint)`, value)
1583
+ if (typeof value !== "string") {
1584
+ throw literalShapeError(context, `a ${closed.name} handle name (string)`, value)
1320
1585
  }
1321
- if (closed.handles[Number(value)] === undefined) {
1586
+ const id = closed.handles.indexOf(value)
1587
+ if (id < 0) {
1322
1588
  throw errors.new(
1323
- `${context}: closed relation ${closed.name} has no handle with id ${value} (roster holds ${closed.handles.length})`
1589
+ `${context}: "${value}" is not a handle of ${closed.name} — the roster is ${closed.handles.join(", ")}`
1324
1590
  )
1325
1591
  }
1326
- return { kind: "u64", value }
1592
+ return { kind: "u64", value: BigInt(id) }
1327
1593
  }
1328
1594
 
1329
1595
  /**
@@ -1504,7 +1770,12 @@ function lowerAtom(ctx: LowerContext, atom: AtomData, ids: VarIds): AtomIr {
1504
1770
  return { source: { kind: "edb", relation: relationId }, bindings }
1505
1771
  }
1506
1772
 
1507
- /** Lowers one binding term. */
1773
+ /**
1774
+ * Lowers one binding term. A membership ARRAY (`literalSet`) lowers to the
1775
+ * existing param-set term over its content-addressed registry entry — the
1776
+ * program IR is byte-identical to the same set spelled `r.inSet`; the SDK
1777
+ * supplies the translated member set itself at execute (`wireParams`).
1778
+ */
1508
1779
  function lowerBindingTerm(ctx: LowerContext, context: string, binding: BindingEntry, ids: VarIds): TermIr {
1509
1780
  const bound = binding.term
1510
1781
  switch (bound.kind) {
@@ -1514,6 +1785,8 @@ function lowerBindingTerm(ctx: LowerContext, context: string, binding: BindingEn
1514
1785
  return { kind: "param", param: paramIdOf(ctx, bound.name) }
1515
1786
  case "setParam":
1516
1787
  return { kind: "paramSet", param: paramIdOf(ctx, bound.name) }
1788
+ case "literalSet":
1789
+ return { kind: "paramSet", param: paramIdOf(ctx, bound.name) }
1517
1790
  case "literal":
1518
1791
  return { kind: "literal", value: taggedLiteral(context, binding.data, bound.value) }
1519
1792
  }
package/src/query/run.ts CHANGED
@@ -8,12 +8,16 @@
8
8
  * values — the marshal boundary is pure both ways: the engine computed
9
9
  * the answer under the prepared head, so a decoded row that carries every
10
10
  * select column IS a row (the trusted read seam), and nothing is asserted
11
- * on any value. Answers are SETS no order or limit exists anywhere;
12
- * hosts sort. The `Prepared` VALUE itself (no lifecycle, GC-reclaimed
13
- * plan) lives in `#db.ts`.
11
+ * on any value. A CLOSED answer column decodes id handle NAME through
12
+ * the marshal's one bijection (`handleOf` the same read half every fact
13
+ * decode rides; the column's roster rides `SelectColumn.closed`), so query
14
+ * rows speak the vocabulary exactly as scans and gets do. Answers are
15
+ * SETS — no order or limit exists anywhere; hosts sort. The `Prepared`
16
+ * VALUE itself (no lifecycle, GC-reclaimed plan) lives in `#db.ts`.
14
17
  */
15
18
 
16
19
  import * as errors from "@superbuilders/errors"
20
+ import { handleOf } from "#marshal.ts"
17
21
  import type { FactValue, QueryParam, TaggedValue } from "#native.ts"
18
22
  import type { SelectColumn } from "#query/atom.ts"
19
23
  import { taggedCmpLiteral } from "#query/lower.ts"
@@ -45,10 +49,23 @@ function wireValue(entry: ParamEntry, context: string, value: unknown): TaggedVa
45
49
  * in registry order (= the lowering's dense `ParamId`s). A missing entry
46
50
  * is a typed error naming the param; values tag by the anchoring use's
47
51
  * structural type; a set param takes a readonly array (the empty set is
48
- * legal and matches nothing — the engine's rule).
52
+ * legal and matches nothing — the engine's rule). A MEMBERSHIP-ARRAY
53
+ * entry (`members` present — a literal set folded into the program) is
54
+ * supplied by the SDK itself: each handle name rides the one
55
+ * roster-verification point (`taggedHandleId`, through `wireValue`) and
56
+ * crosses as the same `{ kind: "set", values }` a bound `r.inSet` param
57
+ * crosses as — the host's params object is never consulted for it.
49
58
  */
50
59
  function wireParams(entries: readonly ParamEntry[], supplied: Readonly<Record<string, unknown>>): QueryParam[] {
51
60
  return entries.map(function wireOne(entry): QueryParam {
61
+ if (entry.members !== undefined) {
62
+ return {
63
+ kind: "set",
64
+ values: entry.members.map(function wireMember(member, index) {
65
+ return wireValue(entry, `membership array ${entry.name}[${index}]`, member)
66
+ })
67
+ }
68
+ }
52
69
  const value = supplied[entry.name]
53
70
  if (value === undefined) {
54
71
  throw errors.new(`execute params object is missing param ${entry.name}`)
@@ -90,7 +107,9 @@ function isAnswerRow<Row>(
90
107
  /**
91
108
  * Decodes positional answer rows (column order = the program's head order
92
109
  * = the select's written order) to named, frozen row objects of bare
93
- * structural values.
110
+ * structural values. A closed column lifts its row id back to the handle
111
+ * NAME through the marshal's bijection — an out-of-roster id is the same
112
+ * pointed throw a fact decode gives, never a silent fallback.
94
113
  */
95
114
  function decodeAnswers<Row>(select: readonly SelectColumn[], rows: FactValue[][]): Row[] {
96
115
  return rows.map(function decodeRow(row) {
@@ -103,7 +122,8 @@ function decodeAnswers<Row>(select: readonly SelectColumn[], rows: FactValue[][]
103
122
  if (cell === undefined) {
104
123
  throw errors.new(`query answer cell ${ordinal} (${column.name}) is absent`)
105
124
  }
106
- decoded[column.name] = cell
125
+ decoded[column.name] =
126
+ column.closed === undefined ? cell : handleOf(`query answer column ${column.name}`, column.closed, cell)
107
127
  })
108
128
  Object.freeze(decoded)
109
129
  if (!isAnswerRow<Row>(select, decoded)) {