@fabrika/db 0.5.5 → 0.7.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/store.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { BinaryOperationNode, type OperationNode, OperatorNode, type ReturningNode, ValueNode } from 'kysely'
2
+ import { admissions } from './admissions'
2
3
  import {
4
+ defineValue,
3
5
  freeze,
4
6
  hasOwn,
5
7
  inSet,
@@ -42,9 +44,8 @@ import {
42
44
  type TableShape,
43
45
  } from './description'
44
46
  import { CapabilityError, ConflictError, NotFoundError, PolicyError, QueryRefusedError } from './errors'
45
- import { fileTables } from './file-tables'
46
- import { formAdmissions, type InboxQuota, takeFromBucket } from './form-admissions'
47
- import { formFiles, type HeldFormFiles, holdFormFiles } from './form-files'
47
+ import { answerFiles, type FileBinding, fileValue, referenceFiles, rowScope, storedRowScope } from './file-columns'
48
+ import { fileReference } from './file-references'
48
49
  import { webhookTables } from './framework-tables'
49
50
  import type { ForeignKeyPair, GuardContext, RootKind } from './guard'
50
51
  import { fencedTransaction, jobLeases } from './job-leases'
@@ -78,6 +79,7 @@ import {
78
79
  type ContainerOf,
79
80
  type DimensionOf,
80
81
  type DimensionTableName,
82
+ includes,
81
83
  type InsertValues,
82
84
  type JoinableChildren,
83
85
  type ListDimensionOf,
@@ -99,6 +101,7 @@ import {
99
101
  } from './policy'
100
102
  import { frameworkSideOf, type Locator, register } from './registry'
101
103
  import type { ScopeTerms, ScopeTest, ScopeUse } from './sql'
104
+ import { storedFiles, storedFilesMetadata } from './stored-files'
102
105
  import { type TablePolicies, tablePolicies } from './tables'
103
106
  import { answerDays, dayInput, timestampInput } from './temporal'
104
107
 
@@ -161,25 +164,32 @@ export function listDimension(scope: DimensionScopeModel): string {
161
164
  return scope.container?.dimension ?? scope.dimension
162
165
  }
163
166
 
164
- export function requiredAction(context: TableContext, operation: Operation): string {
165
- const action = context.model.actions[operation]
166
- if (action === undefined) {
167
+ /** The actions that allow `operation` on the table, any one of them; throws when its policy declares none. */
168
+ export function requiredActions(context: TableContext, operation: Operation): readonly string[] {
169
+ const actions = context.model.actions[operation]
170
+ if (actions.length === 0) {
167
171
  throw new CapabilityError('undeclared_operation', `${context.model.name} allows no ${operation}; its policy declares no ${operation} action`)
168
172
  }
169
- return action
173
+ return actions
174
+ }
175
+
176
+ /** Refuses a capability whose action is not one of `operation`'s actions on the table. */
177
+ export function checkAction(context: TableContext, operation: Operation, action: string): void {
178
+ const actions = requiredActions(context, operation)
179
+ if (!includes(actions, action)) {
180
+ throw new CapabilityError('wrong_action', `${operation} on ${context.model.name} requires ${actions.join(' or ')}; the capability holds ${action}`)
181
+ }
170
182
  }
171
183
 
172
184
  function checkGrant(context: TableContext, grant: RefGrant | WithinGrant, dimension: string, operation: Operation): void {
173
- const action = requiredAction(context, operation)
185
+ requiredActions(context, operation)
174
186
  if (grant.dimension !== dimension) {
175
187
  throw new CapabilityError(
176
188
  'wrong_capability',
177
189
  `${operation} on ${context.model.name} needs a ${grant.kind} of ${dimension}, got a ${grant.kind} of ${grant.dimension}`,
178
190
  )
179
191
  }
180
- if (grant.action !== action) {
181
- throw new CapabilityError('wrong_action', `${operation} on ${context.model.name} requires ${action}; the capability holds ${grant.action}`)
182
- }
192
+ checkAction(context, operation, grant.action)
183
193
  }
184
194
 
185
195
  function refGrant(context: TableContext, capability: unknown, dimension: string, operation: Operation): RefGrant {
@@ -292,8 +302,8 @@ export function childListPredicate(context: TableContext, capability: unknown, o
292
302
  checkGrant(context, grant, scope.parent.dimension, operation)
293
303
  return parentPredicate(context, grant)
294
304
  }
295
- if (grant.kind === 'app') {
296
- throw new CapabilityError('wrong_capability', `a list of ${context.model.name} by its parent needs a Ref or a Within, got an app capability`)
305
+ if (grant.kind === 'app' || grant.kind === 'row') {
306
+ throw new CapabilityError('wrong_capability', `a list of ${context.model.name} by its parent needs a Ref or a Within, got ${describeGrant(grant)}`)
297
307
  }
298
308
  checkGrant(context, grant, listDimension(scope.parent), operation)
299
309
  return withinPredicate(context.model, scope.parent.container?.column ?? scope.column, grant)
@@ -326,10 +336,7 @@ export function reachPredicate(context: TableContext, capability: unknown): Oper
326
336
  if (grant.kind !== 'within') {
327
337
  throw new CapabilityError('wrong_capability', `a list of ${table} needs a Within, got ${describeGrant(grant)}`)
328
338
  }
329
- const action = requiredAction(context, 'read')
330
- if (grant.action !== action) {
331
- throw new CapabilityError('wrong_action', `read on ${table} requires ${action}; the capability holds ${grant.action}`)
332
- }
339
+ checkAction(context, 'read', grant.action)
333
340
  const column = reachColumn(context.model, grant.dimension)
334
341
  if (column === undefined) {
335
342
  throw new CapabilityError(
@@ -355,21 +362,21 @@ function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'u
355
362
  if (grant.kind !== 'app' && model.scope.mode === 'app') {
356
363
  throw new CapabilityError('table_kind', `a scoped site writes no application table: ${statement} needs the capability of an app endpoint`)
357
364
  }
358
- const action = model.actions[use]
359
- if (grant.action === action) {
365
+ const actions = model.actions[use]
366
+ if (includes(actions, grant.action)) {
360
367
  return
361
368
  }
362
369
  const columnWrites: string[] = []
363
370
  const updates = use === 'update' ? updateActions(model) : []
364
371
  for (let index = 0; index < updates.length; index++) {
365
372
  const write = updates[index]
366
- if (write !== undefined && write !== action) columnWrites[columnWrites.length] = write
373
+ if (write !== undefined && !includes(actions, write)) columnWrites[columnWrites.length] = write
367
374
  }
368
- if (action === undefined && columnWrites.length === 0) {
375
+ if (actions.length === 0 && columnWrites.length === 0) {
369
376
  throw new CapabilityError('undeclared_operation', `${statement}: its policy declares no ${use} action`)
370
377
  }
371
- if (!columnWrites.includes(grant.action)) {
372
- const needs = action === undefined ? [] : [`its ${use} action ${action}`]
378
+ if (!includes(columnWrites, grant.action)) {
379
+ const needs = actions.length === 0 ? [] : [`its ${use} action ${actions.join(' or ')}`]
373
380
  if (columnWrites.length > 0) needs[needs.length] = `a column's own write action (${columnWrites.join(', ')})`
374
381
  throw new CapabilityError('wrong_action', `${statement} needs ${needs.join(' or ')}; the capability holds ${grant.action}`)
375
382
  }
@@ -404,6 +411,9 @@ function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'u
404
411
  export function scopeTerms(context: TableContext, capability: unknown, use: ScopeUse, assigned?: readonly string[]): ScopeTerms {
405
412
  const grant = runtimeOf(context.connection).capabilities.resolve(capability)
406
413
  const model = context.model
414
+ if (grant.kind === 'row') {
415
+ throw new CapabilityError('wrong_capability', `ctx.sql.scope takes a Ref, a Within or an app capability, got ${describeGrant(grant)}`)
416
+ }
407
417
  if (use !== 'read') {
408
418
  checkWriteTarget(context, grant, use, assigned)
409
419
  }
@@ -442,10 +452,10 @@ export function scopeTerms(context: TableContext, capability: unknown, use: Scop
442
452
  `a Within of ${grant.dimension} does not reach ${model.name}: its containment chain does not hold ${grant.dimension}`,
443
453
  )
444
454
  }
445
- if (use === 'read' && grant.action !== model.actions.read) {
455
+ if (use === 'read' && !includes(model.actions.read, grant.action)) {
446
456
  throw new CapabilityError(
447
457
  'wrong_action',
448
- `a Within of ${grant.action} does not read ${model.name}: it reads with its read action ${model.actions.read}`,
458
+ `a Within of ${grant.action} does not read ${model.name}: it reads with its read action ${model.actions.read.join(' or ')}`,
449
459
  )
450
460
  }
451
461
  if (grant.values === 'all') {
@@ -510,11 +520,14 @@ export type AppReach = 'rows' | 'counts'
510
520
  /** Refuses an `app` capability whose action does not reach `reach` of a dimension or child table. */
511
521
  function checkRecordReach(context: TableContext, grant: AppGrant, reach: AppReach): void {
512
522
  const model = context.model
513
- const { read, appRead, appCount } = model.actions
514
- if (grant.action === read || includesName(appRead, grant.action) || (reach === 'counts' && includesName(appCount, grant.action))) {
523
+ const { appRead, appCount } = model.actions
524
+ const read = model.actions.read.join(' or ')
525
+ if (
526
+ includes(model.actions.read, grant.action) || includes(appRead, grant.action) || (reach === 'counts' && includes(appCount, grant.action))
527
+ ) {
515
528
  return
516
529
  }
517
- if (includesName(appCount, grant.action)) {
530
+ if (includes(appCount, grant.action)) {
518
531
  throw new CapabilityError(
519
532
  'wrong_action',
520
533
  `an app capability of ${grant.action} only counts ${model.name} (its appCount): it reads no row, so list, aggregate and a scope fragment need its read action ${read}${
@@ -530,15 +543,6 @@ function checkRecordReach(context: TableContext, grant: AppGrant, reach: AppReac
530
543
  )
531
544
  }
532
545
 
533
- function includesName(names: readonly string[], name: string): boolean {
534
- for (let index = 0; index < names.length; index++) {
535
- if (names[index] === name) {
536
- return true
537
- }
538
- }
539
- return false
540
- }
541
-
542
546
  /**
543
547
  * Refuses a table `ctx.db.app` does not list for the capability (`applicationTables` in `orm.ts`): it lists an
544
548
  * application table under any action the table names, and the rows of a dimension or child table under its read
@@ -609,10 +613,7 @@ function appGrant(context: TableContext, capability: unknown, operation: Operati
609
613
  checkApplicationTable(context, operation)
610
614
  }
611
615
  checkAppReach(context, grant)
612
- const action = requiredAction(context, operation)
613
- if (grant.action !== action) {
614
- throw new CapabilityError('wrong_action', `${operation} on ${table} requires ${action}; the capability holds ${grant.action}`)
615
- }
616
+ checkAction(context, operation, grant.action)
616
617
  return grant
617
618
  }
618
619
 
@@ -635,11 +636,11 @@ export function scopedApplicationRead(context: TableContext, capability: unknown
635
636
  if (grant.kind === 'app') {
636
637
  throw new CapabilityError('wrong_capability', `a read of ${model.name} from a scoped site needs a Ref or a Within, got ${describeGrant(grant)}`)
637
638
  }
638
- const read = requiredAction(context, 'read')
639
- if (grant.action !== read) {
639
+ const read = requiredActions(context, 'read')
640
+ if (!includes(read, grant.action)) {
640
641
  throw new CapabilityError(
641
642
  'wrong_action',
642
- `a scoped site reads ${model.name} with its read action ${read} only; the capability holds ${grant.action}`,
643
+ `a scoped site reads ${model.name} with its read action ${read.join(' or ')} only; the capability holds ${grant.action}`,
643
644
  )
644
645
  }
645
646
  return everything()
@@ -652,6 +653,62 @@ export function appRowPredicate(context: TableContext, capability: unknown, key:
652
653
  return appKeyPredicate(context.model, key)
653
654
  }
654
655
 
656
+ /**
657
+ * What a claim of a queue table leases through `capability`: an application table's every row for an `app` capability,
658
+ * a dimension or child table's rows a `Within` lists. Either way its action is one of the table's update actions.
659
+ */
660
+ export function claimScope(
661
+ context: TableContext,
662
+ capability: unknown,
663
+ ): { readonly grant: AppGrant | WithinGrant; readonly scope: OperationNode | undefined } {
664
+ const model = context.model
665
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
666
+ if (model.queue === undefined) {
667
+ throw new CapabilityError('undeclared_operation', `${model.name} is not a queue table: its policy declares no queue`)
668
+ }
669
+ if (grant.kind === 'app') {
670
+ return { grant: appGrant(context, capability, 'update'), scope: everything() }
671
+ }
672
+ if (grant.kind !== 'within') {
673
+ throw new CapabilityError('wrong_capability', `a claim of ${model.name} needs a Within or an app capability, got ${describeGrant(grant)}`)
674
+ }
675
+ if (model.scope.mode === 'app') {
676
+ throw new CapabilityError('table_kind', `${model.name} is an application table: claim it through ctx.db.app`)
677
+ }
678
+ checkAction(context, 'update', grant.action)
679
+ const column = reachColumn(model, grant.dimension)
680
+ if (column === undefined) {
681
+ throw new CapabilityError(
682
+ 'out_of_reach',
683
+ `a Within of ${grant.dimension} does not reach ${model.name}: its containment chain does not hold ${grant.dimension}`,
684
+ )
685
+ }
686
+ return { grant, scope: withinPredicate(model, column, grant) }
687
+ }
688
+
689
+ /**
690
+ * The one row a claim's `row.ref` names, for an action of `operation`: its key and every containment column it had when
691
+ * it was claimed, so a row moved since is not reached.
692
+ */
693
+ export function claimedRowPredicate(context: TableContext, capability: unknown, operation: 'update' | 'delete'): OperationNode {
694
+ const model = context.model
695
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
696
+ if (grant.kind !== 'row' || grant.table !== model.name) {
697
+ throw new CapabilityError('wrong_capability', `${operation} of a claimed ${model.name} row needs its row.ref, got ${describeGrant(grant)}`)
698
+ }
699
+ checkAction(context, operation, grant.action)
700
+ const terms: OperationNode[] = [equals(model.name, keyOf(model), grant.id)]
701
+ for (let index = 0; index < model.chain.length; index++) {
702
+ const link = model.chain[index]
703
+ const value = link === undefined ? undefined : grant.chain.get(link.column)
704
+ if (link === undefined || value === undefined) {
705
+ throw new CapabilityError('unenforceable', `the row.ref of ${model.name} carries no ${link?.column ?? 'containment'} value`)
706
+ }
707
+ terms[terms.length] = equals(model.name, link.column, value)
708
+ }
709
+ return allOf(terms)
710
+ }
711
+
655
712
  /** The row of an application table `key` names. */
656
713
  export function appKeyPredicate(model: TableModel, key: unknown): OperationNode {
657
714
  if (typeof key !== 'string' || key.length === 0) {
@@ -705,6 +762,8 @@ export interface Runner {
705
762
  read(): Promise<Session>
706
763
  /** Runs `work` in one transaction. */
707
764
  write<T>(work: (transaction: Session) => Promise<T>): Promise<T>
765
+ /** Where file references are made and checked; a store's own runner has none, and its file columns read as ids. */
766
+ readonly files?: FileBinding
708
767
  }
709
768
 
710
769
  /**
@@ -717,6 +776,11 @@ export function fencedWork<T>(runtime: DataLayerRuntime, work: (transaction: Ses
717
776
  return fence === null ? session.transaction(work) : fencedTransaction(session, fence, work)
718
777
  }
719
778
 
779
+ /** The action a capability holds, which its operation's checks already admitted. */
780
+ export function heldAction(context: TableContext, capability: unknown): string {
781
+ return runtimeOf(context.connection).capabilities.resolve(capability).action
782
+ }
783
+
720
784
  function storeRunner(context: TableContext): Runner {
721
785
  return {
722
786
  read: async () => sessionOf(runtimeOf(context.connection)),
@@ -762,6 +826,10 @@ export function readValues(values: unknown, allowed: NameSet, what: 'insert' | '
762
826
  if (value === undefined) {
763
827
  continue
764
828
  }
829
+ if (hasOwn(model.files, key)) {
830
+ copied.set(key, fileValue(model, key, value))
831
+ continue
832
+ }
765
833
  if (value !== null && !isPrimitive(value)) {
766
834
  throw new QueryRefusedError('invalid_argument', `${table}.${key} takes a plain value (string, number, bigint, boolean, Date or null)`)
767
835
  }
@@ -817,8 +885,11 @@ async function selectRows<S>(
817
885
  const run = storeRunner(context)
818
886
  for (let index = 0; index < state.joins.length; index++) {
819
887
  const join = state.joins[index]
820
- if (join !== undefined && join.join.readAction !== action) {
821
- throw new CapabilityError('wrong_action', `joining ${join.join.shape.table} requires ${join.join.readAction}; the capability holds ${action}`)
888
+ if (join !== undefined && !includes(join.join.readActions, action)) {
889
+ throw new CapabilityError(
890
+ 'wrong_action',
891
+ `joining ${join.join.shape.table} requires ${join.join.readActions.join(' or ')}; the capability holds ${action}`,
892
+ )
822
893
  }
823
894
  }
824
895
  if (scope === undefined) {
@@ -849,19 +920,68 @@ function joinedDays(own: NameSet, state: SelectState): NameSet {
849
920
  return nameSet(names)
850
921
  }
851
922
 
852
- /** One row by its scope predicate, with `visible` columns (the table's read action's, unless a capability reads its own). */
853
- export async function selectOne<O>(context: TableContext, run: Runner, scope: OperationNode, visible = context.model.visible): Promise<O> {
923
+ /**
924
+ * One row by its scope predicate, with `visible` columns (the table's read action's, unless a capability reads its own).
925
+ * `action` is what read it: each file column then answers a reference that reads the file only under a read action.
926
+ */
927
+ export async function selectOne<O>(
928
+ context: TableContext,
929
+ run: Runner,
930
+ scope: OperationNode,
931
+ visible = context.model.visible,
932
+ action?: string,
933
+ ): Promise<O> {
854
934
  const start = startSelect(context.shape)
855
935
  const state = finishSelect(start, start)
856
936
  const node = selectNode(state, visible, scope, undefined, context.model.dates)
857
- const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
937
+ const session = await run.read()
938
+ const rows = await session.run<O>(node, guard(context, 'SelectQueryNode', scope))
858
939
  const [row] = answerDays(context.model.name, context.model.dates, rows)
859
940
  if (row === undefined) {
860
941
  throw new NotFoundError(`${context.model.name} not found`)
861
942
  }
943
+ if (action !== undefined) {
944
+ await answerFiles(context.model, run.files, session, [row], action)
945
+ }
862
946
  return row
863
947
  }
864
948
 
949
+ /** The file references an insert wrote, in its transaction: stamped with the scope of the row's own values. */
950
+ async function referenceInserted(
951
+ context: TableContext,
952
+ run: Runner,
953
+ transaction: Session,
954
+ values: ReadonlyMap<string, Primitive | null>,
955
+ ): Promise<void> {
956
+ const model = context.model
957
+ await referenceFiles(model, run.files, transaction, values, async () => {
958
+ const scope = rowScope(model, values)
959
+ if (scope === undefined) {
960
+ throw new QueryRefusedError('unexpected_row', `an insert into ${model.name} carries no value of its top-level scope`)
961
+ }
962
+ return scope
963
+ })
964
+ }
965
+
966
+ /** The file references an update wrote, in its transaction: stamped with the scope the updated row lies in. */
967
+ async function referenceUpdated(
968
+ context: TableContext,
969
+ run: Runner,
970
+ transaction: Session,
971
+ changes: ReadonlyMap<string, Primitive | null>,
972
+ row: unknown,
973
+ ): Promise<void> {
974
+ const model = context.model
975
+ await referenceFiles(model, run.files, transaction, changes, async () => {
976
+ const key = keyOf(model)
977
+ const value: unknown = typeof row === 'object' && row !== null ? ownDescriptor(row, key)?.value : undefined
978
+ if (typeof value !== 'string') {
979
+ throw new QueryRefusedError('unexpected_row', `an update of ${model.name} answered no ${key}`)
980
+ }
981
+ return storedRowScope(model, transaction, key, value)
982
+ })
983
+ }
984
+
865
985
  export function answer(context: TableContext): ReturningNode {
866
986
  return acknowledgement(context.model.name, keyOf(context.model), context.model.version)
867
987
  }
@@ -919,10 +1039,185 @@ export async function insertRow<O>(
919
1039
  throw new QueryRefusedError('unexpected_row', `the insert into ${context.model.name} returned no row`)
920
1040
  }
921
1041
  checkVersion(context, row)
1042
+ await referenceInserted(context, run, transaction, values)
922
1043
  return row
923
1044
  })
924
1045
  }
925
1046
 
1047
+ /**
1048
+ * `insert(values, { onConflict: { columns, do } })`: on a conflict of `columns` (a unique index of the table), `nothing`
1049
+ * writes nothing and `update` sets the columns the table's update action writes, from `updates`.
1050
+ */
1051
+ export interface OnConflict {
1052
+ readonly columns: readonly string[]
1053
+ readonly mode: 'nothing' | 'update'
1054
+ /** The values given for the table's update columns: what `update` sets on the conflicting row. */
1055
+ readonly updates: ReadonlyMap<string, Primitive | null>
1056
+ }
1057
+
1058
+ const CONFLICT_FIELDS = nameSet(['columns', 'do'])
1059
+
1060
+ /**
1061
+ * The `onConflict` of an insert's options, read through own data properties; `undefined` without options. `'update'`
1062
+ * also needs the capability's action to be one of the table's update actions.
1063
+ */
1064
+ export function onConflictOf(
1065
+ context: TableContext,
1066
+ options: unknown,
1067
+ action: string,
1068
+ given: ReadonlyMap<string, Primitive | null>,
1069
+ ): OnConflict | undefined {
1070
+ if (options === undefined) {
1071
+ return undefined
1072
+ }
1073
+ const model = context.model
1074
+ const read = plainOptions(options, nameSet(['onConflict']), 'insert')
1075
+ const input = ownEntry(read, 'onConflict')
1076
+ if (input === undefined) {
1077
+ return undefined
1078
+ }
1079
+ const conflict = plainOptions(input, CONFLICT_FIELDS, 'onConflict')
1080
+ const columns = parseList(ownEntry(conflict, 'columns'), (name) => {
1081
+ if (!inSet(model.columns, name)) {
1082
+ throw new QueryRefusedError('on_conflict', `onConflict names ${String(name)}, which is not a column of ${model.name}`)
1083
+ }
1084
+ return name
1085
+ })
1086
+ if (columns === undefined || columns.length === 0) {
1087
+ throw new QueryRefusedError('on_conflict', 'onConflict.columns takes a plain array of the columns of a unique index')
1088
+ }
1089
+ const mode = ownEntry(conflict, 'do')
1090
+ if (mode !== 'nothing' && mode !== 'update') {
1091
+ throw new QueryRefusedError('on_conflict', "onConflict.do takes 'nothing' or 'update'")
1092
+ }
1093
+ const updates = new Map<string, Primitive | null>()
1094
+ if (mode === 'update') {
1095
+ if (!includes(model.actions.update, action)) {
1096
+ throw new CapabilityError(
1097
+ 'wrong_action',
1098
+ `an insert of ${model.name} that updates on a conflict also needs its update action ${
1099
+ model.actions.update.join(' or ') || '(none)'
1100
+ }; the capability holds ${action}`,
1101
+ )
1102
+ }
1103
+ const columns = namesOf(model.update)
1104
+ for (let index = 0; index < columns.length; index++) {
1105
+ const name = columns[index] ?? ''
1106
+ const value = given.get(name)
1107
+ if (value !== undefined) {
1108
+ updates.set(name, value)
1109
+ }
1110
+ }
1111
+ }
1112
+ return freeze({ columns: freeze(columns), mode, updates })
1113
+ }
1114
+
1115
+ /** A plain object's own data properties, each one of `allowed`; the options of an insert or its `onConflict`. */
1116
+ function plainOptions(value: unknown, allowed: NameSet, what: string): Readonly<Record<string, unknown>> {
1117
+ if (typeof value !== 'object' || value === null || isArray(value)) {
1118
+ throw new QueryRefusedError('invalid_argument', `${what} takes { ${namesOf(allowed).join(', ')} }`)
1119
+ }
1120
+ const prototype: unknown = prototypeOf(value)
1121
+ if ((prototype !== OBJECT_PROTOTYPE && prototype !== null) || ownSymbols(value).length > 0) {
1122
+ throw new QueryRefusedError('invalid_argument', `${what} takes a plain object literal`)
1123
+ }
1124
+ const copy: Record<string, unknown> = {}
1125
+ const keys = ownNames(value)
1126
+ for (let index = 0; index < keys.length; index++) {
1127
+ const key = keys[index] ?? ''
1128
+ const descriptor = ownDescriptor(value, key)
1129
+ if (!inSet(allowed, key)) {
1130
+ throw new QueryRefusedError('invalid_argument', `${key} is not an option of ${what} (options: ${namesOf(allowed).join(', ')})`)
1131
+ }
1132
+ if (descriptor === undefined || !hasOwn(descriptor, 'value')) {
1133
+ throw new QueryRefusedError('invalid_argument', `${what}.${key} is an accessor; ${what} takes plain values`)
1134
+ }
1135
+ setOwn(copy, key, descriptor.value)
1136
+ }
1137
+ return copy
1138
+ }
1139
+
1140
+ /**
1141
+ * The rows an insert's conflict may update: those in the containment the insert stamped (its chain, or a top-level
1142
+ * object's own key), or every row of an application table. A conflicting row outside it is never written.
1143
+ */
1144
+ function insertedScope(context: TableContext, values: ReadonlyMap<string, Primitive | null>): OperationNode {
1145
+ const model = context.model
1146
+ const columns: string[] = []
1147
+ for (let index = 0; index < model.chain.length; index++) {
1148
+ columns[columns.length] = model.chain[index]?.column ?? ''
1149
+ }
1150
+ if (model.scope.mode === 'dimension' && model.scope.container === undefined) {
1151
+ columns[columns.length] = model.scope.key
1152
+ }
1153
+ const terms: OperationNode[] = []
1154
+ for (let index = 0; index < columns.length; index++) {
1155
+ const name = columns[index] ?? ''
1156
+ const value = values.get(name)
1157
+ if (value === undefined || value === null) {
1158
+ throw new QueryRefusedError('on_conflict', `the insert into ${model.name} carries no ${name}`)
1159
+ }
1160
+ terms[terms.length] = equals(model.name, name, value)
1161
+ }
1162
+ return terms.length === 0 ? everything() : allOf(terms)
1163
+ }
1164
+
1165
+ /**
1166
+ * `insert … on conflict (columns) do nothing`, and for `update` the conflicting row's update in the same transaction,
1167
+ * within the insert's own containment. A row deleted between the two is inserted on the second try; a conflicting row
1168
+ * the capability does not reach is a 409. Answers `{ id, version?, inserted: true }`, `{ id, version?, inserted:
1169
+ * false }` after an update, and `{ inserted: false }` when `nothing` wrote nothing.
1170
+ */
1171
+ async function insertOnConflict(
1172
+ context: TableContext,
1173
+ run: Runner,
1174
+ transaction: Session,
1175
+ values: ReadonlyMap<string, Primitive | null>,
1176
+ conflict: OnConflict,
1177
+ ): Promise<Readonly<Record<string, unknown>>> {
1178
+ const model = context.model
1179
+ const matches: OperationNode[] = []
1180
+ for (let index = 0; index < conflict.columns.length; index++) {
1181
+ const name = conflict.columns[index] ?? ''
1182
+ const value = values.get(name)
1183
+ if (value === undefined || value === null) {
1184
+ throw new QueryRefusedError('on_conflict', `onConflict names ${name}, which this insert into ${model.name} leaves empty`)
1185
+ }
1186
+ matches[matches.length] = equals(model.name, name, value)
1187
+ }
1188
+ const scope = conflict.mode === 'update' ? insertedScope(context, values) : undefined
1189
+ for (let attempt = 0; attempt < 2; attempt++) {
1190
+ const node = insertNode(model.name, values, answer(context), conflict.columns)
1191
+ const [row] = await transaction.run<Readonly<Record<string, unknown>>>(node, {
1192
+ ...guard(context, 'InsertQueryNode', undefined),
1193
+ onConflict: 'nothing',
1194
+ })
1195
+ if (row !== undefined) {
1196
+ checkVersion(context, row)
1197
+ await referenceInserted(context, run, transaction, values)
1198
+ return { ...row, inserted: true }
1199
+ }
1200
+ if (scope === undefined) {
1201
+ return { inserted: false }
1202
+ }
1203
+ const where = scopedWhere(allOf(matches), scope)
1204
+ const unchanged = conflict.updates.size === 0
1205
+ const update = unchanged
1206
+ ? acknowledgementNode(model.name, keyOf(model), model.version, where)
1207
+ : updateNode(model.name, conflict.updates, model.version, where, answer(context))
1208
+ const [updated] = await transaction.run<Readonly<Record<string, unknown>>>(
1209
+ update,
1210
+ guard(context, unchanged ? 'SelectQueryNode' : 'UpdateQueryNode', scope),
1211
+ )
1212
+ if (updated !== undefined) {
1213
+ checkVersion(context, updated)
1214
+ await referenceUpdated(context, run, transaction, conflict.updates, updated)
1215
+ return { ...updated, inserted: false }
1216
+ }
1217
+ }
1218
+ throw new ConflictError(`${model.name} holds a row with these ${conflict.columns.join(', ')} that this insert does not reach`)
1219
+ }
1220
+
926
1221
  /**
927
1222
  * The rows a write addresses: the scope predicate, and the caller's conditions beside it (an unscoped row's key, whose
928
1223
  * scope predicate is `true`).
@@ -986,6 +1281,7 @@ export async function updateRow<O>(
986
1281
  const [row] = await transaction.run<O>(node, guard(context, unchanged ? 'SelectQueryNode' : 'UpdateQueryNode', target.scope))
987
1282
  if (row !== undefined) {
988
1283
  checkVersion(context, row)
1284
+ await referenceUpdated(context, run, transaction, changes, row)
989
1285
  return row
990
1286
  }
991
1287
  return missingOrChanged(context, transaction, target, expectedVersion)
@@ -1115,42 +1411,87 @@ function aggregateDays(dates: NameSet, columns: AggregateColumns): NameSet {
1115
1411
  return nameSet(names)
1116
1412
  }
1117
1413
 
1118
- /** `DimensionTable.insert` over a capability nobody type-checked; the table probe of `@fabrika/db/testing` calls it too. */
1119
- export function insertIntoDimension<O>(context: TableContext, run: Runner, target: unknown, values: unknown): Promise<O> {
1414
+ /** An insert's row, stamped from its capability, the destination it locks, and what it does on a conflict. */
1415
+ export interface PreparedInsert {
1416
+ readonly row: Map<string, Primitive | null>
1417
+ readonly destination: RefGrant | undefined
1418
+ readonly conflict: OnConflict | undefined
1419
+ }
1420
+
1421
+ /**
1422
+ * A dimension table's insert over a capability nobody type-checked: into a container (a new UUIDv7 key, the chain from
1423
+ * the container's `Ref`), or for a top-level table the object the `Ref` names.
1424
+ */
1425
+ export function prepareDimensionInsert(context: TableContext, target: unknown, values: unknown, options?: unknown): PreparedInsert {
1120
1426
  const scope = dimensionScope(context.model)
1121
1427
  const grant = refGrant(context, target, listDimension(scope), 'insert')
1122
1428
  const row = readValues(values, context.model.insert, 'insert', context.model)
1429
+ const conflict = onConflictOf(context, options, grant.action, row)
1123
1430
  if (scope.container === undefined) {
1124
1431
  // The object does not exist yet; this only refuses a chain the table has no column for.
1125
1432
  linkValues(context.model.name, [{ dimension: scope.dimension, column: scope.key }], grant)
1126
1433
  stampInsert(context, grant, row, scope.key, grant.id)
1127
- return insertRow(context, run, row, undefined)
1434
+ return { row, destination: undefined, conflict }
1128
1435
  }
1129
1436
  stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
1130
1437
  for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
1131
1438
  row.set(link.column, value)
1132
1439
  }
1133
- return insertRow(context, run, row, grant)
1440
+ return { row, destination: grant, conflict }
1134
1441
  }
1135
1442
 
1136
1443
  /** An application table's insert, for an `app` capability of its insert action: a new key, no containment. */
1137
- export function insertIntoApplication<O>(context: TableContext, run: Runner, capability: unknown, values: unknown): Promise<O> {
1444
+ export function prepareApplicationInsert(context: TableContext, capability: unknown, values: unknown, options?: unknown): PreparedInsert {
1138
1445
  const grant = appGrant(context, capability, 'insert')
1139
1446
  const row = readValues(values, context.model.insert, 'insert', context.model)
1447
+ const conflict = onConflictOf(context, options, grant.action, row)
1140
1448
  stampInsert(context, grant, row, keyOf(context.model), Bun.randomUUIDv7())
1141
- return insertRow(context, run, row, undefined)
1449
+ return { row, destination: undefined, conflict }
1142
1450
  }
1143
1451
 
1144
- /** `ChildTable.insert` over a capability nobody type-checked. */
1145
- export function insertIntoChild<O>(context: TableContext, run: Runner, parent: unknown, values: unknown): Promise<O> {
1452
+ /** A child table's insert under the parent its `Ref` names, whose chain is checked against the database. */
1453
+ export function prepareChildInsert(context: TableContext, parent: unknown, values: unknown, options?: unknown): PreparedInsert {
1146
1454
  const scope = childScope(context)
1147
1455
  const grant = refGrant(context, parent, scope.parent.dimension, 'insert')
1148
1456
  const row = readValues(values, context.model.insert, 'insert', context.model)
1457
+ const conflict = onConflictOf(context, options, grant.action, row)
1149
1458
  stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
1150
1459
  for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
1151
1460
  row.set(link.column, value)
1152
1461
  }
1153
- return insertRow(context, run, row, grant)
1462
+ return { row, destination: grant, conflict }
1463
+ }
1464
+
1465
+ /** A prepared insert, with its conflict handling when the caller named one. */
1466
+ export function insertPrepared(context: TableContext, run: Runner, prepared: PreparedInsert): Promise<Readonly<Record<string, unknown>>> {
1467
+ const conflict = prepared.conflict
1468
+ if (conflict === undefined) {
1469
+ return insertRow(context, run, prepared.row, prepared.destination)
1470
+ }
1471
+ return run.write(async (transaction) => {
1472
+ if (prepared.destination !== undefined) {
1473
+ await lockDestination(transaction, context, prepared.destination)
1474
+ }
1475
+ return insertOnConflict(context, run, transaction, prepared.row, conflict)
1476
+ })
1477
+ }
1478
+
1479
+ /** `DimensionTable.insert` over a capability nobody type-checked; the table probe of `@fabrika/db/testing` calls it too. */
1480
+ export function insertIntoDimension<O>(context: TableContext, run: Runner, target: unknown, values: unknown): Promise<O> {
1481
+ const prepared = prepareDimensionInsert(context, target, values)
1482
+ return insertRow(context, run, prepared.row, prepared.destination)
1483
+ }
1484
+
1485
+ /** An application table's insert, for an `app` capability of its insert action. */
1486
+ export function insertIntoApplication<O>(context: TableContext, run: Runner, capability: unknown, values: unknown): Promise<O> {
1487
+ const prepared = prepareApplicationInsert(context, capability, values)
1488
+ return insertRow(context, run, prepared.row, prepared.destination)
1489
+ }
1490
+
1491
+ /** `ChildTable.insert` over a capability nobody type-checked. */
1492
+ export function insertIntoChild<O>(context: TableContext, run: Runner, parent: unknown, values: unknown): Promise<O> {
1493
+ const prepared = prepareChildInsert(context, parent, values)
1494
+ return insertRow(context, run, prepared.row, prepared.destination)
1154
1495
  }
1155
1496
 
1156
1497
  type ReadAction<P, T> = ActionOf<P, T, 'read'>
@@ -1176,7 +1517,7 @@ export class DimensionTable<DB, P, T extends string> {
1176
1517
  const scope = dimensionListPredicate(context, within, 'read')
1177
1518
  const start = startSelect<Row<DB, P, T>, Joinable<DB, P, T>>(context.shape)
1178
1519
  const state = finishSelect(build(start), start)
1179
- return selectRows<S>(context, scope, state, requiredAction(context, 'read'))
1520
+ return selectRows<S>(context, scope, state, heldAction(context, within))
1180
1521
  }
1181
1522
 
1182
1523
  async get(ref: Ref<DimensionOf<P, T>, ReadAction<P, T>>): Promise<Row<DB, P, T>> {
@@ -1261,7 +1602,7 @@ export class ChildTable<DB, P, T extends string> {
1261
1602
  const predicate = childListPredicate(context, scope, 'read')
1262
1603
  const start = startSelect<Row<DB, P, T>, NoColumns>(context.shape)
1263
1604
  const state = finishSelect(build(start), start)
1264
- return selectRows<S>(context, predicate, state, requiredAction(context, 'read'))
1605
+ return selectRows<S>(context, predicate, state, heldAction(context, scope))
1265
1606
  }
1266
1607
 
1267
1608
  async get(parent: Ref<ParentDimension<P, T>, ReadAction<P, T>>, key: string): Promise<Row<DB, P, T>> {
@@ -1296,6 +1637,37 @@ export class ChildTable<DB, P, T extends string> {
1296
1637
  }
1297
1638
  Object.freeze(ChildTable.prototype)
1298
1639
 
1640
+ /**
1641
+ * The runner of `@fabrika/db/testing`'s probe: a store's own, with a file binding of its own, and `held`, which turns
1642
+ * each stored file's id in a file column of `values` into a reference the probe holds. The probe writes the file the
1643
+ * isolation fixture seeded as a handler writes a reference it read; an id of no stored file stays an id, and is refused.
1644
+ */
1645
+ function probeRunner(context: TableContext): { readonly run: Runner; readonly held: (values: unknown) => Promise<unknown> } {
1646
+ const holder = {}
1647
+ const run: Runner = { ...storeRunner(context), files: { holder, request: 'probe', graceMs: 1, scans: () => false } }
1648
+ const held = async (values: unknown): Promise<unknown> => {
1649
+ const columns = ownKeys(context.model.files)
1650
+ if (columns.length === 0 || typeof values !== 'object' || values === null) {
1651
+ return values
1652
+ }
1653
+ const ids = columns.flatMap((column) => {
1654
+ const value: unknown = ownDescriptor(values, column)?.value
1655
+ return typeof value === 'string' ? [value] : []
1656
+ })
1657
+ const metadata = ids.length === 0 ? new Map() : await storedFilesMetadata(await run.read(), ids)
1658
+ const copy = { ...values }
1659
+ for (const column of columns) {
1660
+ const value: unknown = ownDescriptor(values, column)?.value
1661
+ const meta = typeof value === 'string' ? metadata.get(value) : undefined
1662
+ if (meta !== undefined) {
1663
+ defineValue(copy, column, fileReference(meta, holder, true))
1664
+ }
1665
+ }
1666
+ return copy
1667
+ }
1668
+ return { run, held }
1669
+ }
1670
+
1299
1671
  /**
1300
1672
  * One table's operations without the store types, for `@fabrika/db/testing` only: the generated isolation tests address
1301
1673
  * a table by name. Every runtime check of a store applies; a child row is addressed by its parent's capability and
@@ -1332,12 +1704,9 @@ function probeOf(context: TableContext): TableProbe {
1332
1704
  }
1333
1705
  return child ? childListPredicate(context, capability, 'read') : dimensionListPredicate(context, capability, 'read')
1334
1706
  }
1335
- // An `app` capability reads the columns of its own action, as ctx.db.app does; any other the read action's.
1336
- const visible = (capability: unknown): readonly string[] => {
1337
- const grant = runtimeOf(context.connection).capabilities.resolve(capability)
1338
- return visibleFor(context.model, grant.kind === 'app' ? grant.action : requiredAction(context, 'read'))
1339
- }
1340
- const run = storeRunner(context)
1707
+ // A capability reads the columns of its own action, as ctx.db does.
1708
+ const visible = (capability: unknown): readonly string[] => visibleFor(context.model, heldAction(context, capability))
1709
+ const { run, held } = probeRunner(context)
1341
1710
  return freeze({
1342
1711
  mode: child ? 'child' : 'dimension',
1343
1712
  get: async (capability: unknown, key?: string) => selectOne<Readonly<Record<string, unknown>>>(context, run, object(capability, key, 'read')),
@@ -1347,7 +1716,7 @@ function probeOf(context: TableContext): TableProbe {
1347
1716
  context,
1348
1717
  list(capability, 'rows'),
1349
1718
  finishSelect(start, start),
1350
- requiredAction(context, 'read'),
1719
+ heldAction(context, capability),
1351
1720
  visible(capability),
1352
1721
  )
1353
1722
  },
@@ -1371,10 +1740,12 @@ function probeOf(context: TableContext): TableProbe {
1371
1740
  return typeof count === 'string' ? count : '0'
1372
1741
  },
1373
1742
  insert: async (capability: unknown, values: unknown) =>
1374
- child ? insertIntoChild(context, run, capability, values) : insertIntoDimension(context, run, capability, values),
1743
+ child
1744
+ ? insertIntoChild(context, run, capability, await held(values))
1745
+ : insertIntoDimension(context, run, capability, await held(values)),
1375
1746
  update: async (capability: unknown, key: string | undefined, values: unknown, expectVersion?: number) => {
1376
1747
  const scope = object(capability, key, 'update')
1377
- const changes = readValues(values, context.model.update, 'update', context.model)
1748
+ const changes = readValues(await held(values), context.model.update, 'update', context.model)
1378
1749
  return updateRow(context, run, addressed(scope), changes, versionArgument(context, expectVersion === undefined ? [] : [{ expectVersion }]))
1379
1750
  },
1380
1751
  remove: async (capability: unknown, key?: string) => removeRow(context, run, addressed(object(capability, key, 'delete'))),
@@ -1386,7 +1757,7 @@ function probeOf(context: TableContext): TableProbe {
1386
1757
  * also takes a scoped site's `Ref` or `Within` of the table's read action.
1387
1758
  */
1388
1759
  function appProbeOf(context: TableContext): TableProbe {
1389
- const run = storeRunner(context)
1760
+ const { run, held } = probeRunner(context)
1390
1761
  // A scoped site reads an application table too: the probe answers both, as ctx.db.app does.
1391
1762
  const scoped = (capability: unknown): boolean => runtimeOf(context.connection).capabilities.resolve(capability).kind !== 'app'
1392
1763
  const every = (capability: unknown): OperationNode =>
@@ -1417,7 +1788,7 @@ function appProbeOf(context: TableContext): TableProbe {
1417
1788
  context,
1418
1789
  every(capability),
1419
1790
  finishSelect(start, start),
1420
- requiredAction(context, 'read'),
1791
+ heldAction(context, capability),
1421
1792
  )
1422
1793
  },
1423
1794
  count,
@@ -1426,10 +1797,10 @@ function appProbeOf(context: TableContext): TableProbe {
1426
1797
  groupBy: columns,
1427
1798
  count: true,
1428
1799
  }),
1429
- insert: async (capability: unknown, values: unknown) => insertIntoApplication(context, run, capability, values),
1800
+ insert: async (capability: unknown, values: unknown) => insertIntoApplication(context, run, capability, await held(values)),
1430
1801
  update: async (capability: unknown, key: string | undefined, values: unknown, expectVersion?: number) => {
1431
1802
  const scope = appRowPredicate(context, capability, key, 'update')
1432
- const changes = readValues(values, context.model.update, 'update', context.model)
1803
+ const changes = readValues(await held(values), context.model.update, 'update', context.model)
1433
1804
  return updateRow(context, run, addressed(scope), changes, versionArgument(context, expectVersion === undefined ? [] : [{ expectVersion }]))
1434
1805
  },
1435
1806
  remove: async (capability: unknown, key?: string) => removeRow(context, run, addressed(appRowPredicate(context, capability, key, 'delete'))),
@@ -1477,7 +1848,7 @@ function joinShapes(models: TableModels, parent: TableModel): Readonly<Record<st
1477
1848
  const name = model.scope.inherit[position] ?? ''
1478
1849
  on[on.length] = { childColumn: name, parentColumn: name }
1479
1850
  }
1480
- setOwn(joinable, model.name, freeze({ shape: shapeOf(model, freeze({})), readAction: model.actions.read, on: freeze(on) }))
1851
+ setOwn(joinable, model.name, freeze({ shape: shapeOf(model, freeze({})), readActions: model.actions.read, on: freeze(on) }))
1481
1852
  }
1482
1853
  }
1483
1854
  return freeze(joinable)
@@ -1554,15 +1925,11 @@ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
1554
1925
  connection.runtime = bound
1555
1926
  return async (dimension, id, using) => locateObject(models, bound, dimension, id, using)
1556
1927
  },
1557
- files: fileTables(models, () => runtimeOf(connection)),
1558
1928
  framework: freeze({
1559
- inboxProblems: (form: string, fields: readonly string[]) => inboxProblems(models, form, fields),
1560
- appendToInbox: (form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota, held?: HeldFormFiles) =>
1561
- appendToInbox(models, runtimeOf(connection), form, values, quota, held),
1562
1929
  webhooks: webhookTables(() => runtimeOf(connection)),
1563
1930
  jobs: jobLeases(() => runtimeOf(connection)),
1564
- forms: formAdmissions(() => runtimeOf(connection)),
1565
- formFiles: formFiles(() => runtimeOf(connection)),
1931
+ admissions: admissions(() => runtimeOf(connection)),
1932
+ files: storedFiles(() => runtimeOf(connection)),
1566
1933
  }),
1567
1934
  data: dataAccess({
1568
1935
  models,
@@ -1612,69 +1979,6 @@ export function defineChildStore<DB, P, const T extends ChildTableName<P>, S>(
1612
1979
  return build(new ChildTable(storeContext(database, table, 'child')))
1613
1980
  }
1614
1981
 
1615
- /** The inbox table of `form`: the one table whose scope is `{ inbox: form }`. */
1616
- function inboxOf(models: TableModels, form: string): TableModel | undefined {
1617
- const names = ownKeys(models)
1618
- for (let index = 0; index < names.length; index++) {
1619
- const model = modelOf(models, names[index] ?? '')
1620
- if (model?.scope.mode === 'inbox' && model.scope.form === form) {
1621
- return model
1622
- }
1623
- }
1624
- return undefined
1625
- }
1626
-
1627
- function inboxProblems(models: TableModels, form: string, fields: readonly string[]): readonly string[] {
1628
- const inbox = inboxOf(models, form)
1629
- if (inbox === undefined) {
1630
- return [`no table in src/db/tables.ts is the inbox of '${form}' (scope: { inbox: '${form}', read })`]
1631
- }
1632
- const problems: string[] = []
1633
- for (let index = 0; index < fields.length; index++) {
1634
- const field = fields[index] ?? ''
1635
- if (!inSet(inbox.columns, field)) {
1636
- problems[problems.length] = `the input field '${field}' of '${form}' is not a column of its inbox ${inbox.name}`
1637
- }
1638
- }
1639
- return problems
1640
- }
1641
-
1642
- /**
1643
- * One row of the form's declared fields, taken from the form's `quota` bucket in the same transaction: a row that
1644
- * fails to insert gives its place back. The submission's files become held in that transaction too, so a row that does
1645
- * not commit holds none. `false`, with nothing written, when the bucket is full. An inbox answers nothing, so the form
1646
- * reads nothing back.
1647
- */
1648
- async function appendToInbox(
1649
- models: TableModels,
1650
- runtime: DataLayerRuntime,
1651
- form: string,
1652
- values: Readonly<Record<string, unknown>>,
1653
- quota: InboxQuota,
1654
- held: HeldFormFiles | undefined,
1655
- ): Promise<boolean> {
1656
- const inbox = inboxOf(models, form)
1657
- if (inbox === undefined) {
1658
- throw new QueryRefusedError('unknown_table', `no table is the inbox of ${form}`)
1659
- }
1660
- const row = readValues(values, inbox.columns, 'insert', inbox)
1661
- return await sessionOf(runtime).transaction(async (transaction) => {
1662
- if (!(await takeFromBucket(transaction, form, quota.bucket, quota.limit))) {
1663
- return false
1664
- }
1665
- await transaction.run(insertNode(inbox.name, row, undefined), {
1666
- root: 'InsertQueryNode',
1667
- table: inbox.name,
1668
- joins: {},
1669
- scope: undefined,
1670
- })
1671
- if (held !== undefined) {
1672
- await holdFormFiles(transaction, held)
1673
- }
1674
- return true
1675
- })
1676
- }
1677
-
1678
1982
  /** The table that owns `dimension`: the one dimension table scoped by it (`defineTables` refuses a second). */
1679
1983
  export function ownerOf(models: TableModels, dimension: string): TableModel | undefined {
1680
1984
  const names = ownKeys(models)