@fabrika/db 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/policy.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
hasOwn,
|
|
5
5
|
inSet,
|
|
6
6
|
isArray,
|
|
7
|
+
isSafeInteger,
|
|
7
8
|
type NameSet,
|
|
8
9
|
nameSet,
|
|
9
10
|
namesOf,
|
|
@@ -46,16 +47,24 @@ export interface ApplicationScope<Col extends string> {
|
|
|
46
47
|
readonly key?: Col
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
readonly read: A
|
|
52
|
-
}
|
|
50
|
+
/** One action, or a list of them: a table that staff and a public procedure both read names both actions. */
|
|
51
|
+
export type TableAction<A extends string> = A | readonly A[]
|
|
53
52
|
|
|
54
53
|
export interface TableActions<A extends string> {
|
|
55
|
-
readonly read: A
|
|
56
|
-
readonly insert?: A
|
|
57
|
-
readonly update?: A
|
|
58
|
-
readonly delete?: A
|
|
54
|
+
readonly read: TableAction<A>
|
|
55
|
+
readonly insert?: TableAction<A>
|
|
56
|
+
readonly update?: TableAction<A>
|
|
57
|
+
readonly delete?: TableAction<A>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A queue table's columns (ADR-0060's drain): `due` is when a row is next claimed, `null` once it is done; `attempts`
|
|
62
|
+
* counts its claims, and a row with `maxAttempts` (default 10) of them is no longer claimed.
|
|
63
|
+
*/
|
|
64
|
+
export interface QueuePolicy<Col extends string> {
|
|
65
|
+
readonly attempts: Col
|
|
66
|
+
readonly due: Col
|
|
67
|
+
readonly maxAttempts?: number
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/**
|
|
@@ -75,8 +84,18 @@ interface WritablePolicy<Col extends string, A extends string, V extends Col, Ac
|
|
|
75
84
|
readonly version?: V
|
|
76
85
|
readonly createdBy?: Col
|
|
77
86
|
readonly columns?: ColumnPolicies<Col, A>
|
|
87
|
+
/** A queue table: its rows are claimed (`claim`) and finished through `row.ref`; it needs `version` and an update action. */
|
|
88
|
+
readonly queue?: QueuePolicy<Col>
|
|
89
|
+
/**
|
|
90
|
+
* The file columns, each with the file kind it holds: a `uuid` foreign key to `fabrika_files`, written only with a
|
|
91
|
+
* file reference. The table's actions are the file's authority.
|
|
92
|
+
*/
|
|
93
|
+
readonly files?: FilePolicies<Col>
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
/** A table's file columns: column → the name of a file kind of the contract (`c.files`). */
|
|
97
|
+
export type FilePolicies<Col extends string> = { readonly [C in Col]?: string }
|
|
98
|
+
|
|
80
99
|
/**
|
|
81
100
|
* A column's own actions. `read`: only a capability of exactly that action reads, filters or sorts by the column;
|
|
82
101
|
* without one it falls back to the table's read action. `write`: only a capability of that action updates the column,
|
|
@@ -90,7 +109,7 @@ export interface ColumnPolicy<A extends string> {
|
|
|
90
109
|
readonly write?: A
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
/** A table no handler writes (a reference
|
|
112
|
+
/** A table no handler writes (a reference table) declares column read actions only. */
|
|
94
113
|
export type ColumnReadPolicies<Col extends string, A extends string> = { readonly [C in Col]?: { readonly read: A } }
|
|
95
114
|
|
|
96
115
|
export interface DimensionPolicy<Col extends string, A extends string, V extends Col = Col> extends WritablePolicy<Col, A, V, RecordActions<A>> {
|
|
@@ -113,17 +132,14 @@ interface ReadOnlyPolicy<Col extends string, A extends string> {
|
|
|
113
132
|
readonly update?: never
|
|
114
133
|
readonly version?: never
|
|
115
134
|
readonly createdBy?: never
|
|
135
|
+
readonly queue?: never
|
|
136
|
+
readonly files?: never
|
|
116
137
|
readonly columns?: ColumnReadPolicies<Col, A>
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
export interface ReferencePolicy<Col extends string, A extends string> extends ReadOnlyPolicy<Col, A> {
|
|
120
141
|
readonly scope: ReferenceScope
|
|
121
|
-
readonly actions: { readonly read: A }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface InboxPolicy<Col extends string, A extends string> extends ReadOnlyPolicy<Col, A> {
|
|
125
|
-
readonly scope: InboxScope<A>
|
|
126
|
-
readonly actions?: never
|
|
142
|
+
readonly actions: { readonly read: TableAction<A> }
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
/** The columns a version can live in: those whose generated select type is `number`, an INTEGER (int4). */
|
|
@@ -158,20 +174,29 @@ export type ScopeModel =
|
|
|
158
174
|
| { readonly mode: 'dimension'; readonly dimension: string; readonly key: string; readonly container: Container | undefined }
|
|
159
175
|
| { readonly mode: 'child'; readonly parent: string; readonly key: string; readonly column: string; readonly inherit: readonly string[] }
|
|
160
176
|
| { readonly mode: 'reference'; readonly reason: string }
|
|
161
|
-
| { readonly mode: 'inbox'; readonly form: string }
|
|
162
177
|
| { readonly mode: 'app'; readonly reason: string; readonly key: string }
|
|
163
178
|
|
|
179
|
+
/** Each operation's actions: any one of them allows it. `read` names at least one; an empty list allows no operation. */
|
|
164
180
|
export interface ActionModel {
|
|
165
|
-
readonly read: string
|
|
166
|
-
readonly insert: string
|
|
167
|
-
readonly update: string
|
|
168
|
-
readonly delete: string
|
|
181
|
+
readonly read: readonly string[]
|
|
182
|
+
readonly insert: readonly string[]
|
|
183
|
+
readonly update: readonly string[]
|
|
184
|
+
readonly delete: readonly string[]
|
|
169
185
|
/** A dimension or child table's actions besides `read` with which an `app` endpoint reads every row, read-only. */
|
|
170
186
|
readonly appRead: readonly string[]
|
|
171
187
|
/** A dimension or child table's actions with which an `app` endpoint counts and aggregates every row, and reads none. */
|
|
172
188
|
readonly appCount: readonly string[]
|
|
173
189
|
}
|
|
174
190
|
|
|
191
|
+
export interface QueueModel {
|
|
192
|
+
readonly attempts: string
|
|
193
|
+
readonly due: string
|
|
194
|
+
readonly maxAttempts: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** How many claims a queue row takes before it is no longer claimed, unless its policy states `maxAttempts`. */
|
|
198
|
+
export const DEFAULT_MAX_ATTEMPTS = 10
|
|
199
|
+
|
|
175
200
|
/** One table's policy, checked against its column list. */
|
|
176
201
|
export interface TableModel {
|
|
177
202
|
readonly name: string
|
|
@@ -179,12 +204,15 @@ export interface TableModel {
|
|
|
179
204
|
readonly actions: ActionModel
|
|
180
205
|
/** Every column the table has. */
|
|
181
206
|
readonly columns: NameSet
|
|
182
|
-
/** The columns
|
|
207
|
+
/** The columns every read action of the table reads, in column-list order. */
|
|
183
208
|
readonly visible: readonly string[]
|
|
184
209
|
readonly insert: NameSet
|
|
185
210
|
readonly update: NameSet
|
|
186
211
|
readonly version: string | undefined
|
|
187
212
|
readonly createdBy: string | undefined
|
|
213
|
+
readonly queue: QueueModel | undefined
|
|
214
|
+
/** The file columns, each with its file kind: a reference to `fabrika_files`, written only with a file reference. */
|
|
215
|
+
readonly files: Readonly<Record<string, string>>
|
|
188
216
|
/** The columns that declare their own read action, as declared (also those equal to the table's). */
|
|
189
217
|
readonly columnReads: Readonly<Record<string, string>>
|
|
190
218
|
/** The columns that declare their own write action: only a capability of it updates them. */
|
|
@@ -241,15 +269,6 @@ export function tableModels<DB, P>(tables: Tables<DB, P>): TableModels {
|
|
|
241
269
|
return readModels(tables)
|
|
242
270
|
}
|
|
243
271
|
|
|
244
|
-
/**
|
|
245
|
-
* The same policies without the forms' inbox tables: what a contract that serves no form compiles against. A form's
|
|
246
|
-
* inbox is reached by its form only, so no other table's policy names one.
|
|
247
|
-
*/
|
|
248
|
-
export function withoutInboxes<DB, P>(tables: Tables<DB, P>): Tables<DB, P> {
|
|
249
|
-
const models = readModels(tables)
|
|
250
|
-
return new Tables(Object.freeze(Object.fromEntries(Object.entries(models).filter(([, model]) => model.scope.mode !== 'inbox'))))
|
|
251
|
-
}
|
|
252
|
-
|
|
253
272
|
/** Whether `value` is what `defineTables` returned, rather than another form of the same policies. */
|
|
254
273
|
export function isTables<DB, P, Other extends object>(value: Tables<DB, P> | Other): value is Tables<DB, P> {
|
|
255
274
|
return hasModels(value)
|
|
@@ -325,14 +344,14 @@ function parseVocabulary(input: unknown): VocabularyModel {
|
|
|
325
344
|
|
|
326
345
|
const COLUMN_LIST_FIELDS = nameSet(['columns', 'foreignKeys', 'dates', 'timestamps'])
|
|
327
346
|
const FOREIGN_KEY_FIELDS = nameSet(['columns', 'table', 'references'])
|
|
328
|
-
const POLICY_FIELDS = nameSet(['scope', 'actions', 'insert', 'update', 'version', 'createdBy', 'columns'])
|
|
347
|
+
const POLICY_FIELDS = nameSet(['scope', 'actions', 'insert', 'update', 'version', 'createdBy', 'columns', 'queue', 'files'])
|
|
348
|
+
const QUEUE_FIELDS = nameSet(['attempts', 'due', 'maxAttempts'])
|
|
329
349
|
const ACTION_FIELDS = nameSet(['read', 'insert', 'update', 'delete', 'appRead', 'appCount'])
|
|
330
350
|
const DIMENSION_FIELDS = nameSet(['dimension', 'key', 'in'])
|
|
331
351
|
const CONTAINER_FIELDS = nameSet(['dimension', 'column'])
|
|
332
352
|
const CHILD_FIELDS = nameSet(['child', 'key', 'column', 'inherit'])
|
|
333
353
|
const REFERENCE_FIELDS = nameSet(['reference'])
|
|
334
354
|
const APPLICATION_FIELDS = nameSet(['app', 'key'])
|
|
335
|
-
const INBOX_FIELDS = nameSet(['inbox', 'read'])
|
|
336
355
|
const COLUMN_FIELDS = nameSet(['read', 'write'])
|
|
337
356
|
|
|
338
357
|
/** A copy of a plain object's own data properties; anything else (an instance, a symbol key, an accessor) is refused. */
|
|
@@ -475,7 +494,8 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
|
|
|
475
494
|
if (readInput !== undefined) {
|
|
476
495
|
const read = action(readInput, `columns.${name}.read`)
|
|
477
496
|
setOwn(columnReads, name, read)
|
|
478
|
-
|
|
497
|
+
// With several read actions, a column of one of them is hidden from the others.
|
|
498
|
+
if (actions.read.length > 1 || !includes(actions.read, read)) {
|
|
479
499
|
restricted[restricted.length] = name
|
|
480
500
|
}
|
|
481
501
|
}
|
|
@@ -493,15 +513,21 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
|
|
|
493
513
|
throw new PolicyError(`${table}: the version ${version} takes no action of its own`)
|
|
494
514
|
}
|
|
495
515
|
const writable = scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app'
|
|
516
|
+
const queueInput = ownEntry(policy, 'queue')
|
|
517
|
+
const filesInput = ownEntry(policy, 'files')
|
|
496
518
|
if (
|
|
497
519
|
!writable
|
|
498
520
|
&& (namesOf(insert).length > 0 || namesOf(update).length > 0 || version !== undefined || createdBy !== undefined
|
|
499
|
-
|| ownKeys(columnWrites).length > 0)
|
|
521
|
+
|| ownKeys(columnWrites).length > 0 || queueInput !== undefined || filesInput !== undefined)
|
|
500
522
|
) {
|
|
501
523
|
throw new PolicyError(`${table}: a ${scope.mode} table is never written by a handler`)
|
|
502
524
|
}
|
|
503
525
|
const dates = parseColumnSet(table, ownEntry(entry, 'dates'), column, 'dates')
|
|
504
526
|
const timestamps = parseColumnSet(table, ownEntry(entry, 'timestamps'), column, 'timestamps')
|
|
527
|
+
const queue = queueInput === undefined
|
|
528
|
+
? undefined
|
|
529
|
+
: parseQueue(table, queueInput, column, { columns, timestamps, version, actions, update, columnWrites })
|
|
530
|
+
const files = filesInput === undefined ? freeze({}) : parseFileColumns(table, filesInput, column, { scope, version, createdBy, queue })
|
|
505
531
|
const visible: string[] = []
|
|
506
532
|
for (let index = 0; index < columnNames.length; index++) {
|
|
507
533
|
const name = columnNames[index] ?? ''
|
|
@@ -519,6 +545,8 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
|
|
|
519
545
|
update,
|
|
520
546
|
version,
|
|
521
547
|
createdBy,
|
|
548
|
+
queue,
|
|
549
|
+
files,
|
|
522
550
|
columnReads: freeze(columnReads),
|
|
523
551
|
columnWrites: freeze(columnWrites),
|
|
524
552
|
dates,
|
|
@@ -527,6 +555,57 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
|
|
|
527
555
|
}
|
|
528
556
|
}
|
|
529
557
|
|
|
558
|
+
/**
|
|
559
|
+
* The file columns: each names a column that holds data, never one the layer stamps (the key, the version, the
|
|
560
|
+
* creator, a queue's), with a file kind's name. The kind is the contract's, which the contract's build checks.
|
|
561
|
+
*/
|
|
562
|
+
function parseFileColumns(
|
|
563
|
+
table: string,
|
|
564
|
+
input: unknown,
|
|
565
|
+
column: (name: unknown, field: string) => string,
|
|
566
|
+
stamped: {
|
|
567
|
+
readonly scope: ParsedScope
|
|
568
|
+
readonly version: string | undefined
|
|
569
|
+
readonly createdBy: string | undefined
|
|
570
|
+
readonly queue: QueueModel | undefined
|
|
571
|
+
},
|
|
572
|
+
): Readonly<Record<string, string>> {
|
|
573
|
+
const files = plainRecord(input, `${table}: files`)
|
|
574
|
+
const names = ownKeys(files)
|
|
575
|
+
if (names.length === 0) {
|
|
576
|
+
throw new PolicyError(`${table}: files declares no column; remove it or name a column and its file kind`)
|
|
577
|
+
}
|
|
578
|
+
const scope = stamped.scope
|
|
579
|
+
const layerColumns: string[] = []
|
|
580
|
+
if (scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app') {
|
|
581
|
+
layerColumns[layerColumns.length] = scope.key
|
|
582
|
+
}
|
|
583
|
+
if (scope.mode === 'dimension' && scope.container !== undefined) {
|
|
584
|
+
layerColumns[layerColumns.length] = scope.container.column
|
|
585
|
+
}
|
|
586
|
+
if (scope.mode === 'child' && scope.column !== undefined) {
|
|
587
|
+
layerColumns[layerColumns.length] = scope.column
|
|
588
|
+
}
|
|
589
|
+
for (const name of [stamped.version, stamped.createdBy, stamped.queue?.attempts, stamped.queue?.due]) {
|
|
590
|
+
if (name !== undefined) {
|
|
591
|
+
layerColumns[layerColumns.length] = name
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const parsed: Record<string, string> = {}
|
|
595
|
+
for (let index = 0; index < names.length; index++) {
|
|
596
|
+
const name = column(names[index], 'files')
|
|
597
|
+
if (includes(layerColumns, name)) {
|
|
598
|
+
throw new PolicyError(`${table}: files names ${name}, which the layer writes itself; a file column holds a file reference`)
|
|
599
|
+
}
|
|
600
|
+
const kind = ownEntry(files, name)
|
|
601
|
+
if (typeof kind !== 'string' || !isPlainIdentifier(kind)) {
|
|
602
|
+
throw new PolicyError(`${table}: files.${name} names no file kind (allowed: the name of a c.files({ … }) kind of the contract)`)
|
|
603
|
+
}
|
|
604
|
+
setOwn(parsed, name, kind)
|
|
605
|
+
}
|
|
606
|
+
return freeze(parsed)
|
|
607
|
+
}
|
|
608
|
+
|
|
530
609
|
/**
|
|
531
610
|
* A column's own write action takes the column away from the table's update action: one action writes a column, so
|
|
532
611
|
* the column is not in `update`, and the action is not the update action (that one lists the column in `update`).
|
|
@@ -537,12 +616,63 @@ function columnWrite(table: string, name: string, write: string, actions: Action
|
|
|
537
616
|
`${table}: ${name} is in update, which the table's update action writes, and declares its own write action ${write}; remove it from update`,
|
|
538
617
|
)
|
|
539
618
|
}
|
|
540
|
-
if (
|
|
619
|
+
if (includes(actions.update, write)) {
|
|
541
620
|
throw new PolicyError(`${table}: columns.${name}.write names ${write}, the table's update action; list ${name} in update instead`)
|
|
542
621
|
}
|
|
543
622
|
return write
|
|
544
623
|
}
|
|
545
624
|
|
|
625
|
+
/**
|
|
626
|
+
* The queue's columns are written by the layer only: a claim counts `attempts` and moves `due`, `done: true` clears
|
|
627
|
+
* `due`, and `requeue` sets both. An insert may set `due` (a reminder's time); nothing else names either.
|
|
628
|
+
*/
|
|
629
|
+
function parseQueue(
|
|
630
|
+
table: string,
|
|
631
|
+
input: unknown,
|
|
632
|
+
column: (name: unknown, field: string) => string,
|
|
633
|
+
policy: {
|
|
634
|
+
readonly columns: NameSet
|
|
635
|
+
readonly timestamps: NameSet
|
|
636
|
+
readonly version: string | undefined
|
|
637
|
+
readonly actions: ActionModel
|
|
638
|
+
readonly update: NameSet
|
|
639
|
+
readonly columnWrites: Readonly<Record<string, string>>
|
|
640
|
+
},
|
|
641
|
+
): QueueModel {
|
|
642
|
+
const queue = plainRecord(input, `${table}: queue`)
|
|
643
|
+
onlyFields(queue, QUEUE_FIELDS, `${table}: queue`)
|
|
644
|
+
const attempts = column(ownEntry(queue, 'attempts'), 'queue.attempts')
|
|
645
|
+
const due = column(ownEntry(queue, 'due'), 'queue.due')
|
|
646
|
+
if (attempts === due) {
|
|
647
|
+
throw new PolicyError(`${table}: queue.attempts and queue.due name one column, ${due}; they need two`)
|
|
648
|
+
}
|
|
649
|
+
if (!inSet(policy.timestamps, due)) {
|
|
650
|
+
throw new PolicyError(`${table}: queue.due names ${due}, which is not a timestamp column; a claim moves it by the backoff`)
|
|
651
|
+
}
|
|
652
|
+
const maxInput = ownEntry(queue, 'maxAttempts')
|
|
653
|
+
if (maxInput !== undefined && (typeof maxInput !== 'number' || !isSafeInteger(maxInput) || maxInput < 1)) {
|
|
654
|
+
throw new PolicyError(`${table}: queue.maxAttempts must be a positive integer`)
|
|
655
|
+
}
|
|
656
|
+
if (policy.version === undefined) {
|
|
657
|
+
throw new PolicyError(`${table}: a queue table needs a version column; a claimed row is finished with expectVersion`)
|
|
658
|
+
}
|
|
659
|
+
if (policy.actions.update.length === 0) {
|
|
660
|
+
throw new PolicyError(`${table}: a queue table needs an update action; claim and requeue need it`)
|
|
661
|
+
}
|
|
662
|
+
// A claimed row answers its new version as `version` and its capability as `ref`, beside its columns.
|
|
663
|
+
if (inSet(policy.columns, 'ref') || (inSet(policy.columns, 'version') && policy.version !== 'version')) {
|
|
664
|
+
throw new PolicyError(`${table}: a queue table has no column named ref, nor version unless it is the version column; a claimed row carries both`)
|
|
665
|
+
}
|
|
666
|
+
const owned: readonly (readonly [string, string])[] = [[attempts, 'queue.attempts'], [due, 'queue.due']]
|
|
667
|
+
for (let index = 0; index < owned.length; index++) {
|
|
668
|
+
const [name, field] = owned[index] ?? ['', '']
|
|
669
|
+
if (inSet(policy.update, name) || hasOwn(policy.columnWrites, name)) {
|
|
670
|
+
throw new PolicyError(`${table}: ${name} is ${field}, which the layer writes; remove it from update and its write action`)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return freeze({ attempts, due, maxAttempts: maxInput === undefined ? DEFAULT_MAX_ATTEMPTS : maxInput })
|
|
674
|
+
}
|
|
675
|
+
|
|
546
676
|
/** The column list's `date` (`dates`) or timestamp (`timestamps`) columns, each one of the table's. */
|
|
547
677
|
function parseColumnSet(table: string, input: unknown, column: (name: unknown, field: string) => string, field: string): NameSet {
|
|
548
678
|
if (input === undefined) {
|
|
@@ -560,7 +690,7 @@ function parseColumnSet(table: string, input: unknown, column: (name: unknown, f
|
|
|
560
690
|
* columns that declare it as their own write action for any other; none for an action that writes nothing here.
|
|
561
691
|
*/
|
|
562
692
|
export function updatableBy(model: TableModel, action: string): NameSet {
|
|
563
|
-
if (
|
|
693
|
+
if (includes(model.actions.update, action)) {
|
|
564
694
|
return model.update
|
|
565
695
|
}
|
|
566
696
|
const names = ownKeys(model.columnWrites)
|
|
@@ -577,7 +707,7 @@ export function updatableBy(model: TableModel, action: string): NameSet {
|
|
|
577
707
|
/** Whether the table names `action`: one of its table actions, or a column's own write action. */
|
|
578
708
|
export function namesAction(model: TableModel, action: string): boolean {
|
|
579
709
|
const { read, insert, update, delete: remove } = model.actions
|
|
580
|
-
if (
|
|
710
|
+
if (includes(read, action) || includes(insert, action) || includes(update, action) || includes(remove, action)) {
|
|
581
711
|
return true
|
|
582
712
|
}
|
|
583
713
|
const columns = ownKeys(model.columnWrites)
|
|
@@ -608,7 +738,10 @@ export function visibleFor(model: TableModel, action: string): readonly string[]
|
|
|
608
738
|
|
|
609
739
|
/** Every action that updates a column of `model`: its update action and each column's own write action. */
|
|
610
740
|
export function updateActions(model: TableModel): readonly string[] {
|
|
611
|
-
const actions: string[] =
|
|
741
|
+
const actions: string[] = []
|
|
742
|
+
for (let index = 0; index < model.actions.update.length; index++) {
|
|
743
|
+
actions[actions.length] = model.actions.update[index] ?? ''
|
|
744
|
+
}
|
|
612
745
|
const names = ownKeys(model.columnWrites)
|
|
613
746
|
for (let index = 0; index < names.length; index++) {
|
|
614
747
|
const write = model.columnWrites[names[index] ?? '']
|
|
@@ -672,15 +805,16 @@ function parseScope(table: string, scope: Readonly<Record<string, unknown>>, col
|
|
|
672
805
|
return freeze({ mode: 'reference', reason: nonEmpty(table, ownEntry(scope, 'reference'), 'scope.reference') })
|
|
673
806
|
}
|
|
674
807
|
if (hasOwn(scope, 'inbox')) {
|
|
675
|
-
|
|
676
|
-
|
|
808
|
+
throw new PolicyError(
|
|
809
|
+
`${table}: the inbox scope was removed; declare an application table, scope: { app: '<why>' }, that a public procedure inserts into`,
|
|
810
|
+
)
|
|
677
811
|
}
|
|
678
812
|
if (hasOwn(scope, 'app')) {
|
|
679
813
|
onlyFields(scope, APPLICATION_FIELDS, what)
|
|
680
814
|
return freeze({ mode: 'app', reason: nonEmpty(table, ownEntry(scope, 'app'), 'scope.app'), key: key() })
|
|
681
815
|
}
|
|
682
816
|
throw new PolicyError(
|
|
683
|
-
`${what} takes one mode: { dimension, key?, in? }, { child, key?, column?, inherit? }, { app, key? }
|
|
817
|
+
`${what} takes one mode: { dimension, key?, in? }, { child, key?, column?, inherit? }, { app, key? } or { reference }`,
|
|
684
818
|
)
|
|
685
819
|
}
|
|
686
820
|
|
|
@@ -720,29 +854,22 @@ function parseActions(
|
|
|
720
854
|
actionsInput: unknown,
|
|
721
855
|
action: (value: unknown, field: string) => string,
|
|
722
856
|
): ActionModel {
|
|
723
|
-
if (hasOwn(scope, 'inbox')) {
|
|
724
|
-
if (actionsInput !== undefined) {
|
|
725
|
-
throw new PolicyError(`${table}: an inbox table takes its read action as scope.read and no actions; only its form appends`)
|
|
726
|
-
}
|
|
727
|
-
return freeze({
|
|
728
|
-
read: action(ownEntry(scope, 'read'), 'scope.read'),
|
|
729
|
-
insert: undefined,
|
|
730
|
-
update: undefined,
|
|
731
|
-
delete: undefined,
|
|
732
|
-
appRead: freeze([]),
|
|
733
|
-
appCount: freeze([]),
|
|
734
|
-
})
|
|
735
|
-
}
|
|
736
857
|
if (actionsInput === undefined) {
|
|
737
858
|
throw new PolicyError(`${table}: the policy needs actions`)
|
|
738
859
|
}
|
|
739
860
|
const actions = plainRecord(actionsInput, `${table}: actions`)
|
|
740
861
|
onlyFields(actions, ACTION_FIELDS, `${table}: actions`)
|
|
741
|
-
const
|
|
862
|
+
const operation = (field: 'read' | 'insert' | 'update' | 'delete'): readonly string[] => {
|
|
742
863
|
const value = ownEntry(actions, field)
|
|
743
|
-
|
|
864
|
+
if (value === undefined && field !== 'read') {
|
|
865
|
+
return freeze([])
|
|
866
|
+
}
|
|
867
|
+
if (!isArray(value)) {
|
|
868
|
+
return freeze([action(value, `actions.${field}`)])
|
|
869
|
+
}
|
|
870
|
+
return distinctActions(table, value, `actions.${field}`, action)
|
|
744
871
|
}
|
|
745
|
-
const read =
|
|
872
|
+
const read = operation('read')
|
|
746
873
|
const records = hasOwn(scope, 'dimension') || hasOwn(scope, 'child')
|
|
747
874
|
const kind = hasOwn(scope, 'app') ? 'an application table' : 'a reference table'
|
|
748
875
|
const reach = (field: 'appRead' | 'appCount'): readonly string[] => {
|
|
@@ -753,25 +880,17 @@ function parseActions(
|
|
|
753
880
|
if (!records) {
|
|
754
881
|
throw new PolicyError(`${table}: actions.${field} is for a dimension or child table; ${kind} takes none`)
|
|
755
882
|
}
|
|
756
|
-
|
|
757
|
-
if (names === undefined) {
|
|
883
|
+
if (!isArray(input)) {
|
|
758
884
|
throw new PolicyError(`${table}: actions.${field} takes a plain array of actions`)
|
|
759
885
|
}
|
|
760
|
-
|
|
761
|
-
throw new PolicyError(`${table}: actions.${field} lists no action; leave it out`)
|
|
762
|
-
}
|
|
886
|
+
const names = distinctActions(table, input, `actions.${field}`, action)
|
|
763
887
|
for (let index = 0; index < names.length; index++) {
|
|
764
888
|
const name = names[index] ?? ''
|
|
765
|
-
if (name
|
|
889
|
+
if (includes(read, name)) {
|
|
766
890
|
throw new PolicyError(`${table}: actions.${field} names ${name}, its read action, which reads every row app-wide already`)
|
|
767
891
|
}
|
|
768
|
-
for (let earlier = 0; earlier < index; earlier++) {
|
|
769
|
-
if (names[earlier] === name) {
|
|
770
|
-
throw new PolicyError(`${table}: actions.${field} names ${name} twice`)
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
892
|
}
|
|
774
|
-
return
|
|
893
|
+
return names
|
|
775
894
|
}
|
|
776
895
|
const appRead = reach('appRead')
|
|
777
896
|
const appCount = reach('appCount')
|
|
@@ -781,7 +900,26 @@ function parseActions(
|
|
|
781
900
|
throw new PolicyError(`${table}: actions.appRead and actions.appCount both name ${name}`)
|
|
782
901
|
}
|
|
783
902
|
}
|
|
784
|
-
return freeze({ read, insert:
|
|
903
|
+
return freeze({ read, insert: operation('insert'), update: operation('update'), delete: operation('delete'), appRead, appCount })
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** A plain array of distinct actions of the vocabulary, at least one. */
|
|
907
|
+
function distinctActions(table: string, input: unknown, field: string, action: (value: unknown, field: string) => string): readonly string[] {
|
|
908
|
+
const names = parseList(input, (value) => action(value, field))
|
|
909
|
+
if (names === undefined) {
|
|
910
|
+
throw new PolicyError(`${table}: ${field} takes an action or a plain array of actions`)
|
|
911
|
+
}
|
|
912
|
+
if (names.length === 0) {
|
|
913
|
+
throw new PolicyError(`${table}: ${field} lists no action; leave it out`)
|
|
914
|
+
}
|
|
915
|
+
for (let index = 0; index < names.length; index++) {
|
|
916
|
+
for (let earlier = 0; earlier < index; earlier++) {
|
|
917
|
+
if (names[earlier] === names[index]) {
|
|
918
|
+
throw new PolicyError(`${table}: ${field} names ${names[index]} twice`)
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return freeze(names)
|
|
785
923
|
}
|
|
786
924
|
|
|
787
925
|
function nonEmpty(table: string, value: unknown, field: string): string {
|
|
@@ -794,6 +932,8 @@ function nonEmpty(table: string, value: unknown, field: string): string {
|
|
|
794
932
|
interface Stamp {
|
|
795
933
|
readonly column: string
|
|
796
934
|
readonly role: string
|
|
935
|
+
/** An insert may set it: a queue row's first due time. */
|
|
936
|
+
readonly insertable?: true
|
|
797
937
|
}
|
|
798
938
|
|
|
799
939
|
/** The columns the layer writes itself: the key, every containment link, the version and the creator. */
|
|
@@ -814,6 +954,10 @@ function stampsOf(table: ParsedTable, chain: readonly Container[]): readonly Sta
|
|
|
814
954
|
if (table.createdBy !== undefined) {
|
|
815
955
|
stamps[stamps.length] = { column: table.createdBy, role: 'createdBy' }
|
|
816
956
|
}
|
|
957
|
+
if (table.queue !== undefined) {
|
|
958
|
+
stamps[stamps.length] = { column: table.queue.attempts, role: 'queue.attempts' }
|
|
959
|
+
stamps[stamps.length] = { column: table.queue.due, role: 'queue.due', insertable: true }
|
|
960
|
+
}
|
|
817
961
|
return stamps
|
|
818
962
|
}
|
|
819
963
|
|
|
@@ -837,7 +981,7 @@ function checkStamps(table: ParsedTable, chain: readonly Container[]): void {
|
|
|
837
981
|
)
|
|
838
982
|
}
|
|
839
983
|
}
|
|
840
|
-
if (inSet(table.insert, stamp.column) || inSet(table.update, stamp.column)) {
|
|
984
|
+
if ((stamp.insertable !== true && inSet(table.insert, stamp.column)) || inSet(table.update, stamp.column)) {
|
|
841
985
|
throw new PolicyError(`${table.name}: ${stamp.column} is stamped by the layer; remove it from insert and update`)
|
|
842
986
|
}
|
|
843
987
|
if (hasOwn(table.columnWrites, stamp.column)) {
|
|
@@ -857,7 +1001,8 @@ function prepend(first: Container, rest: readonly Container[]): readonly Contain
|
|
|
857
1001
|
return freeze(chain)
|
|
858
1002
|
}
|
|
859
1003
|
|
|
860
|
-
|
|
1004
|
+
/** Whether `names` holds `name`: an operation's actions, the one way a policy check reads a list. */
|
|
1005
|
+
export function includes(names: readonly string[], name: unknown): boolean {
|
|
861
1006
|
for (let index = 0; index < names.length; index++) {
|
|
862
1007
|
if (names[index] === name) {
|
|
863
1008
|
return true
|
|
@@ -1132,8 +1277,9 @@ type ScopeOf<P, T> = PolicyOf<P, T> extends { readonly scope: infer S } ? S : ne
|
|
|
1132
1277
|
|
|
1133
1278
|
export type Operation = 'read' | 'insert' | 'update' | 'delete'
|
|
1134
1279
|
|
|
1280
|
+
/** The action, or the union of the actions, of operation `Op` on table `T`; `never` when the policy names none. */
|
|
1135
1281
|
export type ActionOf<P, T, Op extends Operation> = PolicyOf<P, T> extends { readonly actions: infer Acts }
|
|
1136
|
-
? Acts extends { readonly [K in Op]: infer A extends string
|
|
1282
|
+
? Acts extends { readonly [K in Op]: infer A } ? A extends string ? A : A extends readonly (infer L extends string)[] ? L : never
|
|
1137
1283
|
: never
|
|
1138
1284
|
: never
|
|
1139
1285
|
|
|
@@ -1158,7 +1304,8 @@ type TableReadAction<P, T> = PolicyOf<P, T> extends { readonly scope: { readonly
|
|
|
1158
1304
|
/** Columns whose own read action is not the table's: no read by the table's read action returns them. */
|
|
1159
1305
|
type RestrictedColumns<P, T> = [ColumnPoliciesOf<P, T>] extends [never] ? never
|
|
1160
1306
|
: {
|
|
1161
|
-
[K in keyof ColumnPoliciesOf<P, T>]: ColumnPoliciesOf<P, T>[K] extends { readonly read: infer R }
|
|
1307
|
+
[K in keyof ColumnPoliciesOf<P, T>]: ColumnPoliciesOf<P, T>[K] extends { readonly read: infer R }
|
|
1308
|
+
? ([TableReadAction<P, T>] extends [R] ? never : K)
|
|
1162
1309
|
: never
|
|
1163
1310
|
}[keyof ColumnPoliciesOf<P, T>]
|
|
1164
1311
|
|
package/src/registry.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
// `store.ts`, which registers and builds the framework's stores, `framework.ts`, which `@fabrika/app` alone imports, and `testing.ts`, which only test
|
|
3
3
|
// files may import.
|
|
4
4
|
|
|
5
|
+
import type { Admissions } from './admissions'
|
|
5
6
|
import type { Session } from './database'
|
|
6
|
-
import type { FileTables } from './file-tables'
|
|
7
|
-
import type { FormAdmissions, InboxQuota } from './form-admissions'
|
|
8
|
-
import type { FormFiles, HeldFormFiles } from './form-files'
|
|
9
7
|
import type { WebhookTables } from './framework-tables'
|
|
10
8
|
import type { JobLeases } from './job-leases'
|
|
11
9
|
import type { DataAccess } from './orm'
|
|
12
10
|
import type { DataLayerRuntime, TableContext, TableProbe } from './store'
|
|
11
|
+
import type { StoredFiles } from './stored-files'
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* Answers what the framework's access check reads from the database: an object's containers, by dimension. `using` is
|
|
@@ -17,29 +16,19 @@ import type { DataLayerRuntime, TableContext, TableProbe } from './store'
|
|
|
17
16
|
*/
|
|
18
17
|
export type Locator = (dimension: string, id: string, using?: Session) => Promise<ReadonlyMap<string, string> | null>
|
|
19
18
|
|
|
20
|
-
/** What the framework writes itself:
|
|
19
|
+
/** What the framework writes itself: its own `fabrika_*` tables. Usable once bound. */
|
|
21
20
|
export interface FrameworkTables {
|
|
22
|
-
/** Why `fields` of form `form` cannot be appended to an inbox table of this layer; empty when they can. */
|
|
23
|
-
inboxProblems(form: string, fields: readonly string[]): readonly string[]
|
|
24
|
-
/**
|
|
25
|
-
* Appends one row of `values` (column → value) to the inbox table of `form`, taking one from `quota` in the same
|
|
26
|
-
* transaction, and makes the submission's `held` files held in it too; `false`, with nothing written, when the
|
|
27
|
-
* bucket is full.
|
|
28
|
-
*/
|
|
29
|
-
appendToInbox(form: string, values: Readonly<Record<string, unknown>>, quota: InboxQuota, held?: HeldFormFiles): Promise<boolean>
|
|
30
21
|
readonly webhooks: WebhookTables
|
|
31
22
|
readonly jobs: JobLeases
|
|
32
|
-
/** A
|
|
33
|
-
readonly
|
|
34
|
-
/**
|
|
35
|
-
readonly
|
|
23
|
+
/** A procedure's admission buckets, the challenge tokens it accepted and its byte budget. */
|
|
24
|
+
readonly admissions: Admissions
|
|
25
|
+
/** Every stored file (`fabrika_files`): staged before its object, claimed at its first reference, swept unreferenced. */
|
|
26
|
+
readonly files: StoredFiles
|
|
36
27
|
}
|
|
37
28
|
|
|
38
29
|
export interface FrameworkSide {
|
|
39
30
|
/** Connects the layer once and answers its locator; a second call throws. */
|
|
40
31
|
bind(runtime: DataLayerRuntime): Locator
|
|
41
|
-
/** The rows of the file collections' tables; usable once bound. */
|
|
42
|
-
readonly files: FileTables
|
|
43
32
|
readonly framework: FrameworkTables
|
|
44
33
|
/** The untyped handle over one table that `@fabrika/db/testing` hands the isolation tests. */
|
|
45
34
|
probe(table: string): TableProbe
|