@bjornpagen/bumbledb 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db.ts CHANGED
@@ -37,17 +37,7 @@ import type { Exhumed } from "#exhume.ts"
37
37
  import { exhumeStore } from "#exhume.ts"
38
38
  import { rosterOf } from "#fields.ts"
39
39
  import { lower } from "#lower.ts"
40
- import {
41
- factOf,
42
- handleOf,
43
- isFreshField,
44
- isInserted,
45
- type KeyFact,
46
- keyRowOf,
47
- type Minted,
48
- recordOf,
49
- rowOf
50
- } from "#marshal.ts"
40
+ import { factOf, handleOf, isFreshField, type KeyFact, keyRowOf, recordOf, rowOf } from "#marshal.ts"
51
41
 
52
42
  import type {
53
43
  DbHandle,
@@ -56,6 +46,7 @@ import type {
56
46
  PreparedHandle,
57
47
  SnapshotHandle,
58
48
  TxHandle,
49
+ WireFreshRange,
59
50
  Violation as WireViolation,
60
51
  ViolationFact as WireViolationFact
61
52
  } from "#native.ts"
@@ -65,7 +56,7 @@ import type { Query } from "#query/lower.ts"
65
56
  import { lowerQuery } from "#query/lower.ts"
66
57
  import { decodeAnswers, wireParams } from "#query/run.ts"
67
58
  import type { ParamEntry, ParamsRecord } from "#query/scope.ts"
68
- import type { AnyRelation, Fact, InsertFact } from "#relation.ts"
59
+ import type { AnyRelation, Fact, FreshKeys } from "#relation.ts"
69
60
  import type { AnySchema, Schema, SchemaRelation, SchemaRelations } from "#schema.ts"
70
61
  import { isStatement, type KeyStatement, type Statement } from "#statements.ts"
71
62
 
@@ -76,6 +67,70 @@ import { isStatement, type KeyStatement, type Statement } from "#statements.ts"
76
67
  */
77
68
  type MemberRelation<Rels extends SchemaRelations> = Extract<Rels[keyof Rels], AnyRelation>
78
69
 
70
+ /**
71
+ * Facts consumed vs facts that changed the in-memory final-state view.
72
+ * The length-1 report is `{ submitted: 1n, changed: 0n | 1n }`.
73
+ */
74
+ interface MutationReport {
75
+ readonly submitted: bigint
76
+ readonly changed: bigint
77
+ }
78
+
79
+ /**
80
+ * Half-open fresh-id range from one `reserve`. Empty cannot yield a
81
+ * minted id — `start` exists only on the nonempty arm.
82
+ */
83
+ type FreshRange =
84
+ | {
85
+ readonly empty: true
86
+ readonly count: 0n
87
+ at(index: bigint): undefined
88
+ [Symbol.iterator](): IterableIterator<bigint>
89
+ }
90
+ | {
91
+ readonly empty: false
92
+ readonly start: bigint
93
+ readonly endExclusive: bigint
94
+ readonly count: bigint
95
+ at(index: bigint): bigint | undefined
96
+ [Symbol.iterator](): IterableIterator<bigint>
97
+ }
98
+
99
+ function freshRangeOf(wire: WireFreshRange): FreshRange {
100
+ if (wire.empty) {
101
+ return Object.freeze({
102
+ empty: true,
103
+ count: 0n,
104
+ at(_index: bigint) {
105
+ return undefined
106
+ },
107
+ *[Symbol.iterator](): IterableIterator<bigint> {}
108
+ })
109
+ }
110
+ const start = wire.start
111
+ const endExclusive = wire.endExclusive
112
+ const count = endExclusive - start
113
+ return Object.freeze({
114
+ empty: false,
115
+ start,
116
+ endExclusive,
117
+ get count() {
118
+ return count
119
+ },
120
+ at(index: bigint) {
121
+ if (index < 0n || index >= count) {
122
+ return undefined
123
+ }
124
+ return start + index
125
+ },
126
+ *[Symbol.iterator](): IterableIterator<bigint> {
127
+ for (let id = start; id < endExclusive; id++) {
128
+ yield id
129
+ }
130
+ }
131
+ })
132
+ }
133
+
79
134
  /**
80
135
  * The key object of a key-statement-selected `get`: exactly the selected
81
136
  * `key()` statement's projection fields, each at the relation's own BARE
@@ -297,21 +352,22 @@ function abandonedOutcome<Rels extends SchemaRelations, R>(
297
352
  */
298
353
  interface Tx<Rels extends SchemaRelations> {
299
354
  /**
300
- * Records one insert. Omitted fresh fields are MINTED through the
301
- * engine's alloc lane and returned as bare bigints; supplying them instead
302
- * preserves identity (the resupply idiom). Returns `{ changed, ...fresh }`
303
- * (ruled 2026-07-23, R11): the engine's changed-state report the Rust
304
- * surface's `insert(&fact) -> bool` bijection `delete` always honored —
305
- * beside the relation's fresh cells, minted or resupplied. The
306
- * idempotent-replay lane reads the bit from the insert itself; no extra
307
- * `contains` round trip exists. The flattened shape cannot carry a FRESH
308
- * cell literally named `changed` beside the report, so admission refuses
309
- * that one spelling ({@link refuseShadowedChanged}) — never a silent
310
- * shadow here.
355
+ * Records a collection of inserts. Singleton is `[fact]`. Empty is
356
+ * lawful. Returns how many facts were consumed and how many changed
357
+ * the in-memory final-state view. Every fact is complete — omitted
358
+ * fresh cells are a type error; mint first with {@link Tx.reserve}.
359
+ */
360
+ insert<R extends MemberRelation<Rels>>(relation: R, facts: Iterable<Fact<R>>): MutationReport
361
+ /**
362
+ * Records a collection of deletes. Singleton is `[fact]`. Returns
363
+ * how many facts were consumed and how many changed the view.
364
+ */
365
+ delete<R extends MemberRelation<Rels>>(relation: R, facts: Iterable<Fact<R>>): MutationReport
366
+ /**
367
+ * Mints `count` consecutive fresh values for a `.fresh` field.
368
+ * `count === 0n` is empty and does not yield a start.
311
369
  */
312
- insert<R extends MemberRelation<Rels>>(relation: R, fact: InsertFact<R>): { readonly changed: boolean } & Minted<R>
313
- /** Records one delete; `true` iff the final state changed. */
314
- delete<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
370
+ reserve<R extends MemberRelation<Rels>>(relation: R, field: FreshKeys<R> & string, count: bigint): FreshRange
315
371
  /** Final-state membership of one complete fact. */
316
372
  contains<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean
317
373
  /**
@@ -791,44 +847,6 @@ const ErrGenerationMoved = errors.new(
791
847
  "bumbledb generationMoved: a state-changing commit landed since the witness snapshot"
792
848
  )
793
849
 
794
- /**
795
- * Fills one insert's omitted fresh cells through the engine's
796
- * alloc-then-insert dyn lane (there is no insert-with-omitted-fields wire
797
- * spelling) and collects every fresh cell — minted or resupplied — for the
798
- * insert's return. Mutates `values` in place with the minted cells.
799
- */
800
- function mintFreshCells(
801
- txHandle: TxHandle,
802
- entry: RelationEntry,
803
- relation: AnyRelation,
804
- values: Record<string, unknown>
805
- ): Record<string, FactValue> {
806
- const fresh: Record<string, FactValue> = {}
807
- for (const declared of relation.data.fields) {
808
- if (!isFreshField(declared.field)) {
809
- continue
810
- }
811
- let cell = values[declared.name]
812
- if (cell === undefined) {
813
- const fieldId = entry.fieldIds.get(declared.name)
814
- if (fieldId === undefined) {
815
- throw errors.new(`bumbledb manifest drift: relation ${relation.name} has no field id for ${declared.name}`)
816
- }
817
- cell = bridged("bumbledb tx alloc", function mint() {
818
- return native.txAlloc(txHandle, entry.id, fieldId)
819
- })
820
- values[declared.name] = cell
821
- }
822
- if (typeof cell !== "bigint") {
823
- throw errors.new(
824
- `relation ${relation.name} field ${declared.name}: a fresh cell is a u64 bigint, got ${typeof cell}`
825
- )
826
- }
827
- fresh[declared.name] = cell
828
- }
829
- return fresh
830
- }
831
-
832
850
  /**
833
851
  * Constructs one open `Db` over an already-admitted handle: builds the
834
852
  * id-resolution tables once and closes over them — the `Db` owns handle
@@ -1215,38 +1233,59 @@ function openDb<Rels extends SchemaRelations>(handle: DbHandle, theory: Schema<R
1215
1233
  })
1216
1234
  }
1217
1235
  })
1218
- function insert<R extends MemberRelation<Rels>>(
1219
- relation: R,
1220
- fact: InsertFact<R>
1221
- ): { readonly changed: boolean } & Minted<R> {
1236
+ function insert<R extends MemberRelation<Rels>>(relation: R, facts: Iterable<Fact<R>>): MutationReport {
1222
1237
  assertLive()
1238
+ const rows: FactValue[][] = []
1239
+ for (const fact of facts) {
1240
+ rows.push(rowOf(relation.data, recordOf(fact)))
1241
+ }
1223
1242
  const entry = resolveOrdinary(relation)
1224
1243
  const txHandle = resolveTx()
1225
- /** The one spread copy of the write path: `mintFreshCells` writes minted cells in place, and they must never land in the caller's own fact object. */
1226
- const values: Record<string, unknown> = { ...recordOf(fact) }
1227
- const fresh = mintFreshCells(txHandle, entry, relation, values)
1228
- const row = rowOf(relation.data, values)
1229
- const changed = bridged("bumbledb tx insert", function record() {
1230
- return native.txInsert(txHandle, entry.id, row)
1244
+ const report = bridged("bumbledb tx insert", function record() {
1245
+ return native.txInsert(txHandle, entry.id, rows)
1231
1246
  })
1232
- const inserted: Readonly<Record<string, FactValue | boolean>> = Object.freeze({ changed, ...fresh })
1233
- if (!isInserted(relation, inserted)) {
1234
- throw errors.new(`relation ${relation.name}: insert return record is incomplete`)
1247
+ return Object.freeze({ submitted: report.submitted, changed: report.changed })
1248
+ }
1249
+ function remove<R extends MemberRelation<Rels>>(relation: R, facts: Iterable<Fact<R>>): MutationReport {
1250
+ assertLive()
1251
+ const rows: FactValue[][] = []
1252
+ for (const fact of facts) {
1253
+ rows.push(rowOf(relation.data, recordOf(fact)))
1235
1254
  }
1236
- return inserted
1255
+ const entry = resolveOrdinary(relation)
1256
+ const txHandle = resolveTx()
1257
+ const report = bridged("bumbledb tx delete", function record() {
1258
+ return native.txDelete(txHandle, entry.id, rows)
1259
+ })
1260
+ return Object.freeze({ submitted: report.submitted, changed: report.changed })
1237
1261
  }
1238
- function remove<R extends MemberRelation<Rels>>(relation: R, fact: Fact<R>): boolean {
1262
+ function reserve<R extends MemberRelation<Rels>>(
1263
+ relation: R,
1264
+ field: FreshKeys<R> & string,
1265
+ count: bigint
1266
+ ): FreshRange {
1239
1267
  assertLive()
1240
1268
  const entry = resolveOrdinary(relation)
1269
+ const declared = relation.data.fields.find(function byName(candidate) {
1270
+ return candidate.name === field
1271
+ })
1272
+ if (declared === undefined || !isFreshField(declared.field)) {
1273
+ throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`)
1274
+ }
1275
+ const fieldId = entry.fieldIds.get(field)
1276
+ if (fieldId === undefined) {
1277
+ throw errors.new(`bumbledb manifest drift: relation ${relation.name} has no field id for ${field}`)
1278
+ }
1241
1279
  const txHandle = resolveTx()
1242
- const row = rowOf(relation.data, recordOf(fact))
1243
- return bridged("bumbledb tx delete", function record() {
1244
- return native.txDelete(txHandle, entry.id, row)
1280
+ const range = bridged("bumbledb tx reserve", function mint() {
1281
+ return native.txReserve(txHandle, entry.id, fieldId, count)
1245
1282
  })
1283
+ return freshRangeOf(range)
1246
1284
  }
1247
1285
  const tx: Tx<Rels> = Object.freeze({
1248
1286
  insert,
1249
1287
  delete: remove,
1288
+ reserve,
1250
1289
  contains: reads.contains,
1251
1290
  get: reads.get
1252
1291
  })
@@ -1418,27 +1457,6 @@ const ErrNewtypeMismatch = errors.new(
1418
1457
  "bumbledb newtypeMismatch: a statement pairs faces whose newtypes disagree — the faces of a dependency agree on their newtype, or neither carries one"
1419
1458
  )
1420
1459
 
1421
- /**
1422
- * `Tx.insert` returns the flattened `{ changed, ...fresh }` record (R11),
1423
- * where the spread wins: a FRESH field literally named `changed` would
1424
- * shadow the engine's changed-state report on every insert of its
1425
- * relation. No field name is reserved SILENTLY — the one unspeakable
1426
- * spelling is refused here at admission, before any store is touched.
1427
- * Supplied (non-fresh) fields named `changed` never enter the return
1428
- * record and stay legal.
1429
- */
1430
- function refuseShadowedChanged(theory: AnySchema): void {
1431
- for (const [name, member] of Object.entries(theory.relations)) {
1432
- for (const declared of sealedFieldsOf(member)) {
1433
- if (declared.name === "changed" && isFreshField(declared.field)) {
1434
- throw errors.new(
1435
- `relation ${name}: a fresh field named "changed" would shadow tx.insert's changed-state report in its { changed, ...fresh } return (R11) — rename the fresh field; a supplied field named "changed" stays legal (only fresh cells ride the return)`
1436
- )
1437
- }
1438
- }
1439
- }
1440
- }
1441
-
1442
1460
  /**
1443
1461
  * The one admission path both verbs share: lower the theory, run one
1444
1462
  * bridge call, and wrap the domain refusals — `schemaError` (spec
@@ -1455,7 +1473,6 @@ function admit<Rels extends SchemaRelations>(
1455
1473
  storePath: string,
1456
1474
  theory: Schema<Rels>
1457
1475
  ): Db<Rels> {
1458
- refuseShadowedChanged(theory)
1459
1476
  const canonical = path.resolve(storePath)
1460
1477
  const spec = lower(theory)
1461
1478
  const opened = bridged(`${verb} bumbledb store at ${canonical}`, function callBridge() {
@@ -1527,9 +1544,11 @@ export type {
1527
1544
  DeclaredKeyFact,
1528
1545
  DeclaredKeyViolation,
1529
1546
  DeltaBuild,
1547
+ FreshRange,
1530
1548
  ImpliedKeyViolation,
1531
1549
  MemberRelation,
1532
1550
  MirrorViolation,
1551
+ MutationReport,
1533
1552
  OffendingFact,
1534
1553
  Prepared,
1535
1554
  ReadScope,
package/src/index.ts CHANGED
@@ -56,9 +56,11 @@ export type {
56
56
  DeclaredKeyFact,
57
57
  DeclaredKeyViolation,
58
58
  DeltaBuild,
59
+ FreshRange,
59
60
  ImpliedKeyViolation,
60
61
  MemberRelation,
61
62
  MirrorViolation,
63
+ MutationReport,
62
64
  OffendingFact,
63
65
  Prepared,
64
66
  ReadScope,
@@ -114,7 +116,7 @@ export type {
114
116
  export { bool, bytes, i64, interval, span, str, u64 } from "#fields.ts"
115
117
  export type { ClassesOf, ClassWall, LawfulStatements, RelationClasses, SchemaClasses } from "#law.ts"
116
118
  export { lower, lowerClosed, lowerRelation } from "#lower.ts"
117
- export type { KeyFact, Minted } from "#marshal.ts"
119
+ export type { KeyFact } from "#marshal.ts"
118
120
  export type { FactValue, ParsedQuery, QueryIr, StatementKindTag } from "#native.ts"
119
121
 
120
122
  export type {
@@ -169,7 +171,6 @@ export type {
169
171
  Fact,
170
172
  FieldsShape,
171
173
  FreshKeys,
172
- InsertFact,
173
174
  Relation,
174
175
  RelationData,
175
176
  RelationField,
package/src/marshal.ts CHANGED
@@ -43,13 +43,6 @@ function isFreshField(field: AnyField): boolean {
43
43
  return "fresh" in field && field.fresh === true
44
44
  }
45
45
 
46
- /**
47
- * The inferred object type `tx.insert` returns: one property per
48
- * fresh-marked field of `R`, carrying the minted (or resupplied) id as a
49
- * bare `bigint`. A relation with no fresh field returns the empty object.
50
- */
51
- type Minted<R extends AnyRelation> = { [K in FreshKeys<R>]: Fact<R>[K] }
52
-
53
46
  /**
54
47
  * The key object `get` reads through. THE PRIMARY-KEY RULE: `get` always
55
48
  * reads through the PRIMARY candidate key — the first-declared one in the
@@ -74,10 +67,7 @@ type KeyFact<R extends AnyRelation> = [FreshKeys<R>] extends [never]
74
67
  * ALLOCATION-FREE IDENTITY (the admission predicate is the type
75
68
  * reprojection; the value passes through untouched): every consumer
76
69
  * downstream — `rowOf`, `keyRowOf`, the query param marshal — only READS
77
- * properties, so no copy is warranted. The one mutating consumer
78
- * (`mintFreshCells` on the insert path) takes its own spread copy at the
79
- * call site, so the caller's fact object is never written through this
80
- * seam.
70
+ * properties, so no copy is warranted.
81
71
  */
82
72
  function recordOf(fact: object): Readonly<Record<string, unknown>> {
83
73
  if (!isStringIndexed(fact)) {
@@ -203,9 +193,8 @@ function cellOf(context: string, field: AnyField, value: unknown): FactValue {
203
193
 
204
194
  /**
205
195
  * Marshals one complete fact object to its positional row, in field
206
- * declaration order (= ordinal ids). Every declared field must be present;
207
- * fresh minting happens BEFORE this point (the transaction fills omitted
208
- * fresh cells via the engine's alloc lane).
196
+ * declaration order (= ordinal ids). Every declared field must be present.
197
+ * Mint with `tx.reserve` first; insert takes complete facts.
209
198
  */
210
199
  function rowOf(relation: RelationData, fact: Readonly<Record<string, unknown>>): FactValue[] {
211
200
  return relation.fields.map(function marshalCell(declared) {
@@ -260,25 +249,6 @@ function isCompleteFact<R extends AnyRelation>(
260
249
  })
261
250
  }
262
251
 
263
- /**
264
- * The insert-return trusted seam (R11): one insert's return carries the
265
- * engine's changed-state report beside the collected fresh cells (minted by
266
- * the engine or resupplied by the caller) — the bit is verified boolean and
267
- * the fresh ids present, same presence-only direction as
268
- * {@link isCompleteFact}.
269
- */
270
- function isInserted<R extends AnyRelation>(
271
- relation: R,
272
- value: Readonly<Record<string, FactValue | boolean>>
273
- ): value is Readonly<Record<string, FactValue | boolean>> & { readonly changed: boolean } & Minted<R> {
274
- return (
275
- typeof value.changed === "boolean" &&
276
- relation.data.fields.every(function presentWhenFresh(declared) {
277
- return !isFreshField(declared.field) || value[declared.name] !== undefined
278
- })
279
- )
280
- }
281
-
282
252
  /**
283
253
  * Unmarshals one positional row to the relation's named, frozen fact object
284
254
  * of bare structural values — the inverse of {@link rowOf},
@@ -310,5 +280,5 @@ function factOf<R extends AnyRelation>(relation: R, row: readonly FactValue[]):
310
280
  return decoded
311
281
  }
312
282
 
313
- export type { KeyFact, Minted }
314
- export { cellOf, factOf, handleOf, isFreshField, isInserted, keyRowOf, recordOf, rowOf }
283
+ export type { KeyFact }
284
+ export { cellOf, factOf, handleOf, isFreshField, keyRowOf, recordOf, rowOf }
package/src/native.ts CHANGED
@@ -44,6 +44,24 @@ type TxHandle = { readonly __brand: "bumbledb.tx" }
44
44
  /** One prepared query (plan pinned at prepare). */
45
45
  type PreparedHandle = { readonly __brand: "bumbledb.prepared" }
46
46
 
47
+ /**
48
+ * Engine mutation report as it crosses napi: both counts are engine
49
+ * values, never reconstructed from JS length.
50
+ */
51
+ interface WireMutationReport {
52
+ readonly submitted: bigint
53
+ readonly changed: bigint
54
+ }
55
+
56
+ /**
57
+ * Engine fresh-id range as it crosses napi. Empty cannot yield a start —
58
+ * `start` is a minted id only on the nonempty arm. (C wires empty as
59
+ * `{ start: 0, end_exclusive: 0 }` at that boundary only.)
60
+ */
61
+ type WireFreshRange =
62
+ | { readonly empty: true }
63
+ | { readonly empty: false; readonly start: bigint; readonly endExclusive: bigint }
64
+
47
65
  /** A half-open interval `[start, end)` as it crosses the boundary. */
48
66
  interface IntervalValue {
49
67
  readonly start: bigint
@@ -118,15 +136,14 @@ interface RuleIr {
118
136
  readonly conditions: readonly ConditionTreeIr[]
119
137
  }
120
138
 
121
- /** One find term (mirrors `ir::FindTerm`). Count carries no `over`; folds require it. */
139
+ /** One find term (mirrors `ir::FindTerm`). Count is nullary; pack and folds carry `over`. */
122
140
  type FoldOpIr = { readonly kind: "sum" } | { readonly kind: "min" } | { readonly kind: "max" }
123
141
 
124
- type ArgOpIr = FoldOpIr | { readonly kind: "pack" }
125
-
126
142
  type FindTermIr =
127
143
  | { readonly kind: "var"; readonly var: number }
128
- | { readonly kind: "aggregate"; readonly op: { readonly kind: "count" } }
129
- | { readonly kind: "aggregate"; readonly op: ArgOpIr; readonly over: number }
144
+ | { readonly kind: "count" }
145
+ | { readonly kind: "aggregate"; readonly op: FoldOpIr; readonly over: number }
146
+ | { readonly kind: "pack"; readonly over: number }
130
147
  | { readonly kind: "measure"; readonly var: number }
131
148
  | { readonly kind: "aggregateMeasure"; readonly op: FoldOpIr; readonly over: number }
132
149
 
@@ -497,12 +514,14 @@ interface Native {
497
514
  */
498
515
  dbWriteFrom(db: DbHandle, snap: SnapshotHandle): WriteFromResult
499
516
  /**
500
- * Records an insert into the delta; `true` iff the final state changed.
501
- * Nothing is judged until commit; shape violations throw typed.
517
+ * Records a collection of inserts into the delta; returns the engine
518
+ * `{ submitted, changed }` report. `rows` is an array of value-arrays
519
+ * in sealed field order. Empty is lawful and still a mutation (poison
520
+ * is observed). Nothing is judged until commit; shape violations throw typed.
502
521
  */
503
- txInsert(tx: TxHandle, relationId: number, values: readonly FactValue[]): boolean
504
- /** Records a delete into the delta; `true` iff the final state changed. */
505
- txDelete(tx: TxHandle, relationId: number, values: readonly FactValue[]): boolean
522
+ txInsert(tx: TxHandle, relationId: number, rows: readonly (readonly FactValue[])[]): WireMutationReport
523
+ /** Records a collection of deletes; returns the engine `{ submitted, changed }` report. */
524
+ txDelete(tx: TxHandle, relationId: number, rows: readonly (readonly FactValue[])[]): WireMutationReport
506
525
  /**
507
526
  * Final-state membership (base + pending delta — the exact view the
508
527
  * commit judgment judges; check-then-act is race-free by construction).
@@ -511,12 +530,10 @@ interface Native {
511
530
  /** Final-state point lookup through a key statement; `null` on a miss. */
512
531
  txGet(tx: TxHandle, relationId: number, keyStatementId: number, keyValues: readonly FactValue[]): FactValue[] | null
513
532
  /**
514
- * Mints the next fresh value for `(relationId, fieldId)` and returns it
515
- * the engine's alloc-then-insert dyn-lane mint (there is no
516
- * insert-with-omitted-fields spelling; include the minted id in the
517
- * full row).
533
+ * Mints `count` consecutive fresh values for `(relationId, fieldId)`.
534
+ * `count === 0n` is empty and does not yield a start.
518
535
  */
519
- txAlloc(tx: TxHandle, relationId: number, fieldId: number): bigint
536
+ txReserve(tx: TxHandle, relationId: number, fieldId: number, count: bigint): WireFreshRange
520
537
  /**
521
538
  * Commits the delta: every dependency statement judged against the
522
539
  * final state; a rejection carries the complete violation rendering.
@@ -641,7 +658,6 @@ function bridged<T>(context: string, run: () => T): T {
641
658
 
642
659
  export type {
643
660
  AggOpIr,
644
- ArgOpIr,
645
661
  AtomIr,
646
662
  AtomSourceIr,
647
663
  CmpOpIr,
@@ -683,6 +699,8 @@ export type {
683
699
  TxHandle,
684
700
  Violation,
685
701
  ViolationFact,
702
+ WireFreshRange,
703
+ WireMutationReport,
686
704
  WriteFromResult
687
705
  }
688
706
  export { bridged, loadNativeBinding, native, SHIPPED_PLATFORMS }
package/src/query/find.ts CHANGED
@@ -24,19 +24,25 @@ import type { IntervalVarOk, NumericVarOk, OrderVarOk } from "#query/atom.ts"
24
24
  import type { AnyVar, Duration, MintSlotOf } from "#query/scope.ts"
25
25
 
26
26
  /** One aggregate operator name of the find vocabulary. */
27
- type AggOpName = "count" | "sum" | "min" | "max" | "pack"
27
+ type FoldOpName = "sum" | "min" | "max" | "pack"
28
+ type AggOpName = "count" | FoldOpName
29
+
30
+ /** Nullary count: no `over` exists to inhabit. */
31
+ interface CountAgg {
32
+ readonly agg: "count"
33
+ }
28
34
 
29
35
  /**
30
- * One aggregate find VALUE: the op and the variable (or measure) it folds
31
- * BY REFERENCE. The variable's own descriptor types the result.
36
+ * One fold aggregate: the op and the variable (or measure) it folds
37
+ * BY REFERENCE. Count is [`CountAgg`], not this type with `undefined`.
32
38
  */
33
- interface Agg<Op extends AggOpName, Over extends AnyVar | Duration | undefined> {
39
+ interface Agg<Op extends FoldOpName, Over extends AnyVar | Duration> {
34
40
  readonly agg: Op
35
41
  readonly over: Over
36
42
  }
37
43
 
38
44
  /** Any aggregate find value. */
39
- type AnyAgg = Agg<AggOpName, AnyVar | Duration | undefined>
45
+ type AnyAgg = CountAgg | Agg<FoldOpName, AnyVar | Duration>
40
46
 
41
47
  /** One find entry: a projected variable, the measure, or an aggregate. */
42
48
  type FindEntry = AnyVar | Duration | AnyAgg
@@ -44,17 +50,14 @@ type FindEntry = AnyVar | Duration | AnyAgg
44
50
  /** The `find` record: column name → find entry. Keys ARE the answer columns. */
45
51
  type FindShape = Readonly<Record<string, FindEntry>>
46
52
 
47
- /** Builds one aggregate value. */
48
- function aggregate<Op extends AggOpName, Over extends AnyVar | Duration | undefined>(
49
- op: Op,
50
- over: Over
51
- ): Agg<Op, Over> {
53
+ /** Builds one fold aggregate value. */
54
+ function aggregate<Op extends FoldOpName, Over extends AnyVar | Duration>(op: Op, over: Over): Agg<Op, Over> {
52
55
  return Object.freeze({ agg: op, over })
53
56
  }
54
57
 
55
58
  /** Nullary count: |the group's set of distinct full bindings|, `bigint`. */
56
- function count(): Agg<"count", undefined> {
57
- return aggregate("count", undefined)
59
+ function count(): CountAgg {
60
+ return Object.freeze({ agg: "count" })
58
61
  }
59
62
 
60
63
  /**
@@ -119,7 +122,7 @@ type FindEntryOk<E> = E extends AnyVar
119
122
  ? true
120
123
  : E extends Duration<infer V extends AnyVar>
121
124
  ? IntervalVarOk<V>
122
- : E extends Agg<"count", undefined>
125
+ : E extends CountAgg
123
126
  ? true
124
127
  : E extends Agg<"sum", infer O>
125
128
  ? SumOverOk<O>
@@ -151,7 +154,7 @@ type FindValue<E> = E extends AnyVar
151
154
  ? Infer<E["field"]>
152
155
  : E extends Duration<AnyVar>
153
156
  ? bigint
154
- : E extends Agg<"count", undefined>
157
+ : E extends CountAgg
155
158
  ? bigint
156
159
  : E extends Agg<"sum" | "min" | "max", infer O>
157
160
  ? O extends AnyVar
@@ -179,10 +182,12 @@ export type {
179
182
  AnyAgg,
180
183
  CheckFind,
181
184
  CheckRecFind,
185
+ CountAgg,
182
186
  FindEntry,
183
187
  FindEntryOk,
184
188
  FindShape,
185
189
  FindValue,
190
+ FoldOpName,
186
191
  HeadRecordOf,
187
192
  RowOfFind
188
193
  }
@@ -772,7 +772,7 @@ function advanceInterior(
772
772
  }
773
773
 
774
774
  /** Narrows a find entry to an aggregate value. */
775
- function isAggregateEntry(value: unknown): value is { readonly agg: string; readonly over: unknown } {
775
+ function isAggregateEntry(value: unknown): value is { readonly agg: string; readonly over?: unknown } {
776
776
  return typeof value === "object" && value !== null && "agg" in value
777
777
  }
778
778
 
@@ -785,11 +785,12 @@ function asVarTerm(context: string, value: unknown): AnyVar {
785
785
  }
786
786
 
787
787
  /** Classifies one aggregate find entry into its runtime data (variables ride by reference). */
788
- function aggDataOf(name: string, entry: { readonly agg: string; readonly over: unknown }): AggData {
788
+ function aggDataOf(name: string, entry: { readonly agg: string; readonly over?: unknown }): AggData {
789
+ if (entry.agg === "count") {
790
+ return Object.freeze({ op: "count" as const })
791
+ }
789
792
  const over = entry.over
790
793
  switch (entry.agg) {
791
- case "count":
792
- return Object.freeze({ op: "count" as const })
793
794
  case "sum":
794
795
  case "min":
795
796
  case "max": {
@@ -2051,7 +2052,7 @@ function lowerFind(entry: FindEntryData, ids: VarIds): FindTermIr {
2051
2052
  const agg = entry.agg
2052
2053
  switch (agg.op) {
2053
2054
  case "count":
2054
- return { kind: "aggregate", op: { kind: "count" } }
2055
+ return { kind: "count" }
2055
2056
  case "fold": {
2056
2057
  if ("duration" in agg.over) {
2057
2058
  return { kind: "aggregateMeasure", op: { kind: agg.fold }, over: ids.of(agg.over.duration) }
@@ -2059,7 +2060,7 @@ function lowerFind(entry: FindEntryData, ids: VarIds): FindTermIr {
2059
2060
  return { kind: "aggregate", op: { kind: agg.fold }, over: ids.of(agg.over) }
2060
2061
  }
2061
2062
  case "pack":
2062
- return { kind: "aggregate", op: { kind: "pack" }, over: ids.of(agg.over) }
2063
+ return { kind: "pack", over: ids.of(agg.over) }
2063
2064
  }
2064
2065
  }
2065
2066
 
@@ -50,28 +50,30 @@ function align(context: string, head: readonly HeadTermIr[], rules: readonly Rul
50
50
  }
51
51
  }
52
52
 
53
- /** Count forbids `over`; every other aggregate requires it. */
53
+ /** Count is nullary; pack and folds require `over`. */
54
54
  function parseFind(context: string, find: FindTermIr): void {
55
- if (find.kind !== "aggregate") {
56
- return
57
- }
58
- if (find.op.kind === "count") {
59
- if ("over" in find) {
55
+ const raw = find as Record<string, unknown>
56
+ if (find.kind === "count") {
57
+ if ("over" in raw) {
60
58
  throw errors.new(`${context}: Count carries no over`)
61
59
  }
62
60
  return
63
61
  }
64
- if (!("over" in find)) {
65
- throw errors.new(`${context}: fold aggregate requires over`)
62
+ if (find.kind === "pack" || find.kind === "aggregate" || find.kind === "aggregateMeasure") {
63
+ if (!("over" in raw)) {
64
+ throw errors.new(`${context}: ${find.kind} requires over`)
65
+ }
66
66
  }
67
67
  }
68
68
 
69
- /** Head family of one find term: measure is a var slot; measure-folds are aggregates. */
69
+ /** Head family of one find term: measure is a var slot; count/pack/folds are aggregates. */
70
70
  function findFamily(find: FindTermIr): "var" | "aggregate" {
71
71
  switch (find.kind) {
72
72
  case "var":
73
73
  case "measure":
74
74
  return "var"
75
+ case "count":
76
+ case "pack":
75
77
  case "aggregate":
76
78
  case "aggregateMeasure":
77
79
  return "aggregate"