@fabrika/db 0.8.2 → 0.9.0-cloudflare.2

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 (71) hide show
  1. package/package.json +44 -12
  2. package/src/admissions.ts +78 -12
  3. package/src/application-rows.ts +108 -0
  4. package/src/capability.ts +5 -0
  5. package/src/codegen-sqlite.ts +154 -0
  6. package/src/codegen.ts +59 -9
  7. package/src/database.ts +208 -56
  8. package/src/errors.ts +1 -0
  9. package/src/file-columns.ts +4 -1
  10. package/src/framework-tables.ts +28 -8
  11. package/src/framework.ts +10 -2
  12. package/src/guard.ts +8 -0
  13. package/src/index.ts +9 -1
  14. package/src/job-leases.ts +157 -37
  15. package/src/links.ts +8 -1
  16. package/src/migrations-sqlite.ts +132 -0
  17. package/src/nodes.ts +45 -9
  18. package/src/orm.ts +25 -15
  19. package/src/policy.ts +8 -2
  20. package/src/postgres.ts +26 -0
  21. package/src/registry.ts +8 -2
  22. package/src/sql.ts +15 -7
  23. package/src/sqlite-bun.ts +122 -0
  24. package/src/sqlite-migrate.ts +242 -0
  25. package/src/sqlite.ts +224 -0
  26. package/src/store.ts +336 -62
  27. package/src/stored-files.ts +149 -46
  28. package/src/tables.ts +16 -0
  29. package/src/temporal.ts +115 -9
  30. package/src/testing-shared.ts +733 -0
  31. package/src/testing-sqlite.ts +513 -0
  32. package/src/testing.ts +170 -482
  33. package/types/src/admissions.d.ts +21 -0
  34. package/types/src/application-rows.d.ts +58 -0
  35. package/types/src/builtins.d.ts +42 -0
  36. package/types/src/capability.d.ts +119 -0
  37. package/types/src/codegen-sqlite.d.ts +6 -0
  38. package/types/src/codegen.d.ts +82 -0
  39. package/types/src/database.d.ts +179 -0
  40. package/types/src/description.d.ts +109 -0
  41. package/types/src/errors.d.ts +98 -0
  42. package/types/src/file-columns.d.ts +37 -0
  43. package/types/src/file-references.d.ts +27 -0
  44. package/types/src/framework-tables.d.ts +59 -0
  45. package/types/src/framework.d.ts +37 -0
  46. package/types/src/guard.d.ts +23 -0
  47. package/types/src/identifier.d.ts +4 -0
  48. package/types/src/index.d.ts +10 -0
  49. package/types/src/job-leases.d.ts +93 -0
  50. package/types/src/links.d.ts +13 -0
  51. package/types/src/migrations-sqlite.d.ts +4 -0
  52. package/types/src/migrations.d.ts +25 -0
  53. package/types/src/nodes.d.ts +63 -0
  54. package/types/src/orm-types.d.ts +638 -0
  55. package/types/src/orm.d.ts +77 -0
  56. package/types/src/policy.d.ts +354 -0
  57. package/types/src/postgres.d.ts +12 -0
  58. package/types/src/production.d.ts +5 -0
  59. package/types/src/registry.d.ts +40 -0
  60. package/types/src/sql.d.ts +59 -0
  61. package/types/src/sqlite-bun.d.ts +13 -0
  62. package/types/src/sqlite-migrate.d.ts +28 -0
  63. package/types/src/sqlite.d.ts +24 -0
  64. package/types/src/store.d.ts +345 -0
  65. package/types/src/stored-files.d.ts +98 -0
  66. package/types/src/tables.d.ts +21 -0
  67. package/types/src/temporal.d.ts +33 -0
  68. package/types/src/testing-shared.d.ts +201 -0
  69. package/types/src/testing-sqlite.d.ts +22 -0
  70. package/types/src/testing.d.ts +35 -0
  71. package/types/src/vocabulary-tables.d.ts +225 -0
package/src/store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { uuidv7 } from '@fabrika/platform'
1
2
  import { BinaryOperationNode, type OperationNode, OperatorNode, type ReturningNode, ValueNode } from 'kysely'
2
3
  import { admissions } from './admissions'
3
4
  import {
@@ -31,8 +32,11 @@ import {
31
32
  type Within,
32
33
  type WithinGrant,
33
34
  } from './capability'
34
- import { type Database, InvocationTransaction, type Session, sessionOf } from './database'
35
+ import { type Database, InvocationTransaction, type Session, sessionOf, type SqlDialect } from './database'
35
36
  import {
37
+ type Compare,
38
+ type Condition,
39
+ type Contains,
36
40
  finishSelect,
37
41
  isPrimitive,
38
42
  type JoinShape,
@@ -49,7 +53,7 @@ import { fileReference } from './file-references'
49
53
  import { webhookTables } from './framework-tables'
50
54
  import type { ForeignKeyPair, GuardContext, RootKind } from './guard'
51
55
  import { fencedTransaction, jobLeases } from './job-leases'
52
- import { linksPredicate, linkValues } from './links'
56
+ import { linksPredicate, type LinkValue, linkValues } from './links'
53
57
  import {
54
58
  acknowledgement,
55
59
  acknowledgementNode,
@@ -99,11 +103,11 @@ import {
99
103
  type VersionArguments,
100
104
  visibleFor,
101
105
  } from './policy'
102
- import { frameworkSideOf, type Locator, register } from './registry'
106
+ import { frameworkSideOf, type ProvisionalBinding, register } from './registry'
103
107
  import type { ScopeTerms, ScopeTest, ScopeUse } from './sql'
104
108
  import { storedFiles, storedFilesMetadata } from './stored-files'
105
109
  import { type TablePolicies, tablePolicies } from './tables'
106
- import { answerDays, dayInput, timestampInput } from './temporal'
110
+ import { answerRows, dayInput, type RowColumns, storedTimestamp, timestampInput } from './temporal'
107
111
 
108
112
  export interface DataLayerRuntime {
109
113
  readonly database: Database
@@ -124,9 +128,86 @@ export interface TableContext {
124
128
  readonly destination: TableModel | undefined
125
129
  readonly shape: TableShape
126
130
  readonly joins: Readonly<Record<string, readonly ForeignKeyPair[]>>
131
+ /** The model of each child a select may join, by its table name. */
132
+ readonly children: Readonly<Record<string, TableModel>>
127
133
  readonly connection: Connection
128
134
  }
129
135
 
136
+ /** The dialect of the database the layer is bound to: what a read answers and a write binds depends on it. */
137
+ export function dialectOf(context: TableContext): SqlDialect {
138
+ return runtimeOf(context.connection).database.dialect
139
+ }
140
+
141
+ /** The columns of `model`'s own rows that a read answers in a form of its own (`answerRows`). */
142
+ export function rowColumnsOf(model: TableModel): RowColumns {
143
+ return { days: model.dates, timestamps: model.timestamps, booleans: model.booleans }
144
+ }
145
+
146
+ /** A timestamp's bound value on SQLite: its stored UTC text. Postgres takes a `Date` or ISO text as it is. */
147
+ function boundTimestamp(model: TableModel, column: string, value: Primitive): Primitive {
148
+ if (typeof value !== 'string' && !(value instanceof Date)) {
149
+ throw new QueryRefusedError('invalid_value', `${model.name}.${column} takes a Date or ISO 8601 text with Z or an offset`)
150
+ }
151
+ return storedTimestamp(value, `${model.name}.${column}`)
152
+ }
153
+
154
+ /** The values a write binds: on SQLite each timestamp as its stored UTC text, so text order is time order. */
155
+ export function boundValues(context: TableContext, values: ReadonlyMap<string, Primitive | null>): ReadonlyMap<string, Primitive | null> {
156
+ const model = context.model
157
+ if (dialectOf(context) === 'postgres' || ownKeys(model.timestamps).length === 0) {
158
+ return values
159
+ }
160
+ const bound = new Map<string, Primitive | null>()
161
+ for (const [name, value] of values) {
162
+ bound.set(name, value === null || !inSet(model.timestamps, name) ? value : boundTimestamp(model, name, value))
163
+ }
164
+ return bound
165
+ }
166
+
167
+ /** A term's values as they are bound: on SQLite, one over a timestamp of `model` compares stored UTC text. */
168
+ function boundTerm(model: TableModel, term: Compare | Contains): Compare | Contains {
169
+ const value = term.kind === 'compare' ? term.value : null
170
+ if (term.kind !== 'compare' || value === null || !inSet(model.timestamps, term.column)) {
171
+ return term
172
+ }
173
+ const bind = (item: Primitive): Primitive => boundTimestamp(model, term.column, item)
174
+ return { ...term, value: isList(value) ? value.map(bind) : bind(value) }
175
+ }
176
+
177
+ function isList(value: Primitive | readonly Primitive[]): value is readonly Primitive[] {
178
+ return isArray(value)
179
+ }
180
+
181
+ /** A list's terms over `model`'s columns, as `boundTerm` binds each. */
182
+ export function boundTerms(context: TableContext, model: TableModel, terms: readonly (Compare | Contains)[]): readonly (Compare | Contains)[] {
183
+ if (dialectOf(context) === 'postgres' || ownKeys(model.timestamps).length === 0) {
184
+ return terms
185
+ }
186
+ return terms.map((term) => boundTerm(model, term))
187
+ }
188
+
189
+ /** A description's conditions over `model`'s columns, an OR group's terms included, as `boundTerm` binds each. */
190
+ function boundConditions(context: TableContext, model: TableModel, conditions: readonly Condition[]): readonly Condition[] {
191
+ if (dialectOf(context) === 'postgres' || ownKeys(model.timestamps).length === 0) {
192
+ return conditions
193
+ }
194
+ return conditions.map((condition) =>
195
+ condition.kind === 'any' ? { ...condition, conditions: condition.conditions.map((term) => boundTerm(model, term)) } : boundTerm(model, condition)
196
+ )
197
+ }
198
+
199
+ /** A select's conditions, its joined children's included, as they are bound. */
200
+ function boundState(context: TableContext, state: SelectState): SelectState {
201
+ if (dialectOf(context) === 'postgres') {
202
+ return state
203
+ }
204
+ const joins = state.joins.map((join) => {
205
+ const child = ownEntry(context.children, join.join.shape.table)
206
+ return child === undefined ? join : { ...join, select: { ...join.select, conditions: boundConditions(context, child, join.select.conditions) } }
207
+ })
208
+ return { ...state, conditions: boundConditions(context, context.model, state.conditions), joins }
209
+ }
210
+
130
211
  export function runtimeOf(connection: Connection): DataLayerRuntime {
131
212
  const runtime = connection.runtime
132
213
  if (runtime === undefined) {
@@ -227,6 +308,76 @@ function linksAbove(model: TableModel, dimension: string): readonly Container[]
227
308
  return []
228
309
  }
229
310
 
311
+ /**
312
+ * The links of a row that lie inside the `Within`'s own dimension: the object itself for a dimension table, and the
313
+ * chain links nearer than `dimension`. A grant on one of them admits the row as a single-object read admits it.
314
+ */
315
+ function linksBelow(model: TableModel, dimension: string): readonly Container[] {
316
+ const links = model.scope.mode === 'dimension' ? selfLinks(model) : model.chain
317
+ const below: Container[] = []
318
+ for (let index = 0; index < links.length; index++) {
319
+ const link = links[index]
320
+ if (link === undefined || link.dimension === dimension) {
321
+ return below
322
+ }
323
+ below[below.length] = link
324
+ }
325
+ return []
326
+ }
327
+
328
+ /**
329
+ * One `<column> in (values)` per contained grant on a link of the row inside the `Within`'s dimension. A grant on a
330
+ * dimension the table carries no link of admits none of its rows; one on the `Within`'s dimension or above it is refused.
331
+ */
332
+ function containedTests(model: TableModel, grant: WithinGrant): { readonly column: string; readonly values: readonly string[] }[] {
333
+ const below = linksBelow(model, grant.dimension)
334
+ const above = linksAbove(model, grant.dimension)
335
+ const tests: { readonly column: string; readonly values: readonly string[] }[] = []
336
+ for (let index = 0; index < grant.contained.length; index++) {
337
+ const contained = grant.contained[index]
338
+ if (contained === undefined) {
339
+ continue
340
+ }
341
+ if (contained.dimension === grant.dimension || linkOf(above, contained.dimension) !== undefined) {
342
+ throw new CapabilityError(
343
+ 'unenforceable',
344
+ `the Within of ${grant.dimension} carries a grant on ${contained.dimension}, which does not lie inside ${grant.dimension} in ${model.name}`,
345
+ )
346
+ }
347
+ const link = linkOf(below, contained.dimension)
348
+ if (link !== undefined && contained.values.length > 0) {
349
+ tests[tests.length] = { column: link.column, values: contained.values }
350
+ }
351
+ }
352
+ return tests
353
+ }
354
+
355
+ /** The link of `dimension` among `links`, by an index loop: no array method an application could replace. */
356
+ function linkOf(links: readonly Container[], dimension: string): Container | undefined {
357
+ for (let index = 0; index < links.length; index++) {
358
+ const link = links[index]
359
+ if (link !== undefined && link.dimension === dimension) {
360
+ return link
361
+ }
362
+ }
363
+ return undefined
364
+ }
365
+
366
+ /** Every scope value a `Within` carries: its own, its containers' and its contained dimensions'. */
367
+ function withinCount(grant: WithinGrant): number {
368
+ if (grant.values === 'all') {
369
+ return 0
370
+ }
371
+ let count = grant.values.length
372
+ for (let index = 0; index < grant.containers.length; index++) {
373
+ count += grant.containers[index]?.values.length ?? 0
374
+ }
375
+ for (let index = 0; index < grant.contained.length; index++) {
376
+ count += grant.contained[index]?.values.length ?? 0
377
+ }
378
+ return count
379
+ }
380
+
230
381
  /**
231
382
  * `column in (values) or <container column> in (container values) …`, one disjunct per container dimension, so
232
383
  * the statement itself judges each row by the chain it carries now. Undefined for an empty `Within`: the caller
@@ -238,7 +389,7 @@ function withinPredicate(model: TableModel, column: string, grant: WithinGrant):
238
389
  }
239
390
  const above = linksAbove(model, grant.dimension)
240
391
  const predicates: OperationNode[] = []
241
- let count = grant.values.length
392
+ const count = withinCount(grant)
242
393
  if (grant.values.length > 0) {
243
394
  predicates[predicates.length] = inList(model.name, column, grant.values)
244
395
  }
@@ -247,23 +398,24 @@ function withinPredicate(model: TableModel, column: string, grant: WithinGrant):
247
398
  if (container === undefined) {
248
399
  continue
249
400
  }
250
- let link: Container | undefined
251
- for (let position = 0; position < above.length; position++) {
252
- if (above[position]?.dimension === container.dimension) {
253
- link = above[position]
254
- }
255
- }
401
+ const link = linkOf(above, container.dimension)
256
402
  if (link === undefined) {
257
403
  throw new CapabilityError(
258
404
  'unenforceable',
259
405
  `the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
260
406
  )
261
407
  }
262
- count += container.values.length
263
408
  if (container.values.length > 0) {
264
409
  predicates[predicates.length] = inList(model.name, link.column, container.values)
265
410
  }
266
411
  }
412
+ const contained = containedTests(model, grant)
413
+ for (let index = 0; index < contained.length; index++) {
414
+ const test = contained[index]
415
+ if (test !== undefined) {
416
+ predicates[predicates.length] = inList(model.name, test.column, test.values)
417
+ }
418
+ }
267
419
  if (count > MAX_WITHIN_VALUES) {
268
420
  throw new CapabilityError('within_size', `a Within carries at most ${MAX_WITHIN_VALUES} scope values; this one carries ${count}`)
269
421
  }
@@ -273,7 +425,14 @@ function withinPredicate(model: TableModel, column: string, grant: WithinGrant):
273
425
  /** The object a Ref of the model's own dimension names, with every link of its chain. */
274
426
  export function selfLinks(model: TableModel): readonly Container[] {
275
427
  const scope = dimensionScope(model)
276
- return [{ dimension: scope.dimension, column: scope.key }, ...model.chain]
428
+ const links: Container[] = [{ dimension: scope.dimension, column: scope.key }]
429
+ for (let index = 0; index < model.chain.length; index++) {
430
+ const link = model.chain[index]
431
+ if (link !== undefined) {
432
+ links[links.length] = link
433
+ }
434
+ }
435
+ return links
277
436
  }
278
437
 
279
438
  /** This object, over the whole chain the Ref carries. */
@@ -443,7 +602,22 @@ export function scopeTerms(context: TableContext, capability: unknown, use: Scop
443
602
  if (use === 'read' && !namesAction(model, grant.action)) {
444
603
  throw new CapabilityError('wrong_action', `${model.name} does not name the action ${grant.action}: a Ref reads it with an action it names`)
445
604
  }
446
- return [linkValues(model.name, links.slice(start), grant).map(({ link, value }) => ({ column: link.column, value }))]
605
+ const reached: Container[] = []
606
+ for (let index = start; index < links.length; index++) {
607
+ const link = links[index]
608
+ if (link !== undefined) {
609
+ reached[reached.length] = link
610
+ }
611
+ }
612
+ const values = linkValues(model.name, reached, grant)
613
+ const tests: ScopeTest[] = []
614
+ for (let index = 0; index < values.length; index++) {
615
+ const entry = values[index]
616
+ if (entry !== undefined) {
617
+ tests[tests.length] = { column: entry.link.column, value: entry.value }
618
+ }
619
+ }
620
+ return [tests]
447
621
  }
448
622
  const column = reachColumn(model, grant.dimension)
449
623
  if (column === undefined) {
@@ -463,21 +637,31 @@ export function scopeTerms(context: TableContext, capability: unknown, use: Scop
463
637
  }
464
638
  const above = linksAbove(model, grant.dimension)
465
639
  const groups: ScopeTest[][] = []
466
- let count = grant.values.length
640
+ const count = withinCount(grant)
467
641
  if (grant.values.length > 0) {
468
- groups.push([{ column, value: grant.values }])
642
+ groups[groups.length] = [{ column, value: grant.values }]
469
643
  }
470
- for (const container of grant.containers) {
471
- const link = above.find((candidate) => candidate.dimension === container.dimension)
644
+ for (let index = 0; index < grant.containers.length; index++) {
645
+ const container = grant.containers[index]
646
+ if (container === undefined) {
647
+ continue
648
+ }
649
+ const link = linkOf(above, container.dimension)
472
650
  if (link === undefined) {
473
651
  throw new CapabilityError(
474
652
  'unenforceable',
475
653
  `the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
476
654
  )
477
655
  }
478
- count += container.values.length
479
656
  if (container.values.length > 0) {
480
- groups.push([{ column: link.column, value: container.values }])
657
+ groups[groups.length] = [{ column: link.column, value: container.values }]
658
+ }
659
+ }
660
+ const contained = containedTests(model, grant)
661
+ for (let index = 0; index < contained.length; index++) {
662
+ const test = contained[index]
663
+ if (test !== undefined) {
664
+ groups[groups.length] = [{ column: test.column, value: test.values }]
481
665
  }
482
666
  }
483
667
  if (count > MAX_WITHIN_VALUES) {
@@ -895,29 +1079,39 @@ async function selectRows<S>(
895
1079
  if (scope === undefined) {
896
1080
  return []
897
1081
  }
898
- const node = selectNode(state, visible, scope, undefined, context.model.dates)
1082
+ const dialect = dialectOf(context)
1083
+ const node = selectNode(boundState(context, state), visible, scope, undefined, context.model.dates, dialect)
899
1084
  const rows = await (await run.read()).run<S>(node, guard(context, 'SelectQueryNode', scope))
900
- return answerDays(context.model.name, joinedDays(context.model.dates, state), rows)
1085
+ return answerRows(dialect, context.model.name, joinedColumns(context, state), rows)
901
1086
  }
902
1087
 
903
- /** The `date` columns of a select's rows: the table's own, and each joined child's under its `<child>.<column>` alias. */
904
- function joinedDays(own: NameSet, state: SelectState): NameSet {
1088
+ /** The column sets of a select's rows: the table's own, and each joined child's under its `<child>.<column>` alias. */
1089
+ function joinedColumns(context: TableContext, state: SelectState): RowColumns {
1090
+ const own = rowColumnsOf(context.model)
905
1091
  if (state.joins.length === 0) {
906
1092
  return own
907
1093
  }
908
- const names: string[] = []
909
- const ownDates = namesOf(own)
910
- for (let index = 0; index < ownDates.length; index++) {
911
- names[names.length] = ownDates[index] ?? ''
912
- }
913
- for (let index = 0; index < state.joins.length; index++) {
914
- const shape = state.joins[index]?.join.shape
915
- const dates = shape === undefined ? [] : namesOf(shape.dates)
916
- for (let position = 0; position < dates.length; position++) {
917
- names[names.length] = `${shape?.table}.${dates[position]}`
1094
+ const joined = (pick: (columns: RowColumns) => NameSet): NameSet => {
1095
+ const names: string[] = []
1096
+ const owned = namesOf(pick(own))
1097
+ for (let index = 0; index < owned.length; index++) {
1098
+ names[names.length] = owned[index] ?? ''
1099
+ }
1100
+ for (let index = 0; index < state.joins.length; index++) {
1101
+ const table = state.joins[index]?.join.shape.table ?? ''
1102
+ const child = ownEntry(context.children, table)
1103
+ const columns = child === undefined ? [] : namesOf(pick(rowColumnsOf(child)))
1104
+ for (let position = 0; position < columns.length; position++) {
1105
+ names[names.length] = `${table}.${columns[position]}`
1106
+ }
918
1107
  }
1108
+ return nameSet(names)
1109
+ }
1110
+ return {
1111
+ days: joined((columns) => columns.days),
1112
+ timestamps: joined((columns) => columns.timestamps),
1113
+ booleans: joined((columns) => columns.booleans),
919
1114
  }
920
- return nameSet(names)
921
1115
  }
922
1116
 
923
1117
  /**
@@ -933,10 +1127,11 @@ export async function selectOne<O>(
933
1127
  ): Promise<O> {
934
1128
  const start = startSelect(context.shape)
935
1129
  const state = finishSelect(start, start)
936
- const node = selectNode(state, visible, scope, undefined, context.model.dates)
1130
+ const dialect = dialectOf(context)
1131
+ const node = selectNode(state, visible, scope, undefined, context.model.dates, dialect)
937
1132
  const session = await run.read()
938
1133
  const rows = await session.run<O>(node, guard(context, 'SelectQueryNode', scope))
939
- const [row] = answerDays(context.model.name, context.model.dates, rows)
1134
+ const [row] = answerRows(dialect, context.model.name, rowColumnsOf(context.model), rows)
940
1135
  if (row === undefined) {
941
1136
  throw new NotFoundError(`${context.model.name} not found`)
942
1137
  }
@@ -1027,9 +1222,10 @@ async function lockDestination(transaction: Session, context: TableContext, gran
1027
1222
  export async function insertRow<O>(
1028
1223
  context: TableContext,
1029
1224
  run: Runner,
1030
- values: ReadonlyMap<string, Primitive | null>,
1225
+ given: ReadonlyMap<string, Primitive | null>,
1031
1226
  destination: RefGrant | undefined,
1032
1227
  ): Promise<O> {
1228
+ const values = boundValues(context, given)
1033
1229
  return run.write(async (transaction) => {
1034
1230
  if (destination !== undefined) {
1035
1231
  await lockDestination(transaction, context, destination)
@@ -1172,10 +1368,12 @@ async function insertOnConflict(
1172
1368
  context: TableContext,
1173
1369
  run: Runner,
1174
1370
  transaction: Session,
1175
- values: ReadonlyMap<string, Primitive | null>,
1371
+ given: ReadonlyMap<string, Primitive | null>,
1176
1372
  conflict: OnConflict,
1177
1373
  ): Promise<Readonly<Record<string, unknown>>> {
1178
1374
  const model = context.model
1375
+ const values = boundValues(context, given)
1376
+ const updates = boundValues(context, conflict.updates)
1179
1377
  const matches: OperationNode[] = []
1180
1378
  for (let index = 0; index < conflict.columns.length; index++) {
1181
1379
  const name = conflict.columns[index] ?? ''
@@ -1201,17 +1399,17 @@ async function insertOnConflict(
1201
1399
  return { inserted: false }
1202
1400
  }
1203
1401
  const where = scopedWhere(allOf(matches), scope)
1204
- const unchanged = conflict.updates.size === 0
1402
+ const unchanged = updates.size === 0
1205
1403
  const update = unchanged
1206
1404
  ? acknowledgementNode(model.name, keyOf(model), model.version, where)
1207
- : updateNode(model.name, conflict.updates, model.version, where, answer(context))
1405
+ : updateNode(model.name, updates, model.version, where, answer(context))
1208
1406
  const [updated] = await transaction.run<Readonly<Record<string, unknown>>>(
1209
1407
  update,
1210
1408
  guard(context, unchanged ? 'SelectQueryNode' : 'UpdateQueryNode', scope),
1211
1409
  )
1212
1410
  if (updated !== undefined) {
1213
1411
  checkVersion(context, updated)
1214
- await referenceUpdated(context, run, transaction, conflict.updates, updated)
1412
+ await referenceUpdated(context, run, transaction, updates, updated)
1215
1413
  return { ...updated, inserted: false }
1216
1414
  }
1217
1415
  }
@@ -1264,11 +1462,12 @@ export async function updateRow<O>(
1264
1462
  context: TableContext,
1265
1463
  run: Runner,
1266
1464
  target: Addressed,
1267
- changes: ReadonlyMap<string, Primitive | null>,
1465
+ given: ReadonlyMap<string, Primitive | null>,
1268
1466
  expectedVersion: OperationNode | undefined,
1269
1467
  destination?: RefGrant,
1270
1468
  ): Promise<O> {
1271
1469
  const { name: table, version } = context.model
1470
+ const changes = boundValues(context, given)
1272
1471
  const where = scopedWhere(conditionsWith(target, expectedVersion), target.scope)
1273
1472
  const unchanged = changes.size === 0
1274
1473
  const node = unchanged
@@ -1389,18 +1588,26 @@ export async function aggregateRows<Row, O>(
1389
1588
  if (scope === undefined) {
1390
1589
  return []
1391
1590
  }
1392
- const node = aggregateNode(context.model.name, columns, filter?.conditions ?? [], scope, context.model.dates)
1591
+ const dialect = dialectOf(context)
1592
+ const conditions = boundConditions(context, context.model, filter?.conditions ?? [])
1593
+ const node = aggregateNode(context.model.name, columns, conditions, scope, context.model.dates)
1393
1594
  const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
1394
- return answerDays(context.model.name, aggregateDays(context.model.dates, columns), rows)
1595
+ const own = rowColumnsOf(context.model)
1596
+ const answered = answerRows(dialect, context.model.name, {
1597
+ days: aggregated(own.days, columns),
1598
+ timestamps: aggregated(own.timestamps, columns),
1599
+ booleans: aggregated(own.booleans, columns),
1600
+ }, rows)
1601
+ return dialect === 'postgres' ? answered : sqliteAggregates(context.model.name, columns, answered)
1395
1602
  }
1396
1603
 
1397
- /** The `date` columns of an aggregate's rows: a grouped one by its name, and its `min_` and `max_`. */
1398
- function aggregateDays(dates: NameSet, columns: AggregateColumns): NameSet {
1604
+ /** The columns of `set` among an aggregate's: a grouped one by its name, and its `min_` and `max_`. */
1605
+ function aggregated(set: NameSet, columns: AggregateColumns): NameSet {
1399
1606
  const names: string[] = []
1400
1607
  const add = (prefix: string, list: readonly string[]) => {
1401
1608
  for (let index = 0; index < list.length; index++) {
1402
1609
  const name = list[index] ?? ''
1403
- if (inSet(dates, name)) {
1610
+ if (inSet(set, name)) {
1404
1611
  names[names.length] = `${prefix}${name}`
1405
1612
  }
1406
1613
  }
@@ -1411,6 +1618,37 @@ function aggregateDays(dates: NameSet, columns: AggregateColumns): NameSet {
1411
1618
  return nameSet(names)
1412
1619
  }
1413
1620
 
1621
+ /**
1622
+ * An aggregate's numbers as Postgres's driver answers them: `count` as bigint text, and an integral `sum` as text too
1623
+ * (Postgres sums an integer as bigint or numeric). SQLite answers both as numbers, and a whole sum of a REAL column is
1624
+ * text here where Postgres's float8 sum is a number; `AggregateRow` types a sum as either.
1625
+ */
1626
+ function sqliteAggregates<O>(table: string, columns: AggregateColumns, rows: O[]): O[] {
1627
+ const integral = (where: string, value: unknown): unknown => {
1628
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
1629
+ return value
1630
+ }
1631
+ if (!isSafeInteger(value)) {
1632
+ throw new QueryRefusedError('unexpected_row', `${where} came back as an integer past the safe range, which a number cannot hold exactly`)
1633
+ }
1634
+ return String(value)
1635
+ }
1636
+ for (let index = 0; index < rows.length; index++) {
1637
+ const row = rows[index]
1638
+ if (typeof row !== 'object' || row === null) {
1639
+ continue
1640
+ }
1641
+ if (columns.count) {
1642
+ defineValue(row, 'count', integral(`${table}.count`, ownDescriptor(row, 'count')?.value))
1643
+ }
1644
+ for (let position = 0; position < columns.sum.length; position++) {
1645
+ const name = `sum_${columns.sum[position] ?? ''}`
1646
+ defineValue(row, name, integral(`${table}.${name}`, ownDescriptor(row, name)?.value))
1647
+ }
1648
+ }
1649
+ return rows
1650
+ }
1651
+
1414
1652
  /** An insert's row, stamped from its capability, the destination it locks, and what it does on a conflict. */
1415
1653
  export interface PreparedInsert {
1416
1654
  readonly row: Map<string, Primitive | null>
@@ -1433,10 +1671,8 @@ export function prepareDimensionInsert(context: TableContext, target: unknown, v
1433
1671
  stampInsert(context, grant, row, scope.key, grant.id)
1434
1672
  return { row, destination: undefined, conflict }
1435
1673
  }
1436
- stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
1437
- for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
1438
- row.set(link.column, value)
1439
- }
1674
+ stampInsert(context, grant, row, scope.key, uuidv7())
1675
+ stampChain(row, linkValues(context.model.name, context.model.chain, grant))
1440
1676
  return { row, destination: grant, conflict }
1441
1677
  }
1442
1678
 
@@ -1445,7 +1681,7 @@ export function prepareApplicationInsert(context: TableContext, capability: unkn
1445
1681
  const grant = appGrant(context, capability, 'insert')
1446
1682
  const row = readValues(values, context.model.insert, 'insert', context.model)
1447
1683
  const conflict = onConflictOf(context, options, grant.action, row)
1448
- stampInsert(context, grant, row, keyOf(context.model), Bun.randomUUIDv7())
1684
+ stampInsert(context, grant, row, keyOf(context.model), uuidv7())
1449
1685
  return { row, destination: undefined, conflict }
1450
1686
  }
1451
1687
 
@@ -1455,13 +1691,21 @@ export function prepareChildInsert(context: TableContext, parent: unknown, value
1455
1691
  const grant = refGrant(context, parent, scope.parent.dimension, 'insert')
1456
1692
  const row = readValues(values, context.model.insert, 'insert', context.model)
1457
1693
  const conflict = onConflictOf(context, options, grant.action, row)
1458
- stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
1459
- for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
1460
- row.set(link.column, value)
1461
- }
1694
+ stampInsert(context, grant, row, scope.key, uuidv7())
1695
+ stampChain(row, linkValues(context.model.name, context.model.chain, grant))
1462
1696
  return { row, destination: grant, conflict }
1463
1697
  }
1464
1698
 
1699
+ /** Writes each chain column the destination `Ref` settles into an insert's row. */
1700
+ function stampChain(row: Map<string, Primitive | null>, values: readonly LinkValue[]): void {
1701
+ for (let index = 0; index < values.length; index++) {
1702
+ const entry = values[index]
1703
+ if (entry !== undefined) {
1704
+ row.set(entry.link.column, entry.value)
1705
+ }
1706
+ }
1707
+ }
1708
+
1465
1709
  /** A prepared insert, with its conflict handling when the caller named one. */
1466
1710
  export function insertPrepared(context: TableContext, run: Runner, prepared: PreparedInsert): Promise<Readonly<Record<string, unknown>>> {
1467
1711
  const conflict = prepared.conflict
@@ -1565,9 +1809,17 @@ export class DimensionTable<DB, P, T extends string> {
1565
1809
  const scope = objectPredicate(context, ref, 'delete')
1566
1810
  const target = refGrant(context, to, container.dimension, 'insert')
1567
1811
  const values = linkValues(context.model.name, context.model.chain, target)
1568
- const changes = new Map<string, Primitive | null>(values.map(({ link, value }) => [link.column, value]))
1812
+ const changes = new Map<string, Primitive | null>()
1813
+ stampChain(changes, values)
1814
+ const chain: Record<string, string> = {}
1815
+ for (let index = 0; index < values.length; index++) {
1816
+ const entry = values[index]
1817
+ if (entry !== undefined) {
1818
+ setOwn(chain, entry.link.dimension, entry.value)
1819
+ }
1820
+ }
1569
1821
  const acknowledged = await updateRow<Acknowledgement<P, T>>(context, storeRunner(context), addressed(scope), changes, undefined, target)
1570
- return { ...acknowledged, chain: Object.fromEntries(values.map(({ link, value }) => [link.dimension, value])) }
1822
+ return { ...acknowledged, chain }
1571
1823
  }
1572
1824
 
1573
1825
  async aggregate<const S extends AggregateSpec<Row<DB, P, T>>>(
@@ -1900,8 +2152,23 @@ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
1900
2152
  setOwn(joins, child, join.on)
1901
2153
  }
1902
2154
  }
2155
+ const childModels: Record<string, TableModel> = {}
2156
+ for (let index = 0; index < children.length; index++) {
2157
+ const child = modelOf(models, children[index] ?? '')
2158
+ if (child !== undefined) {
2159
+ setOwn(childModels, child.name, child)
2160
+ }
2161
+ }
1903
2162
  const shape = shapeOf(model, joinable)
1904
- return freeze({ model, parent, destination: destinationOf(models, model, parent), shape, joins: freeze(joins), connection })
2163
+ return freeze({
2164
+ model,
2165
+ parent,
2166
+ destination: destinationOf(models, model, parent),
2167
+ shape,
2168
+ joins: freeze(joins),
2169
+ children: freeze(childModels),
2170
+ connection,
2171
+ })
1905
2172
  }
1906
2173
 
1907
2174
  const database: ScopedDatabase<DB, P> = Object.freeze({ tables: tablePolicies(tables) })
@@ -1917,13 +2184,20 @@ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
1917
2184
  }
1918
2185
  return probeOf(contextFor(table, mode))
1919
2186
  },
1920
- bind(runtime: DataLayerRuntime): Locator {
2187
+ bind(runtime: DataLayerRuntime): ProvisionalBinding {
1921
2188
  if (connection.runtime !== undefined) {
1922
2189
  throw new CapabilityError('layer_unbound', 'the data layer is already connected; only the framework connects it, once, at boot')
1923
2190
  }
1924
2191
  const bound: DataLayerRuntime = freeze({ database: runtime.database, capabilities: runtime.capabilities, readOnly: runtime.readOnly })
1925
2192
  connection.runtime = bound
1926
- return async (dimension, id, using) => locateObject(models, bound, dimension, id, using)
2193
+ return freeze({
2194
+ locate: async (dimension: string, id: string, using?: Session) => locateObject(models, bound, dimension, id, using),
2195
+ release() {
2196
+ if (connection.runtime === bound) {
2197
+ connection.runtime = undefined
2198
+ }
2199
+ },
2200
+ })
1927
2201
  },
1928
2202
  framework: freeze({
1929
2203
  webhooks: webhookTables(() => runtimeOf(connection)),