@fabrika/db 0.0.0-stage → 0.4.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.
@@ -0,0 +1,309 @@
1
+ import { inSet, isSafeInteger, type NameSet, nameSet, namesOf, ownEntry, ownKeys, parseList } from './builtins'
2
+ import { QueryRefusedError } from './errors'
3
+ import { isPlainIdentifier } from './identifier'
4
+
5
+ export type Primitive = string | number | bigint | boolean | Date
6
+ export type ComparisonOperator = '=' | '<>' | '<' | '<=' | '>' | '>='
7
+ export type PatternOperator = 'like' | 'ilike' | 'not like' | 'not ilike'
8
+ export type ListOperator = 'in' | 'not in'
9
+ export type NullOperator = 'is' | 'is not'
10
+ export type AllowedOperator = ComparisonOperator | PatternOperator | ListOperator | NullOperator
11
+
12
+ const OPERATOR_LIST: readonly AllowedOperator[] = [
13
+ '=',
14
+ '<>',
15
+ '<',
16
+ '<=',
17
+ '>',
18
+ '>=',
19
+ 'like',
20
+ 'ilike',
21
+ 'not like',
22
+ 'not ilike',
23
+ 'in',
24
+ 'not in',
25
+ 'is',
26
+ 'is not',
27
+ ]
28
+ const OPERATORS = nameSet(OPERATOR_LIST)
29
+
30
+ function isOperator(value: unknown): value is AllowedOperator {
31
+ return inSet(OPERATORS, value)
32
+ }
33
+
34
+ export interface Compare {
35
+ readonly kind: 'compare'
36
+ readonly column: string
37
+ readonly operator: AllowedOperator
38
+ readonly value: Primitive | readonly Primitive[] | null
39
+ }
40
+
41
+ /** A case-insensitive substring match; the text's `%`, `_` and `\` match literally. */
42
+ export interface Contains {
43
+ readonly kind: 'contains'
44
+ readonly column: string
45
+ readonly text: string
46
+ }
47
+
48
+ export interface AnyOf {
49
+ readonly kind: 'any'
50
+ readonly conditions: readonly (Compare | Contains)[]
51
+ }
52
+
53
+ export type Condition = Compare | Contains | AnyOf
54
+
55
+ /** What a select may name: the visible columns of its table and of the children it may join. */
56
+ export interface TableShape {
57
+ readonly table: string
58
+ readonly visible: NameSet
59
+ readonly joinable: Readonly<Record<string, JoinShape>>
60
+ /** The `date` columns, which a read answers as `YYYY-MM-DD` text. */
61
+ readonly dates: NameSet
62
+ }
63
+
64
+ export interface JoinShape {
65
+ readonly shape: TableShape
66
+ readonly readAction: string
67
+ /** The composite foreign key: child column = parent column, for each pair. */
68
+ readonly on: readonly { readonly childColumn: string; readonly parentColumn: string }[]
69
+ }
70
+
71
+ export interface JoinState {
72
+ readonly join: JoinShape
73
+ readonly select: SelectState
74
+ }
75
+
76
+ export interface SelectState {
77
+ /** Identifies the description a callback received, so it cannot return one from elsewhere. */
78
+ readonly origin: object
79
+ readonly table: string
80
+ readonly columns: readonly string[] | undefined
81
+ readonly conditions: readonly Condition[]
82
+ readonly orderBy: readonly { readonly column: string; readonly direction: 'asc' | 'desc' }[]
83
+ readonly limit: number | undefined
84
+ readonly offset: number | undefined
85
+ readonly joins: readonly JoinState[]
86
+ }
87
+
88
+ type Comparable<V> = Exclude<V, null | undefined>
89
+ type Columns<Row> = keyof Row & string
90
+ type StringColumn<Row> = { [K in Columns<Row>]: Comparable<Row[K]> extends string ? K : never }[Columns<Row>]
91
+ type NullableColumn<Row> = { [K in Columns<Row>]: null extends Row[K] ? K : never }[Columns<Row>]
92
+
93
+ export type ConditionTuple<Row> =
94
+ | {
95
+ [K in Columns<Row>]: readonly [K, ComparisonOperator, Comparable<Row[K]>] | readonly [K, ListOperator, readonly Comparable<Row[K]>[]]
96
+ }[Columns<Row>]
97
+ | { [K in StringColumn<Row>]: readonly [K, PatternOperator | 'contains', string] }[StringColumn<Row>]
98
+
99
+ type Prefixed<C extends string, J> = { [K in keyof J & string as `${C}.${K}`]: J[K] }
100
+ export type NoColumns = Record<never, never>
101
+
102
+ const selectToken: unique symbol = Symbol('scoped-select')
103
+
104
+ let readState: <Row, Selected, Children>(select: ScopedSelect<Row, Selected, Children>) => SelectState
105
+ let readShape: <Row, Selected, Children>(select: ScopedSelect<Row, Selected, Children>) => TableShape
106
+
107
+ /**
108
+ * The only query surface a store callback sees: a frozen description, not a Kysely builder. There is nothing to
109
+ * execute, no raw SQL entry point, and no way to name a table other than this one and its declared children.
110
+ */
111
+ export class ScopedSelect<Row, Selected, Children> {
112
+ readonly #state: SelectState
113
+ readonly #shape: TableShape
114
+ readonly #types: { readonly row?: Row; readonly selected?: Selected; readonly children?: Children } = {}
115
+
116
+ constructor(token: symbol, state: SelectState, shape: TableShape) {
117
+ if (token !== selectToken) {
118
+ throw new QueryRefusedError('invalid_argument', 'a query description is created by the store only')
119
+ }
120
+ this.#state = Object.freeze(state)
121
+ this.#shape = shape
122
+ Object.freeze(this)
123
+ }
124
+
125
+ static {
126
+ readState = (select) => select.#state
127
+ readShape = (select) => select.#shape
128
+ }
129
+
130
+ /** The columns to return; without it, every visible column. */
131
+ columns<const C extends Columns<Row>>(columns: readonly C[]): ScopedSelect<Row, Pick<Row, C>, Children> {
132
+ const names = parseList(columns, (name) => this.#column(name))
133
+ if (names === undefined || names.length === 0) {
134
+ throw new QueryRefusedError('invalid_argument', 'columns needs a non-empty plain array of column names')
135
+ }
136
+ return this.#next({ columns: names })
137
+ }
138
+
139
+ where<C extends Columns<Row>>(column: C, operator: ComparisonOperator, value: Comparable<Row[C]>): ScopedSelect<Row, Selected, Children>
140
+ where<C extends Columns<Row>>(column: C, operator: ListOperator, values: readonly Comparable<Row[C]>[]): ScopedSelect<Row, Selected, Children>
141
+ where<C extends StringColumn<Row>>(column: C, operator: PatternOperator, pattern: string): ScopedSelect<Row, Selected, Children>
142
+ where<C extends NullableColumn<Row>>(column: C, operator: NullOperator, value: null): ScopedSelect<Row, Selected, Children>
143
+ where(column: string, operator: string, value: unknown): ScopedSelect<Row, Selected, Children> {
144
+ return this.#withCondition(this.#parseCondition(column, operator, value))
145
+ }
146
+
147
+ /** One OR group of conditions on this table's columns. */
148
+ whereAny(conditions: readonly ConditionTuple<Row>[]): ScopedSelect<Row, Selected, Children>
149
+ whereAny(conditions: readonly (readonly unknown[])[]): ScopedSelect<Row, Selected, Children> {
150
+ const parsed = parseList(conditions, (condition) => {
151
+ const parts = parseList(condition, (part) => part)
152
+ if (parts === undefined || parts.length !== 3) {
153
+ throw new QueryRefusedError('invalid_argument', 'whereAny takes [column, operator, value] conditions, each a plain array')
154
+ }
155
+ return this.#parseCondition(parts[0], parts[1], parts[2])
156
+ })
157
+ if (parsed === undefined || parsed.length === 0) {
158
+ throw new QueryRefusedError('invalid_argument', 'whereAny needs a non-empty plain array of [column, operator, value] conditions')
159
+ }
160
+ return this.#withCondition({ kind: 'any', conditions: parsed })
161
+ }
162
+
163
+ /** A case-insensitive substring match in which `%` and `_` in the text match themselves. */
164
+ contains(column: StringColumn<Row>, text: string): ScopedSelect<Row, Selected, Children> {
165
+ return this.#withCondition(this.#parseCondition(column, 'contains', text))
166
+ }
167
+
168
+ orderBy(column: Columns<Row>, direction: 'asc' | 'desc' = 'asc'): ScopedSelect<Row, Selected, Children> {
169
+ if (direction !== 'asc' && direction !== 'desc') {
170
+ throw new QueryRefusedError('list_page', `orderBy direction must be 'asc' or 'desc'`)
171
+ }
172
+ return this.#next({ orderBy: [...this.#state.orderBy, { column: this.#column(column), direction }] })
173
+ }
174
+
175
+ limit(limit: number): ScopedSelect<Row, Selected, Children> {
176
+ return this.#next({ limit: count(limit, 'limit') })
177
+ }
178
+
179
+ offset(offset: number): ScopedSelect<Row, Selected, Children> {
180
+ return this.#next({ offset: count(offset, 'offset') })
181
+ }
182
+
183
+ /** An inner join to a declared child; its columns come back prefixed (`note_comments.body`). */
184
+ join<C extends keyof Children & string, J>(
185
+ child: C,
186
+ build: (q: ScopedSelect<Children[C], Children[C], NoColumns>) => ScopedSelect<Children[C], J, NoColumns>,
187
+ ): ScopedSelect<Row, Selected & Prefixed<C, J>, Children> {
188
+ const join = typeof child === 'string' ? ownEntry(this.#shape.joinable, child) : undefined
189
+ if (join === undefined) {
190
+ const children = ownKeys(this.#shape.joinable).join(', ') || 'none'
191
+ throw new QueryRefusedError('unknown_table', `${String(child)} is not a joinable child of ${this.#state.table} (joinable: ${children})`)
192
+ }
193
+ const start = startSelect<Children[C], NoColumns>(join.shape)
194
+ const select = finishSelect(build(start), start)
195
+ if (select.joins.length > 0 || select.orderBy.length > 0 || select.limit !== undefined || select.offset !== undefined) {
196
+ throw new QueryRefusedError('invalid_argument', 'a joined child takes columns and where conditions only')
197
+ }
198
+ return this.#next({ joins: [...this.#state.joins, { join, select }] })
199
+ }
200
+
201
+ #next<S>(changes: Partial<Omit<SelectState, 'origin' | 'table'>>): ScopedSelect<Row, S, Children> {
202
+ return new ScopedSelect(selectToken, { ...this.#state, ...changes }, this.#shape)
203
+ }
204
+
205
+ #withCondition(condition: Condition): ScopedSelect<Row, Selected, Children> {
206
+ return this.#next({ conditions: [...this.#state.conditions, condition] })
207
+ }
208
+
209
+ #column(name: unknown): string {
210
+ return columnOf(this.#shape, name)
211
+ }
212
+
213
+ #parseCondition(column: unknown, operator: unknown, value: unknown): Compare | Contains {
214
+ return parseCondition(this.#shape, column, operator, value)
215
+ }
216
+ }
217
+ Object.freeze(ScopedSelect.prototype)
218
+ Object.freeze(ScopedSelect)
219
+
220
+ /** A column the shape lets a query name; a hidden one is refused like a missing one. */
221
+ export function columnOf(shape: TableShape, name: unknown): string {
222
+ const columns = namesOf(shape.visible).join(', ')
223
+ if (typeof name !== 'string') {
224
+ throw new QueryRefusedError(
225
+ 'invalid_argument',
226
+ `a column is named by a string, not by ${typeof name === 'object' ? 'an expression' : typeof name} (columns: ${columns})`,
227
+ )
228
+ }
229
+ if (!isPlainIdentifier(name) || !inSet(shape.visible, name)) {
230
+ throw new QueryRefusedError('unknown_column', `${name} is not a column of ${shape.table} (columns: ${columns})`)
231
+ }
232
+ return name
233
+ }
234
+
235
+ /** One `[column, operator, value]` condition over a caller's values, checked before anything reads it further. */
236
+ export function parseCondition(shape: TableShape, column: unknown, operator: unknown, value: unknown): Compare | Contains {
237
+ const name = columnOf(shape, column)
238
+ if (operator === 'contains') {
239
+ if (typeof value !== 'string') {
240
+ throw new QueryRefusedError('invalid_argument', 'contains takes a string')
241
+ }
242
+ return { kind: 'contains', column: name, text: value }
243
+ }
244
+ if (!isOperator(operator)) {
245
+ throw new QueryRefusedError('invalid_argument', `operator ${String(operator)} is not allowed (allowed: ${OPERATOR_LIST.join(', ')}, contains)`)
246
+ }
247
+ const allowed = operator
248
+ if (allowed === 'in' || allowed === 'not in') {
249
+ const list = parseList(value, (item) => {
250
+ if (!isPrimitive(item)) {
251
+ throw new QueryRefusedError('invalid_argument', `${allowed} takes plain values only`)
252
+ }
253
+ return item
254
+ })
255
+ if (list === undefined || list.length === 0) {
256
+ throw new QueryRefusedError('invalid_argument', `${allowed} needs a non-empty plain array of values`)
257
+ }
258
+ return { kind: 'compare', column: name, operator: allowed, value: list }
259
+ }
260
+ if (allowed === 'is' || allowed === 'is not') {
261
+ if (value !== null) {
262
+ throw new QueryRefusedError('invalid_argument', `${allowed} takes null only`)
263
+ }
264
+ return { kind: 'compare', column: name, operator: allowed, value: null }
265
+ }
266
+ if (!isPrimitive(value)) {
267
+ throw new QueryRefusedError('invalid_argument', 'where takes a plain value (string, number, bigint, boolean or Date), not an expression')
268
+ }
269
+ return { kind: 'compare', column: name, operator: allowed, value }
270
+ }
271
+
272
+ export function startSelect<Row, Children>(shape: TableShape): ScopedSelect<Row, Row, Children> {
273
+ const state: SelectState = {
274
+ origin: Object.freeze({}),
275
+ table: shape.table,
276
+ columns: undefined,
277
+ conditions: [],
278
+ orderBy: [],
279
+ limit: undefined,
280
+ offset: undefined,
281
+ joins: [],
282
+ }
283
+ return new ScopedSelect(selectToken, state, shape)
284
+ }
285
+
286
+ /** The description a callback returned; it must derive from the one it received. */
287
+ export function finishSelect<Row, Children>(built: unknown, start: ScopedSelect<Row, Row, Children>): SelectState {
288
+ if (!(built instanceof ScopedSelect)) {
289
+ throw new QueryRefusedError('invalid_argument', 'the query callback must return the description it received')
290
+ }
291
+ const state = readState(built)
292
+ const expected = readState(start)
293
+ if (state.origin !== expected.origin || readShape(built) !== readShape(start)) {
294
+ throw new QueryRefusedError('invalid_argument', 'the query callback must return the description it received, not one from another query')
295
+ }
296
+ return state
297
+ }
298
+
299
+ export function isPrimitive(value: unknown): value is Primitive {
300
+ const type = typeof value
301
+ return type === 'string' || type === 'number' || type === 'bigint' || type === 'boolean' || value instanceof Date
302
+ }
303
+
304
+ function count(value: unknown, what: string): number {
305
+ if (typeof value !== 'number' || !isSafeInteger(value) || value < 0) {
306
+ throw new QueryRefusedError('invalid_argument', `${what} must be a non-negative integer`)
307
+ }
308
+ return value
309
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Why the layer refused a capability, as a closed catalog: a server log names the reason and its fixed text, never the
3
+ * message, which can name a table, a dimension or a value.
4
+ */
5
+ export const CAPABILITY_ERRORS = Object.freeze({
6
+ wrong_capability: 'the operation takes another capability than it was given: another kind (a Ref, a Within or an app capability) or dimension',
7
+ wrong_action: 'the capability holds another action than the table names for the operation',
8
+ out_of_reach: "the table does not lie in the capability's dimension, or the capability does not reach it",
9
+ unenforceable: "the table carries no column for a dimension of the capability's chain, so the layer cannot enforce it",
10
+ undeclared_operation: "the table's policy declares no action for the operation, or handlers do not write the table",
11
+ table_kind: 'the operation needs another kind of table (a dimension, a child or an application table)',
12
+ within_size: 'a Within carries more scope values than one statement binds',
13
+ layer_unbound: 'the data layer was used before the framework connected it at boot, or connected a second time',
14
+ })
15
+
16
+ export type CapabilityErrorReason = keyof typeof CAPABILITY_ERRORS
17
+
18
+ /** A capability of the wrong kind, dimension or action for the operation, or the layer used outside its binding. */
19
+ export class CapabilityError extends Error {
20
+ override name = 'CapabilityError'
21
+ /** What a log may say; the message is for the author's own run. */
22
+ readonly reason: CapabilityErrorReason
23
+
24
+ constructor(reason: CapabilityErrorReason, message: string) {
25
+ super(message)
26
+ this.reason = reason
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Why the layer refused, as a closed catalog: a server log names the reason and its fixed text, never the message,
32
+ * which can quote a value the request sent.
33
+ */
34
+ export const QUERY_REFUSALS = {
35
+ invalid_argument: 'a data-layer call took an argument of another shape than its type declares',
36
+ invalid_value: 'a value cannot be bound: an invalid or out-of-range date, or a non-finite number',
37
+ invalid_name: 'a configured name (application, schema, file collection or owner column) breaks its naming rule',
38
+ unknown_column: 'a column the table does not declare, or one this operation may not set',
39
+ unknown_table: 'a table src/db/tables.ts does not declare, or one no store, join or locator reaches',
40
+ framework_table: "a framework table or a catalog name: application data reaches only the application's own tables",
41
+ row_address: 'a row is addressed otherwise than its table declares (its Ref, its parent Ref and key, or its key)',
42
+ version_required: 'the table has a version column, so update and remove take expectVersion',
43
+ read_only: 'a read-only request, query or table was asked to write or lock a row',
44
+ ended: 'the transaction, invocation or handle has ended or belongs to other work',
45
+ statement_failed: 'a statement of this transaction failed, so nothing it wrote commits',
46
+ unexpected_row: 'the database answered a row in another shape than its migration declares',
47
+ statement_shape: 'the compiled statement has a form the guard does not admit',
48
+ scope_predicate: 'the scope predicate is missing or is not a top-level conjunct',
49
+ list_page: 'a list limit, cursor or order outside what a page admits',
50
+ undeclared_reach: 'the site reaches data it did not declare (sql or unscoped, or their mode)',
51
+ sql_template: 'ctx.sql was not called as one tagged template whose values are interpolated whole',
52
+ sql_statement: 'ctx.sql runs one statement of an admitted kind, and none inside another',
53
+ sql_read_only: "sql: 'read' runs queries only; declare sql: 'write' on a mutation, a job or a webhook",
54
+ sql_name: 'ctx.sql names a framework, catalog or foreign-schema object, or writes a name in U& form',
55
+ sql_function: 'ctx.sql calls a function that is not a reviewed built-in',
56
+ sql_outside_transaction: 'a job runs ctx.sql only inside ctx.transaction((tx) => …), as tx.sql',
57
+ sql_scope: 'ctx.sql.scope(…) stands alone, joined by AND, in a WHERE or JOIN … ON clause of its own invocation',
58
+ file_scan: "a scan operation on a file collection without scan: true, or a scan verdict outside the scan job's lease",
59
+ } as const
60
+
61
+ export type QueryRefusal = keyof typeof QUERY_REFUSALS
62
+
63
+ /** A query description or an operation tree outside what the layer allows. */
64
+ export class QueryRefusedError extends Error {
65
+ override name = 'QueryRefusedError'
66
+ /** What a log may say; the message is for the author's own run and may quote a value. */
67
+ readonly reason: QueryRefusal
68
+
69
+ constructor(reason: QueryRefusal, message: string) {
70
+ super(message)
71
+ this.reason = reason
72
+ }
73
+ }
74
+
75
+ /** A `ctx.sql` row its row schema refuses: the handler's bug. The message names the column, never its value. */
76
+ export class SqlRowError extends Error {
77
+ override name = 'SqlRowError'
78
+ }
79
+
80
+ /** A table policy that cannot be enforced as written. */
81
+ export class PolicyError extends Error {
82
+ override name = 'PolicyError'
83
+ }
84
+
85
+ /** The addressed object does not exist in the capability's scope (HTTP 404). */
86
+ export class NotFoundError extends Error {
87
+ override name = 'NotFoundError'
88
+ }
89
+
90
+ /** The object exists, but its version is not the one the caller expected (HTTP 409). */
91
+ export class ConflictError extends Error {
92
+ override name = 'ConflictError'
93
+ }
94
+
95
+ /** A job attempt wrote after it lost its lease (timed out, taken over or expired); nothing was written. */
96
+ export class LeaseLostError extends Error {
97
+ override name = 'LeaseLostError'
98
+ }
99
+
100
+ const SQLSTATE = /^[0-9A-Z]{5}$/
101
+
102
+ /**
103
+ * A statement the database refused or could not run, as the layer reports it: its SQLSTATE only. The driver's message,
104
+ * detail and hint can quote the whole failing row, columns the capability may not read included, so none is kept.
105
+ */
106
+ export class DatabaseError extends Error {
107
+ override name = 'DatabaseError'
108
+ /** The five-character SQLSTATE, or `null` when the statement never reached the database (a lost connection). */
109
+ readonly sqlState: string | null
110
+
111
+ constructor(sqlState: string | null) {
112
+ super(sqlState === null ? 'the database could not run the statement' : `the database refused the statement (SQLSTATE ${sqlState})`)
113
+ this.sqlState = sqlState
114
+ }
115
+ }
116
+
117
+ /** The driver's failure as a `DatabaseError`; the driver's error object goes no further. */
118
+ export function databaseError(error: unknown): DatabaseError {
119
+ if (error instanceof DatabaseError) {
120
+ return error
121
+ }
122
+ const state: unknown = typeof error === 'object' && error !== null ? Reflect.get(error, 'errno') : undefined
123
+ return new DatabaseError(typeof state === 'string' && SQLSTATE.test(state) ? state : null)
124
+ }
@@ -0,0 +1,133 @@
1
+ // The names and the DDL of a file collection's table (`fabrika_files_<collection>`). Pure: `@fabrika/db/framework`
2
+ // re-exports `fileTableName` for `boot`, which the Worker adapter imports, and an application's migration uses the DDL.
3
+
4
+ import {
5
+ FILE_SCAN_COLUMNS,
6
+ FILE_STATUSES,
7
+ FILE_TABLE_PREFIX,
8
+ fileScanAlter,
9
+ fileScanIndex,
10
+ fileStatusCheck,
11
+ legacyStatusCheckName,
12
+ } from '@fabrika/platform-node/sql'
13
+ import { QueryRefusedError } from './errors'
14
+ import { isPlainIdentifier } from './identifier'
15
+
16
+ export { FILE_STATUSES, FILE_TABLE_PREFIX, legacyStatusCheckName }
17
+
18
+ /** `<table>_removable`, the longest name the DDL derives, must stay within Postgres' 63 characters. */
19
+ const MAX_COLLECTION_LENGTH = 63 - FILE_TABLE_PREFIX.length - '_removable'.length
20
+
21
+ /** The columns every file row has; an owner's containment column may not take one of these names. */
22
+ export const FILE_COLUMNS: readonly string[] = [
23
+ 'id',
24
+ 'owner_id',
25
+ 'status',
26
+ 'object_key',
27
+ 'size',
28
+ 'content_type',
29
+ 'sha256',
30
+ 'original_name',
31
+ 'created_by',
32
+ 'created_at',
33
+ 'deleted_at',
34
+ 'object_version',
35
+ 'scan_attempts',
36
+ 'scan_after',
37
+ ]
38
+
39
+ export type FileStatus = (typeof FILE_STATUSES)[number]
40
+
41
+ /** The column types whose values `Bun.SQL` decodes as strings, as a capability carries them. */
42
+ export type FileColumnType = 'text' | 'uuid'
43
+
44
+ export interface FileColumn {
45
+ readonly column: string
46
+ readonly type: FileColumnType
47
+ }
48
+
49
+ export interface FileOwner {
50
+ readonly table: string
51
+ readonly key: FileColumn
52
+ /** The owner's containment columns, as its table policy declares them, nearest container first. */
53
+ readonly chain: readonly FileColumn[]
54
+ }
55
+
56
+ /** `fabrika_files_<collection>`, or `null` when the collection's name cannot name a table. */
57
+ export function fileTableName(collection: string): string | null {
58
+ const table = `${FILE_TABLE_PREFIX}${collection}`
59
+ return collection.length <= MAX_COLLECTION_LENGTH && isPlainIdentifier(collection) && isPlainIdentifier(table) ? table : null
60
+ }
61
+
62
+ export function tableOf(collection: string): string {
63
+ const table = fileTableName(collection)
64
+ if (table === null) {
65
+ throw new QueryRefusedError(
66
+ 'invalid_name',
67
+ `the file collection '${collection}' needs a lowercase snake_case name of at most ${MAX_COLLECTION_LENGTH} characters`,
68
+ )
69
+ }
70
+ return table
71
+ }
72
+
73
+ function sqlType(column: FileColumn): string {
74
+ if (!isPlainIdentifier(column.column)) {
75
+ throw new QueryRefusedError('invalid_name', 'an owner column must match ^[a-z_][a-z0-9_]*$')
76
+ }
77
+ if (column.type !== 'text' && column.type !== 'uuid') {
78
+ throw new QueryRefusedError('invalid_name', "an owner column's type must be 'text' or 'uuid'")
79
+ }
80
+ return column.type.toUpperCase()
81
+ }
82
+
83
+ /**
84
+ * The migration that creates a collection's table. A row carries its owner's key and the owner's whole containment
85
+ * chain under the owner's column names, with a composite foreign key: a move of the owner cascades into its files,
86
+ * so a capability minted before the move matches none of them, and deleting the owner orphans them (every owner
87
+ * column becomes null) for the sweep. The owner table needs `UNIQUE (<key>, <chain columns>)`, as a child table's
88
+ * parent does. A row is `pending` from before its object is written until the write completed.
89
+ */
90
+ export function fileTableSql(collection: string, owner: FileOwner): string {
91
+ const table = tableOf(collection)
92
+ if (!isPlainIdentifier(owner.table)) {
93
+ throw new QueryRefusedError('invalid_name', 'the owner table must match ^[a-z_][a-z0-9_]*$')
94
+ }
95
+ const keyType = sqlType(owner.key)
96
+ const chain = owner.chain.map((link) => ({ column: link.column, type: sqlType(link) }))
97
+ const taken = chain.find((link) => FILE_COLUMNS.includes(link.column))
98
+ if (taken !== undefined) {
99
+ throw new QueryRefusedError('invalid_name', `the owner column ${taken.column} has the name of a file column (${FILE_COLUMNS.join(', ')})`)
100
+ }
101
+ const fileColumns = ['owner_id', ...chain.map((link) => link.column)].join(', ')
102
+ const ownerColumns = [owner.key.column, ...chain.map((link) => link.column)].join(', ')
103
+ return `CREATE TABLE ${table} (
104
+ id UUID PRIMARY KEY,
105
+ owner_id ${keyType},
106
+ ${chain.map((link) => `\t${link.column} ${link.type},\n`).join('')} status TEXT NOT NULL ${fileStatusCheck(table)},
107
+ object_key TEXT NOT NULL UNIQUE,
108
+ size BIGINT,
109
+ content_type TEXT NOT NULL,
110
+ sha256 TEXT,
111
+ original_name TEXT,
112
+ created_by TEXT NOT NULL,
113
+ created_at TIMESTAMPTZ NOT NULL,
114
+ deleted_at TIMESTAMPTZ,
115
+ ${
116
+ FILE_SCAN_COLUMNS.map((column) => `\t${column},\n`).join('')
117
+ } FOREIGN KEY (${fileColumns}) REFERENCES ${owner.table} (${ownerColumns}) ON UPDATE CASCADE ON DELETE SET NULL
118
+ );
119
+ CREATE INDEX ${table}_owner ON ${table} (owner_id) WHERE deleted_at IS NULL;
120
+ CREATE INDEX ${table}_removable ON ${table} (created_at) WHERE deleted_at IS NOT NULL OR owner_id IS NULL OR status = 'pending';
121
+ ${fileScanIndex(table)};
122
+ `
123
+ }
124
+
125
+ /**
126
+ * The migration that lets an existing collection turn `scan: true` on: it replaces the status check of a table an
127
+ * earlier `fileTableSql` created, which admitted only `pending` and `live`, by the check of every state, and adds the
128
+ * scan job's columns and index. A table `fileTableSql` creates now needs none of it.
129
+ */
130
+ export function fileScanSql(collection: string): string {
131
+ const table = tableOf(collection)
132
+ return `${fileScanAlter(table)};\n${fileScanIndex(table)};\n`
133
+ }