@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/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
- export interface InboxScope<A extends string> {
50
- readonly inbox: string
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 or an inbox) declares column read actions only. */
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 | undefined
167
- readonly update: string | undefined
168
- readonly delete: string | undefined
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 the table's read action reads, in column-list order. */
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
- if (read !== actions.read) {
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 (write === actions.update) {
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 (action === model.actions.update) {
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 (action === read || action === insert || action === update || action === remove) {
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[] = model.actions.update === undefined ? [] : [model.actions.update]
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
- onlyFields(scope, INBOX_FIELDS, what)
676
- return freeze({ mode: 'inbox', form: nonEmpty(table, ownEntry(scope, 'inbox'), 'scope.inbox') })
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? }, { reference } or { inbox, read }`,
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 optional = (field: 'insert' | 'update' | 'delete') => {
862
+ const operation = (field: 'read' | 'insert' | 'update' | 'delete'): readonly string[] => {
742
863
  const value = ownEntry(actions, field)
743
- return value === undefined ? undefined : action(value, `actions.${field}`)
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 = action(ownEntry(actions, 'read'), 'actions.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
- const names = parseList(input, (value) => action(value, `actions.${field}`))
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
- if (names.length === 0) {
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 === read) {
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 freeze(names)
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: optional('insert'), update: optional('update'), delete: optional('delete'), appRead, appCount })
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
- function includes(names: readonly string[], name: string): boolean {
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 } ? A
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 } ? (R extends TableReadAction<P, T> ? never : K)
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: a form's inbox row and its own `fabrika_*` tables. Usable once bound. */
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 form's admission counters and the challenge tokens it accepted. */
33
- readonly forms: FormAdmissions
34
- /** The files forms received, until the job that drains each inbox attaches them to their owner. */
35
- readonly formFiles: FormFiles
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