@fabrika/db 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.ts ADDED
@@ -0,0 +1,1194 @@
1
+ import type { Insertable, Selectable, SelectType, Updateable } from 'kysely'
2
+ import {
3
+ freeze,
4
+ hasOwn,
5
+ inSet,
6
+ isArray,
7
+ type NameSet,
8
+ nameSet,
9
+ namesOf,
10
+ OBJECT_PROTOTYPE,
11
+ ownDescriptor,
12
+ ownEntry,
13
+ ownKeys,
14
+ ownNames,
15
+ ownSymbols,
16
+ parseList,
17
+ prototypeOf,
18
+ setOwn,
19
+ } from './builtins'
20
+ import { PolicyError } from './errors'
21
+ import { isPlainIdentifier } from './identifier'
22
+ import type { Unavailable } from './orm-types'
23
+ export type { RowOf } from './orm-types'
24
+ import type { ColumnTypes, DefineVocabularyPolicies, TableVocabulary } from './vocabulary-tables'
25
+
26
+ export interface DimensionScope<Col extends string> {
27
+ readonly dimension: string
28
+ readonly key: Col
29
+ readonly in?: { readonly dimension: string; readonly column: Col }
30
+ }
31
+
32
+ export interface ChildScope<Table extends string, Col extends string> {
33
+ readonly child: Table
34
+ readonly key: Col
35
+ readonly column: Col
36
+ readonly inherit: readonly Col[]
37
+ }
38
+
39
+ export interface ReferenceScope {
40
+ readonly reference: string
41
+ }
42
+
43
+ /** Rows of the whole application, in no dimension; the value says why. `key` defaults to `id`. */
44
+ export interface ApplicationScope<Col extends string> {
45
+ readonly app: string
46
+ readonly key?: Col
47
+ }
48
+
49
+ export interface InboxScope<A extends string> {
50
+ readonly inbox: string
51
+ readonly read: A
52
+ }
53
+
54
+ export interface TableActions<A extends string> {
55
+ readonly read: A
56
+ readonly insert?: A
57
+ readonly update?: A
58
+ readonly delete?: A
59
+ }
60
+
61
+ /**
62
+ * A dimension or child table's actions: also those besides `read` with which an `app` endpoint lists every row,
63
+ * read-only (`appRead`), and those with which it only counts and aggregates every row (`appCount`).
64
+ */
65
+ export interface RecordActions<A extends string> extends TableActions<A> {
66
+ readonly appRead?: readonly A[]
67
+ readonly appCount?: readonly A[]
68
+ }
69
+
70
+ interface WritablePolicy<Col extends string, A extends string, V extends Col, Acts = TableActions<A>> {
71
+ readonly actions: Acts
72
+ readonly insert?: readonly Col[]
73
+ readonly update?: readonly Col[]
74
+ /** An INTEGER (int4) column: Postgres returns a BIGINT as a string, so it cannot be compared as a number. */
75
+ readonly version?: V
76
+ readonly createdBy?: Col
77
+ readonly columns?: ColumnPolicies<Col, A>
78
+ }
79
+
80
+ /**
81
+ * A column's own actions. `read`: only a capability of exactly that action reads, filters or sorts by the column;
82
+ * without one it falls back to the table's read action. `write`: only a capability of that action updates the column,
83
+ * and the table's update action does not, so the column is not in `update`.
84
+ */
85
+ export type ColumnPolicies<Col extends string, A extends string> = { readonly [C in Col]?: ColumnPolicy<A> }
86
+
87
+ /** A column's own read action, its own write action, or both. */
88
+ export interface ColumnPolicy<A extends string> {
89
+ readonly read?: A
90
+ readonly write?: A
91
+ }
92
+
93
+ /** A table no handler writes (a reference or an inbox) declares column read actions only. */
94
+ export type ColumnReadPolicies<Col extends string, A extends string> = { readonly [C in Col]?: { readonly read: A } }
95
+
96
+ export interface DimensionPolicy<Col extends string, A extends string, V extends Col = Col> extends WritablePolicy<Col, A, V, RecordActions<A>> {
97
+ readonly scope: DimensionScope<Col>
98
+ }
99
+
100
+ export interface ChildPolicy<Table extends string, Col extends string, A extends string, V extends Col = Col>
101
+ extends WritablePolicy<Col, A, V, RecordActions<A>>
102
+ {
103
+ readonly scope: ChildScope<Table, Col>
104
+ }
105
+
106
+ /** An application table: written like a dimension table, by an `app` endpoint whose action is the operation's. */
107
+ export interface ApplicationPolicy<Col extends string, A extends string, V extends Col = Col> extends WritablePolicy<Col, A, V> {
108
+ readonly scope: ApplicationScope<Col>
109
+ }
110
+
111
+ interface ReadOnlyPolicy<Col extends string, A extends string> {
112
+ readonly insert?: never
113
+ readonly update?: never
114
+ readonly version?: never
115
+ readonly createdBy?: never
116
+ readonly columns?: ColumnReadPolicies<Col, A>
117
+ }
118
+
119
+ export interface ReferencePolicy<Col extends string, A extends string> extends ReadOnlyPolicy<Col, A> {
120
+ 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
127
+ }
128
+
129
+ /** The columns a version can live in: those whose generated select type is `number`, an INTEGER (int4). */
130
+ export type VersionColumn<Row> = { [K in keyof Row & string]: SelectType<Row[K]> extends number ? K : never }[keyof Row & string]
131
+
132
+ /** A foreign key within the schema, as codegen reads it: `columns[i]` references column `references[i]` of `table`. */
133
+ export interface ForeignKey<Col extends string = string, Table extends string = string> {
134
+ readonly columns: readonly Col[]
135
+ readonly table: Table
136
+ readonly references: readonly string[]
137
+ }
138
+
139
+ /** One table of the generated column list: its column names, and its foreign keys to tables of the schema. */
140
+ export interface TableColumns<Col extends string = string, Table extends string = string> {
141
+ readonly columns: readonly Col[]
142
+ readonly foreignKeys: readonly ForeignKey<Col, Table>[]
143
+ /** The columns of Postgres type `date`, which a read answers as `YYYY-MM-DD` text; absent when there are none. */
144
+ readonly dates?: readonly Col[]
145
+ /** The `timestamp` and `timestamptz` columns, which a write takes as a `Date` or strict ISO 8601 text; absent when none. */
146
+ readonly timestamps?: readonly Col[]
147
+ }
148
+
149
+ /** The columns and foreign keys of every table, generated next to the schema types. */
150
+ export type ColumnList<DB> = { readonly [T in keyof DB & string]: TableColumns<keyof DB[T] & string, keyof DB & string> }
151
+
152
+ export interface Container {
153
+ readonly dimension: string
154
+ readonly column: string
155
+ }
156
+
157
+ export type ScopeModel =
158
+ | { readonly mode: 'dimension'; readonly dimension: string; readonly key: string; readonly container: Container | undefined }
159
+ | { readonly mode: 'child'; readonly parent: string; readonly key: string; readonly column: string; readonly inherit: readonly string[] }
160
+ | { readonly mode: 'reference'; readonly reason: string }
161
+ | { readonly mode: 'inbox'; readonly form: string }
162
+ | { readonly mode: 'app'; readonly reason: string; readonly key: string }
163
+
164
+ export interface ActionModel {
165
+ readonly read: string
166
+ readonly insert: string | undefined
167
+ readonly update: string | undefined
168
+ readonly delete: string | undefined
169
+ /** A dimension or child table's actions besides `read` with which an `app` endpoint reads every row, read-only. */
170
+ readonly appRead: readonly string[]
171
+ /** A dimension or child table's actions with which an `app` endpoint counts and aggregates every row, and reads none. */
172
+ readonly appCount: readonly string[]
173
+ }
174
+
175
+ /** One table's policy, checked against its column list. */
176
+ export interface TableModel {
177
+ readonly name: string
178
+ readonly scope: ScopeModel
179
+ readonly actions: ActionModel
180
+ /** Every column the table has. */
181
+ readonly columns: NameSet
182
+ /** The columns the table's read action reads, in column-list order. */
183
+ readonly visible: readonly string[]
184
+ readonly insert: NameSet
185
+ readonly update: NameSet
186
+ readonly version: string | undefined
187
+ readonly createdBy: string | undefined
188
+ /** The columns that declare their own read action, as declared (also those equal to the table's). */
189
+ readonly columnReads: Readonly<Record<string, string>>
190
+ /** The columns that declare their own write action: only a capability of it updates them. */
191
+ readonly columnWrites: Readonly<Record<string, string>>
192
+ /** The columns of Postgres type `date`, read as `YYYY-MM-DD` text. */
193
+ readonly dates: NameSet
194
+ /** The `timestamp` and `timestamptz` columns: a write's text must be strict ISO 8601 with an offset. */
195
+ readonly timestamps: NameSet
196
+ /** The table's foreign keys, from the column list; a containment column must be one to its container's key. */
197
+ readonly foreignKeys: readonly ForeignKey[]
198
+ /**
199
+ * The containment links this table carries a column for, nearest first: a dimension table's container and
200
+ * the container's own chain; a child's parent and the parent's chain. Every predicate of a Ref covers all.
201
+ */
202
+ readonly chain: readonly Container[]
203
+ }
204
+
205
+ /** A child's `column` and `inherit`, when omitted, are derived from its parent while the tables are linked. */
206
+ type ParsedScope =
207
+ | Exclude<ScopeModel, { readonly mode: 'child' }>
208
+ | {
209
+ readonly mode: 'child'
210
+ readonly parent: string
211
+ readonly key: string
212
+ readonly column: string | undefined
213
+ readonly inherit: readonly string[] | undefined
214
+ }
215
+
216
+ type ParsedTable = Omit<TableModel, 'chain' | 'scope'> & { readonly scope: ParsedScope }
217
+
218
+ let readModels: <DB, P>(tables: Tables<DB, P>) => TableModels
219
+ let hasModels: (value: object) => boolean = () => false
220
+
221
+ /** Every table's model by name; read it through `modelOf`, never by a plain property access. */
222
+ export type TableModels = Readonly<Record<string, TableModel>>
223
+
224
+ /** The validated policies of every table; carries the schema and the literal policy types to the store. */
225
+ export class Tables<DB, P> {
226
+ readonly #models: TableModels
227
+ readonly #types: { readonly db?: (db: DB) => DB; readonly policies?: (policies: P) => P } = {}
228
+
229
+ constructor(models: TableModels) {
230
+ this.#models = models
231
+ }
232
+
233
+ static {
234
+ readModels = (tables) => tables.#models
235
+ hasModels = (value) => #models in value
236
+ }
237
+ }
238
+ Object.freeze(Tables.prototype)
239
+
240
+ export function tableModels<DB, P>(tables: Tables<DB, P>): TableModels {
241
+ return readModels(tables)
242
+ }
243
+
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
+ /** Whether `value` is what `defineTables` returned, rather than another form of the same policies. */
254
+ export function isTables<DB, P, Other extends object>(value: Tables<DB, P> | Other): value is Tables<DB, P> {
255
+ return hasModels(value)
256
+ }
257
+
258
+ export function modelOf(models: TableModels, table: string): TableModel | undefined {
259
+ return ownEntry(models, table)
260
+ }
261
+
262
+ /**
263
+ * Declares the access policy of every table, under the application's vocabulary:
264
+ * `defineTables(vocabulary, columns)({ notes: { scope: …, actions: … }, … })`. `columns` is the generated column list,
265
+ * which carries the schema's row types; a list without them makes the policies uncallable. The action and dimension
266
+ * names are the vocabulary's, and each table's containment comes from it: a key defaults to `id`, a container column
267
+ * to `<container>_id`, a child's column to `<parent dimension>_id`, and its inherited chain is its parent's.
268
+ */
269
+ export function defineTables<const V extends TableVocabulary, C extends object>(vocabulary: V, columns: C): VocabularyTables<V, C>
270
+ export function defineTables<DB, P>(vocabularyInput: unknown, columns: unknown): (policies: P) => Tables<DB, P> {
271
+ if (columns === undefined) {
272
+ throw new PolicyError('defineTables takes the vocabulary and the generated column list: defineTables(vocabulary, columns)({ … })')
273
+ }
274
+ const vocabulary = parseVocabulary(vocabularyInput)
275
+ return (policies) => new Tables(parseTables(columns, policies, vocabulary))
276
+ }
277
+
278
+ /** `defineTables(vocabulary, columns)` answers the policies' builder, or names the fix for an untyped column list. */
279
+ export type VocabularyTables<V, C> = [ColumnTypes<C>] extends [never]
280
+ ? Unavailable<'the column list carries no row types: import columns from the generated src/db/schema.generated.ts (fabrika db codegen)'>
281
+ : DefineVocabularyPolicies<ColumnTypes<C>, V>
282
+
283
+ /**
284
+ * The same policies read from values nobody type-checked: `src/db/tables.ts` and its vocabulary as the static reader
285
+ * has them from a commit. Every shape `defineTables` refuses at runtime is refused here, through the same parse.
286
+ */
287
+ export function readTables(columns: unknown, policies: unknown, vocabulary: unknown): Tables<unknown, unknown> {
288
+ return new Tables(parseTables(columns, policies, parseVocabulary(vocabulary)))
289
+ }
290
+
291
+ /** Each dimension of the vocabulary, by name, with the dimension it lies in (`undefined` for a top-level one). */
292
+ type Containment = Readonly<Record<string, { readonly container: string | undefined }>>
293
+
294
+ /** What the parse reads of the vocabulary: its containment and its action names; its roles are the contract's. */
295
+ interface VocabularyModel {
296
+ readonly dimensions: Containment
297
+ readonly actions: NameSet
298
+ }
299
+
300
+ function parseVocabulary(input: unknown): VocabularyModel {
301
+ const vocabulary = plainRecord(input, 'the vocabulary')
302
+ const dimensions = plainRecord(ownEntry(vocabulary, 'dimensions'), 'the vocabulary dimensions')
303
+ const names = ownKeys(dimensions)
304
+ const containment: Record<string, { readonly container: string | undefined }> = {}
305
+ for (let index = 0; index < names.length; index++) {
306
+ const name = names[index] ?? ''
307
+ const container = ownEntry(plainRecord(ownEntry(dimensions, name), `the vocabulary dimension ${name}`), 'in')
308
+ if (container !== undefined && (typeof container !== 'string' || !hasOwn(dimensions, container))) {
309
+ throw new PolicyError(`the vocabulary puts ${name} in ${String(container)}, which is not one of its dimensions`)
310
+ }
311
+ setOwn(containment, name, freeze({ container }))
312
+ }
313
+ for (let index = 0; index < names.length; index++) {
314
+ const path = [names[index] ?? '']
315
+ for (let above = ownEntry(containment, path[0] ?? '')?.container; above !== undefined; above = ownEntry(containment, above)?.container) {
316
+ path[path.length] = above
317
+ if (above === path[0] || path.length > names.length) {
318
+ throw new PolicyError(`the vocabulary's containment is a cycle (${path.join(' → ')})`)
319
+ }
320
+ }
321
+ }
322
+ const actions = ownKeys(plainRecord(ownEntry(vocabulary, 'actions'), 'the vocabulary actions'))
323
+ return freeze({ dimensions: freeze(containment), actions: nameSet(actions) })
324
+ }
325
+
326
+ const COLUMN_LIST_FIELDS = nameSet(['columns', 'foreignKeys', 'dates', 'timestamps'])
327
+ const FOREIGN_KEY_FIELDS = nameSet(['columns', 'table', 'references'])
328
+ const POLICY_FIELDS = nameSet(['scope', 'actions', 'insert', 'update', 'version', 'createdBy', 'columns'])
329
+ const ACTION_FIELDS = nameSet(['read', 'insert', 'update', 'delete', 'appRead', 'appCount'])
330
+ const DIMENSION_FIELDS = nameSet(['dimension', 'key', 'in'])
331
+ const CONTAINER_FIELDS = nameSet(['dimension', 'column'])
332
+ const CHILD_FIELDS = nameSet(['child', 'key', 'column', 'inherit'])
333
+ const REFERENCE_FIELDS = nameSet(['reference'])
334
+ const APPLICATION_FIELDS = nameSet(['app', 'key'])
335
+ const INBOX_FIELDS = nameSet(['inbox', 'read'])
336
+ const COLUMN_FIELDS = nameSet(['read', 'write'])
337
+
338
+ /** A copy of a plain object's own data properties; anything else (an instance, a symbol key, an accessor) is refused. */
339
+ function plainRecord(value: unknown, what: string): Readonly<Record<string, unknown>> {
340
+ const prototype: unknown = typeof value === 'object' && value !== null && !isArray(value) ? prototypeOf(value) : undefined
341
+ if ((prototype !== OBJECT_PROTOTYPE && prototype !== null) || typeof value !== 'object' || value === null || ownSymbols(value).length > 0) {
342
+ throw new PolicyError(`${what} must be a plain object`)
343
+ }
344
+ const copy: Record<string, unknown> = {}
345
+ const keys = ownNames(value)
346
+ for (let index = 0; index < keys.length; index++) {
347
+ const key = keys[index] ?? ''
348
+ const descriptor = ownDescriptor(value, key)
349
+ if (descriptor === undefined || !hasOwn(descriptor, 'value')) {
350
+ throw new PolicyError(`${what}: ${key} is an accessor; a policy holds plain values`)
351
+ }
352
+ setOwn(copy, key, descriptor.value)
353
+ }
354
+ return freeze(copy)
355
+ }
356
+
357
+ /** Unknown keys are refused, not dropped: a misspelt `insrt` would otherwise leave the table without its column list. */
358
+ function onlyFields(record: Readonly<Record<string, unknown>>, allowed: NameSet, what: string): void {
359
+ const keys = ownKeys(record)
360
+ for (let index = 0; index < keys.length; index++) {
361
+ const key = keys[index] ?? ''
362
+ if (!inSet(allowed, key)) {
363
+ throw new PolicyError(`${what}: ${key} is not a field here (allowed: ${namesOf(allowed).join(', ')})`)
364
+ }
365
+ }
366
+ }
367
+
368
+ function parseTables(columnsInput: unknown, policiesInput: unknown, vocabulary: VocabularyModel): TableModels {
369
+ const columns = plainRecord(columnsInput, 'the column list')
370
+ const policies = plainRecord(policiesInput, 'the table policies')
371
+ const tables = ownKeys(columns)
372
+ const policyTables = ownKeys(policies)
373
+ for (let index = 0; index < policyTables.length; index++) {
374
+ const table = policyTables[index] ?? ''
375
+ if (!hasOwn(columns, table)) {
376
+ throw new PolicyError(`${table}: no such table in the column list (tables: ${tables.join(', ')})`)
377
+ }
378
+ }
379
+ const parsed: ParsedTable[] = []
380
+ for (let index = 0; index < tables.length; index++) {
381
+ const table = tables[index] ?? ''
382
+ const policy = ownEntry(policies, table)
383
+ if (policy === undefined) {
384
+ throw new PolicyError(`${table}: every table needs a policy in tables.ts`)
385
+ }
386
+ parsed[index] = parseTable(table, ownEntry(columns, table), policy, vocabulary)
387
+ }
388
+ return linkTables(parsed, vocabulary.dimensions)
389
+ }
390
+
391
+ /** How a policy names its columns: one it states must exist, and so must one a convention derives for it. */
392
+ interface ColumnReader {
393
+ readonly column: (name: unknown, field: string) => string
394
+ readonly columnsOf: (value: unknown, field: string) => string[]
395
+ /** `name` is what `field` defaults to, for `reason`; when the table has no such column, the policy states `fix`. */
396
+ readonly derived: (name: string, field: string, reason: string, fix: string) => string
397
+ }
398
+
399
+ function parseTable(table: string, columnList: unknown, policyInput: unknown, vocabulary: VocabularyModel): ParsedTable {
400
+ if (!isPlainIdentifier(table)) {
401
+ throw new PolicyError(`${table}: a table name must match ^[a-z_][a-z0-9_]*$`)
402
+ }
403
+ const policy = plainRecord(policyInput, `${table}: the policy`)
404
+ onlyFields(policy, POLICY_FIELDS, `${table}: the policy`)
405
+ const entry = plainRecord(columnList, `${table}: the column list entry`)
406
+ onlyFields(entry, COLUMN_LIST_FIELDS, `${table}: the column list entry`)
407
+ const columnNames = parseList(ownEntry(entry, 'columns'), (name) => {
408
+ if (!isPlainIdentifier(name)) {
409
+ throw new PolicyError(`${table}.${String(name)}: a column name must match ^[a-z_][a-z0-9_]*$`)
410
+ }
411
+ return name
412
+ })
413
+ if (columnNames === undefined) {
414
+ throw new PolicyError(`${table}: the column list takes a plain array of column names`)
415
+ }
416
+ const columns = nameSet(columnNames)
417
+ const column = (name: unknown, field: string): string => {
418
+ if (!inSet(columns, name)) {
419
+ throw new PolicyError(`${table}: ${field} names ${String(name)}, which is not a column (columns: ${columnNames.join(', ')})`)
420
+ }
421
+ return name
422
+ }
423
+ const columnsOf = (value: unknown, field: string): string[] => {
424
+ if (value === undefined) {
425
+ return []
426
+ }
427
+ const names = parseList(value, (name) => column(name, field))
428
+ if (names === undefined) {
429
+ throw new PolicyError(`${table}: ${field} takes a plain array of column names`)
430
+ }
431
+ return names
432
+ }
433
+ const derived = (name: string, field: string, reason: string, fix: string): string => {
434
+ if (!inSet(columns, name)) {
435
+ throw new PolicyError(
436
+ `${table}: ${field} defaults to ${name} (${reason}), which is not a column; state ${fix} (columns: ${columnNames.join(', ')})`,
437
+ )
438
+ }
439
+ return name
440
+ }
441
+ const scopeInput = plainRecord(ownEntry(policy, 'scope'), `${table}: scope`)
442
+ const foreignKeys = parseForeignKeys(table, ownEntry(entry, 'foreignKeys'), column)
443
+ const scope = parseScope(table, scopeInput, { column, columnsOf, derived }, vocabulary.dimensions)
444
+ const action = (value: unknown, field: string): string => {
445
+ const name = nonEmpty(table, value, field)
446
+ if (!inSet(vocabulary.actions, name)) {
447
+ throw new PolicyError(
448
+ `${table}: ${field} names ${name}, which is not an action of the vocabulary (actions: ${namesOf(vocabulary.actions).join(', ')})`,
449
+ )
450
+ }
451
+ return name
452
+ }
453
+ const actions = parseActions(table, scopeInput, ownEntry(policy, 'actions'), action)
454
+ const insert = nameSet(columnsOf(ownEntry(policy, 'insert'), 'insert'))
455
+ const update = nameSet(columnsOf(ownEntry(policy, 'update'), 'update'))
456
+ const versionInput = ownEntry(policy, 'version')
457
+ const createdByInput = ownEntry(policy, 'createdBy')
458
+ const version = versionInput === undefined ? undefined : column(versionInput, 'version')
459
+ const createdBy = createdByInput === undefined ? undefined : column(createdByInput, 'createdBy')
460
+ const restricted: string[] = []
461
+ const columnReads: Record<string, string> = {}
462
+ const columnWrites: Record<string, string> = {}
463
+ const columnsInput = ownEntry(policy, 'columns')
464
+ const columnPolicies = columnsInput === undefined ? freeze({}) : plainRecord(columnsInput, `${table}: columns`)
465
+ const declared = ownKeys(columnPolicies)
466
+ for (let index = 0; index < declared.length; index++) {
467
+ const name = column(declared[index], 'columns')
468
+ const columnPolicy = plainRecord(ownEntry(columnPolicies, name), `${table}: columns.${name}`)
469
+ onlyFields(columnPolicy, COLUMN_FIELDS, `${table}: columns.${name}`)
470
+ const readInput = ownEntry(columnPolicy, 'read')
471
+ const writeInput = ownEntry(columnPolicy, 'write')
472
+ if (readInput === undefined && writeInput === undefined) {
473
+ throw new PolicyError(`${table}: columns.${name} declares no action; it takes read, write or both`)
474
+ }
475
+ if (readInput !== undefined) {
476
+ const read = action(readInput, `columns.${name}.read`)
477
+ setOwn(columnReads, name, read)
478
+ if (read !== actions.read) {
479
+ restricted[restricted.length] = name
480
+ }
481
+ }
482
+ if (writeInput !== undefined) {
483
+ setOwn(columnWrites, name, columnWrite(table, name, action(writeInput, `columns.${name}.write`), actions, update))
484
+ }
485
+ }
486
+ const hidden = nameSet(restricted)
487
+
488
+ // Every capability reads the key and the version: a page is ordered by the key, and a write answers both.
489
+ if ((scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app') && hasOwn(columnPolicies, scope.key)) {
490
+ throw new PolicyError(`${table}: the key ${scope.key} takes no action of its own`)
491
+ }
492
+ if (version !== undefined && hasOwn(columnPolicies, version)) {
493
+ throw new PolicyError(`${table}: the version ${version} takes no action of its own`)
494
+ }
495
+ const writable = scope.mode === 'dimension' || scope.mode === 'child' || scope.mode === 'app'
496
+ if (
497
+ !writable
498
+ && (namesOf(insert).length > 0 || namesOf(update).length > 0 || version !== undefined || createdBy !== undefined
499
+ || ownKeys(columnWrites).length > 0)
500
+ ) {
501
+ throw new PolicyError(`${table}: a ${scope.mode} table is never written by a handler`)
502
+ }
503
+ const dates = parseColumnSet(table, ownEntry(entry, 'dates'), column, 'dates')
504
+ const timestamps = parseColumnSet(table, ownEntry(entry, 'timestamps'), column, 'timestamps')
505
+ const visible: string[] = []
506
+ for (let index = 0; index < columnNames.length; index++) {
507
+ const name = columnNames[index] ?? ''
508
+ if (!inSet(hidden, name)) {
509
+ visible[visible.length] = name
510
+ }
511
+ }
512
+ return {
513
+ name: table,
514
+ scope,
515
+ actions,
516
+ columns,
517
+ visible: freeze(visible),
518
+ insert,
519
+ update,
520
+ version,
521
+ createdBy,
522
+ columnReads: freeze(columnReads),
523
+ columnWrites: freeze(columnWrites),
524
+ dates,
525
+ timestamps,
526
+ foreignKeys,
527
+ }
528
+ }
529
+
530
+ /**
531
+ * A column's own write action takes the column away from the table's update action: one action writes a column, so
532
+ * the column is not in `update`, and the action is not the update action (that one lists the column in `update`).
533
+ */
534
+ function columnWrite(table: string, name: string, write: string, actions: ActionModel, update: NameSet): string {
535
+ if (inSet(update, name)) {
536
+ throw new PolicyError(
537
+ `${table}: ${name} is in update, which the table's update action writes, and declares its own write action ${write}; remove it from update`,
538
+ )
539
+ }
540
+ if (write === actions.update) {
541
+ throw new PolicyError(`${table}: columns.${name}.write names ${write}, the table's update action; list ${name} in update instead`)
542
+ }
543
+ return write
544
+ }
545
+
546
+ /** The column list's `date` (`dates`) or timestamp (`timestamps`) columns, each one of the table's. */
547
+ function parseColumnSet(table: string, input: unknown, column: (name: unknown, field: string) => string, field: string): NameSet {
548
+ if (input === undefined) {
549
+ return nameSet([])
550
+ }
551
+ const names = parseList(input, (name) => column(name, field))
552
+ if (names === undefined) {
553
+ throw new PolicyError(`${table}: the column list entry takes ${field}, a plain array of column names`)
554
+ }
555
+ return nameSet(names)
556
+ }
557
+
558
+ /**
559
+ * The columns a capability of `action` updates in `model`: the update list for the table's update action, the
560
+ * columns that declare it as their own write action for any other; none for an action that writes nothing here.
561
+ */
562
+ export function updatableBy(model: TableModel, action: string): NameSet {
563
+ if (action === model.actions.update) {
564
+ return model.update
565
+ }
566
+ const names = ownKeys(model.columnWrites)
567
+ const columns: string[] = []
568
+ for (let index = 0; index < names.length; index++) {
569
+ const name = names[index] ?? ''
570
+ if (model.columnWrites[name] === action) {
571
+ columns[columns.length] = name
572
+ }
573
+ }
574
+ return nameSet(columns)
575
+ }
576
+
577
+ /** Whether the table names `action`: one of its table actions, or a column's own write action. */
578
+ export function namesAction(model: TableModel, action: string): boolean {
579
+ const { read, insert, update, delete: remove } = model.actions
580
+ if (action === read || action === insert || action === update || action === remove) {
581
+ return true
582
+ }
583
+ const columns = ownKeys(model.columnWrites)
584
+ for (let index = 0; index < columns.length; index++) {
585
+ if (model.columnWrites[columns[index] ?? ''] === action) {
586
+ return true
587
+ }
588
+ }
589
+ return false
590
+ }
591
+
592
+ /**
593
+ * The columns a capability of `action` reads: every one that declares no read action of its own, or exactly this
594
+ * one. A capability reads its own row, so a write `Ref` sees the columns of its action and none of another's.
595
+ */
596
+ export function visibleFor(model: TableModel, action: string): readonly string[] {
597
+ const columns = namesOf(model.columns)
598
+ const visible: string[] = []
599
+ for (let index = 0; index < columns.length; index++) {
600
+ const name = columns[index] ?? ''
601
+ const declared = hasOwn(model.columnReads, name) ? model.columnReads[name] : undefined
602
+ if (declared === undefined || declared === action) {
603
+ visible[visible.length] = name
604
+ }
605
+ }
606
+ return freeze(visible)
607
+ }
608
+
609
+ /** Every action that updates a column of `model`: its update action and each column's own write action. */
610
+ export function updateActions(model: TableModel): readonly string[] {
611
+ const actions: string[] = model.actions.update === undefined ? [] : [model.actions.update]
612
+ const names = ownKeys(model.columnWrites)
613
+ for (let index = 0; index < names.length; index++) {
614
+ const write = model.columnWrites[names[index] ?? '']
615
+ if (write !== undefined && !includes(actions, write)) {
616
+ actions[actions.length] = write
617
+ }
618
+ }
619
+ return actions
620
+ }
621
+
622
+ /** The table's foreign keys, each column one of its own; the referenced side is checked once every table is parsed. */
623
+ function parseForeignKeys(table: string, input: unknown, column: (name: unknown, field: string) => string): readonly ForeignKey[] {
624
+ const keys = parseList(input, (item) => {
625
+ const key = plainRecord(item, `${table}: a foreign key`)
626
+ onlyFields(key, FOREIGN_KEY_FIELDS, `${table}: a foreign key`)
627
+ const columns = parseList(ownEntry(key, 'columns'), (name) => column(name, 'a foreign key'))
628
+ const references = parseList(ownEntry(key, 'references'), (name) => nonEmpty(table, name, 'a foreign key reference'))
629
+ if (columns === undefined || references === undefined || columns.length === 0 || columns.length !== references.length) {
630
+ throw new PolicyError(`${table}: a foreign key takes columns and references, two plain arrays of one length`)
631
+ }
632
+ return freeze({ columns: freeze(columns), table: nonEmpty(table, ownEntry(key, 'table'), 'a foreign key table'), references: freeze(references) })
633
+ })
634
+ if (keys === undefined) {
635
+ throw new PolicyError(`${table}: the column list entry takes foreignKeys, a plain array`)
636
+ }
637
+ return freeze(keys)
638
+ }
639
+
640
+ function parseScope(table: string, scope: Readonly<Record<string, unknown>>, columns: ColumnReader, containment: Containment): ParsedScope {
641
+ const what = `${table}: scope`
642
+ const key = (): string => {
643
+ const stated = ownEntry(scope, 'key')
644
+ return stated === undefined ? columns.derived('id', 'scope.key', 'the key convention', 'scope.key') : columns.column(stated, 'scope.key')
645
+ }
646
+ if (hasOwn(scope, 'dimension')) {
647
+ onlyFields(scope, DIMENSION_FIELDS, what)
648
+ const dimension = nonEmpty(table, ownEntry(scope, 'dimension'), 'scope.dimension')
649
+ const definition = ownEntry(containment, dimension)
650
+ if (definition === undefined) {
651
+ throw new PolicyError(
652
+ `${table}: scope.dimension names ${dimension}, which is not a dimension of the vocabulary (dimensions: ${ownKeys(containment).join(', ')})`,
653
+ )
654
+ }
655
+ const container = parseContainer(table, dimension, definition.container, ownEntry(scope, 'in'), columns)
656
+ return freeze({ mode: 'dimension', dimension, key: key(), container })
657
+ }
658
+ if (hasOwn(scope, 'child')) {
659
+ onlyFields(scope, CHILD_FIELDS, what)
660
+ const column = ownEntry(scope, 'column')
661
+ const inherit = ownEntry(scope, 'inherit')
662
+ return freeze({
663
+ mode: 'child',
664
+ parent: nonEmpty(table, ownEntry(scope, 'child'), 'scope.child'),
665
+ key: key(),
666
+ column: column === undefined ? undefined : columns.column(column, 'scope.column'),
667
+ inherit: inherit === undefined ? undefined : freeze(columns.columnsOf(inherit, 'scope.inherit')),
668
+ })
669
+ }
670
+ if (hasOwn(scope, 'reference')) {
671
+ onlyFields(scope, REFERENCE_FIELDS, what)
672
+ return freeze({ mode: 'reference', reason: nonEmpty(table, ownEntry(scope, 'reference'), 'scope.reference') })
673
+ }
674
+ if (hasOwn(scope, 'inbox')) {
675
+ onlyFields(scope, INBOX_FIELDS, what)
676
+ return freeze({ mode: 'inbox', form: nonEmpty(table, ownEntry(scope, 'inbox'), 'scope.inbox') })
677
+ }
678
+ if (hasOwn(scope, 'app')) {
679
+ onlyFields(scope, APPLICATION_FIELDS, what)
680
+ return freeze({ mode: 'app', reason: nonEmpty(table, ownEntry(scope, 'app'), 'scope.app'), key: key() })
681
+ }
682
+ throw new PolicyError(
683
+ `${what} takes one mode: { dimension, key?, in? }, { child, key?, column?, inherit? }, { app, key? }, { reference } or { inbox, read }`,
684
+ )
685
+ }
686
+
687
+ /**
688
+ * A dimension table's container is the one the vocabulary puts its dimension in, never one the policy chooses. Its
689
+ * column is `<container>_id`, or the one `scope.in.column` names off that convention.
690
+ */
691
+ function parseContainer(
692
+ table: string,
693
+ dimension: string,
694
+ container: string | undefined,
695
+ input: unknown,
696
+ columns: ColumnReader,
697
+ ): Container | undefined {
698
+ if (container === undefined) {
699
+ if (input !== undefined) {
700
+ throw new PolicyError(`${table}: the vocabulary puts ${dimension} in no container, so scope.in is not a field here`)
701
+ }
702
+ return undefined
703
+ }
704
+ if (input === undefined) {
705
+ const column = columns.derived(`${container}_id`, 'scope.in.column', `the vocabulary puts ${dimension} in ${container}`, 'scope.in: { column }')
706
+ return freeze({ dimension: container, column })
707
+ }
708
+ const stated = plainRecord(input, `${table}: scope.in`)
709
+ onlyFields(stated, CONTAINER_FIELDS, `${table}: scope.in`)
710
+ const statedDimension = ownEntry(stated, 'dimension')
711
+ if (statedDimension !== undefined && statedDimension !== container) {
712
+ throw new PolicyError(`${table}: the vocabulary puts ${dimension} in ${container}, not in ${String(statedDimension)}`)
713
+ }
714
+ return freeze({ dimension: container, column: columns.column(ownEntry(stated, 'column'), 'scope.in.column') })
715
+ }
716
+
717
+ function parseActions(
718
+ table: string,
719
+ scope: Readonly<Record<string, unknown>>,
720
+ actionsInput: unknown,
721
+ action: (value: unknown, field: string) => string,
722
+ ): 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
+ if (actionsInput === undefined) {
737
+ throw new PolicyError(`${table}: the policy needs actions`)
738
+ }
739
+ const actions = plainRecord(actionsInput, `${table}: actions`)
740
+ onlyFields(actions, ACTION_FIELDS, `${table}: actions`)
741
+ const optional = (field: 'insert' | 'update' | 'delete') => {
742
+ const value = ownEntry(actions, field)
743
+ return value === undefined ? undefined : action(value, `actions.${field}`)
744
+ }
745
+ const read = action(ownEntry(actions, 'read'), 'actions.read')
746
+ const records = hasOwn(scope, 'dimension') || hasOwn(scope, 'child')
747
+ const kind = hasOwn(scope, 'app') ? 'an application table' : 'a reference table'
748
+ const reach = (field: 'appRead' | 'appCount'): readonly string[] => {
749
+ const input = ownEntry(actions, field)
750
+ if (input === undefined) {
751
+ return freeze([])
752
+ }
753
+ if (!records) {
754
+ throw new PolicyError(`${table}: actions.${field} is for a dimension or child table; ${kind} takes none`)
755
+ }
756
+ const names = parseList(input, (value) => action(value, `actions.${field}`))
757
+ if (names === undefined) {
758
+ throw new PolicyError(`${table}: actions.${field} takes a plain array of actions`)
759
+ }
760
+ if (names.length === 0) {
761
+ throw new PolicyError(`${table}: actions.${field} lists no action; leave it out`)
762
+ }
763
+ for (let index = 0; index < names.length; index++) {
764
+ const name = names[index] ?? ''
765
+ if (name === read) {
766
+ throw new PolicyError(`${table}: actions.${field} names ${name}, its read action, which reads every row app-wide already`)
767
+ }
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
+ }
774
+ return freeze(names)
775
+ }
776
+ const appRead = reach('appRead')
777
+ const appCount = reach('appCount')
778
+ for (let index = 0; index < appCount.length; index++) {
779
+ const name = appCount[index] ?? ''
780
+ if (includes(appRead, name)) {
781
+ throw new PolicyError(`${table}: actions.appRead and actions.appCount both name ${name}`)
782
+ }
783
+ }
784
+ return freeze({ read, insert: optional('insert'), update: optional('update'), delete: optional('delete'), appRead, appCount })
785
+ }
786
+
787
+ function nonEmpty(table: string, value: unknown, field: string): string {
788
+ if (typeof value !== 'string' || value.length === 0) {
789
+ throw new PolicyError(`${table}: ${field} must be a non-empty string`)
790
+ }
791
+ return value
792
+ }
793
+
794
+ interface Stamp {
795
+ readonly column: string
796
+ readonly role: string
797
+ }
798
+
799
+ /** The columns the layer writes itself: the key, every containment link, the version and the creator. */
800
+ function stampsOf(table: ParsedTable, chain: readonly Container[]): readonly Stamp[] {
801
+ const stamps: Stamp[] = []
802
+ if (table.scope.mode === 'dimension' || table.scope.mode === 'child' || table.scope.mode === 'app') {
803
+ stamps[stamps.length] = { column: table.scope.key, role: 'the key' }
804
+ }
805
+ for (let index = 0; index < chain.length; index++) {
806
+ const link = chain[index]
807
+ if (link !== undefined) {
808
+ stamps[stamps.length] = { column: link.column, role: `the ${link.dimension} containment column` }
809
+ }
810
+ }
811
+ if (table.version !== undefined) {
812
+ stamps[stamps.length] = { column: table.version, role: 'the version' }
813
+ }
814
+ if (table.createdBy !== undefined) {
815
+ stamps[stamps.length] = { column: table.createdBy, role: 'createdBy' }
816
+ }
817
+ return stamps
818
+ }
819
+
820
+ /**
821
+ * Each stamped column holds one role and is never written by a handler. A shared column would let one stamp
822
+ * overwrite another: `createdBy` on the key inserts the principal as the id, `version` on a container column
823
+ * moves the row on every update.
824
+ */
825
+ function checkStamps(table: ParsedTable, chain: readonly Container[]): void {
826
+ const stamps = stampsOf(table, chain)
827
+ for (let index = 0; index < stamps.length; index++) {
828
+ const stamp = stamps[index]
829
+ if (stamp === undefined) {
830
+ continue
831
+ }
832
+ for (let other = 0; other < index; other++) {
833
+ const earlier = stamps[other]
834
+ if (earlier !== undefined && earlier.column === stamp.column) {
835
+ throw new PolicyError(
836
+ `${table.name}: ${stamp.column} is both ${earlier.role} and ${stamp.role}; the layer stamps each, so they need distinct columns`,
837
+ )
838
+ }
839
+ }
840
+ if (inSet(table.insert, stamp.column) || inSet(table.update, stamp.column)) {
841
+ throw new PolicyError(`${table.name}: ${stamp.column} is stamped by the layer; remove it from insert and update`)
842
+ }
843
+ if (hasOwn(table.columnWrites, stamp.column)) {
844
+ throw new PolicyError(`${table.name}: ${stamp.column} is stamped by the layer; it takes no write action of its own`)
845
+ }
846
+ }
847
+ }
848
+
849
+ function prepend(first: Container, rest: readonly Container[]): readonly Container[] {
850
+ const chain: Container[] = [first]
851
+ for (let index = 0; index < rest.length; index++) {
852
+ const link = rest[index]
853
+ if (link !== undefined) {
854
+ chain[chain.length] = link
855
+ }
856
+ }
857
+ return freeze(chain)
858
+ }
859
+
860
+ function includes(names: readonly string[], name: string): boolean {
861
+ for (let index = 0; index < names.length; index++) {
862
+ if (names[index] === name) {
863
+ return true
864
+ }
865
+ }
866
+ return false
867
+ }
868
+
869
+ /** Resolves every table's containment chain and refuses a shape the layer cannot enforce. */
870
+ function linkTables(parsed: readonly ParsedTable[], containment: Containment): TableModels {
871
+ const byName: Record<string, ParsedTable> = {}
872
+ const owners: Record<string, ParsedTable> = {}
873
+ for (let index = 0; index < parsed.length; index++) {
874
+ const table = parsed[index]
875
+ if (table === undefined) {
876
+ continue
877
+ }
878
+ setOwn(byName, table.name, table)
879
+ if (table.scope.mode !== 'dimension') {
880
+ continue
881
+ }
882
+ const owner = ownEntry(owners, table.scope.dimension)
883
+ if (owner !== undefined) {
884
+ throw new PolicyError(`${table.name}: dimension ${table.scope.dimension} is already owned by ${owner.name}`)
885
+ }
886
+ setOwn(owners, table.scope.dimension, table)
887
+ }
888
+ for (let index = 0; index < parsed.length; index++) {
889
+ const table = parsed[index]
890
+ if (table !== undefined) {
891
+ checkForeignKeys(table, byName)
892
+ }
893
+ }
894
+
895
+ const resolved: Record<string, Resolved> = {}
896
+ const resolve = (table: ParsedTable, visiting: readonly string[]): Resolved => {
897
+ const known = ownEntry(resolved, table.name)
898
+ if (known !== undefined) {
899
+ return known
900
+ }
901
+ const path = [...visiting, table.name]
902
+ if (includes(visiting, table.name)) {
903
+ throw new PolicyError(`${table.name}: its containment is a cycle (${path.join(' → ')})`)
904
+ }
905
+ const result = resolveChain(table, byName, owners, containment, (next) => resolve(next, path).chain)
906
+ const chain = result.chain
907
+ for (let index = 0; index < chain.length; index++) {
908
+ const link = chain[index]
909
+ if (link !== undefined && !inSet(table.columns, link.column)) {
910
+ throw new PolicyError(
911
+ `${table.name}: must carry ${link.column}, because it lies in ${link.dimension}; a row carries its whole containment chain`,
912
+ )
913
+ }
914
+ }
915
+ checkStamps(table, chain)
916
+ setOwn(resolved, table.name, result)
917
+ return result
918
+ }
919
+
920
+ const models: Record<string, TableModel> = {}
921
+ for (let index = 0; index < parsed.length; index++) {
922
+ const table = parsed[index]
923
+ if (table !== undefined) {
924
+ const { scope, chain } = resolve(table, [])
925
+ setOwn(models, table.name, freeze({ ...table, scope, chain }))
926
+ }
927
+ }
928
+ return freeze(models)
929
+ }
930
+
931
+ /** A table's scope with nothing left to derive, and its containment chain. */
932
+ interface Resolved {
933
+ readonly scope: ScopeModel
934
+ readonly chain: readonly Container[]
935
+ }
936
+
937
+ function resolveChain(
938
+ table: ParsedTable,
939
+ byName: Readonly<Record<string, ParsedTable>>,
940
+ owners: Readonly<Record<string, ParsedTable>>,
941
+ containment: Containment,
942
+ chainOf: (table: ParsedTable) => readonly Container[],
943
+ ): Resolved {
944
+ /**
945
+ * The links above `dimension`: the chain of the table that owns it, whose ancestors' columns a contained row carries
946
+ * under the same names; past a dimension no table owns, the vocabulary's ancestry on the `<dimension>_id` convention.
947
+ */
948
+ const above = (dimension: string): readonly Container[] => {
949
+ const owner = ownEntry(owners, dimension)
950
+ if (owner !== undefined) {
951
+ return chainOf(owner)
952
+ }
953
+ const container = ownEntry(containment, dimension)?.container
954
+ return container === undefined ? freeze([]) : prepend({ dimension: container, column: `${container}_id` }, above(container))
955
+ }
956
+ const scope = table.scope
957
+ if (scope.mode === 'dimension') {
958
+ const container = scope.container
959
+ if (container === undefined) {
960
+ return { scope, chain: freeze([]) }
961
+ }
962
+ const owner = ownEntry(owners, container.dimension)
963
+ if (owner !== undefined) {
964
+ requireReference(table, container, owner, (column) => `scope.in: { column: '${column}' }`)
965
+ requireCoveringKey(table, container, owner, chainOf(owner))
966
+ }
967
+ return { scope, chain: prepend(container, above(container.dimension)) }
968
+ }
969
+ if (scope.mode !== 'child') {
970
+ return { scope, chain: freeze([]) }
971
+ }
972
+ const parent = ownEntry(byName, scope.parent)
973
+ if (parent === undefined || parent.scope.mode !== 'dimension') {
974
+ throw new PolicyError(`${table.name}: the parent ${scope.parent} must be a dimension table`)
975
+ }
976
+ const parentChain = chainOf(parent)
977
+ const expected: string[] = []
978
+ for (let index = 0; index < parentChain.length; index++) {
979
+ expected[index] = parentChain[index]?.column ?? ''
980
+ }
981
+ const inherit = scope.inherit ?? freeze(expected)
982
+ const inherited = nameSet(inherit)
983
+ let matches = inherit.length === expected.length && namesOf(inherited).length === expected.length
984
+ for (let index = 0; index < expected.length; index++) {
985
+ matches = matches && inSet(inherited, expected[index])
986
+ }
987
+ if (!matches) {
988
+ throw new PolicyError(
989
+ `${table.name}: must inherit exactly the containment columns of ${scope.parent} (${
990
+ expected.join(', ') || 'none'
991
+ }), so a composite foreign key keeps it in scope`,
992
+ )
993
+ }
994
+ const dimension = parent.scope.dimension
995
+ const link = { dimension, column: scope.column ?? parentColumn(table, `${dimension}_id`, scope.parent) }
996
+ requireReference(table, link, parent, (column) => `scope.column: '${column}'`)
997
+ requireCoveringKey(table, link, parent, parentChain)
998
+ return {
999
+ scope: freeze({ mode: 'child', parent: scope.parent, key: scope.key, column: link.column, inherit }),
1000
+ chain: prepend(link, parentChain),
1001
+ }
1002
+ }
1003
+
1004
+ /** Whether a foreign key of `table` takes `column` to `key` of `target`, alone or as part of a composite key. */
1005
+ function references(table: ParsedTable, column: string, target: string, key: string): boolean {
1006
+ for (let index = 0; index < table.foreignKeys.length; index++) {
1007
+ const foreignKey = table.foreignKeys[index]
1008
+ if (foreignKey === undefined || foreignKey.table !== target) {
1009
+ continue
1010
+ }
1011
+ for (let position = 0; position < foreignKey.columns.length; position++) {
1012
+ if (foreignKey.columns[position] === column && foreignKey.references[position] === key) {
1013
+ return true
1014
+ }
1015
+ }
1016
+ }
1017
+ return false
1018
+ }
1019
+
1020
+ /**
1021
+ * A containment column is a foreign key to the key of the table that owns the container: a column that only shares
1022
+ * the convention's name would place a row in a scope its real reference contradicts.
1023
+ */
1024
+ function requireReference(table: ParsedTable, link: Container, owner: ParsedTable, fix: (column: string) => string): void {
1025
+ const key = owner.scope.mode === 'dimension' ? owner.scope.key : ''
1026
+ if (references(table, link.column, owner.name, key)) {
1027
+ return
1028
+ }
1029
+ const names = namesOf(table.columns)
1030
+ const candidates: string[] = []
1031
+ for (let index = 0; index < names.length; index++) {
1032
+ const name = names[index] ?? ''
1033
+ if (references(table, name, owner.name, key)) {
1034
+ candidates[candidates.length] = name
1035
+ }
1036
+ }
1037
+ const [only] = candidates
1038
+ const hint = candidates.length === 1 && only !== undefined
1039
+ ? `state ${fix(only)}`
1040
+ : `add a foreign key from ${link.column} to ${owner.name} (${key}) in a migration`
1041
+ throw new PolicyError(`${table.name}: ${link.column} is not a foreign key to ${owner.name}.${key}, which owns ${link.dimension}; ${hint}`)
1042
+ }
1043
+
1044
+ /**
1045
+ * The columns a row inherits from its owner's chain are bound to the owner's own values by one composite foreign key,
1046
+ * with the containment column: `(project_id, org_id) references projects (id, org_id)`. A `Within` of an ancestor
1047
+ * reads a row by its inherited column alone, so a column the database does not tie to the owner would place the row
1048
+ * in a scope its owner is not in.
1049
+ */
1050
+ function requireCoveringKey(table: ParsedTable, link: Container, owner: ParsedTable, ownerChain: readonly Container[]): void {
1051
+ if (ownerChain.length === 0) {
1052
+ return
1053
+ }
1054
+ const key = owner.scope.mode === 'dimension' ? owner.scope.key : ''
1055
+ const pairs: [string, string][] = [[link.column, key]]
1056
+ for (let index = 0; index < ownerChain.length; index++) {
1057
+ const column = ownerChain[index]?.column ?? ''
1058
+ if (!inSet(table.columns, column)) {
1059
+ // The chain check refuses the missing column itself, with the message that names it.
1060
+ return
1061
+ }
1062
+ pairs[pairs.length] = [column, column]
1063
+ }
1064
+ for (let index = 0; index < table.foreignKeys.length; index++) {
1065
+ const foreignKey = table.foreignKeys[index]
1066
+ if (foreignKey !== undefined && foreignKey.table === owner.name && coversPairs(foreignKey, pairs)) {
1067
+ return
1068
+ }
1069
+ }
1070
+ const columns: string[] = []
1071
+ const referenced: string[] = []
1072
+ for (let index = 0; index < pairs.length; index++) {
1073
+ columns[index] = pairs[index]?.[0] ?? ''
1074
+ referenced[index] = pairs[index]?.[1] ?? ''
1075
+ }
1076
+ throw new PolicyError(
1077
+ `${table.name}: ${link.column} and the chain it inherits must be one foreign key to ${owner.name}, so a row lies where its ${link.dimension} lies; add foreign key (${
1078
+ columns.join(', ')
1079
+ }) references ${owner.name} (${referenced.join(', ')}) in a migration, with unique (${referenced.join(', ')}) on ${owner.name}`,
1080
+ )
1081
+ }
1082
+
1083
+ /** Whether every `[column, referenced]` pair is a column of `foreignKey` with that referenced column. */
1084
+ function coversPairs(foreignKey: ForeignKey, pairs: readonly (readonly [string, string])[]): boolean {
1085
+ for (let index = 0; index < pairs.length; index++) {
1086
+ const [column, referenced] = pairs[index] ?? ['', '']
1087
+ let found = false
1088
+ for (let position = 0; position < foreignKey.columns.length; position++) {
1089
+ found = found || (foreignKey.columns[position] === column && foreignKey.references[position] === referenced)
1090
+ }
1091
+ if (!found) {
1092
+ return false
1093
+ }
1094
+ }
1095
+ return true
1096
+ }
1097
+
1098
+ /** Every foreign key names a table of the column list and columns it has. */
1099
+ function checkForeignKeys(table: ParsedTable, byName: Readonly<Record<string, ParsedTable>>): void {
1100
+ for (let index = 0; index < table.foreignKeys.length; index++) {
1101
+ const foreignKey = table.foreignKeys[index]
1102
+ if (foreignKey === undefined) {
1103
+ continue
1104
+ }
1105
+ const target = ownEntry(byName, foreignKey.table)
1106
+ if (target === undefined) {
1107
+ throw new PolicyError(`${table.name}: a foreign key references ${foreignKey.table}, which is not a table of the column list`)
1108
+ }
1109
+ for (let position = 0; position < foreignKey.references.length; position++) {
1110
+ const referenced = foreignKey.references[position]
1111
+ if (!inSet(target.columns, referenced)) {
1112
+ throw new PolicyError(`${table.name}: a foreign key references ${foreignKey.table}.${String(referenced)}, which is not a column`)
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ /** A child's column for its parent defaults to `<parent dimension>_id`. */
1119
+ function parentColumn(table: ParsedTable, column: string, parent: string): string {
1120
+ if (!inSet(table.columns, column)) {
1121
+ throw new PolicyError(
1122
+ `${table.name}: scope.column defaults to ${column} (the key of its parent ${parent}), which is not a column; state scope.column (columns: ${
1123
+ namesOf(table.columns).join(', ')
1124
+ })`,
1125
+ )
1126
+ }
1127
+ return column
1128
+ }
1129
+
1130
+ type PolicyOf<P, T> = T extends keyof P ? P[T] : never
1131
+ type ScopeOf<P, T> = PolicyOf<P, T> extends { readonly scope: infer S } ? S : never
1132
+
1133
+ export type Operation = 'read' | 'insert' | 'update' | 'delete'
1134
+
1135
+ 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
1137
+ : never
1138
+ : never
1139
+
1140
+ export type DimensionOf<P, T> = ScopeOf<P, T> extends { readonly dimension: infer D extends string } ? D : never
1141
+ export type ContainerOf<P, T> = ScopeOf<P, T> extends { readonly in: { readonly dimension: infer D extends string } } ? D : never
1142
+ /** The dimension that lists and inserts of T are scoped by: its container, or for a top-level table its own. */
1143
+ export type ListDimensionOf<P, T> = [ContainerOf<P, T>] extends [never] ? DimensionOf<P, T> : ContainerOf<P, T>
1144
+ export type ParentOf<P, T> = ScopeOf<P, T> extends { readonly child: infer C extends string } ? C : never
1145
+
1146
+ export type DimensionTableName<P> = { [K in keyof P]: P[K] extends { readonly scope: { readonly dimension: string } } ? K : never }[keyof P] & string
1147
+ export type ChildTableName<P> = { [K in keyof P]: P[K] extends { readonly scope: { readonly child: string } } ? K : never }[keyof P] & string
1148
+ export type ApplicationTableName<P> = { [K in keyof P]: P[K] extends { readonly scope: { readonly app: string } } ? K : never }[keyof P] & string
1149
+
1150
+ type ListedColumns<P, T, Field extends 'insert' | 'update'> = PolicyOf<P, T> extends { readonly [K in Field]: readonly (infer C extends string)[] }
1151
+ ? C
1152
+ : never
1153
+
1154
+ type Flat<T> = { [K in keyof T]: T[K] }
1155
+
1156
+ type ColumnPoliciesOf<P, T> = PolicyOf<P, T> extends { readonly columns: infer C } ? C : never
1157
+ type TableReadAction<P, T> = PolicyOf<P, T> extends { readonly scope: { readonly read: infer A } } ? A : ActionOf<P, T, 'read'>
1158
+ /** Columns whose own read action is not the table's: no read by the table's read action returns them. */
1159
+ type RestrictedColumns<P, T> = [ColumnPoliciesOf<P, T>] extends [never] ? never
1160
+ : {
1161
+ [K in keyof ColumnPoliciesOf<P, T>]: ColumnPoliciesOf<P, T>[K] extends { readonly read: infer R } ? (R extends TableReadAction<P, T> ? never : K)
1162
+ : never
1163
+ }[keyof ColumnPoliciesOf<P, T>]
1164
+
1165
+ /** A row of table `T` as a store reads it, by the table's read action. */
1166
+ export type PolicyRow<DB, P, T> = T extends keyof DB ? Flat<Omit<Selectable<DB[T]>, RestrictedColumns<P, T>>> : never
1167
+ export type InsertValues<DB, P, T> = T extends keyof DB ? Flat<Pick<Insertable<DB[T]>, ListedColumns<P, T, 'insert'> & keyof Insertable<DB[T]>>>
1168
+ : never
1169
+ export type UpdateValues<DB, P, T> = T extends keyof DB ? Flat<Pick<Updateable<DB[T]>, ListedColumns<P, T, 'update'> & keyof Updateable<DB[T]>>>
1170
+ : never
1171
+
1172
+ /** The values a store inserts into table `T` of a `defineTables` result: `TableInsert<typeof tables, 'deals'>`. */
1173
+ export type TableInsert<Tb, T> = Tb extends Tables<infer DB, infer P> ? InsertValues<DB, P, T> : never
1174
+ /** The values a store updates in table `T` of a `defineTables` result: `TableUpdate<typeof tables, 'deals'>`. */
1175
+ export type TableUpdate<Tb, T> = Tb extends Tables<infer DB, infer P> ? UpdateValues<DB, P, T> : never
1176
+
1177
+ /** What a mutation answers: the key, and the new version of a versioned table. It returns no row data. */
1178
+ export type Acknowledgement<P, T> = PolicyOf<P, T> extends { readonly version: string } ? { readonly id: string; readonly version: number }
1179
+ : { readonly id: string }
1180
+
1181
+ /** A move also answers the object's new containment chain, by dimension. */
1182
+ export type MoveAcknowledgement<P, T> = Acknowledgement<P, T> & { readonly chain: Readonly<Record<string, string>> }
1183
+
1184
+ /** A table with a version column takes `{ expectVersion }` on update; one without takes nothing. */
1185
+ export type VersionArguments<P, T> = PolicyOf<P, T> extends { readonly version: string } ? [options: { readonly expectVersion: number }] : []
1186
+
1187
+ /** Children of T that a select of T can join: their read action is T's read action. */
1188
+ export type JoinableChildren<DB, P, T> = {
1189
+ [K in ChildTableName<P> as ParentOf<P, K> extends T ? (ActionOf<P, K, 'read'> extends ActionOf<P, T, 'read'> ? K : never) : never]: PolicyRow<
1190
+ DB,
1191
+ P,
1192
+ K
1193
+ >
1194
+ }