@fabrika/db 0.0.0-stage → 0.0.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.
package/src/store.ts ADDED
@@ -0,0 +1,1693 @@
1
+ import { BinaryOperationNode, type OperationNode, OperatorNode, type ReturningNode, ValueNode } from 'kysely'
2
+ import {
3
+ freeze,
4
+ hasOwn,
5
+ inSet,
6
+ isArray,
7
+ isSafeInteger,
8
+ type NameSet,
9
+ nameSet,
10
+ namesOf,
11
+ OBJECT_PROTOTYPE,
12
+ ownDescriptor,
13
+ ownEntry,
14
+ ownKeys,
15
+ ownNames,
16
+ ownSymbols,
17
+ parseList,
18
+ prototypeOf,
19
+ setOwn,
20
+ } from './builtins'
21
+ import {
22
+ type AppGrant,
23
+ type CapabilityAuthority,
24
+ type CapabilityGrant,
25
+ describeGrant,
26
+ MAX_WITHIN_VALUES,
27
+ type Ref,
28
+ type RefGrant,
29
+ type Within,
30
+ type WithinGrant,
31
+ } from './capability'
32
+ import { type Database, InvocationTransaction, type Session, sessionOf } from './database'
33
+ import {
34
+ finishSelect,
35
+ isPrimitive,
36
+ type JoinShape,
37
+ type NoColumns,
38
+ type Primitive,
39
+ type ScopedSelect,
40
+ type SelectState,
41
+ startSelect,
42
+ type TableShape,
43
+ } from './description'
44
+ 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 { webhookTables } from './framework-tables'
48
+ import type { ForeignKeyPair, GuardContext, RootKind } from './guard'
49
+ import { fencedTransaction, jobLeases } from './job-leases'
50
+ import { linksPredicate, linkValues } from './links'
51
+ import {
52
+ acknowledgement,
53
+ acknowledgementNode,
54
+ type AggregateColumns,
55
+ aggregateNode,
56
+ allOf,
57
+ anyOf,
58
+ column,
59
+ deleteNode,
60
+ equals,
61
+ everything,
62
+ existsNode,
63
+ inList,
64
+ insertNode,
65
+ locateNode,
66
+ lockNode,
67
+ scopedWhere,
68
+ selectNode,
69
+ updateNode,
70
+ } from './nodes'
71
+ import { dataAccess } from './orm'
72
+ import {
73
+ type Acknowledgement,
74
+ type ActionOf,
75
+ type ChildTableName,
76
+ type Container,
77
+ type ContainerOf,
78
+ type DimensionOf,
79
+ type DimensionTableName,
80
+ type InsertValues,
81
+ type JoinableChildren,
82
+ type ListDimensionOf,
83
+ modelOf,
84
+ type MoveAcknowledgement,
85
+ namesAction,
86
+ type Operation,
87
+ type ParentOf,
88
+ type PolicyRow,
89
+ type TableModel,
90
+ type TableModels,
91
+ tableModels,
92
+ type Tables,
93
+ updateActions,
94
+ type UpdateValues,
95
+ type VersionArguments,
96
+ visibleFor,
97
+ } from './policy'
98
+ import { frameworkSideOf, type Locator, register } from './registry'
99
+ import type { ScopeTerms, ScopeTest, ScopeUse } from './sql'
100
+ import { type TablePolicies, tablePolicies } from './tables'
101
+ import { answerDays, dayInput, timestampInput } from './temporal'
102
+
103
+ export interface DataLayerRuntime {
104
+ readonly database: Database
105
+ readonly capabilities: CapabilityAuthority
106
+ /** Whether the request running now may only read (an export's); its every statement then runs `READ ONLY`. */
107
+ readonly readOnly?: () => boolean
108
+ }
109
+
110
+ /** Empty until the framework connects the layer at boot; every table handle of one `scoped` call shares it. */
111
+ export interface Connection {
112
+ runtime: DataLayerRuntime | undefined
113
+ }
114
+
115
+ export interface TableContext {
116
+ readonly model: TableModel
117
+ readonly parent: TableModel | undefined
118
+ /** The table that owns the dimension an insert or a move targets: the container's table, or a child's parent. */
119
+ readonly destination: TableModel | undefined
120
+ readonly shape: TableShape
121
+ readonly joins: Readonly<Record<string, readonly ForeignKeyPair[]>>
122
+ readonly connection: Connection
123
+ }
124
+
125
+ export function runtimeOf(connection: Connection): DataLayerRuntime {
126
+ const runtime = connection.runtime
127
+ if (runtime === undefined) {
128
+ throw new CapabilityError(
129
+ 'layer_unbound',
130
+ 'the data layer is not connected: the framework connects it at boot, so a store runs inside a handler, never while a module loads',
131
+ )
132
+ }
133
+ return runtime
134
+ }
135
+
136
+ export interface DimensionScopeModel {
137
+ readonly dimension: string
138
+ readonly key: string
139
+ readonly container: Container | undefined
140
+ }
141
+
142
+ export function dimensionScope(model: TableModel): DimensionScopeModel {
143
+ if (model.scope.mode !== 'dimension') {
144
+ throw new CapabilityError('table_kind', `${model.name} is not a dimension table`)
145
+ }
146
+ return model.scope
147
+ }
148
+
149
+ export function childScope(context: TableContext): { readonly key: string; readonly column: string; readonly parent: DimensionScopeModel } {
150
+ const scope = context.model.scope
151
+ if (scope.mode !== 'child' || context.parent === undefined) {
152
+ throw new CapabilityError('table_kind', `${context.model.name} is not a child table`)
153
+ }
154
+ return { key: scope.key, column: scope.column, parent: dimensionScope(context.parent) }
155
+ }
156
+
157
+ /** The dimension that lists and inserts are scoped by: the container, or for a top-level table its own. */
158
+ export function listDimension(scope: DimensionScopeModel): string {
159
+ return scope.container?.dimension ?? scope.dimension
160
+ }
161
+
162
+ export function requiredAction(context: TableContext, operation: Operation): string {
163
+ const action = context.model.actions[operation]
164
+ if (action === undefined) {
165
+ throw new CapabilityError('undeclared_operation', `${context.model.name} allows no ${operation}; its policy declares no ${operation} action`)
166
+ }
167
+ return action
168
+ }
169
+
170
+ function checkGrant(context: TableContext, grant: RefGrant | WithinGrant, dimension: string, operation: Operation): void {
171
+ const action = requiredAction(context, operation)
172
+ if (grant.dimension !== dimension) {
173
+ throw new CapabilityError(
174
+ 'wrong_capability',
175
+ `${operation} on ${context.model.name} needs a ${grant.kind} of ${dimension}, got a ${grant.kind} of ${grant.dimension}`,
176
+ )
177
+ }
178
+ if (grant.action !== action) {
179
+ throw new CapabilityError('wrong_action', `${operation} on ${context.model.name} requires ${action}; the capability holds ${grant.action}`)
180
+ }
181
+ }
182
+
183
+ function refGrant(context: TableContext, capability: unknown, dimension: string, operation: Operation): RefGrant {
184
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
185
+ if (grant.kind !== 'ref') {
186
+ throw new CapabilityError('wrong_capability', `${operation} on ${context.model.name} needs a Ref of ${dimension}, got ${describeGrant(grant)}`)
187
+ }
188
+ checkGrant(context, grant, dimension, operation)
189
+ return grant
190
+ }
191
+
192
+ function withinGrant(context: TableContext, capability: unknown, dimension: string, operation: Operation): WithinGrant {
193
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
194
+ if (grant.kind !== 'within') {
195
+ throw new CapabilityError('wrong_capability', `${operation} on ${context.model.name} needs a Within of ${dimension}, got ${describeGrant(grant)}`)
196
+ }
197
+ checkGrant(context, grant, dimension, operation)
198
+ return grant
199
+ }
200
+
201
+ /** The chain links that lie above the `Within`'s own dimension: the only columns a container grant may filter on. */
202
+ function linksAbove(model: TableModel, dimension: string): readonly Container[] {
203
+ for (let index = 0; index < model.chain.length; index++) {
204
+ if (model.chain[index]?.dimension === dimension) {
205
+ const above: Container[] = []
206
+ for (let position = index + 1; position < model.chain.length; position++) {
207
+ const link = model.chain[position]
208
+ if (link !== undefined) {
209
+ above[above.length] = link
210
+ }
211
+ }
212
+ return above
213
+ }
214
+ }
215
+ return []
216
+ }
217
+
218
+ /**
219
+ * `column in (values) or <container column> in (container values) …`, one disjunct per container dimension, so
220
+ * the statement itself judges each row by the chain it carries now. Undefined for an empty `Within`: the caller
221
+ * answers `[]` without a query.
222
+ */
223
+ function withinPredicate(model: TableModel, column: string, grant: WithinGrant): OperationNode | undefined {
224
+ if (grant.values === 'all') {
225
+ return everything()
226
+ }
227
+ const above = linksAbove(model, grant.dimension)
228
+ const predicates: OperationNode[] = []
229
+ let count = grant.values.length
230
+ if (grant.values.length > 0) {
231
+ predicates[predicates.length] = inList(model.name, column, grant.values)
232
+ }
233
+ for (let index = 0; index < grant.containers.length; index++) {
234
+ const container = grant.containers[index]
235
+ if (container === undefined) {
236
+ continue
237
+ }
238
+ let link: Container | undefined
239
+ for (let position = 0; position < above.length; position++) {
240
+ if (above[position]?.dimension === container.dimension) {
241
+ link = above[position]
242
+ }
243
+ }
244
+ if (link === undefined) {
245
+ throw new CapabilityError(
246
+ 'unenforceable',
247
+ `the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
248
+ )
249
+ }
250
+ count += container.values.length
251
+ if (container.values.length > 0) {
252
+ predicates[predicates.length] = inList(model.name, link.column, container.values)
253
+ }
254
+ }
255
+ if (count > MAX_WITHIN_VALUES) {
256
+ throw new CapabilityError('within_size', `a Within carries at most ${MAX_WITHIN_VALUES} scope values; this one carries ${count}`)
257
+ }
258
+ return predicates.length === 0 ? undefined : anyOf(predicates)
259
+ }
260
+
261
+ /** The object a Ref of the model's own dimension names, with every link of its chain. */
262
+ export function selfLinks(model: TableModel): readonly Container[] {
263
+ const scope = dimensionScope(model)
264
+ return [{ dimension: scope.dimension, column: scope.key }, ...model.chain]
265
+ }
266
+
267
+ /** This object, over the whole chain the Ref carries. */
268
+ export function objectPredicate(context: TableContext, capability: unknown, operation: Operation): OperationNode {
269
+ const scope = dimensionScope(context.model)
270
+ const grant = refGrant(context, capability, scope.dimension, operation)
271
+ return linksPredicate(context.model.name, linkValues(context.model.name, selfLinks(context.model), grant))
272
+ }
273
+
274
+ /** Rows of a dimension table within the scope values of its list dimension. */
275
+ export function dimensionListPredicate(context: TableContext, capability: unknown, operation: Operation): OperationNode | undefined {
276
+ const scope = dimensionScope(context.model)
277
+ const grant = withinGrant(context, capability, listDimension(scope), operation)
278
+ return withinPredicate(context.model, scope.container?.column ?? scope.key, grant)
279
+ }
280
+
281
+ /** Rows of a child table that belong to the parent object the Ref names, over the whole chain. */
282
+ export function parentPredicate(context: TableContext, grant: RefGrant): OperationNode {
283
+ return linksPredicate(context.model.name, linkValues(context.model.name, context.model.chain, grant))
284
+ }
285
+
286
+ export function childListPredicate(context: TableContext, capability: unknown, operation: Operation): OperationNode | undefined {
287
+ const scope = childScope(context)
288
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
289
+ if (grant.kind === 'ref') {
290
+ checkGrant(context, grant, scope.parent.dimension, operation)
291
+ return parentPredicate(context, grant)
292
+ }
293
+ if (grant.kind === 'app') {
294
+ throw new CapabilityError('wrong_capability', `a list of ${context.model.name} by its parent needs a Ref or a Within, got an app capability`)
295
+ }
296
+ checkGrant(context, grant, listDimension(scope.parent), operation)
297
+ return withinPredicate(context.model, scope.parent.container?.column ?? scope.column, grant)
298
+ }
299
+
300
+ /**
301
+ * The column that places a row of `model` in `dimension`: the column of that link of its containment chain, or a
302
+ * top-level table's own key; `undefined` when no `Within` of the dimension reaches the table.
303
+ */
304
+ export function reachColumn(model: TableModel, dimension: string): string | undefined {
305
+ if (model.scope.mode === 'dimension' && model.scope.container === undefined && model.scope.dimension === dimension) {
306
+ return model.scope.key
307
+ }
308
+ for (let index = 0; index < model.chain.length; index++) {
309
+ const link = model.chain[index]
310
+ if (link?.dimension === dimension) {
311
+ return link.column
312
+ }
313
+ }
314
+ return undefined
315
+ }
316
+
317
+ /**
318
+ * Rows of a table a `Within` of any dimension of its chain lists, by the table's read action: tasks under a `Within` of
319
+ * `org` through their stamped `org_id`, as under one of `project` through `project_id`.
320
+ */
321
+ export function reachPredicate(context: TableContext, capability: unknown): OperationNode | undefined {
322
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
323
+ const table = context.model.name
324
+ if (grant.kind !== 'within') {
325
+ throw new CapabilityError('wrong_capability', `a list of ${table} needs a Within, got ${describeGrant(grant)}`)
326
+ }
327
+ const action = requiredAction(context, 'read')
328
+ if (grant.action !== action) {
329
+ throw new CapabilityError('wrong_action', `read on ${table} requires ${action}; the capability holds ${grant.action}`)
330
+ }
331
+ const column = reachColumn(context.model, grant.dimension)
332
+ if (column === undefined) {
333
+ throw new CapabilityError(
334
+ 'out_of_reach',
335
+ `a Within of ${grant.dimension} does not reach ${table}: its containment chain does not hold ${grant.dimension}`,
336
+ )
337
+ }
338
+ return withinPredicate(context.model, column, grant)
339
+ }
340
+
341
+ /**
342
+ * A fragment on an UPDATE or DELETE target: the capability's action is that operation's, and an application table is
343
+ * written by an `app` capability only, a dimension or child table never by one.
344
+ */
345
+ function checkWriteTarget(context: TableContext, grant: CapabilityGrant, use: 'update' | 'delete'): void {
346
+ const model = context.model
347
+ const statement = `${use === 'update' ? 'UPDATE' : 'DELETE'} of ${model.name}`
348
+ if (grant.kind === 'app' && model.scope.mode !== 'app') {
349
+ throw new CapabilityError('table_kind', `an app capability writes application tables only: ${statement} needs a Ref or a Within`)
350
+ }
351
+ if (grant.kind !== 'app' && model.scope.mode === 'app') {
352
+ throw new CapabilityError('table_kind', `a scoped site writes no application table: ${statement} needs the capability of an app endpoint`)
353
+ }
354
+ const action = model.actions[use]
355
+ if (action === undefined) {
356
+ throw new CapabilityError('undeclared_operation', `${statement}: its policy declares no ${use} action`)
357
+ }
358
+ if (grant.action !== action) {
359
+ throw new CapabilityError('wrong_action', `${statement} needs its ${use} action ${action}; the capability holds ${grant.action}`)
360
+ }
361
+ }
362
+
363
+ /**
364
+ * `ctx.sql.scope`'s predicate: what the handles of `capability` match in the table for `use`, as column tests. A
365
+ * fragment that reads (`'read'`) needs an action the handles read the table with: any action it names for a `Ref`,
366
+ * its read action for a `Within`, an action `ctx.db.app` lists it for with an `app` capability, and its read action
367
+ * for a scoped site's read of an application table. A fragment on the statement's UPDATE or DELETE target needs that
368
+ * operation's own action, and an `app` capability writes application tables only. A `Ref` matches its object's column
369
+ * and every link of the chain it carries; a `Within` matches like `ctx.db.within` (its values, each container grant on
370
+ * its own column, or every row for `all`); an `app` capability, and a scoped read of an application table, every row.
371
+ */
372
+ export function scopeTerms(context: TableContext, capability: unknown, use: ScopeUse): ScopeTerms {
373
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
374
+ const model = context.model
375
+ if (use !== 'read') {
376
+ checkWriteTarget(context, grant, use)
377
+ }
378
+ if (grant.kind === 'app') {
379
+ if (use === 'read') {
380
+ // The action is held as a global grant, so its scope is every row of a table it reaches.
381
+ checkAppReach(context, grant)
382
+ }
383
+ return 'all'
384
+ }
385
+ if (model.scope.mode === 'app') {
386
+ scopedApplicationRead(context, capability)
387
+ return 'all'
388
+ }
389
+ if (grant.kind === 'ref') {
390
+ const links = model.scope.mode === 'dimension' ? selfLinks(model) : model.chain
391
+ let start = -1
392
+ for (let index = 0; index < links.length && start === -1; index++) {
393
+ if (links[index]?.dimension === grant.dimension) start = index
394
+ }
395
+ if (start === -1) {
396
+ throw new CapabilityError(
397
+ 'out_of_reach',
398
+ `a Ref of ${grant.dimension} does not reach ${model.name}: its containment chain does not hold ${grant.dimension}`,
399
+ )
400
+ }
401
+ if (use === 'read' && !namesAction(model, grant.action)) {
402
+ throw new CapabilityError('wrong_action', `${model.name} does not name the action ${grant.action}: a Ref reads it with an action it names`)
403
+ }
404
+ return [linkValues(model.name, links.slice(start), grant).map(({ link, value }) => ({ column: link.column, value }))]
405
+ }
406
+ const column = reachColumn(model, grant.dimension)
407
+ if (column === undefined) {
408
+ throw new CapabilityError(
409
+ 'out_of_reach',
410
+ `a Within of ${grant.dimension} does not reach ${model.name}: its containment chain does not hold ${grant.dimension}`,
411
+ )
412
+ }
413
+ if (use === 'read' && grant.action !== model.actions.read) {
414
+ throw new CapabilityError(
415
+ 'wrong_action',
416
+ `a Within of ${grant.action} does not read ${model.name}: it reads with its read action ${model.actions.read}`,
417
+ )
418
+ }
419
+ if (grant.values === 'all') {
420
+ return 'all'
421
+ }
422
+ const above = linksAbove(model, grant.dimension)
423
+ const groups: ScopeTest[][] = []
424
+ let count = grant.values.length
425
+ if (grant.values.length > 0) {
426
+ groups.push([{ column, value: grant.values }])
427
+ }
428
+ for (const container of grant.containers) {
429
+ const link = above.find((candidate) => candidate.dimension === container.dimension)
430
+ if (link === undefined) {
431
+ throw new CapabilityError(
432
+ 'unenforceable',
433
+ `the Within of ${grant.dimension} reaches through ${container.dimension}, which ${model.name} carries no column for above ${grant.dimension}; the layer cannot enforce it`,
434
+ )
435
+ }
436
+ count += container.values.length
437
+ if (container.values.length > 0) {
438
+ groups.push([{ column: link.column, value: container.values }])
439
+ }
440
+ }
441
+ if (count > MAX_WITHIN_VALUES) {
442
+ throw new CapabilityError('within_size', `a Within carries at most ${MAX_WITHIN_VALUES} scope values; this one carries ${count}`)
443
+ }
444
+ return groups
445
+ }
446
+
447
+ /**
448
+ * A `Ref` of `dimension` whose action updates columns of the table: its update action, or a column's own write action.
449
+ * Which columns it updates follows from the action (`updatableBy`).
450
+ */
451
+ export function updatingRef(context: TableContext, capability: unknown, dimension: string): RefGrant {
452
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
453
+ const table = context.model.name
454
+ if (grant.kind !== 'ref') {
455
+ throw new CapabilityError('wrong_capability', `update on ${table} needs a Ref of ${dimension}, got ${describeGrant(grant)}`)
456
+ }
457
+ if (grant.dimension !== dimension) {
458
+ throw new CapabilityError('wrong_capability', `update on ${table} needs a ref of ${dimension}, got a ref of ${grant.dimension}`)
459
+ }
460
+ const actions = updateActions(context.model)
461
+ if (actions.length === 0) {
462
+ throw new CapabilityError('undeclared_operation', `${table} allows no update; its policy declares no update action`)
463
+ }
464
+ for (let index = 0; index < actions.length; index++) {
465
+ if (actions[index] === grant.action) {
466
+ return grant
467
+ }
468
+ }
469
+ throw new CapabilityError('wrong_action', `update on ${table} requires ${actions.join(' or ')}; the capability holds ${grant.action}`)
470
+ }
471
+
472
+ /**
473
+ * What an `app` capability reaches of a dimension or child table: its rows (`list`, a scope fragment) with the read
474
+ * action or an action of `appRead`; its counts (`count`, `aggregate`) with those or an action of `appCount`.
475
+ */
476
+ export type AppReach = 'rows' | 'counts'
477
+
478
+ /** Refuses an `app` capability whose action does not reach `reach` of a dimension or child table. */
479
+ function checkRecordReach(context: TableContext, grant: AppGrant, reach: AppReach): void {
480
+ const model = context.model
481
+ const { read, appRead, appCount } = model.actions
482
+ if (grant.action === read || includesName(appRead, grant.action) || (reach === 'counts' && includesName(appCount, grant.action))) {
483
+ return
484
+ }
485
+ if (includesName(appCount, grant.action)) {
486
+ throw new CapabilityError(
487
+ 'wrong_action',
488
+ `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}${
489
+ appRead.length === 0 ? '' : ` or one of its appRead (${appRead.join(', ')})`
490
+ }`,
491
+ )
492
+ }
493
+ throw new CapabilityError(
494
+ 'wrong_action',
495
+ `an app capability of ${grant.action} does not reach ${model.name}: ctx.db.app lists it for its read action ${read}${
496
+ appRead.length === 0 ? '' : ` or one of its appRead (${appRead.join(', ')})`
497
+ } only`,
498
+ )
499
+ }
500
+
501
+ function includesName(names: readonly string[], name: string): boolean {
502
+ for (let index = 0; index < names.length; index++) {
503
+ if (names[index] === name) {
504
+ return true
505
+ }
506
+ }
507
+ return false
508
+ }
509
+
510
+ /**
511
+ * Refuses a table `ctx.db.app` does not list for the capability (`applicationTables` in `orm.ts`): it lists an
512
+ * application table under any action the table names, and the rows of a dimension or child table under its read
513
+ * action or an action of its `appRead`.
514
+ */
515
+ function checkAppReach(context: TableContext, grant: AppGrant): void {
516
+ const model = context.model
517
+ const mode = model.scope.mode
518
+ if (mode === 'app') {
519
+ if (!namesAction(model, grant.action)) {
520
+ throw new CapabilityError(
521
+ 'wrong_action',
522
+ `an app capability of ${grant.action} does not reach ${model.name}: ${model.name} does not name the action`,
523
+ )
524
+ }
525
+ return
526
+ }
527
+ if (mode !== 'dimension' && mode !== 'child') {
528
+ throw new CapabilityError(
529
+ 'table_kind',
530
+ `an app capability does not reach ${model.name}: ctx.db.app lists application, dimension and child tables only`,
531
+ )
532
+ }
533
+ checkRecordReach(context, grant, 'rows')
534
+ }
535
+
536
+ /**
537
+ * Every row of a dimension or child table an `app` capability reads through `ctx.db.app`: `true`, for an action that
538
+ * reaches `reach` (`checkRecordReach`).
539
+ */
540
+ export function appRecordPredicate(context: TableContext, capability: unknown, reach: AppReach): OperationNode {
541
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
542
+ const model = context.model
543
+ if (grant.kind !== 'app') {
544
+ throw new CapabilityError(
545
+ 'wrong_capability',
546
+ `a read of ${model.name} through ctx.db.app needs the capability of an app endpoint, got ${describeGrant(grant)}`,
547
+ )
548
+ }
549
+ if (model.scope.mode !== 'dimension' && model.scope.mode !== 'child') {
550
+ throw new CapabilityError('table_kind', `${model.name} is not a dimension or child table`)
551
+ }
552
+ checkRecordReach(context, grant, reach)
553
+ return everything()
554
+ }
555
+
556
+ /** An `app` capability addresses a row by its key, and writes, only in an application table. */
557
+ function checkApplicationTable(context: TableContext, operation: Operation): void {
558
+ if (context.model.scope.mode !== 'app') {
559
+ throw new CapabilityError(
560
+ 'table_kind',
561
+ `${operation} of a ${context.model.name} row needs a Ref: an app capability addresses and writes application tables only`,
562
+ )
563
+ }
564
+ }
565
+
566
+ /**
567
+ * The capability of an `app` endpoint for `operation` on `table`: its action is that operation's. An application table
568
+ * is written with it, and a dimension or child table read with it.
569
+ */
570
+ function appGrant(context: TableContext, capability: unknown, operation: Operation): AppGrant {
571
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
572
+ const table = context.model.name
573
+ if (grant.kind !== 'app') {
574
+ throw new CapabilityError('wrong_capability', `${operation} on ${table} here needs the capability of an app endpoint, got ${describeGrant(grant)}`)
575
+ }
576
+ if (operation !== 'read') {
577
+ checkApplicationTable(context, operation)
578
+ }
579
+ checkAppReach(context, grant)
580
+ const action = requiredAction(context, operation)
581
+ if (grant.action !== action) {
582
+ throw new CapabilityError('wrong_action', `${operation} on ${table} requires ${action}; the capability holds ${grant.action}`)
583
+ }
584
+ return grant
585
+ }
586
+
587
+ /** Every row a capability of an `app` endpoint reads in `table`, by the table's read action: `true`. */
588
+ export function appListPredicate(context: TableContext, capability: unknown): OperationNode {
589
+ appGrant(context, capability, 'read')
590
+ return everything()
591
+ }
592
+
593
+ /**
594
+ * Every row of an application table, for a `Ref` or a `Within` of its read action: what `ctx.db.app` of an `on`, `in`
595
+ * or `over` endpoint reads, read-only, as a reference table is read. The table's read action decides who reads it.
596
+ */
597
+ export function scopedApplicationRead(context: TableContext, capability: unknown): OperationNode {
598
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
599
+ const model = context.model
600
+ if (model.scope.mode !== 'app') {
601
+ throw new CapabilityError('table_kind', `${model.name} is not an application table: a scoped site reads it through its own handles`)
602
+ }
603
+ if (grant.kind === 'app') {
604
+ throw new CapabilityError('wrong_capability', `a read of ${model.name} from a scoped site needs a Ref or a Within, got ${describeGrant(grant)}`)
605
+ }
606
+ const read = requiredAction(context, 'read')
607
+ if (grant.action !== read) {
608
+ throw new CapabilityError(
609
+ 'wrong_action',
610
+ `a scoped site reads ${model.name} with its read action ${read} only; the capability holds ${grant.action}`,
611
+ )
612
+ }
613
+ return everything()
614
+ }
615
+
616
+ /** One row of an application table by its key, for an `app` capability of `operation`'s action. */
617
+ export function appRowPredicate(context: TableContext, capability: unknown, key: unknown, operation: Operation): OperationNode {
618
+ checkApplicationTable(context, operation)
619
+ appGrant(context, capability, operation)
620
+ return appKeyPredicate(context.model, key)
621
+ }
622
+
623
+ /** The row of an application table `key` names. */
624
+ export function appKeyPredicate(model: TableModel, key: unknown): OperationNode {
625
+ if (typeof key !== 'string' || key.length === 0) {
626
+ throw new QueryRefusedError('row_address', `a ${model.name} row is addressed by its ${keyOf(model)}, a non-empty string`)
627
+ }
628
+ return equals(model.name, keyOf(model), key)
629
+ }
630
+
631
+ /**
632
+ * The capability of an `app` endpoint whose action updates columns of an application table: its update action, or a
633
+ * column's own write action. Which columns it updates follows from the action (`updatableBy`).
634
+ */
635
+ export function updatingApp(context: TableContext, capability: unknown): AppGrant {
636
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
637
+ const table = context.model.name
638
+ if (grant.kind !== 'app') {
639
+ throw new CapabilityError('wrong_capability', `update on ${table} needs the capability of an app endpoint, got ${describeGrant(grant)}`)
640
+ }
641
+ checkApplicationTable(context, 'update')
642
+ const actions = updateActions(context.model)
643
+ if (actions.length === 0) {
644
+ throw new CapabilityError('undeclared_operation', `${table} allows no update; its policy declares no update action`)
645
+ }
646
+ for (let index = 0; index < actions.length; index++) {
647
+ if (actions[index] === grant.action) {
648
+ return grant
649
+ }
650
+ }
651
+ throw new CapabilityError('wrong_action', `update on ${table} requires ${actions.join(' or ')}; the capability holds ${grant.action}`)
652
+ }
653
+
654
+ export function childObjectPredicate(context: TableContext, parent: unknown, key: unknown, operation: Operation): OperationNode {
655
+ const scope = childScope(context)
656
+ if (typeof key !== 'string' || key.length === 0) {
657
+ throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its parent Ref and its ${scope.key}`)
658
+ }
659
+ const grant = refGrant(context, parent, scope.parent.dimension, operation)
660
+ return allOf([equals(context.model.name, scope.key, key), parentPredicate(context, grant)])
661
+ }
662
+
663
+ export function guard(context: TableContext, root: RootKind, scope: OperationNode | undefined): GuardContext {
664
+ return { root, table: context.model.name, joins: root === 'SelectQueryNode' ? context.joins : {}, scope }
665
+ }
666
+
667
+ /**
668
+ * Where a table operation's statements run: a store's own sessions, one transaction per mutation, or the mini-ORM's
669
+ * invocation transaction (`orm.ts`), where every statement of a handler runs in one.
670
+ */
671
+ export interface Runner {
672
+ /** The session a read runs in. */
673
+ read(): Promise<Session>
674
+ /** Runs `work` in one transaction. */
675
+ write<T>(work: (transaction: Session) => Promise<T>): Promise<T>
676
+ }
677
+
678
+ /**
679
+ * Every mutation of a store runs in its own transaction. In a job attempt that transaction is fenced by the attempt's
680
+ * lease (`fencedTransaction`): an attempt that lost it, or ran out of time, writes nothing.
681
+ */
682
+ export function fencedWork<T>(runtime: DataLayerRuntime, work: (transaction: Session) => Promise<T>): Promise<T> {
683
+ const fence = runtime.capabilities.fence()
684
+ const session = sessionOf(runtime)
685
+ return fence === null ? session.transaction(work) : fencedTransaction(session, fence, work)
686
+ }
687
+
688
+ function storeRunner(context: TableContext): Runner {
689
+ return {
690
+ read: async () => sessionOf(runtimeOf(context.connection)),
691
+ write: (work) => fencedWork(runtimeOf(context.connection), work),
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Reads insert or update data through its own property descriptors, so no getter runs and no inherited or
697
+ * non-enumerable key slips past. Every key is checked before any value is read; unknown keys are refused, not
698
+ * dropped, so an agent learns about them.
699
+ */
700
+ export function readValues(values: unknown, allowed: NameSet, what: 'insert' | 'update', model: TableModel): Map<string, Primitive | null> {
701
+ const table = model.name
702
+ if (typeof values !== 'object' || values === null || isArray(values)) {
703
+ throw new QueryRefusedError('invalid_argument', `${what} takes an object of column values`)
704
+ }
705
+ const prototype: unknown = prototypeOf(values)
706
+ if (prototype !== OBJECT_PROTOTYPE && prototype !== null) {
707
+ throw new QueryRefusedError('invalid_argument', `${what} takes a plain object literal, not an instance of a class`)
708
+ }
709
+ if (ownSymbols(values).length > 0) {
710
+ throw new QueryRefusedError('invalid_argument', `${what} takes column names as keys, not symbols`)
711
+ }
712
+ const keys = ownNames(values)
713
+ const descriptors: PropertyDescriptor[] = []
714
+ for (let index = 0; index < keys.length; index++) {
715
+ const key = keys[index] ?? ''
716
+ const descriptor = ownDescriptor(values, key)
717
+ if (descriptor === undefined || !hasOwn(descriptor, 'value')) {
718
+ throw new QueryRefusedError('invalid_argument', `${table}.${key} is an accessor; ${what} takes plain values`)
719
+ }
720
+ if (!inSet(allowed, key)) {
721
+ const columns = namesOf(allowed).join(', ') || 'none'
722
+ throw new QueryRefusedError('unknown_column', `${key} is not an ${what} column of ${table} (${what} columns: ${columns})`)
723
+ }
724
+ descriptors[index] = descriptor
725
+ }
726
+ const copied = new Map<string, Primitive | null>()
727
+ for (let index = 0; index < keys.length; index++) {
728
+ const key = keys[index] ?? ''
729
+ const value: unknown = descriptors[index]?.value
730
+ if (value === undefined) {
731
+ continue
732
+ }
733
+ if (value !== null && !isPrimitive(value)) {
734
+ throw new QueryRefusedError('invalid_argument', `${table}.${key} takes a plain value (string, number, bigint, boolean, Date or null)`)
735
+ }
736
+ copied.set(key, typeof value === 'string' ? temporalText(model, key, value) : value)
737
+ }
738
+ return copied
739
+ }
740
+
741
+ /** A day's or a timestamp's text, checked before it is bound; any other column's text as it is. */
742
+ function temporalText(model: TableModel, column: string, value: string): string {
743
+ if (inSet(model.dates, column)) {
744
+ return dayInput(value, `${model.name}.${column}`)
745
+ }
746
+ return inSet(model.timestamps, column) ? timestampInput(value, `${model.name}.${column}`) : value
747
+ }
748
+
749
+ function stampInsert(
750
+ context: TableContext,
751
+ grant: { readonly principal: string },
752
+ values: Map<string, Primitive | null>,
753
+ key: string,
754
+ keyValue: string,
755
+ ): void {
756
+ values.set(key, keyValue)
757
+ if (context.model.version !== undefined) {
758
+ values.set(context.model.version, 1)
759
+ }
760
+ if (context.model.createdBy !== undefined) {
761
+ values.set(context.model.createdBy, grant.principal)
762
+ }
763
+ }
764
+
765
+ export function versionArgument(context: TableContext, options: readonly unknown[]): OperationNode | undefined {
766
+ const version = context.model.version
767
+ if (version === undefined) {
768
+ return undefined
769
+ }
770
+ const first = options[0]
771
+ const expected: unknown = typeof first === 'object' && first !== null ? Reflect.get(first, 'expectVersion') : undefined
772
+ if (typeof expected !== 'number' || !isSafeInteger(expected)) {
773
+ throw new QueryRefusedError('version_required', `${context.model.name} has a version column; update and remove take { expectVersion: number }`)
774
+ }
775
+ return BinaryOperationNode.create(column(context.model.name, version), OperatorNode.create('='), ValueNode.create(expected))
776
+ }
777
+
778
+ async function selectRows<S>(
779
+ context: TableContext,
780
+ scope: OperationNode | undefined,
781
+ state: SelectState,
782
+ action: string,
783
+ visible: readonly string[] = context.model.visible,
784
+ ): Promise<S[]> {
785
+ const run = storeRunner(context)
786
+ for (let index = 0; index < state.joins.length; index++) {
787
+ const join = state.joins[index]
788
+ if (join !== undefined && join.join.readAction !== action) {
789
+ throw new CapabilityError('wrong_action', `joining ${join.join.shape.table} requires ${join.join.readAction}; the capability holds ${action}`)
790
+ }
791
+ }
792
+ if (scope === undefined) {
793
+ return []
794
+ }
795
+ const node = selectNode(state, visible, scope, undefined, context.model.dates)
796
+ const rows = await (await run.read()).run<S>(node, guard(context, 'SelectQueryNode', scope))
797
+ return answerDays(context.model.name, joinedDays(context.model.dates, state), rows)
798
+ }
799
+
800
+ /** The `date` columns of a select's rows: the table's own, and each joined child's under its `<child>.<column>` alias. */
801
+ function joinedDays(own: NameSet, state: SelectState): NameSet {
802
+ if (state.joins.length === 0) {
803
+ return own
804
+ }
805
+ const names: string[] = []
806
+ const ownDates = namesOf(own)
807
+ for (let index = 0; index < ownDates.length; index++) {
808
+ names[names.length] = ownDates[index] ?? ''
809
+ }
810
+ for (let index = 0; index < state.joins.length; index++) {
811
+ const shape = state.joins[index]?.join.shape
812
+ const dates = shape === undefined ? [] : namesOf(shape.dates)
813
+ for (let position = 0; position < dates.length; position++) {
814
+ names[names.length] = `${shape?.table}.${dates[position]}`
815
+ }
816
+ }
817
+ return nameSet(names)
818
+ }
819
+
820
+ /** One row by its scope predicate, with `visible` columns (the table's read action's, unless a capability reads its own). */
821
+ export async function selectOne<O>(context: TableContext, run: Runner, scope: OperationNode, visible = context.model.visible): Promise<O> {
822
+ const start = startSelect(context.shape)
823
+ const state = finishSelect(start, start)
824
+ const node = selectNode(state, visible, scope, undefined, context.model.dates)
825
+ const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
826
+ const [row] = answerDays(context.model.name, context.model.dates, rows)
827
+ if (row === undefined) {
828
+ throw new NotFoundError(`${context.model.name} not found`)
829
+ }
830
+ return row
831
+ }
832
+
833
+ export function answer(context: TableContext): ReturningNode {
834
+ return acknowledgement(context.model.name, keyOf(context.model), context.model.version)
835
+ }
836
+
837
+ /** Throws inside the mutation's transaction, so a table whose version is not an INTEGER keeps nothing written. */
838
+ function checkVersion(context: TableContext, row: unknown): void {
839
+ const version = context.model.version
840
+ if (version === undefined) {
841
+ return
842
+ }
843
+ const value: unknown = typeof row === 'object' && row !== null ? Reflect.get(row, 'version') : undefined
844
+ if (typeof value !== 'number' || !isSafeInteger(value)) {
845
+ throw new PolicyError(
846
+ `${context.model.name}.${version} came back as ${typeof value}; a version column must be INTEGER (int4), which Postgres returns as a number`,
847
+ )
848
+ }
849
+ }
850
+
851
+ export function keyOf(model: TableModel): string {
852
+ if (model.scope.mode !== 'dimension' && model.scope.mode !== 'child' && model.scope.mode !== 'app') {
853
+ throw new CapabilityError('undeclared_operation', `${model.name} is not written by handlers`)
854
+ }
855
+ return model.scope.key
856
+ }
857
+
858
+ /**
859
+ * Locks the destination row `for share` and checks that it still lies where the Ref's chain says. Without it, a
860
+ * Ref minted before its object moved would insert into, or move rows into, the object's new container.
861
+ */
862
+ async function lockDestination(transaction: Session, context: TableContext, grant: RefGrant): Promise<void> {
863
+ const owner = context.destination
864
+ if (owner === undefined) {
865
+ return
866
+ }
867
+ const scope = linksPredicate(owner.name, linkValues(owner.name, selfLinks(owner), grant))
868
+ const node = lockNode(owner.name, keyOf(owner), scope)
869
+ const rows = await transaction.run(node, { root: 'SelectQueryNode', table: owner.name, joins: {}, scope, lock: 'share' })
870
+ if (rows.length === 0) {
871
+ throw new NotFoundError(`${owner.name} ${grant.id} is not where its Ref says; obtain a fresh Ref`)
872
+ }
873
+ }
874
+
875
+ export async function insertRow<O>(
876
+ context: TableContext,
877
+ run: Runner,
878
+ values: ReadonlyMap<string, Primitive | null>,
879
+ destination: RefGrant | undefined,
880
+ ): Promise<O> {
881
+ return run.write(async (transaction) => {
882
+ if (destination !== undefined) {
883
+ await lockDestination(transaction, context, destination)
884
+ }
885
+ const [row] = await transaction.run<O>(insertNode(context.model.name, values, answer(context)), guard(context, 'InsertQueryNode', undefined))
886
+ if (row === undefined) {
887
+ throw new QueryRefusedError('unexpected_row', `the insert into ${context.model.name} returned no row`)
888
+ }
889
+ checkVersion(context, row)
890
+ return row
891
+ })
892
+ }
893
+
894
+ /**
895
+ * The rows a write addresses: the scope predicate, and the caller's conditions beside it (an unscoped row's key, whose
896
+ * scope predicate is `true`).
897
+ */
898
+ export interface Addressed {
899
+ readonly scope: OperationNode
900
+ readonly conditions?: OperationNode
901
+ }
902
+
903
+ function addressed(scope: OperationNode): Addressed {
904
+ return { scope }
905
+ }
906
+
907
+ function conditionsWith(target: Addressed, expectedVersion: OperationNode | undefined): OperationNode | undefined {
908
+ if (target.conditions === undefined) {
909
+ return expectedVersion
910
+ }
911
+ return expectedVersion === undefined ? target.conditions : allOf([target.conditions, expectedVersion])
912
+ }
913
+
914
+ /** A zero-row write under `expectVersion`: 409 when the row is still there, 404 when it is gone. */
915
+ async function missingOrChanged(
916
+ context: TableContext,
917
+ transaction: Session,
918
+ target: Addressed,
919
+ expectedVersion: OperationNode | undefined,
920
+ ): Promise<never> {
921
+ const table = context.model.name
922
+ if (expectedVersion !== undefined) {
923
+ const node = existsNode(table, keyOf(context.model), target.scope, target.conditions)
924
+ const existing = await transaction.run(node, guard(context, 'SelectQueryNode', target.scope))
925
+ if (existing.length > 0) {
926
+ throw new ConflictError(`${table} was changed by someone else; read it again and retry`)
927
+ }
928
+ }
929
+ throw new NotFoundError(`${table} not found`)
930
+ }
931
+
932
+ /**
933
+ * Zero changed rows → one follow-up read in the same transaction → 404 when the object is gone, else 409. An update
934
+ * that sets no column writes nothing, not even the version: it reads the acknowledgement under the same conditions.
935
+ */
936
+ export async function updateRow<O>(
937
+ context: TableContext,
938
+ run: Runner,
939
+ target: Addressed,
940
+ changes: ReadonlyMap<string, Primitive | null>,
941
+ expectedVersion: OperationNode | undefined,
942
+ destination?: RefGrant,
943
+ ): Promise<O> {
944
+ const { name: table, version } = context.model
945
+ const where = scopedWhere(conditionsWith(target, expectedVersion), target.scope)
946
+ const unchanged = changes.size === 0
947
+ const node = unchanged
948
+ ? acknowledgementNode(table, keyOf(context.model), version, where)
949
+ : updateNode(table, changes, version, where, answer(context))
950
+ return run.write(async (transaction) => {
951
+ if (destination !== undefined) {
952
+ await lockDestination(transaction, context, destination)
953
+ }
954
+ const [row] = await transaction.run<O>(node, guard(context, unchanged ? 'SelectQueryNode' : 'UpdateQueryNode', target.scope))
955
+ if (row !== undefined) {
956
+ checkVersion(context, row)
957
+ return row
958
+ }
959
+ return missingOrChanged(context, transaction, target, expectedVersion)
960
+ })
961
+ }
962
+
963
+ /** Like `updateRow`: under `expectVersion`, a row that changed is 409 and a missing one 404. */
964
+ export async function removeRow(context: TableContext, run: Runner, target: Addressed, expectedVersion?: OperationNode): Promise<void> {
965
+ const node = deleteNode(context.model.name, scopedWhere(conditionsWith(target, expectedVersion), target.scope), keyOf(context.model))
966
+ await run.write(async (transaction) => {
967
+ const rows = await transaction.run(node, guard(context, 'DeleteQueryNode', target.scope))
968
+ if (rows.length === 0) {
969
+ await missingOrChanged(context, transaction, target, expectedVersion)
970
+ }
971
+ })
972
+ }
973
+
974
+ type Columns<Row> = keyof Row & string
975
+ type NumericColumn<Row> = { [K in Columns<Row>]: Exclude<Row[K], null> extends number | bigint | string ? K : never }[Columns<Row>]
976
+
977
+ export interface AggregateSpec<Row> {
978
+ readonly groupBy?: readonly Columns<Row>[]
979
+ readonly count?: boolean
980
+ readonly sum?: readonly NumericColumn<Row>[]
981
+ readonly min?: readonly Columns<Row>[]
982
+ readonly max?: readonly Columns<Row>[]
983
+ readonly where?: (q: ScopedSelect<Row, Row, NoColumns>) => ScopedSelect<Row, unknown, NoColumns>
984
+ }
985
+
986
+ type Listed<S, K extends 'groupBy' | 'sum' | 'min' | 'max'> = S extends { readonly [P in K]: readonly (infer C extends string)[] } ? C : never
987
+ type Flat<T> = { [K in keyof T]: T[K] }
988
+
989
+ /**
990
+ * `count` is a string because Postgres returns a bigint. A sum is a string for integer and numeric columns and a
991
+ * number for floating-point ones; the column type alone cannot tell them apart.
992
+ */
993
+ export type AggregateRow<Row, S> = Flat<
994
+ & Pick<Row, Listed<S, 'groupBy'> & keyof Row>
995
+ & (S extends { readonly count: true } ? { readonly count: string } : NoColumns)
996
+ & { readonly [K in Listed<S, 'sum'> as `sum_${K}`]: string | number | null }
997
+ & { readonly [K in Listed<S, 'min'> & keyof Row as `min_${K}`]: Row[K] | null }
998
+ & { readonly [K in Listed<S, 'max'> & keyof Row as `max_${K}`]: Row[K] | null }
999
+ >
1000
+
1001
+ /** The aggregate spec as it arrives at runtime, where the types may have been defeated. */
1002
+ interface AggregateInput {
1003
+ readonly groupBy?: unknown
1004
+ readonly count?: unknown
1005
+ readonly sum?: unknown
1006
+ readonly min?: unknown
1007
+ readonly max?: unknown
1008
+ }
1009
+
1010
+ function aggregateColumns(table: string, shape: TableShape, spec: AggregateInput): AggregateColumns {
1011
+ const list = (field: string, value: unknown): string[] => {
1012
+ if (value === undefined) {
1013
+ return []
1014
+ }
1015
+ const names = parseList(value, (name) => {
1016
+ if (!inSet(shape.visible, name)) {
1017
+ throw new QueryRefusedError('unknown_column', `${String(name)} is not a column of ${table} (columns: ${namesOf(shape.visible).join(', ')})`)
1018
+ }
1019
+ return name
1020
+ })
1021
+ if (names === undefined) {
1022
+ throw new QueryRefusedError('invalid_argument', `aggregate ${field} takes a plain array of column names`)
1023
+ }
1024
+ return names
1025
+ }
1026
+ const count = spec.count === true
1027
+ const columns = { groupBy: list('groupBy', spec.groupBy), count, sum: list('sum', spec.sum), min: list('min', spec.min), max: list('max', spec.max) }
1028
+ if (!count && columns.sum.length === 0 && columns.min.length === 0 && columns.max.length === 0) {
1029
+ throw new QueryRefusedError('invalid_argument', 'aggregate needs count, sum, min or max')
1030
+ }
1031
+ return columns
1032
+ }
1033
+
1034
+ function aggregateFilter<Row>(shape: TableShape, where: AggregateSpec<Row>['where']): SelectState | undefined {
1035
+ if (where === undefined) {
1036
+ return undefined
1037
+ }
1038
+ if (typeof where !== 'function') {
1039
+ throw new QueryRefusedError('invalid_argument', 'aggregate where takes a callback')
1040
+ }
1041
+ const start = startSelect<Row, NoColumns>(shape)
1042
+ const state = finishSelect(where(start), start)
1043
+ if (state.columns !== undefined || state.orderBy.length > 0 || state.limit !== undefined || state.offset !== undefined || state.joins.length > 0) {
1044
+ throw new QueryRefusedError('invalid_argument', 'aggregate where takes where conditions only')
1045
+ }
1046
+ return state
1047
+ }
1048
+
1049
+ export async function aggregateRows<Row, O>(
1050
+ context: TableContext,
1051
+ run: Runner,
1052
+ scope: OperationNode | undefined,
1053
+ shape: TableShape,
1054
+ spec: AggregateSpec<Row>,
1055
+ ): Promise<O[]> {
1056
+ if (typeof spec !== 'object' || spec === null) {
1057
+ throw new QueryRefusedError('invalid_argument', 'aggregate takes { groupBy, count, sum, min, max, where }')
1058
+ }
1059
+ const columns = aggregateColumns(context.model.name, shape, spec)
1060
+ const filter = aggregateFilter(shape, spec.where)
1061
+ if (scope === undefined) {
1062
+ return []
1063
+ }
1064
+ const node = aggregateNode(context.model.name, columns, filter?.conditions ?? [], scope, context.model.dates)
1065
+ const rows = await (await run.read()).run<O>(node, guard(context, 'SelectQueryNode', scope))
1066
+ return answerDays(context.model.name, aggregateDays(context.model.dates, columns), rows)
1067
+ }
1068
+
1069
+ /** The `date` columns of an aggregate's rows: a grouped one by its name, and its `min_` and `max_`. */
1070
+ function aggregateDays(dates: NameSet, columns: AggregateColumns): NameSet {
1071
+ const names: string[] = []
1072
+ const add = (prefix: string, list: readonly string[]) => {
1073
+ for (let index = 0; index < list.length; index++) {
1074
+ const name = list[index] ?? ''
1075
+ if (inSet(dates, name)) {
1076
+ names[names.length] = `${prefix}${name}`
1077
+ }
1078
+ }
1079
+ }
1080
+ add('', columns.groupBy)
1081
+ add('min_', columns.min)
1082
+ add('max_', columns.max)
1083
+ return nameSet(names)
1084
+ }
1085
+
1086
+ /** `DimensionTable.insert` over a capability nobody type-checked; the table probe of `@fabrika/db/testing` calls it too. */
1087
+ export function insertIntoDimension<O>(context: TableContext, run: Runner, target: unknown, values: unknown): Promise<O> {
1088
+ const scope = dimensionScope(context.model)
1089
+ const grant = refGrant(context, target, listDimension(scope), 'insert')
1090
+ const row = readValues(values, context.model.insert, 'insert', context.model)
1091
+ if (scope.container === undefined) {
1092
+ // The object does not exist yet; this only refuses a chain the table has no column for.
1093
+ linkValues(context.model.name, [{ dimension: scope.dimension, column: scope.key }], grant)
1094
+ stampInsert(context, grant, row, scope.key, grant.id)
1095
+ return insertRow(context, run, row, undefined)
1096
+ }
1097
+ stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
1098
+ for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
1099
+ row.set(link.column, value)
1100
+ }
1101
+ return insertRow(context, run, row, grant)
1102
+ }
1103
+
1104
+ /** An application table's insert, for an `app` capability of its insert action: a new key, no containment. */
1105
+ export function insertIntoApplication<O>(context: TableContext, run: Runner, capability: unknown, values: unknown): Promise<O> {
1106
+ const grant = appGrant(context, capability, 'insert')
1107
+ const row = readValues(values, context.model.insert, 'insert', context.model)
1108
+ stampInsert(context, grant, row, keyOf(context.model), Bun.randomUUIDv7())
1109
+ return insertRow(context, run, row, undefined)
1110
+ }
1111
+
1112
+ /** `ChildTable.insert` over a capability nobody type-checked. */
1113
+ export function insertIntoChild<O>(context: TableContext, run: Runner, parent: unknown, values: unknown): Promise<O> {
1114
+ const scope = childScope(context)
1115
+ const grant = refGrant(context, parent, scope.parent.dimension, 'insert')
1116
+ const row = readValues(values, context.model.insert, 'insert', context.model)
1117
+ stampInsert(context, grant, row, scope.key, Bun.randomUUIDv7())
1118
+ for (const { link, value } of linkValues(context.model.name, context.model.chain, grant)) {
1119
+ row.set(link.column, value)
1120
+ }
1121
+ return insertRow(context, run, row, grant)
1122
+ }
1123
+
1124
+ type ReadAction<P, T> = ActionOf<P, T, 'read'>
1125
+ type Row<DB, P, T> = PolicyRow<DB, P, T>
1126
+ type Joinable<DB, P, T> = JoinableChildren<DB, P, T>
1127
+ type ParentDimension<P, T> = DimensionOf<P, ParentOf<P, T>>
1128
+
1129
+ /** The table handle of a dimension table: every entry point takes a `Ref` or a `Within`. */
1130
+ export class DimensionTable<DB, P, T extends string> {
1131
+ readonly #context: TableContext
1132
+ readonly #types: { readonly db?: DB; readonly policies?: P; readonly table?: T } = {}
1133
+
1134
+ constructor(context: TableContext) {
1135
+ this.#context = context
1136
+ Object.freeze(this)
1137
+ }
1138
+
1139
+ async select<S>(
1140
+ within: Within<ListDimensionOf<P, T>, ReadAction<P, T>>,
1141
+ build: (q: ScopedSelect<Row<DB, P, T>, Row<DB, P, T>, Joinable<DB, P, T>>) => ScopedSelect<Row<DB, P, T>, S, Joinable<DB, P, T>>,
1142
+ ): Promise<S[]> {
1143
+ const context = this.#context
1144
+ const scope = dimensionListPredicate(context, within, 'read')
1145
+ const start = startSelect<Row<DB, P, T>, Joinable<DB, P, T>>(context.shape)
1146
+ const state = finishSelect(build(start), start)
1147
+ return selectRows<S>(context, scope, state, requiredAction(context, 'read'))
1148
+ }
1149
+
1150
+ async get(ref: Ref<DimensionOf<P, T>, ReadAction<P, T>>): Promise<Row<DB, P, T>> {
1151
+ return selectOne(this.#context, storeRunner(this.#context), objectPredicate(this.#context, ref, 'read'))
1152
+ }
1153
+
1154
+ /**
1155
+ * Into a container (the key is a new UUIDv7, the chain comes from the container's Ref and is checked against
1156
+ * the database), or for a top-level table the object the `Ref` names. Answers an acknowledgement, not the row.
1157
+ */
1158
+ async insert(target: Ref<ListDimensionOf<P, T>, ActionOf<P, T, 'insert'>>, values: InsertValues<DB, P, T>): Promise<Acknowledgement<P, T>> {
1159
+ return insertIntoDimension(this.#context, storeRunner(this.#context), target, values)
1160
+ }
1161
+
1162
+ async update(
1163
+ ref: Ref<DimensionOf<P, T>, ActionOf<P, T, 'update'>>,
1164
+ values: UpdateValues<DB, P, T>,
1165
+ ...options: VersionArguments<P, T>
1166
+ ): Promise<Acknowledgement<P, T>> {
1167
+ const context = this.#context
1168
+ const scope = objectPredicate(context, ref, 'update')
1169
+ const changes = readValues(values, context.model.update, 'update', context.model)
1170
+ return updateRow(context, storeRunner(context), addressed(scope), changes, versionArgument(context, options))
1171
+ }
1172
+
1173
+ async remove(ref: Ref<DimensionOf<P, T>, ActionOf<P, T, 'delete'>>): Promise<void> {
1174
+ const context = this.#context
1175
+ await removeRow(context, storeRunner(context), addressed(objectPredicate(context, ref, 'delete')))
1176
+ }
1177
+
1178
+ /**
1179
+ * Moves the object to another container: the delete action at the source, the insert action at the target,
1180
+ * whose chain is checked against the database. Every chain column changes; children that copy them follow
1181
+ * through `ON UPDATE CASCADE` on their composite foreign key. Answers the acknowledgement and the new chain.
1182
+ */
1183
+ async move(
1184
+ ref: Ref<DimensionOf<P, T>, ActionOf<P, T, 'delete'>>,
1185
+ to: Ref<ContainerOf<P, T>, ActionOf<P, T, 'insert'>>,
1186
+ ): Promise<MoveAcknowledgement<P, T>> {
1187
+ const context = this.#context
1188
+ const container = dimensionScope(context.model).container
1189
+ if (container === undefined) {
1190
+ throw new CapabilityError('table_kind', `${context.model.name} has no container to move between`)
1191
+ }
1192
+ const scope = objectPredicate(context, ref, 'delete')
1193
+ const target = refGrant(context, to, container.dimension, 'insert')
1194
+ const values = linkValues(context.model.name, context.model.chain, target)
1195
+ const changes = new Map<string, Primitive | null>(values.map(({ link, value }) => [link.column, value]))
1196
+ const acknowledged = await updateRow<Acknowledgement<P, T>>(context, storeRunner(context), addressed(scope), changes, undefined, target)
1197
+ return { ...acknowledged, chain: Object.fromEntries(values.map(({ link, value }) => [link.dimension, value])) }
1198
+ }
1199
+
1200
+ async aggregate<const S extends AggregateSpec<Row<DB, P, T>>>(
1201
+ within: Within<ListDimensionOf<P, T>, ReadAction<P, T>>,
1202
+ spec: S,
1203
+ ): Promise<AggregateRow<Row<DB, P, T>, S>[]> {
1204
+ return aggregateRows(this.#context, storeRunner(this.#context), dimensionListPredicate(this.#context, within, 'read'), this.#context.shape, spec)
1205
+ }
1206
+ }
1207
+ Object.freeze(DimensionTable.prototype)
1208
+
1209
+ /** The scope a child list is reached through: one parent object, or the parent's list dimension. */
1210
+ export type ChildListScope<P, T> =
1211
+ | Ref<ParentDimension<P, T>, ReadAction<P, T>>
1212
+ | Within<ListDimensionOf<P, ParentOf<P, T>>, ReadAction<P, T>>
1213
+
1214
+ /** The table handle of a child table: one row is addressed by its parent's `Ref` and its own key. */
1215
+ export class ChildTable<DB, P, T extends string> {
1216
+ readonly #context: TableContext
1217
+ readonly #types: { readonly db?: DB; readonly policies?: P; readonly table?: T } = {}
1218
+
1219
+ constructor(context: TableContext) {
1220
+ this.#context = context
1221
+ Object.freeze(this)
1222
+ }
1223
+
1224
+ async select<S>(
1225
+ scope: ChildListScope<P, T>,
1226
+ build: (q: ScopedSelect<Row<DB, P, T>, Row<DB, P, T>, NoColumns>) => ScopedSelect<Row<DB, P, T>, S, NoColumns>,
1227
+ ): Promise<S[]> {
1228
+ const context = this.#context
1229
+ const predicate = childListPredicate(context, scope, 'read')
1230
+ const start = startSelect<Row<DB, P, T>, NoColumns>(context.shape)
1231
+ const state = finishSelect(build(start), start)
1232
+ return selectRows<S>(context, predicate, state, requiredAction(context, 'read'))
1233
+ }
1234
+
1235
+ async get(parent: Ref<ParentDimension<P, T>, ReadAction<P, T>>, key: string): Promise<Row<DB, P, T>> {
1236
+ return selectOne(this.#context, storeRunner(this.#context), childObjectPredicate(this.#context, parent, key, 'read'))
1237
+ }
1238
+
1239
+ /** Under the parent the Ref names, whose chain is checked against the database. Answers an acknowledgement. */
1240
+ async insert(parent: Ref<ParentDimension<P, T>, ActionOf<P, T, 'insert'>>, values: InsertValues<DB, P, T>): Promise<Acknowledgement<P, T>> {
1241
+ return insertIntoChild(this.#context, storeRunner(this.#context), parent, values)
1242
+ }
1243
+
1244
+ async update(
1245
+ parent: Ref<ParentDimension<P, T>, ActionOf<P, T, 'update'>>,
1246
+ key: string,
1247
+ values: UpdateValues<DB, P, T>,
1248
+ ...options: VersionArguments<P, T>
1249
+ ): Promise<Acknowledgement<P, T>> {
1250
+ const context = this.#context
1251
+ const scope = childObjectPredicate(context, parent, key, 'update')
1252
+ const changes = readValues(values, context.model.update, 'update', context.model)
1253
+ return updateRow(context, storeRunner(context), addressed(scope), changes, versionArgument(context, options))
1254
+ }
1255
+
1256
+ async remove(parent: Ref<ParentDimension<P, T>, ActionOf<P, T, 'delete'>>, key: string): Promise<void> {
1257
+ const context = this.#context
1258
+ await removeRow(context, storeRunner(context), addressed(childObjectPredicate(context, parent, key, 'delete')))
1259
+ }
1260
+
1261
+ async aggregate<const S extends AggregateSpec<Row<DB, P, T>>>(scope: ChildListScope<P, T>, spec: S): Promise<AggregateRow<Row<DB, P, T>, S>[]> {
1262
+ return aggregateRows(this.#context, storeRunner(this.#context), childListPredicate(this.#context, scope, 'read'), this.#context.shape, spec)
1263
+ }
1264
+ }
1265
+ Object.freeze(ChildTable.prototype)
1266
+
1267
+ /**
1268
+ * One table's operations without the store types, for `@fabrika/db/testing` only: the generated isolation tests address
1269
+ * a table by name. Every runtime check of a store applies; a child row is addressed by its parent's capability and
1270
+ * `key`, a dimension row by its capability alone.
1271
+ */
1272
+ export interface TableProbe {
1273
+ readonly mode: 'dimension' | 'child' | 'app'
1274
+ get(capability: unknown, key?: string): Promise<Readonly<Record<string, unknown>>>
1275
+ select(capability: unknown): Promise<readonly Readonly<Record<string, unknown>>[]>
1276
+ /** `aggregate(capability, { count: true })`; zero rows when the capability reaches none. */
1277
+ count(capability: unknown): Promise<string>
1278
+ /** `aggregate(capability, { groupBy: columns, count: true })`: the values of `columns` in each group, and its count. */
1279
+ group(capability: unknown, columns: readonly string[]): Promise<readonly Readonly<Record<string, unknown>>[]>
1280
+ insert(capability: unknown, values: unknown): Promise<unknown>
1281
+ update(capability: unknown, key: string | undefined, values: unknown, expectVersion?: number): Promise<unknown>
1282
+ remove(capability: unknown, key?: string): Promise<void>
1283
+ }
1284
+
1285
+ function probeOf(context: TableContext): TableProbe {
1286
+ const child = context.model.scope.mode === 'child'
1287
+ const object = (capability: unknown, key: string | undefined, operation: Operation): OperationNode => {
1288
+ if (child) {
1289
+ return childObjectPredicate(context, capability, key, operation)
1290
+ }
1291
+ if (key !== undefined) {
1292
+ throw new QueryRefusedError('row_address', `a ${context.model.name} row is addressed by its Ref alone`)
1293
+ }
1294
+ return objectPredicate(context, capability, operation)
1295
+ }
1296
+ // An `app` capability reads every row through ctx.db.app: its rows with appRead, its counts with appCount too.
1297
+ const list = (capability: unknown, reach: AppReach): OperationNode | undefined => {
1298
+ if (runtimeOf(context.connection).capabilities.resolve(capability).kind === 'app') {
1299
+ return appRecordPredicate(context, capability, reach)
1300
+ }
1301
+ return child ? childListPredicate(context, capability, 'read') : dimensionListPredicate(context, capability, 'read')
1302
+ }
1303
+ // An `app` capability reads the columns of its own action, as ctx.db.app does; any other the read action's.
1304
+ const visible = (capability: unknown): readonly string[] => {
1305
+ const grant = runtimeOf(context.connection).capabilities.resolve(capability)
1306
+ return visibleFor(context.model, grant.kind === 'app' ? grant.action : requiredAction(context, 'read'))
1307
+ }
1308
+ const run = storeRunner(context)
1309
+ return freeze({
1310
+ mode: child ? 'child' : 'dimension',
1311
+ get: async (capability: unknown, key?: string) => selectOne<Readonly<Record<string, unknown>>>(context, run, object(capability, key, 'read')),
1312
+ select: async (capability: unknown) => {
1313
+ const start = startSelect<Readonly<Record<string, unknown>>, NoColumns>(context.shape)
1314
+ return selectRows<Readonly<Record<string, unknown>>>(
1315
+ context,
1316
+ list(capability, 'rows'),
1317
+ finishSelect(start, start),
1318
+ requiredAction(context, 'read'),
1319
+ visible(capability),
1320
+ )
1321
+ },
1322
+ group: async (capability: unknown, columns: readonly string[]) =>
1323
+ aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(
1324
+ context,
1325
+ run,
1326
+ list(capability, 'rows'),
1327
+ actionShape(context.model, visible(capability)),
1328
+ { groupBy: columns, count: true },
1329
+ ),
1330
+ count: async (capability: unknown) => {
1331
+ const [row] = await aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(
1332
+ context,
1333
+ run,
1334
+ list(capability, 'counts'),
1335
+ context.shape,
1336
+ { count: true },
1337
+ )
1338
+ const count = row === undefined ? '0' : ownEntry(row, 'count')
1339
+ return typeof count === 'string' ? count : '0'
1340
+ },
1341
+ insert: async (capability: unknown, values: unknown) =>
1342
+ child ? insertIntoChild(context, run, capability, values) : insertIntoDimension(context, run, capability, values),
1343
+ update: async (capability: unknown, key: string | undefined, values: unknown, expectVersion?: number) => {
1344
+ const scope = object(capability, key, 'update')
1345
+ const changes = readValues(values, context.model.update, 'update', context.model)
1346
+ return updateRow(context, run, addressed(scope), changes, versionArgument(context, expectVersion === undefined ? [] : [{ expectVersion }]))
1347
+ },
1348
+ remove: async (capability: unknown, key?: string) => removeRow(context, run, addressed(object(capability, key, 'delete'))),
1349
+ })
1350
+ }
1351
+
1352
+ /**
1353
+ * The probe of an application table: every operation takes an `app` capability and, but for an insert, a key; a read
1354
+ * also takes a scoped site's `Ref` or `Within` of the table's read action.
1355
+ */
1356
+ function appProbeOf(context: TableContext): TableProbe {
1357
+ const run = storeRunner(context)
1358
+ // A scoped site reads an application table too: the probe answers both, as ctx.db.app does.
1359
+ const scoped = (capability: unknown): boolean => runtimeOf(context.connection).capabilities.resolve(capability).kind !== 'app'
1360
+ const every = (capability: unknown): OperationNode =>
1361
+ scoped(capability) ? scopedApplicationRead(context, capability) : appListPredicate(context, capability)
1362
+ const count = async (capability: unknown): Promise<string> => {
1363
+ const [row] = await aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(
1364
+ context,
1365
+ run,
1366
+ every(capability),
1367
+ context.shape,
1368
+ { count: true },
1369
+ )
1370
+ const value = row === undefined ? '0' : ownEntry(row, 'count')
1371
+ return typeof value === 'string' ? value : '0'
1372
+ }
1373
+ return freeze({
1374
+ mode: 'app',
1375
+ get: async (capability: unknown, key?: string) => {
1376
+ if (scoped(capability)) {
1377
+ scopedApplicationRead(context, capability)
1378
+ return selectOne<Readonly<Record<string, unknown>>>(context, run, appKeyPredicate(context.model, key))
1379
+ }
1380
+ return selectOne<Readonly<Record<string, unknown>>>(context, run, appRowPredicate(context, capability, key, 'read'))
1381
+ },
1382
+ select: async (capability: unknown) => {
1383
+ const start = startSelect<Readonly<Record<string, unknown>>, NoColumns>(context.shape)
1384
+ return selectRows<Readonly<Record<string, unknown>>>(
1385
+ context,
1386
+ every(capability),
1387
+ finishSelect(start, start),
1388
+ requiredAction(context, 'read'),
1389
+ )
1390
+ },
1391
+ count,
1392
+ group: async (capability: unknown, columns: readonly string[]) =>
1393
+ aggregateRows<Readonly<Record<string, unknown>>, Readonly<Record<string, unknown>>>(context, run, every(capability), context.shape, {
1394
+ groupBy: columns,
1395
+ count: true,
1396
+ }),
1397
+ insert: async (capability: unknown, values: unknown) => insertIntoApplication(context, run, capability, values),
1398
+ update: async (capability: unknown, key: string | undefined, values: unknown, expectVersion?: number) => {
1399
+ const scope = appRowPredicate(context, capability, key, 'update')
1400
+ const changes = readValues(values, context.model.update, 'update', context.model)
1401
+ return updateRow(context, run, addressed(scope), changes, versionArgument(context, expectVersion === undefined ? [] : [{ expectVersion }]))
1402
+ },
1403
+ remove: async (capability: unknown, key?: string) => removeRow(context, run, addressed(appRowPredicate(context, capability, key, 'delete'))),
1404
+ })
1405
+ }
1406
+
1407
+ function destinationOf(models: TableModels, model: TableModel, parent: TableModel | undefined): TableModel | undefined {
1408
+ if (model.scope.mode === 'child') {
1409
+ return parent
1410
+ }
1411
+ const container = model.scope.mode === 'dimension' ? model.scope.container : undefined
1412
+ if (container === undefined) {
1413
+ return undefined
1414
+ }
1415
+ const names = ownKeys(models)
1416
+ for (let index = 0; index < names.length; index++) {
1417
+ const candidate = modelOf(models, names[index] ?? '')
1418
+ if (candidate?.scope.mode === 'dimension' && candidate.scope.dimension === container.dimension) {
1419
+ return candidate
1420
+ }
1421
+ }
1422
+ return undefined
1423
+ }
1424
+
1425
+ /** What a select or an aggregate of a capability that reads `visible` may name: those columns, and no join. */
1426
+ export function actionShape(model: TableModel, visible: readonly string[]): TableShape {
1427
+ return freeze({ table: model.name, visible: nameSet(visible), joinable: freeze({}), dates: model.dates })
1428
+ }
1429
+
1430
+ function shapeOf(model: TableModel, joinable: Readonly<Record<string, JoinShape>>): TableShape {
1431
+ return freeze({ table: model.name, visible: nameSet(model.visible), joinable, dates: model.dates })
1432
+ }
1433
+
1434
+ function joinShapes(models: TableModels, parent: TableModel): Readonly<Record<string, JoinShape>> {
1435
+ const joinable: Record<string, JoinShape> = {}
1436
+ if (parent.scope.mode !== 'dimension') {
1437
+ return freeze(joinable)
1438
+ }
1439
+ const names = ownKeys(models)
1440
+ for (let index = 0; index < names.length; index++) {
1441
+ const model = modelOf(models, names[index] ?? '')
1442
+ if (model?.scope.mode === 'child' && model.scope.parent === parent.name) {
1443
+ const on: ForeignKeyPair[] = [{ childColumn: model.scope.column, parentColumn: parent.scope.key }]
1444
+ for (let position = 0; position < model.scope.inherit.length; position++) {
1445
+ const name = model.scope.inherit[position] ?? ''
1446
+ on[on.length] = { childColumn: name, parentColumn: name }
1447
+ }
1448
+ setOwn(joinable, model.name, freeze({ shape: shapeOf(model, freeze({})), readAction: model.actions.read, on: freeze(on) }))
1449
+ }
1450
+ }
1451
+ return freeze(joinable)
1452
+ }
1453
+
1454
+ /** What `serve(contract, { database })` takes: the policies as data. `boot` binds only an object `scoped` created. */
1455
+ export interface DataLayer {
1456
+ /** Every table's policy as data, for the contract compiler. */
1457
+ readonly tables: TablePolicies
1458
+ }
1459
+
1460
+ declare const layerTypes: unique symbol
1461
+
1462
+ /**
1463
+ * What `scoped` returns: the policies, typed by the tables they came from. It has no connection, no locator and no
1464
+ * handle of its own; `boot` binds it through `@fabrika/db/framework`, which only `@fabrika/app` imports, and a
1465
+ * handler reaches data through `ctx.db`. The brand is type-only: no value has the property.
1466
+ */
1467
+ export interface ScopedDatabase<DB, P> extends DataLayer {
1468
+ readonly [layerTypes]?: { readonly db: DB; readonly policies: P }
1469
+ }
1470
+
1471
+ /**
1472
+ * Binds the table policies to the data layer. An application calls it once, in `src/app.ts`, and passes the result to
1473
+ * `serve(contract, { database })`. Nothing reaches the database until `boot` binds that one object; another `scoped`
1474
+ * result never gets a connection.
1475
+ */
1476
+ export function scoped<DB, P>(tables: Tables<DB, P>): ScopedDatabase<DB, P> {
1477
+ const models = tableModels(tables)
1478
+ const connection: Connection = { runtime: undefined }
1479
+
1480
+ function contextFor(table: string, mode: 'dimension' | 'child' | 'app'): TableContext {
1481
+ const model = typeof table === 'string' ? modelOf(models, table) : undefined
1482
+ if (model === undefined || model.scope.mode !== mode) {
1483
+ throw new QueryRefusedError('unknown_table', `${String(table)} is not a ${mode} table`)
1484
+ }
1485
+ return contextOf(model)
1486
+ }
1487
+
1488
+ function contextOf(model: TableModel): TableContext {
1489
+ const parent = model.scope.mode === 'child' ? modelOf(models, model.scope.parent) : undefined
1490
+ const joinable = joinShapes(models, model)
1491
+ const joins: Record<string, readonly ForeignKeyPair[]> = {}
1492
+ const children = ownKeys(joinable)
1493
+ for (let index = 0; index < children.length; index++) {
1494
+ const child = children[index] ?? ''
1495
+ const join = ownEntry(joinable, child)
1496
+ if (join !== undefined) {
1497
+ setOwn(joins, child, join.on)
1498
+ }
1499
+ }
1500
+ const shape = shapeOf(model, joinable)
1501
+ return freeze({ model, parent, destination: destinationOf(models, model, parent), shape, joins: freeze(joins), connection })
1502
+ }
1503
+
1504
+ const database: ScopedDatabase<DB, P> = Object.freeze({ tables: tablePolicies(tables) })
1505
+ register(database, {
1506
+ tableContext: contextFor,
1507
+ probe(table: string): TableProbe {
1508
+ const mode = modelOf(models, table)?.scope.mode
1509
+ if (mode === 'app') {
1510
+ return appProbeOf(contextFor(table, mode))
1511
+ }
1512
+ if (mode !== 'dimension' && mode !== 'child') {
1513
+ throw new QueryRefusedError('unknown_table', `${String(table)} is not a dimension, child or application table; no store reaches it`)
1514
+ }
1515
+ return probeOf(contextFor(table, mode))
1516
+ },
1517
+ bind(runtime: DataLayerRuntime): Locator {
1518
+ if (connection.runtime !== undefined) {
1519
+ throw new CapabilityError('layer_unbound', 'the data layer is already connected; only the framework connects it, once, at boot')
1520
+ }
1521
+ const bound: DataLayerRuntime = freeze({ database: runtime.database, capabilities: runtime.capabilities, readOnly: runtime.readOnly })
1522
+ connection.runtime = bound
1523
+ return async (dimension, id) => locateObject(models, bound, dimension, id)
1524
+ },
1525
+ files: fileTables(models, () => runtimeOf(connection)),
1526
+ framework: freeze({
1527
+ inboxProblems: (form: string, fields: readonly string[]) => inboxProblems(models, form, fields),
1528
+ appendToInbox: (form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota) =>
1529
+ appendToInbox(models, runtimeOf(connection), form, values, quota),
1530
+ webhooks: webhookTables(() => runtimeOf(connection)),
1531
+ jobs: jobLeases(() => runtimeOf(connection)),
1532
+ forms: formAdmissions(() => runtimeOf(connection)),
1533
+ }),
1534
+ data: dataAccess({
1535
+ models,
1536
+ contextOf(table: string): TableContext {
1537
+ const model = modelOf(models, table)
1538
+ if (model === undefined) {
1539
+ throw new QueryRefusedError('unknown_table', `${String(table)} is not a table of src/db/tables.ts`)
1540
+ }
1541
+ return contextOf(model)
1542
+ },
1543
+ runtime: () => runtimeOf(connection),
1544
+ newSession: (runtime) => sessionOf(runtime),
1545
+ newInvocation: (runtime, readOnly) => new InvocationTransaction(runtime.database, readOnly || runtime.readOnly?.() === true),
1546
+ fenced: (runtime, work) => fencedWork(runtime, work),
1547
+ }),
1548
+ })
1549
+ return database
1550
+ }
1551
+
1552
+ /** The context of `table` in the layer `scoped` created, for the framework's own store builders below. */
1553
+ function storeContext(database: DataLayer, table: string, mode: 'dimension' | 'child'): TableContext {
1554
+ const side = frameworkSideOf(database)
1555
+ if (side === undefined) {
1556
+ throw new TypeError('not a data layer scoped(tables) created')
1557
+ }
1558
+ return side.tableContext(table, mode)
1559
+ }
1560
+
1561
+ /**
1562
+ * A store over one dimension table of the layer: the framework's own tests and kits build them, never an application,
1563
+ * whose handlers reach data through `ctx.db`. Exported from `@fabrika/db/testing` only.
1564
+ */
1565
+ export function defineStore<DB, P, const T extends DimensionTableName<P>, S>(
1566
+ database: ScopedDatabase<DB, P>,
1567
+ table: T,
1568
+ build: (t: DimensionTable<DB, P, T>) => S,
1569
+ ): S {
1570
+ return build(new DimensionTable(storeContext(database, table, 'dimension')))
1571
+ }
1572
+
1573
+ /** A store over one child table of the layer, as `defineStore`; exported from `@fabrika/db/testing` only. */
1574
+ export function defineChildStore<DB, P, const T extends ChildTableName<P>, S>(
1575
+ database: ScopedDatabase<DB, P>,
1576
+ table: T,
1577
+ build: (t: ChildTable<DB, P, T>) => S,
1578
+ ): S {
1579
+ return build(new ChildTable(storeContext(database, table, 'child')))
1580
+ }
1581
+
1582
+ /** The inbox table of `form`: the one table whose scope is `{ inbox: form }`. */
1583
+ function inboxOf(models: TableModels, form: string): TableModel | undefined {
1584
+ const names = ownKeys(models)
1585
+ for (let index = 0; index < names.length; index++) {
1586
+ const model = modelOf(models, names[index] ?? '')
1587
+ if (model?.scope.mode === 'inbox' && model.scope.form === form) {
1588
+ return model
1589
+ }
1590
+ }
1591
+ return undefined
1592
+ }
1593
+
1594
+ function inboxProblems(models: TableModels, form: string, fields: readonly string[]): readonly string[] {
1595
+ const inbox = inboxOf(models, form)
1596
+ if (inbox === undefined) {
1597
+ return [`no table in src/db/tables.ts is the inbox of '${form}' (scope: { inbox: '${form}', read })`]
1598
+ }
1599
+ const problems: string[] = []
1600
+ for (let index = 0; index < fields.length; index++) {
1601
+ const field = fields[index] ?? ''
1602
+ if (!inSet(inbox.columns, field)) {
1603
+ problems[problems.length] = `the input field '${field}' of '${form}' is not a column of its inbox ${inbox.name}`
1604
+ }
1605
+ }
1606
+ return problems
1607
+ }
1608
+
1609
+ /**
1610
+ * One row of the form's declared fields, taken from the form's `quota` bucket in the same transaction: a row that
1611
+ * fails to insert gives its place back. `false`, with nothing written, when the bucket is full. An inbox answers
1612
+ * nothing, so the form reads nothing back.
1613
+ */
1614
+ async function appendToInbox(
1615
+ models: TableModels,
1616
+ runtime: DataLayerRuntime,
1617
+ form: string,
1618
+ values: Readonly<Record<string, unknown>>,
1619
+ quota: InboxQuota,
1620
+ ): Promise<boolean> {
1621
+ const inbox = inboxOf(models, form)
1622
+ if (inbox === undefined) {
1623
+ throw new QueryRefusedError('unknown_table', `no table is the inbox of ${form}`)
1624
+ }
1625
+ const row = readValues(values, inbox.columns, 'insert', inbox)
1626
+ return await sessionOf(runtime).transaction(async (transaction) => {
1627
+ if (!(await takeFromBucket(transaction, form, quota.bucket, quota.limit))) {
1628
+ return false
1629
+ }
1630
+ await transaction.run(insertNode(inbox.name, row, undefined), {
1631
+ root: 'InsertQueryNode',
1632
+ table: inbox.name,
1633
+ joins: {},
1634
+ scope: undefined,
1635
+ })
1636
+ return true
1637
+ })
1638
+ }
1639
+
1640
+ /** The table that owns `dimension`: the one dimension table scoped by it (`defineTables` refuses a second). */
1641
+ export function ownerOf(models: TableModels, dimension: string): TableModel | undefined {
1642
+ const names = ownKeys(models)
1643
+ for (let index = 0; index < names.length; index++) {
1644
+ const model = modelOf(models, names[index] ?? '')
1645
+ if (model?.scope.mode === 'dimension' && model.scope.dimension === dimension) {
1646
+ return model
1647
+ }
1648
+ }
1649
+ return undefined
1650
+ }
1651
+
1652
+ /** The table that owns `dimension`, which the locator reads; a dimension no table owns is located by nothing. */
1653
+ function locatedOwner(models: TableModels, dimension: unknown): TableModel {
1654
+ const owner = typeof dimension === 'string' ? ownerOf(models, dimension) : undefined
1655
+ if (owner === undefined) {
1656
+ throw new QueryRefusedError('unknown_table', `no table owns the dimension ${String(dimension)}, so nothing locates it`)
1657
+ }
1658
+ return owner
1659
+ }
1660
+
1661
+ /** One guarded select by key of the containment columns the policy declares; the generated locator of the dimension. */
1662
+ async function locateObject(
1663
+ models: TableModels,
1664
+ runtime: DataLayerRuntime,
1665
+ dimension: string,
1666
+ id: string,
1667
+ ): Promise<ReadonlyMap<string, string> | null> {
1668
+ const owner = locatedOwner(models, dimension)
1669
+ if (typeof id !== 'string' || id.length === 0) {
1670
+ throw new QueryRefusedError('row_address', `a ${dimension} is located by its ${keyOf(owner)}, a non-empty string`)
1671
+ }
1672
+ const key = keyOf(owner)
1673
+ const scope = equals(owner.name, key, id)
1674
+ const columns: string[] = [key]
1675
+ for (let index = 0; index < owner.chain.length; index++) {
1676
+ columns[columns.length] = owner.chain[index]?.column ?? ''
1677
+ }
1678
+ const context: GuardContext = { root: 'SelectQueryNode', table: owner.name, joins: {}, scope }
1679
+ const [row] = await sessionOf(runtime).run<Readonly<Record<string, unknown>>>(locateNode(owner.name, columns, scope), context)
1680
+ if (row === undefined) {
1681
+ return null
1682
+ }
1683
+ const chain = new Map<string, string>()
1684
+ for (let index = 0; index < owner.chain.length; index++) {
1685
+ const link = owner.chain[index]
1686
+ const value = link === undefined ? undefined : ownEntry(row, link.column)
1687
+ if (link === undefined || typeof value !== 'string') {
1688
+ throw new PolicyError(`${owner.name}.${link?.column ?? ''} came back as ${typeof value}; a containment column holds its scope value as text`)
1689
+ }
1690
+ chain.set(link.dimension, value)
1691
+ }
1692
+ return chain
1693
+ }