@bjornpagen/bumbledb 1.1.0 → 1.2.1

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 (152) hide show
  1. package/COOKBOOK.md +114 -11
  2. package/README.md +52 -9
  3. package/dist/capacity.d.ts +22 -59
  4. package/dist/capacity.d.ts.map +1 -1
  5. package/dist/capacity.js +66 -13
  6. package/dist/capacity.js.map +1 -1
  7. package/dist/changes.d.ts +26 -3
  8. package/dist/changes.d.ts.map +1 -1
  9. package/dist/changes.js +89 -44
  10. package/dist/changes.js.map +1 -1
  11. package/dist/closed.d.ts +23 -51
  12. package/dist/closed.d.ts.map +1 -1
  13. package/dist/closed.js +110 -168
  14. package/dist/closed.js.map +1 -1
  15. package/dist/codec.d.ts +4 -9
  16. package/dist/codec.d.ts.map +1 -1
  17. package/dist/codec.js +108 -183
  18. package/dist/codec.js.map +1 -1
  19. package/dist/compile.d.ts +16 -16
  20. package/dist/compile.d.ts.map +1 -1
  21. package/dist/compile.js +36 -15
  22. package/dist/compile.js.map +1 -1
  23. package/dist/db-native.d.ts +39 -0
  24. package/dist/db-native.d.ts.map +1 -1
  25. package/dist/db-native.js +8 -0
  26. package/dist/db-native.js.map +1 -1
  27. package/dist/db.d.ts +28 -8
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +91 -38
  30. package/dist/db.js.map +1 -1
  31. package/dist/errors.d.ts +11 -3
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +13 -1
  34. package/dist/errors.js.map +1 -1
  35. package/dist/face.d.ts +19 -25
  36. package/dist/face.d.ts.map +1 -1
  37. package/dist/face.js +30 -24
  38. package/dist/face.js.map +1 -1
  39. package/dist/fields.d.ts +13 -26
  40. package/dist/fields.d.ts.map +1 -1
  41. package/dist/fields.js +89 -116
  42. package/dist/fields.js.map +1 -1
  43. package/dist/immutable.d.ts +10 -0
  44. package/dist/immutable.d.ts.map +1 -0
  45. package/dist/immutable.js +75 -0
  46. package/dist/immutable.js.map +1 -0
  47. package/dist/index.d.ts +16 -12
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/internal/log.d.ts +2 -0
  52. package/dist/internal/log.d.ts.map +1 -1
  53. package/dist/internal/log.js +2 -0
  54. package/dist/internal/log.js.map +1 -1
  55. package/dist/law.d.ts +13 -11
  56. package/dist/law.d.ts.map +1 -1
  57. package/dist/law.js +1 -1
  58. package/dist/law.js.map +1 -1
  59. package/dist/lower.d.ts.map +1 -1
  60. package/dist/lower.js +11 -6
  61. package/dist/lower.js.map +1 -1
  62. package/dist/native.d.ts +1 -1
  63. package/dist/native.d.ts.map +1 -1
  64. package/dist/native.js.map +1 -1
  65. package/dist/query/atom.d.ts +1 -1
  66. package/dist/query/atom.d.ts.map +1 -1
  67. package/dist/query/atom.js.map +1 -1
  68. package/dist/query/description.d.ts +33 -0
  69. package/dist/query/description.d.ts.map +1 -0
  70. package/dist/query/description.js +302 -0
  71. package/dist/query/description.js.map +1 -0
  72. package/dist/query/find.d.ts +21 -6
  73. package/dist/query/find.d.ts.map +1 -1
  74. package/dist/query/find.js.map +1 -1
  75. package/dist/query/lower.d.ts +75 -10
  76. package/dist/query/lower.d.ts.map +1 -1
  77. package/dist/query/lower.js +132 -163
  78. package/dist/query/lower.js.map +1 -1
  79. package/dist/query/parse-ir.d.ts +7 -2
  80. package/dist/query/parse-ir.d.ts.map +1 -1
  81. package/dist/query/parse-ir.js +240 -53
  82. package/dist/query/parse-ir.js.map +1 -1
  83. package/dist/query/run.d.ts.map +1 -1
  84. package/dist/query/run.js +8 -10
  85. package/dist/query/run.js.map +1 -1
  86. package/dist/query/scope.d.ts +24 -16
  87. package/dist/query/scope.d.ts.map +1 -1
  88. package/dist/query/scope.js +5 -10
  89. package/dist/query/scope.js.map +1 -1
  90. package/dist/relation.d.ts +22 -48
  91. package/dist/relation.d.ts.map +1 -1
  92. package/dist/relation.js +30 -82
  93. package/dist/relation.js.map +1 -1
  94. package/dist/rows.d.ts +7 -4
  95. package/dist/rows.d.ts.map +1 -1
  96. package/dist/rows.js +44 -198
  97. package/dist/rows.js.map +1 -1
  98. package/dist/runtime-errors.d.ts +26 -2
  99. package/dist/runtime-errors.d.ts.map +1 -1
  100. package/dist/runtime-errors.js +43 -3
  101. package/dist/runtime-errors.js.map +1 -1
  102. package/dist/schema.d.ts +9 -1
  103. package/dist/schema.d.ts.map +1 -1
  104. package/dist/schema.js +85 -24
  105. package/dist/schema.js.map +1 -1
  106. package/dist/selection.d.ts +27 -0
  107. package/dist/selection.d.ts.map +1 -0
  108. package/dist/selection.js +104 -0
  109. package/dist/selection.js.map +1 -0
  110. package/dist/shape.d.ts +12 -12
  111. package/dist/shape.d.ts.map +1 -1
  112. package/dist/statements.d.ts +17 -52
  113. package/dist/statements.d.ts.map +1 -1
  114. package/dist/statements.js +92 -111
  115. package/dist/statements.js.map +1 -1
  116. package/dist/values.d.ts +25 -0
  117. package/dist/values.d.ts.map +1 -0
  118. package/dist/values.js +203 -0
  119. package/dist/values.js.map +1 -0
  120. package/pack-provenance.json +3 -3
  121. package/package.json +4 -4
  122. package/src/capacity.ts +93 -80
  123. package/src/changes.ts +183 -54
  124. package/src/closed.ts +161 -314
  125. package/src/codec.ts +116 -190
  126. package/src/compile.ts +56 -32
  127. package/src/db-native.ts +42 -0
  128. package/src/db.ts +134 -52
  129. package/src/errors.ts +16 -1
  130. package/src/face.ts +44 -74
  131. package/src/fields.ts +100 -146
  132. package/src/immutable.ts +76 -0
  133. package/src/index.ts +36 -29
  134. package/src/internal/log.ts +2 -0
  135. package/src/law.ts +21 -18
  136. package/src/lower.ts +13 -10
  137. package/src/native.ts +2 -0
  138. package/src/query/atom.ts +1 -0
  139. package/src/query/description.ts +394 -0
  140. package/src/query/find.ts +20 -7
  141. package/src/query/lower.ts +177 -187
  142. package/src/query/parse-ir.ts +247 -54
  143. package/src/query/run.ts +15 -9
  144. package/src/query/scope.ts +42 -33
  145. package/src/relation.ts +58 -142
  146. package/src/rows.ts +62 -205
  147. package/src/runtime-errors.ts +47 -3
  148. package/src/schema.ts +99 -29
  149. package/src/selection.ts +124 -0
  150. package/src/shape.ts +18 -12
  151. package/src/statements.ts +138 -189
  152. package/src/values.ts +223 -0
package/src/capacity.ts CHANGED
@@ -1,55 +1,15 @@
1
1
  import { AuthoringError } from "#errors.ts"
2
- /**
3
- * Capacity-law mints the window, weight, and dependent-bound vocabulary
4
- * the one `capacity` statement constructor consumes
5
- * § the canonical-utterance law, restated
6
- * around the aggregate form). `within` is the ONE window spelling —
7
- * `within(n)` exact (`within(0n)` IS the exclusion's one spelling),
8
- * `within(lo, hi)` range (`within(0n, hi)` the canonical ceiling),
9
- * `within(lo, "*")` floor — `weigh` names the measure on the SOURCE row
10
- * (`weigh("watts")` a u64 field, `weigh(duration("booked"))` an interval's
11
- * measure), and `ref`/`duration` read a dependent bound from the
12
- * TARGET row (hi slot only — ruled 2026-07-24, C6). The ban table is
13
- * enforced REPRESENTATIONALLY in two tiers, split per-aggregate where
14
- * weight-sensitive (design § 6: a ban is canonical-utterance policing when
15
- * it is weight-independent, semantic deduplication when it is not):
16
- *
17
- * - **The type tier**: a banned spelling written as a LITERAL does not
18
- * compile — every negative bound, `within(n, n)`, `within(0n, 0n)`, and
19
- * `within(0n, "*")` are type errors naming the canonical form. The
20
- * weight-SENSITIVE row rides the `capacity` overloads themselves:
21
- * `within(1n, "*")` is banned on the unit overload only (`{1..*}` on the
22
- * count instance is the bare containment respelled —
23
- * `window_floor_containment`), and LEGAL on the weighted one ("positive
24
- * total" is not an existence claim over rows).
25
- * - **The construction tier**: a bound the type level cannot judge — a
26
- * COMPUTED `bigint`, or an inverted `within(lo, hi)` order — is judged at
27
- * construction with the same canonical-naming errors; and past both
28
- * tiers the engine's own spec validation remains the law for a hostile
29
- * FFI caller (the standing two-tier ban enforcement).
30
- *
31
- * The weight vocabulary is closed at the row (ruled 2026-07-24, ruling 6):
32
- * a path weight (`weigh("model.watts")`) is a typed refusal at BOTH tiers
33
- * whose diagnostic names the pinned-column idiom — the two-column
34
- * containment IS the join, stated as a law. Bounds are `bigint` (u64
35
- * crosses as bigint always, PRD-04's law); the witnessed measure comes
36
- * back as `bigint` too (u128-wide engine accumulator, C3).
37
- */
2
+ /** Structural capacity measures and windows. Constructors and generated
3
+ * descriptions share the same checked representation. Bounds are u64;
4
+ * dependent bounds read the target row, measures read the source row. */
38
5
 
39
6
  import type { AnyFace, FaceFields, FaceSource, ProjectedShape } from "#face.ts"
40
7
  import type { CapacityBoundSpec, CapacityWindowSpec, WeightSpec } from "#spec.ts"
8
+ import { integerValue, recordValue } from "#values.ts"
41
9
 
42
- const admitted: unique symbol = Symbol("bumbledb.capacity.admitted")
43
-
44
- interface CapacityWindow<S extends CapacityWindowSpec = CapacityWindowSpec> {
45
- readonly window: S
46
- readonly [admitted]: true
47
- }
10
+ type CapacityWindow<S extends CapacityWindowSpec = CapacityWindowSpec> = S
48
11
 
49
- interface CapacityWeight<S extends WeightSpec = WeightSpec> {
50
- readonly weight: S
51
- readonly [admitted]: true
52
- }
12
+ type CapacityWeight<S extends WeightSpec = WeightSpec> = S
53
13
 
54
14
  interface FieldRef<F extends string = string> {
55
15
  readonly kind: "field"
@@ -61,14 +21,6 @@ interface DurationRef<F extends string = string> {
61
21
  readonly field: F
62
22
  }
63
23
 
64
- function isCapacityWindow(value: unknown): value is CapacityWindow {
65
- return typeof value === "object" && value !== null && admitted in value && "window" in value
66
- }
67
-
68
- function isCapacityWeight(value: unknown): value is CapacityWeight {
69
- return typeof value === "object" && value !== null && admitted in value && "weight" in value
70
- }
71
-
72
24
  interface BannedWindow<Canonical extends string> {
73
25
  readonly "banned window spelling — the canonical-utterance law names the one legal form": Canonical
74
26
  }
@@ -125,11 +77,12 @@ type RangeBan<Lo extends bigint, Hi extends bigint> = bigint extends Lo
125
77
  * The C18 dimension gate's ban row, unit instance (the engine's
126
78
  * `CapacityDimensionMixing` twin, ruled 2026-07-24): a unit (count)
127
79
  * window against a `duration` bound counts facts against a span of
128
- * time — a dimension error. Judged on the `capacity` UNIT overload
129
- * only: Duration weights pair with Duration-capable bounds, so the
130
- * weighted overload takes the same window freely.
80
+ * interval coordinates — a dimension error. Judged on the `capacity`
81
+ * UNIT overload only. Explicit scalar and Duration measures may use
82
+ * either scalar or Duration bounds; the application supplies their
83
+ * common unit (for example, cents or calendar days).
131
84
  */
132
- type UnitDimensionBan<W extends CapacityWindow> = W["window"] extends {
85
+ type UnitDimensionBan<W extends CapacityWindow> = W extends {
133
86
  readonly hi: { readonly kind: "durationField" }
134
87
  }
135
88
  ? BannedWindow<"a count of facts bounded by a span of time mixes dimensions (C18) — weigh the source with weigh(duration(field)), or bound by a u64 field or literal">
@@ -154,17 +107,17 @@ interface BoundKindMismatch<K, Want> {
154
107
 
155
108
  type BoundOnTarget<K extends string, Want extends "u64" | "interval", B extends AnyFace> = string extends K
156
109
  ? unknown
157
- : K extends FaceFields<B["source"]>
158
- ? KindAt<B["source"], K> extends Want
110
+ : K extends FaceFields<B["owner"]>
111
+ ? KindAt<B["owner"], K> extends Want
159
112
  ? unknown
160
113
  : BoundKindMismatch<K, Want>
161
- : BoundOffTargetRoster<K, FaceFields<B["source"]>>
114
+ : BoundOffTargetRoster<K, FaceFields<B["owner"]>>
162
115
 
163
- type BoundsOnTarget<W extends CapacityWindow, B extends AnyFace> = W["window"] extends {
116
+ type BoundsOnTarget<W extends CapacityWindow, B extends AnyFace> = W extends {
164
117
  readonly hi: FieldRef<infer K>
165
118
  }
166
119
  ? BoundOnTarget<K, "u64", B>
167
- : W["window"] extends { readonly hi: DurationRef<infer K> }
120
+ : W extends { readonly hi: DurationRef<infer K> }
168
121
  ? BoundOnTarget<K, "interval", B>
169
122
  : unknown
170
123
 
@@ -189,30 +142,32 @@ interface WeightKindMismatch<K, Want> {
189
142
  * position. Checked at the `capacity` call where the source face is
190
143
  * inferred — the proven constrain-after-inference pattern.
191
144
  */
192
- type WeightOnSource<M extends CapacityWeight, A extends AnyFace> = M["weight"] extends {
145
+ type WeightOnSource<M extends CapacityWeight, A extends AnyFace> = M extends {
193
146
  readonly kind: "field"
194
147
  readonly field: infer K extends string
195
148
  }
196
149
  ? string extends K
197
150
  ? unknown
198
- : K extends FaceFields<A["source"]>
199
- ? KindAt<A["source"], K> extends "u64"
151
+ : K extends FaceFields<A["owner"]>
152
+ ? KindAt<A["owner"], K> extends "u64"
200
153
  ? unknown
201
154
  : WeightKindMismatch<K, "u64">
202
- : WeightOffSourceRoster<K, FaceFields<A["source"]>>
203
- : M["weight"] extends { readonly kind: "durationField"; readonly field: infer K extends string }
155
+ : WeightOffSourceRoster<K, FaceFields<A["owner"]>>
156
+ : M extends { readonly kind: "durationField"; readonly field: infer K extends string }
204
157
  ? string extends K
205
158
  ? unknown
206
- : K extends FaceFields<A["source"]>
207
- ? KindAt<A["source"], K> extends "interval"
159
+ : K extends FaceFields<A["owner"]>
160
+ ? KindAt<A["owner"], K> extends "interval"
208
161
  ? unknown
209
162
  : WeightKindMismatch<K, "interval">
210
- : WeightOffSourceRoster<K, FaceFields<A["source"]>>
163
+ : WeightOffSourceRoster<K, FaceFields<A["owner"]>>
211
164
  : unknown
212
165
 
213
166
  const unitWeight: WeightSpec = Object.freeze({ kind: "unit" })
214
167
 
215
- function assertRowLocal(field: string, role: string): string {
168
+ function assertRowLocal<F extends string>(field: F, role: string): F {
169
+ if (typeof field !== "string" || !field.isWellFormed())
170
+ throw new AuthoringError({ message: `${role}: expected a well-formed field name` })
216
171
  if (field.includes(".")) {
217
172
  throw new AuthoringError({
218
173
  message: `${role} \`${field}\` walks a reference — the vocabulary is closed at the row (ruling 6): pin the column with a two-column containment (Source(ref, f) <= Catalog(id, f)) and name the local field`
@@ -222,8 +177,8 @@ function assertRowLocal(field: string, role: string): string {
222
177
  }
223
178
 
224
179
  function admitWindow<S extends CapacityWindowSpec>(window: S): CapacityWindow<S> {
225
- const value: CapacityWindow<S> = { window, [admitted]: true }
226
- return Object.freeze(value)
180
+ capacityWindow(window)
181
+ return Object.freeze(window)
227
182
  }
228
183
 
229
184
  function lit(value: bigint): CapacityBoundSpec {
@@ -265,6 +220,8 @@ function within<const Lo extends bigint, const Hi extends bigint>(
265
220
  | { readonly kind: "exact"; readonly n: { readonly kind: "lit"; readonly value: Lo } }
266
221
  >
267
222
  function within(lo: bigint, hi?: bigint | "*" | FieldRef | DurationRef): CapacityWindow {
223
+ integerValue("capacity lower bound", "u64", lo)
224
+ if (typeof hi === "bigint") integerValue("capacity upper bound", "u64", hi)
268
225
  if (lo < 0n) {
269
226
  throw new AuthoringError({
270
227
  message: `capacity bounds are u64: within(${lo}${hi === undefined ? "" : ", …"}) is out of domain`
@@ -302,8 +259,8 @@ function within(lo: bigint, hi?: bigint | "*" | FieldRef | DurationRef): Capacit
302
259
  }
303
260
 
304
261
  function admitWeight<S extends WeightSpec>(weight: S): CapacityWeight<S> {
305
- const value: CapacityWeight<S> = { weight, [admitted]: true }
306
- return Object.freeze(value)
262
+ capacityWeight(weight)
263
+ return Object.freeze(weight)
307
264
  }
308
265
 
309
266
  /**
@@ -326,18 +283,74 @@ function weigh(measure: string | DurationRef): CapacityWeight {
326
283
  }
327
284
 
328
285
  function ref<const F extends string>(field: F & PathBan<F>): FieldRef<F> {
329
- return Object.freeze({ kind: "field", field: assertRowLocal(field, "dependent bound") }) as FieldRef<F>
286
+ return Object.freeze({ kind: "field", field: assertRowLocal(field, "dependent bound") })
330
287
  }
331
288
 
332
289
  /**
333
290
  * `Duration(field)` — the interval-measure spelling, one mint for both
334
291
  * slots: handed to `weigh` it is the SOURCE row's interval measure;
335
292
  * in `within`'s hi slot it is the TARGET row's interval-measure bound
336
- * (Duration weights pair with Duration-capable bounds C18).
293
+ * in the interval element's unit, not necessarily a clock unit.
294
+ * Explicit u64 measures/bounds may carry that same unit; an unweighted
295
+ * row count cannot be bounded by an interval measure (C18).
337
296
  */
338
297
  function duration<const F extends string>(field: F & PathBan<F>): DurationRef<F> {
339
- return Object.freeze({ kind: "durationField", field: assertRowLocal(field, "Duration measure") }) as DurationRef<F>
298
+ return Object.freeze({ kind: "durationField", field: assertRowLocal(field, "Duration measure") })
340
299
  }
341
300
 
342
301
  export type { BoundsOnTarget, CapacityWeight, CapacityWindow, DurationRef, FieldRef, UnitDimensionBan, WeightOnSource }
343
- export { duration, isCapacityWeight, isCapacityWindow, ref, unitWeight, weigh, within }
302
+
303
+ /** Read a checked bound without retaining a caller-owned record. */
304
+ function capacityBound(input: unknown): CapacityBoundSpec {
305
+ const kind = typeof input === "object" && input !== null && "kind" in input ? input.kind : undefined
306
+ if (kind === "lit") {
307
+ const bound = recordValue("capacity bound", input, ["kind", "value"])
308
+ return Object.freeze({ kind, value: integerValue("capacity bound", "u64", bound.value) })
309
+ }
310
+ if (kind === "field" || kind === "durationField") {
311
+ const bound = recordValue("capacity bound", input, ["kind", "field"])
312
+ if (typeof bound.field !== "string") throw new AuthoringError({ message: "capacity bound: expected a field name" })
313
+ return Object.freeze({ kind, field: assertRowLocal(bound.field, "dependent bound") })
314
+ }
315
+ throw new AuthoringError({ message: "capacity bound: unknown kind" })
316
+ }
317
+
318
+ function literalBound(input: unknown): CapacityBoundSpec & { readonly kind: "lit" } {
319
+ const bound = capacityBound(input)
320
+ if (bound.kind !== "lit")
321
+ throw new AuthoringError({ message: "dependent bounds are allowed only at a range's upper bound" })
322
+ return bound
323
+ }
324
+
325
+ function capacityWindow(input: unknown): CapacityWindow {
326
+ const kind = typeof input === "object" && input !== null && "kind" in input ? input.kind : undefined
327
+ if (kind === "exact") {
328
+ const window = recordValue("capacity window", input, ["kind", "n"])
329
+ return Object.freeze({ kind, n: literalBound(window.n) })
330
+ }
331
+ if (kind === "floor") {
332
+ const window = recordValue("capacity window", input, ["kind", "lo"])
333
+ return Object.freeze({ kind, lo: literalBound(window.lo) })
334
+ }
335
+ if (kind === "range") {
336
+ const window = recordValue("capacity window", input, ["kind", "lo", "hi"])
337
+ const lo = literalBound(window.lo)
338
+ const hi = capacityBound(window.hi)
339
+ if (hi.kind === "lit" && hi.value < lo.value) throw new AuthoringError({ message: "capacity window is inverted" })
340
+ return Object.freeze({ kind, lo, hi })
341
+ }
342
+ throw new AuthoringError({ message: "capacity window: unknown kind" })
343
+ }
344
+
345
+ function capacityWeight(input: unknown): CapacityWeight {
346
+ const kind = typeof input === "object" && input !== null && "kind" in input ? input.kind : undefined
347
+ if (kind === "unit") {
348
+ recordValue("capacity weight", input, ["kind"])
349
+ return unitWeight
350
+ }
351
+ const weight = capacityBound(input)
352
+ if (weight.kind === "lit") throw new AuthoringError({ message: "capacity weight: expected unit or a source field" })
353
+ return weight
354
+ }
355
+
356
+ export { capacityWeight, capacityWindow, duration, ref, unitWeight, weigh, within }
package/src/changes.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  import type { Scope } from "effect"
2
- import { Effect, Exit } from "effect"
2
+ import { Effect, Exit, Option, Stream } from "effect"
3
3
  import { drainClose, releaseOwner } from "#close.ts"
4
+ import { isClosedMember, membersAgree } from "#closed.ts"
4
5
  import type { SchemaId } from "#compile.ts"
5
6
  import { Schema as CoreSchema, schemaTables } from "#compile.ts"
6
- import type { ChangesHandle, DraftHandle } from "#db-native.ts"
7
+ import type { ChangeRecordWire, ChangesHandle, ChangesWire, DraftHandle } from "#db-native.ts"
7
8
  import { dbNative } from "#db-native.ts"
9
+ import { SdkInvariantError } from "#errors.ts"
8
10
  import { lower } from "#lower.ts"
9
- import type { AnyRelation, Fact } from "#relation.ts"
11
+ import { type AnyRelation, type Fact, relationFields } from "#relation.ts"
10
12
  import type { CellValue } from "#rows.ts"
11
- import { cellOf, hostCellCharge, recordOf } from "#rows.ts"
13
+ import { factCellsOf, factOfCells, hostCellCharge } from "#rows.ts"
12
14
  import { nativeOperationWith, runtimeHandle } from "#runtime.ts"
13
15
  import type { CloseReport } from "#runtime-errors.ts"
14
- import { DbError } from "#runtime-errors.ts"
15
- import type { AnySchema } from "#schema.ts"
16
+ import { argumentError, DbError } from "#runtime-errors.ts"
17
+ import type { AnySchema, SchemaRelation } from "#schema.ts"
16
18
  import type { Rel } from "#shape.ts"
19
+ import { bytesValue, recordValue } from "#values.ts"
17
20
 
18
21
  /**
19
22
  * `ChangeSet` — the engine's checked immutable delta:
@@ -22,10 +25,32 @@ import type { Rel } from "#shape.ts"
22
25
  * add-wins. Immutable and reusable while open; sealing/submitting it later
23
26
  * retains the SAME native value — no second JS row walk ever happens.
24
27
  */
28
+ /** Counts of distinct fact additions and removals, never input events. */
29
+ interface ChangeCounts {
30
+ readonly added: bigint
31
+ readonly removed: bigint
32
+ }
33
+
34
+ /** A relation-name-discriminated union of plain, fully typed facts. */
35
+ type ChangeRecord<S extends AnySchema> = {
36
+ [N in keyof S["relations"]]: S["relations"][N] extends AnyRelation
37
+ ? { readonly relation: N; readonly kind: "add" | "remove"; readonly fact: Fact<S["relations"][N]> }
38
+ : never
39
+ }[keyof S["relations"]]
40
+
25
41
  interface ChangeSet<S extends AnySchema> {
26
42
  readonly schemaId: SchemaId
43
+ /** Distinct requested actions; use judge for effective counts against a store. */
44
+ readonly counts: ChangeCounts
45
+ readonly byteLength: bigint
27
46
  /** Phantom schema brand: a ChangeSet is only ever applied to its own S. */
28
47
  readonly schema?: S
48
+ /** Independent scoped traversal, delivered in bounded native batches. Reusable. */
49
+ records(): Stream.Stream<ChangeRecord<S>, DbError>
50
+ /** Explicit full byte materialization. Mutating returned bytes cannot alter this value. */
51
+ toBytes(): Effect.Effect<Uint8Array, DbError>
52
+ /** Commutative add-wins composition into one command, not sequential replay. */
53
+ compose(other: ChangeSet<S>): Effect.Effect<ChangeSet<S>, DbError, Scope.Scope>
29
54
  close(): Effect.Effect<CloseReport>
30
55
  }
31
56
 
@@ -97,31 +122,15 @@ function eventLoopTurn(): Effect.Effect<void> {
97
122
  }
98
123
 
99
124
  function hostFactCharge(relation: AnyRelation, record: Readonly<Record<string, unknown>>): bigint {
100
- const data = relation.data
125
+ const fields = relationFields(relation)
101
126
  let bytes = 0n
102
- for (const declared of data.fields) {
127
+ for (const declared of fields) {
103
128
  const value = record[declared.name]
104
- if (value === undefined) {
105
- throw refusal("ChangeDraft.ingest", "InvalidArgument")
106
- }
107
129
  bytes += hostCellCharge(value)
108
130
  }
109
131
  return bytes
110
132
  }
111
133
 
112
- function projectFact(relation: AnyRelation, record: Readonly<Record<string, unknown>>): readonly CellValue[] {
113
- const data = relation.data
114
- const cells: CellValue[] = []
115
- for (const declared of data.fields) {
116
- const value = record[declared.name]
117
- if (value === undefined) {
118
- throw refusal("ChangeDraft.ingest", "InvalidArgument")
119
- }
120
- cells.push(cellOf(`relation ${data.name} field ${declared.name}`, declared.field, value))
121
- }
122
- return cells
123
- }
124
-
125
134
  /**
126
135
  * Pulls one bounded chunk off the caller's iterator. Host length is judged
127
136
  * before any string scan or byte copy. A leftover fact that does not fit
@@ -141,13 +150,17 @@ function pullChunk(relation: AnyRelation, iterator: Iterator<object>, pending: o
141
150
  }
142
151
  current = next.value
143
152
  }
144
- const record = recordOf(current)
153
+ const record = recordValue(
154
+ `relation ${relation.name}`,
155
+ current,
156
+ relationFields(relation).map((field) => field.name)
157
+ )
145
158
  const charge = hostFactCharge(relation, record)
146
159
  if (rows > 0n && bytes + charge > CHUNK_BYTES) {
147
160
  leftover = current
148
161
  break
149
162
  }
150
- for (const cell of projectFact(relation, record)) cells.push(cell)
163
+ for (const cell of factCellsOf(relation, record)) cells.push(cell)
151
164
  bytes += charge
152
165
  rows += 1n
153
166
  current = undefined
@@ -185,7 +198,7 @@ function ingest(
185
198
  yield* spendAndDrain(state, operation)
186
199
  return yield* Effect.fail(refusal(operation, "SpentHandle"))
187
200
  }
188
- if (state.theory.relations[relation.name] !== relation) {
201
+ if (!membersAgree(state.theory.relations[relation.name], relation)) {
189
202
  return yield* Effect.fail(refusal(operation, "InvalidArgument"))
190
203
  }
191
204
  const tables = schemaTables(state.theory)
@@ -197,7 +210,7 @@ function ingest(
197
210
  const body = Effect.gen(function* () {
198
211
  const iterator = yield* Effect.try({
199
212
  try: () => rows[Symbol.iterator](),
200
- catch: () => refusal(operation, "InvalidArgument")
213
+ catch: (cause) => argumentError(operation, cause)
201
214
  })
202
215
  let leftover: object | undefined
203
216
  let done = false
@@ -205,7 +218,7 @@ function ingest(
205
218
  while (!done) {
206
219
  const chunk = yield* Effect.try({
207
220
  try: () => pullChunk(relation, iterator, leftover),
208
- catch: (cause) => (cause instanceof DbError ? cause : refusal(operation, "InvalidArgument"))
221
+ catch: (cause) => argumentError(operation, cause)
209
222
  }).pipe(Effect.catch((error) => spendAndDrain(state, operation).pipe(Effect.andThen(Effect.fail(error)))))
210
223
  leftover = chunk.leftover
211
224
  done = chunk.done && leftover === undefined
@@ -244,19 +257,126 @@ function ingest(
244
257
  })
245
258
  }
246
259
 
247
- function makeChangeSet<S extends AnySchema>(handle: ChangesHandle, schemaId: SchemaId): ChangeSet<S> {
260
+ function decodeChangeRecord<S extends AnySchema>(
261
+ relations: readonly SchemaRelation[],
262
+ record: ChangeRecordWire
263
+ ): ChangeRecord<S> {
264
+ const relation = relations[record.relation]
265
+ if (relation === undefined || isClosedMember(relation) || (record.kind !== "add" && record.kind !== "remove")) {
266
+ throw new SdkInvariantError({ message: "ChangeSet.records: invalid native record descriptor" })
267
+ }
268
+ // Relation id resolves through this schema's own ordered roster. The
269
+ // shared projector validates all field values before this union seam.
270
+ return Object.freeze({
271
+ relation: relation.name,
272
+ kind: record.kind,
273
+ fact: factOfCells(relation, record.values)
274
+ }) as ChangeRecord<S>
275
+ }
276
+
277
+ function changeRecords<S extends AnySchema>(
278
+ handle: ChangesHandle,
279
+ relations: readonly SchemaRelation[]
280
+ ): Stream.Stream<ChangeRecord<S>, DbError> {
281
+ return Stream.unwrap(
282
+ Effect.gen(function* () {
283
+ const cursor = yield* Effect.acquireRelease(
284
+ nativeOperationWith(
285
+ "ChangeSet.records",
286
+ (callback) => dbNative.runtimeChangesCursor(handle, callback),
287
+ dbNative.runtimeChangesCursorTake,
288
+ (value) => value
289
+ ),
290
+ (value) =>
291
+ releaseOwner("ChangeCursor.close", (callback) => dbNative.runtimeChangesCursorClose(value, callback)),
292
+ { interruptible: true }
293
+ )
294
+ return Stream.paginate(undefined, () =>
295
+ nativeOperationWith(
296
+ "ChangeSet.records",
297
+ (callback) => dbNative.runtimeChangesCursorNext(cursor, callback),
298
+ dbNative.runtimeChangePageTake,
299
+ (page) => page
300
+ ).pipe(
301
+ Effect.map((page) =>
302
+ page === null
303
+ ? ([[], Option.none<undefined>()] as const)
304
+ : ([page.map((record) => decodeChangeRecord<S>(relations, record)), Option.some(undefined)] as const)
305
+ )
306
+ )
307
+ )
308
+ })
309
+ )
310
+ }
311
+
312
+ function acquireChanges<S extends AnySchema>(
313
+ theory: S,
314
+ schemaId: SchemaId,
315
+ acquire: Effect.Effect<ChangesWire, DbError>
316
+ ): Effect.Effect<ChangeSet<S>, DbError, Scope.Scope> {
317
+ return Effect.acquireRelease(
318
+ acquire.pipe(Effect.map((wire) => makeChangeSet(theory, wire, schemaId))),
319
+ (changes) =>
320
+ Effect.suspend(() => {
321
+ const internal = changesInternals.get(changes)
322
+ if (internal === undefined) return Effect.void
323
+ internal.closed = true
324
+ return releaseOwner("ChangeSet.close", (callback) => dbNative.runtimeChangesClose(internal.handle, callback))
325
+ }),
326
+ { interruptible: true }
327
+ )
328
+ }
329
+
330
+ function makeChangeSet<S extends AnySchema>(theory: S, wire: ChangesWire, schemaId: SchemaId): ChangeSet<S> {
331
+ const handle = wire.changes
332
+ const relations = Object.values(theory.relations)
333
+ const internal: ChangesInternal = { handle, schemaId, closed: false }
248
334
  const value: ChangeSet<S> = {
249
335
  schemaId,
336
+ counts: Object.freeze({ ...wire.counts }),
337
+ byteLength: wire.byteLength,
338
+ records() {
339
+ return changeRecords<S>(handle, relations)
340
+ },
341
+ toBytes() {
342
+ return nativeOperationWith(
343
+ "ChangeSet.toBytes",
344
+ (callback) => dbNative.runtimeChangesBytes(handle, callback),
345
+ dbNative.runtimeBytesTake,
346
+ (bytes) => bytes
347
+ )
348
+ },
349
+ compose(other) {
350
+ return Effect.suspend(() => {
351
+ const right = internalChanges(other)
352
+ if (right === undefined || right.schemaId !== schemaId)
353
+ return Effect.fail(refusal("ChangeSet.compose", "InvalidArgument"))
354
+ if (internal.closed || right.closed) return Effect.fail(refusal("ChangeSet.compose", "ClosedHandle"))
355
+ return acquireChanges(
356
+ theory,
357
+ schemaId,
358
+ nativeOperationWith(
359
+ "ChangeSet.compose",
360
+ (callback) => dbNative.runtimeChangesCompose(handle, right.handle, callback),
361
+ dbNative.runtimeChangesTake,
362
+ (result) => result
363
+ )
364
+ )
365
+ })
366
+ },
250
367
  close() {
251
- return drainClose("ChangeSet.close", (callback) => dbNative.runtimeChangesClose(handle, callback))
368
+ return Effect.suspend(() => {
369
+ internal.closed = true
370
+ return drainClose("ChangeSet.close", (callback) => dbNative.runtimeChangesClose(handle, callback))
371
+ })
252
372
  }
253
373
  }
254
374
  Object.freeze(value)
255
- changesInternals.set(value, { handle, schemaId, closed: false })
375
+ changesInternals.set(value, internal)
256
376
  return value
257
377
  }
258
378
 
259
- function makeDraft<S extends AnySchema>(state: DraftState, schemaId: SchemaId): ChangeDraft<S> {
379
+ function makeDraft<S extends AnySchema>(theory: S, state: DraftState, schemaId: SchemaId): ChangeDraft<S> {
260
380
  const draft: ChangeDraft<S> = {
261
381
  insert(relation, rows) {
262
382
  return ingest(state, "ChangeDraft.insert", relation, rows)
@@ -265,7 +385,9 @@ function makeDraft<S extends AnySchema>(state: DraftState, schemaId: SchemaId):
265
385
  return ingest(state, "ChangeDraft.delete", relation, rows)
266
386
  },
267
387
  finish() {
268
- return Effect.acquireRelease(
388
+ return acquireChanges(
389
+ theory,
390
+ schemaId,
269
391
  Effect.gen(function* () {
270
392
  if (state.spent) {
271
393
  return yield* Effect.fail(refusal("ChangeDraft.finish", "SpentHandle"))
@@ -276,26 +398,13 @@ function makeDraft<S extends AnySchema>(state: DraftState, schemaId: SchemaId):
276
398
  }
277
399
  // Finish CONSUMES the draft, success or failure.
278
400
  state.spent = true
279
- const wire = yield* nativeOperationWith(
401
+ return yield* nativeOperationWith(
280
402
  "ChangeDraft.finish",
281
403
  (callback) => dbNative.runtimeDraftFinish(state.handle, callback),
282
404
  dbNative.runtimeChangesTake,
283
405
  (value) => value
284
406
  )
285
- return makeChangeSet<S>(wire.changes, schemaId)
286
- }),
287
- (changes) =>
288
- Effect.suspend(() => {
289
- const internal = changesInternals.get(changes)
290
- if (internal === undefined || internal.closed) {
291
- return Effect.void
292
- }
293
- internal.closed = true
294
- return releaseOwner("ChangeSet.close", (callback) =>
295
- dbNative.runtimeChangesClose(internal.handle, callback)
296
- )
297
- }),
298
- { interruptible: true }
407
+ })
299
408
  )
300
409
  },
301
410
  close() {
@@ -318,7 +427,7 @@ function makeDraft<S extends AnySchema>(state: DraftState, schemaId: SchemaId):
318
427
  const builder = Effect.fn("ChangeSet.builder")(function* <S extends AnySchema>(schema: S) {
319
428
  const handle = yield* runtimeHandle()
320
429
  const compiled = yield* CoreSchema.compile(schema)
321
- const spec = lower(schema)
430
+ const spec = lower(compiled.schema)
322
431
  return yield* Effect.acquireRelease(
323
432
  Effect.gen(function* () {
324
433
  const draftHandle = yield* nativeOperationWith(
@@ -329,11 +438,11 @@ const builder = Effect.fn("ChangeSet.builder")(function* <S extends AnySchema>(s
329
438
  )
330
439
  const state: DraftState = {
331
440
  handle: draftHandle,
332
- theory: schema,
441
+ theory: compiled.schema,
333
442
  spent: false,
334
443
  inFlight: false
335
444
  }
336
- const draft = makeDraft<S>(state, compiled.schemaId)
445
+ const draft = makeDraft(compiled.schema, state, compiled.schemaId)
337
446
  draftStates.set(draft, state)
338
447
  return draft
339
448
  }),
@@ -357,7 +466,27 @@ const builder = Effect.fn("ChangeSet.builder")(function* <S extends AnySchema>(s
357
466
  // native handle without exposing it on the public capability.
358
467
  const draftStates = new WeakMap<object, DraftState>()
359
468
 
360
- const ChangeSet = Object.freeze({ builder })
469
+ /** Parse canonical native bytes, owning the input when the Effect starts. */
470
+ const fromBytes = Effect.fn("ChangeSet.fromBytes")(function* <S extends AnySchema>(schema: S, bytes: Uint8Array) {
471
+ const ownedBytes = yield* Effect.try({
472
+ try: () => bytesValue("ChangeSet.fromBytes", bytes),
473
+ catch: (cause) => argumentError("ChangeSet.fromBytes", cause)
474
+ })
475
+ const handle = yield* runtimeHandle()
476
+ const compiled = yield* CoreSchema.compile(schema)
477
+ return yield* acquireChanges(
478
+ compiled.schema,
479
+ compiled.schemaId,
480
+ nativeOperationWith(
481
+ "ChangeSet.fromBytes",
482
+ (callback) => dbNative.runtimeChangesParse(handle, lower(compiled.schema), ownedBytes, callback),
483
+ dbNative.runtimeChangesTake,
484
+ (wire) => wire
485
+ )
486
+ )
487
+ })
488
+
489
+ const ChangeSet = Object.freeze({ builder, fromBytes })
361
490
 
362
- export type { ChangeDraft }
491
+ export type { ChangeCounts, ChangeDraft, ChangeRecord }
363
492
  export { ChangeSet, internalChanges }