@fabrika/db 0.6.0 → 0.8.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/package.json +3 -3
- package/src/{form-admissions.ts → admissions.ts} +29 -36
- package/src/capability.ts +30 -3
- package/src/description.ts +2 -1
- package/src/errors.ts +8 -2
- package/src/file-columns.ts +184 -0
- package/src/file-references.ts +60 -0
- package/src/framework-tables.ts +100 -45
- package/src/framework.ts +7 -29
- package/src/guard.ts +42 -8
- package/src/index.ts +2 -13
- package/src/job-leases.ts +11 -0
- package/src/links.ts +1 -1
- package/src/migrations.ts +126 -0
- package/src/nodes.ts +35 -3
- package/src/orm-types.ts +152 -28
- package/src/orm.ts +397 -38
- package/src/policy.ts +221 -74
- package/src/registry.ts +7 -18
- package/src/store.ts +447 -143
- package/src/stored-files.ts +402 -0
- package/src/tables.ts +32 -26
- package/src/testing.ts +39 -7
- package/src/vocabulary-tables.ts +4 -4
- package/src/file-table-sql.ts +0 -133
- package/src/file-tables.ts +0 -622
- package/src/form-files.ts +0 -226
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 {
|
|
46
|
-
import {
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
359
|
-
if (grant.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
|
|
373
|
+
if (write !== undefined && !includes(actions, write)) columnWrites[columnWrites.length] = write
|
|
367
374
|
}
|
|
368
|
-
if (
|
|
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 (!
|
|
372
|
-
const needs =
|
|
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' &&
|
|
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 {
|
|
514
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
639
|
-
if (grant.action
|
|
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.
|
|
821
|
-
throw new CapabilityError(
|
|
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
|
-
/**
|
|
853
|
-
|
|
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
|
|
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
|
-
/**
|
|
1119
|
-
export
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1449
|
+
return { row, destination: undefined, conflict }
|
|
1142
1450
|
}
|
|
1143
1451
|
|
|
1144
|
-
/**
|
|
1145
|
-
export function
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
1336
|
-
const visible = (capability: unknown): readonly string[] =>
|
|
1337
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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({})),
|
|
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
|
-
|
|
1565
|
-
|
|
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)
|