@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/package.json +69 -5
- package/src/builtins.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/codegen.ts +302 -0
- package/src/database.ts +413 -0
- package/src/description.ts +309 -0
- package/src/errors.ts +124 -0
- package/src/file-table-sql.ts +133 -0
- package/src/file-tables.ts +577 -0
- package/src/form-admissions.ts +82 -0
- package/src/framework-tables.ts +147 -0
- package/src/framework.ts +89 -0
- package/src/guard.ts +439 -0
- package/src/identifier.ts +43 -0
- package/src/index.ts +129 -0
- package/src/job-leases.ts +373 -0
- package/src/links.ts +61 -0
- package/src/migrations.ts +97 -0
- package/src/nodes.ts +344 -0
- package/src/orm-types.ts +596 -0
- package/src/orm.ts +891 -0
- package/src/policy.ts +1194 -0
- package/src/production.ts +26 -0
- package/src/registry.ts +57 -0
- package/src/sql.ts +732 -0
- package/src/store.ts +1693 -0
- package/src/tables.ts +167 -0
- package/src/temporal.ts +91 -0
- package/src/testing.ts +695 -0
- package/src/vocabulary-tables.ts +206 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|