@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.
@@ -0,0 +1,147 @@
1
+ // The framework's side of its own tables (`@fabrika/db/migrations`): webhook bindings and the delivery keys that
2
+ // make a replay a no-op. A delivery key is derived from authenticated content (`@fabrika/app` hashes the binding
3
+ // and the signed body), never from a header; the `delivery_id` column holds it. Only `@fabrika/app`'s webhook runtime reaches them, through `frameworkTablesOf`. Every
4
+ // statement is a guarded tree like a store's, keyed by equalities on the framework table itself.
5
+
6
+ import { BinaryOperationNode, type OperationNode, OperatorNode, ValueNode } from 'kysely'
7
+ import { sessionOf } from './database'
8
+ import { DatabaseError, QueryRefusedError } from './errors'
9
+ import type { GuardContext } from './guard'
10
+ import { WEBHOOK_BINDINGS_TABLE, WEBHOOK_DELIVERIES_TABLE } from './migrations'
11
+ import { acknowledgement, allOf, column, deleteNode, equals, insertNode, locateNode, scopedWhere } from './nodes'
12
+ import type { DataLayerRuntime } from './store'
13
+
14
+ /** One webhook binding: the scope value it reaches, the provider account it accepts, and its signing secret. */
15
+ export interface WebhookBinding {
16
+ readonly id: string
17
+ /** The webhook mount's name. */
18
+ readonly webhook: string
19
+ readonly scope: { readonly type: string; readonly value: string }
20
+ /** `installation:<id>` or `repository:<id>`: the GitHub account a delivery must name. */
21
+ readonly account: string
22
+ readonly secret: string
23
+ }
24
+
25
+ export interface NewWebhookBinding extends WebhookBinding {
26
+ readonly createdBy: string
27
+ readonly createdAt: number
28
+ }
29
+
30
+ export interface WebhookTables {
31
+ createBinding(binding: NewWebhookBinding): Promise<void>
32
+ /** The binding `id` of `webhook`, or `null`. */
33
+ binding(webhook: string, id: string): Promise<WebhookBinding | null>
34
+ /** Removes the binding when it belongs to `webhook` and `scope`; `false` when there was none. */
35
+ removeBinding(webhook: string, id: string, scope: { readonly type: string; readonly value: string }): Promise<boolean>
36
+ /**
37
+ * Records a delivery key; `false` when the binding already received it within the retention window. Keys of the
38
+ * binding older than `retainedSince` are dropped first, so the table stays bounded.
39
+ */
40
+ recordDelivery(bindingId: string, deliveryKey: string, receivedAt: number, retainedSince: number): Promise<boolean>
41
+ /** Forgets a delivery whose handler failed, so the provider's redelivery runs it again. */
42
+ forgetDelivery(bindingId: string, deliveryKey: string): Promise<void>
43
+ }
44
+
45
+ const BINDING_COLUMNS = ['id', 'webhook', 'scope_type', 'scope_value', 'account', 'secret']
46
+ const UNIQUE_VIOLATION = '23505'
47
+
48
+ function guard(root: GuardContext['root'], table: string, scope: OperationNode | undefined): GuardContext {
49
+ return { root, table, joins: {}, scope }
50
+ }
51
+
52
+ function text(row: Readonly<Record<string, unknown>>, column: string): string {
53
+ const value = row[column]
54
+ if (typeof value !== 'string') {
55
+ throw new QueryRefusedError('unexpected_row', `${WEBHOOK_BINDINGS_TABLE}.${column} came back as ${typeof value}`)
56
+ }
57
+ return value
58
+ }
59
+
60
+ function isUniqueViolation(error: unknown): boolean {
61
+ return error instanceof DatabaseError && error.sqlState === UNIQUE_VIOLATION
62
+ }
63
+
64
+ export function webhookTables(runtime: () => DataLayerRuntime): WebhookTables {
65
+ const session = () => sessionOf(runtime())
66
+ return Object.freeze({
67
+ async createBinding(binding: NewWebhookBinding): Promise<void> {
68
+ const values = new Map<string, string | number>([
69
+ ['id', binding.id],
70
+ ['webhook', binding.webhook],
71
+ ['scope_type', binding.scope.type],
72
+ ['scope_value', binding.scope.value],
73
+ ['account', binding.account],
74
+ ['secret', binding.secret],
75
+ ['created_by', binding.createdBy],
76
+ ['created_at', binding.createdAt],
77
+ ])
78
+ const node = insertNode(WEBHOOK_BINDINGS_TABLE, values, acknowledgement(WEBHOOK_BINDINGS_TABLE, 'id', undefined))
79
+ await session().run(node, guard('InsertQueryNode', WEBHOOK_BINDINGS_TABLE, undefined))
80
+ },
81
+
82
+ async binding(webhook: string, id: string): Promise<WebhookBinding | null> {
83
+ const scope = allOf([equals(WEBHOOK_BINDINGS_TABLE, 'id', id), equals(WEBHOOK_BINDINGS_TABLE, 'webhook', webhook)])
84
+ const [row] = await session().run<Readonly<Record<string, unknown>>>(
85
+ locateNode(WEBHOOK_BINDINGS_TABLE, BINDING_COLUMNS, scope),
86
+ guard('SelectQueryNode', WEBHOOK_BINDINGS_TABLE, scope),
87
+ )
88
+ if (row === undefined) {
89
+ return null
90
+ }
91
+ return Object.freeze({
92
+ id: text(row, 'id'),
93
+ webhook: text(row, 'webhook'),
94
+ scope: Object.freeze({ type: text(row, 'scope_type'), value: text(row, 'scope_value') }),
95
+ account: text(row, 'account'),
96
+ secret: text(row, 'secret'),
97
+ })
98
+ },
99
+
100
+ async removeBinding(webhook: string, id: string, scope: { readonly type: string; readonly value: string }): Promise<boolean> {
101
+ const predicate = allOf([
102
+ equals(WEBHOOK_BINDINGS_TABLE, 'id', id),
103
+ equals(WEBHOOK_BINDINGS_TABLE, 'webhook', webhook),
104
+ equals(WEBHOOK_BINDINGS_TABLE, 'scope_type', scope.type),
105
+ equals(WEBHOOK_BINDINGS_TABLE, 'scope_value', scope.value),
106
+ ])
107
+ const removed = await session().run(
108
+ deleteNode(WEBHOOK_BINDINGS_TABLE, scopedWhere(undefined, predicate), 'id'),
109
+ guard('DeleteQueryNode', WEBHOOK_BINDINGS_TABLE, predicate),
110
+ )
111
+ return removed.length > 0
112
+ },
113
+
114
+ async recordDelivery(bindingId: string, deliveryKey: string, receivedAt: number, retainedSince: number): Promise<boolean> {
115
+ const binding = equals(WEBHOOK_DELIVERIES_TABLE, 'binding_id', bindingId)
116
+ const expired = BinaryOperationNode.create(
117
+ column(WEBHOOK_DELIVERIES_TABLE, 'received_at'),
118
+ OperatorNode.create('<'),
119
+ ValueNode.create(retainedSince),
120
+ )
121
+ await session().run(
122
+ deleteNode(WEBHOOK_DELIVERIES_TABLE, scopedWhere(expired, binding), 'delivery_id'),
123
+ guard('DeleteQueryNode', WEBHOOK_DELIVERIES_TABLE, binding),
124
+ )
125
+ const values = new Map<string, string | number>([['binding_id', bindingId], ['delivery_id', deliveryKey], ['received_at', receivedAt]])
126
+ const node = insertNode(WEBHOOK_DELIVERIES_TABLE, values, acknowledgement(WEBHOOK_DELIVERIES_TABLE, 'delivery_id', undefined))
127
+ try {
128
+ await session().run(node, guard('InsertQueryNode', WEBHOOK_DELIVERIES_TABLE, undefined))
129
+ return true
130
+ } catch (error) {
131
+ // The primary key decides between two concurrent copies of one delivery: exactly one insert wins.
132
+ if (isUniqueViolation(error)) {
133
+ return false
134
+ }
135
+ throw error
136
+ }
137
+ },
138
+
139
+ async forgetDelivery(bindingId: string, deliveryKey: string): Promise<void> {
140
+ const predicate = allOf([equals(WEBHOOK_DELIVERIES_TABLE, 'binding_id', bindingId), equals(WEBHOOK_DELIVERIES_TABLE, 'delivery_id', deliveryKey)])
141
+ await session().run(
142
+ deleteNode(WEBHOOK_DELIVERIES_TABLE, scopedWhere(undefined, predicate), 'delivery_id'),
143
+ guard('DeleteQueryNode', WEBHOOK_DELIVERIES_TABLE, predicate),
144
+ )
145
+ },
146
+ })
147
+ }
@@ -0,0 +1,89 @@
1
+ // @fabrika/db/framework — for `@fabrika/app`'s `boot` only; the checker refuses an application import of it. It binds
2
+ // the data layer an application passed to `implement` to the framework's connection and capability authority, and
3
+ // hands the framework the locator its access check uses. Browser- and Worker-safe: the Worker adapter imports `boot`.
4
+
5
+ import type { FileTables } from './file-tables'
6
+ import type { DataAccess } from './orm'
7
+ import { frameworkSideOf, type FrameworkTables, type Locator } from './registry'
8
+ import type { DataLayerRuntime } from './store'
9
+
10
+ export type { LeaseFence } from './capability'
11
+ export type { InvocationTransaction, Session } from './database'
12
+ export {
13
+ CAPABILITY_ERRORS,
14
+ CapabilityError,
15
+ type CapabilityErrorReason,
16
+ LeaseLostError,
17
+ QUERY_REFUSALS,
18
+ type QueryRefusal,
19
+ QueryRefusedError,
20
+ } from './errors'
21
+ export { fileTableName } from './file-table-sql'
22
+ export type {
23
+ FileCollectionSpec,
24
+ FileCollectionTable,
25
+ FilePage,
26
+ FileTables,
27
+ HeldStatus,
28
+ ListedFile,
29
+ PendingFile,
30
+ QuarantinedFile,
31
+ RemovableFile,
32
+ StoredFile,
33
+ } from './file-tables'
34
+ export type { FormAdmissions, InboxQuota } from './form-admissions'
35
+ export type { NewWebhookBinding, WebhookBinding, WebhookTables } from './framework-tables'
36
+ export type { JobLeases, JobLeaseState, JobOutcome, OverdueClaim } from './job-leases'
37
+ export type { DataAccess, DataHandles, DataSite } from './orm'
38
+ export type { FrameworkTables, Locator } from './registry'
39
+ export type { SqlMode, SqlSite, SqlTag, SqlValue } from './sql'
40
+
41
+ /** Whether `value` is an object `scoped(tables)` created, rather than a look-alike with the same fields. */
42
+ export function isScopedDatabase(value: unknown): boolean {
43
+ return frameworkSideOf(value) !== undefined
44
+ }
45
+
46
+ /**
47
+ * Connects the layer `scoped(tables)` created to `runtime`, once, and answers its locator, which nothing else can
48
+ * reach. Throws for any other value and for a layer that is already connected.
49
+ */
50
+ export function bindScopedDatabase(value: unknown, runtime: DataLayerRuntime): Locator {
51
+ const side = frameworkSideOf(value)
52
+ if (side === undefined) {
53
+ throw new TypeError('not a data layer scoped(tables) created')
54
+ }
55
+ return side.bind(runtime)
56
+ }
57
+
58
+ /** The file collections' tables of the layer `scoped(tables)` created; each operation needs the layer bound first. */
59
+ export function fileTablesOf(value: unknown): FileTables {
60
+ const side = frameworkSideOf(value)
61
+ if (side === undefined) {
62
+ throw new TypeError('not a data layer scoped(tables) created')
63
+ }
64
+ return side.files
65
+ }
66
+
67
+ /**
68
+ * The framework's own writes over the layer `scoped(tables)` created: inbox appends for `c.form` and the webhook
69
+ * tables. Each operation needs the layer bound first. Throws for any other value.
70
+ */
71
+ export function frameworkTablesOf(value: unknown): FrameworkTables {
72
+ const side = frameworkSideOf(value)
73
+ if (side === undefined) {
74
+ throw new TypeError('not a data layer scoped(tables) created')
75
+ }
76
+ return side.framework
77
+ }
78
+
79
+ /**
80
+ * The mini-ORM of the layer `scoped(tables)` created: the invocation transactions and the `ctx.db` handles the
81
+ * framework builds per site. Each operation needs the layer bound first. Throws for any other value.
82
+ */
83
+ export function dataAccessOf(value: unknown): DataAccess {
84
+ const side = frameworkSideOf(value)
85
+ if (side === undefined) {
86
+ throw new TypeError('not a data layer scoped(tables) created')
87
+ }
88
+ return side.data
89
+ }
package/src/guard.ts ADDED
@@ -0,0 +1,439 @@
1
+ import type { OperationNode } from 'kysely'
2
+ import { freeze, hasOwn, inSet, isArray, type NameSet, nameSet, ownDescriptor, ownKeys } from './builtins'
3
+ import { QueryRefusedError } from './errors'
4
+ import { isPlainAlias, isPlainIdentifier } from './identifier'
5
+
6
+ // The walk below uses index loops and the captured built-ins only, never an iterator or a prototype method.
7
+
8
+ export type RootKind = 'SelectQueryNode' | 'InsertQueryNode' | 'UpdateQueryNode' | 'DeleteQueryNode'
9
+
10
+ export interface ForeignKeyPair {
11
+ readonly childColumn: string
12
+ readonly parentColumn: string
13
+ }
14
+
15
+ export interface GuardContext {
16
+ readonly root: RootKind
17
+ readonly table: string
18
+ /** The children a select may join, each with the composite foreign key its ON must equal. */
19
+ readonly joins: Readonly<Record<string, readonly ForeignKeyPair[]>>
20
+ /** The scope predicate; it must be the WHERE itself or its top-level right conjunct. Absent for an insert only. */
21
+ readonly scope: OperationNode | undefined
22
+ /**
23
+ * A select that locks its rows: `share` is the layer's check of a mutation's destination, `update` a mutation's
24
+ * `getForUpdate` of its own target. No other lock is admitted.
25
+ */
26
+ readonly lock?: 'share' | 'update'
27
+ }
28
+
29
+ function lookup(...names: string[]): NameSet {
30
+ return nameSet(names)
31
+ }
32
+
33
+ /** The properties each root node may carry; anything else (with, explain, modifiers, on conflict, …) is refused. */
34
+ const ROOT_PROPERTIES: Readonly<Record<RootKind, NameSet>> = freeze({
35
+ SelectQueryNode: lookup('kind', 'from', 'selections', 'joins', 'where', 'groupBy', 'orderBy', 'limit', 'offset'),
36
+ InsertQueryNode: lookup('kind', 'into', 'columns', 'values', 'returning'),
37
+ UpdateQueryNode: lookup('kind', 'table', 'updates', 'where', 'returning'),
38
+ DeleteQueryNode: lookup('kind', 'from', 'where', 'returning'),
39
+ })
40
+
41
+ const ROOT_KINDS = lookup('SelectQueryNode', 'InsertQueryNode', 'UpdateQueryNode', 'DeleteQueryNode')
42
+
43
+ const NODE_KINDS = lookup(
44
+ 'FromNode',
45
+ 'TableNode',
46
+ 'SchemableIdentifierNode',
47
+ 'IdentifierNode',
48
+ 'SelectionNode',
49
+ 'SelectAllNode',
50
+ 'ReferenceNode',
51
+ 'ColumnNode',
52
+ 'AliasNode',
53
+ 'WhereNode',
54
+ 'AndNode',
55
+ 'OrNode',
56
+ 'ParensNode',
57
+ 'BinaryOperationNode',
58
+ 'OperatorNode',
59
+ 'ValueNode',
60
+ 'ValueListNode',
61
+ 'OrderByNode',
62
+ 'OrderByItemNode',
63
+ 'LimitNode',
64
+ 'OffsetNode',
65
+ 'JoinNode',
66
+ 'OnNode',
67
+ 'GroupByNode',
68
+ 'GroupByItemNode',
69
+ 'AggregateFunctionNode',
70
+ 'ValuesNode',
71
+ 'ColumnUpdateNode',
72
+ 'ReturningNode',
73
+ // Only as an orderBy direction; checked below.
74
+ 'RawNode',
75
+ // Only as the one lock of a destination check or a getForUpdate; checked below.
76
+ 'SelectModifierNode',
77
+ // Only as a selected own column's text, the exact position a keyset page continues from; checked below.
78
+ 'CastNode',
79
+ 'DataTypeNode',
80
+ )
81
+
82
+ const COMPARISON_OPERATORS = lookup('=', '<>', '<', '<=', '>', '>=', 'like', 'ilike', 'not like', 'not ilike', 'in', 'not in', 'is', 'is not')
83
+ const AGGREGATES = lookup('count', 'sum', 'min', 'max')
84
+
85
+ /** The path from the root to a node, as a linked list (no array spread, which an iterator patch could subvert). */
86
+ interface Visit {
87
+ readonly node: OperationNode
88
+ readonly key: string
89
+ readonly up: Visit | undefined
90
+ }
91
+
92
+ /** Refuses any operation tree outside the allowlist. Runs on the final tree, immediately before it is compiled. */
93
+ export function assertAllowedTree(root: OperationNode, context: GuardContext): void {
94
+ if (root.kind !== context.root) {
95
+ throw new QueryRefusedError('statement_shape', `expected a ${context.root}, got ${String(root.kind)}`)
96
+ }
97
+ const allowed = ROOT_PROPERTIES[context.root]
98
+ const keys = ownKeys(root)
99
+ for (let index = 0; index < keys.length; index++) {
100
+ const key = keys[index] ?? ''
101
+ const lockModifier = key === 'endModifiers' && context.lock !== undefined && context.root === 'SelectQueryNode'
102
+ if (read(root, key) !== undefined && !inSet(allowed, key) && !lockModifier) {
103
+ throw new QueryRefusedError('statement_shape', `${context.root}.${key} is not allowed`)
104
+ }
105
+ }
106
+ checkRoot(root, context)
107
+ walk(root, undefined, context)
108
+ }
109
+
110
+ function checkRoot(root: OperationNode, context: GuardContext): void {
111
+ const target = context.root === 'InsertQueryNode'
112
+ ? read(root, 'into')
113
+ : context.root === 'UpdateQueryNode'
114
+ ? read(root, 'table')
115
+ : soleFrom(read(root, 'from'))
116
+ if (tableName(target) !== context.table) {
117
+ throw new QueryRefusedError('statement_shape', `the statement must address ${context.table} only`)
118
+ }
119
+ const where = read(root, 'where')
120
+ if (context.scope === undefined) {
121
+ if (context.root !== 'InsertQueryNode' || where !== undefined) {
122
+ throw new QueryRefusedError('scope_predicate', 'a statement without its scope predicate is not allowed')
123
+ }
124
+ return
125
+ }
126
+ const predicate = read(where, 'where')
127
+ const scoped = predicate === context.scope
128
+ || (kindOf(predicate) === 'AndNode' && read(predicate, 'right') === context.scope && kindOf(read(predicate, 'left')) === 'ParensNode')
129
+ if (!scoped) {
130
+ throw new QueryRefusedError('scope_predicate', 'the scope predicate must be the top-level conjunct of WHERE')
131
+ }
132
+ const scope = context.scope
133
+ const membership = (leaf: unknown) => isScopeMembership(leaf, context.table)
134
+ const shaped = isEverything(scope)
135
+ || isTreeOf(scope, 'AndNode', (leaf) => isScopeEquality(leaf, context.table))
136
+ || membership(scope)
137
+ || (kindOf(scope) === 'ParensNode' && kindOf(read(scope, 'node')) === 'OrNode' && isTreeOf(read(scope, 'node'), 'OrNode', membership))
138
+ if (!shaped) {
139
+ throw new QueryRefusedError(
140
+ 'scope_predicate',
141
+ 'the scope predicate must be true, an AND of column = value, or a parenthesized OR of column in (values), over the statement table',
142
+ )
143
+ }
144
+ }
145
+
146
+ /** A Within that is not limited to scope values. */
147
+ function isEverything(node: unknown): boolean {
148
+ return kindOf(node) === 'ValueNode' && read(node, 'immediate') === true && read(node, 'value') === true
149
+ }
150
+
151
+ /** Nodes of one connective whose every leaf passes `leaf`; a leaf is never itself a connective. */
152
+ function isTreeOf(node: unknown, connective: 'AndNode' | 'OrNode', leaf: (node: unknown) => boolean): boolean {
153
+ if (kindOf(node) === connective) {
154
+ return isTreeOf(read(node, 'left'), connective, leaf) && isTreeOf(read(node, 'right'), connective, leaf)
155
+ }
156
+ return leaf(node)
157
+ }
158
+
159
+ /** `table.column = $n`: one link of a Ref's chain. */
160
+ function isScopeEquality(node: unknown, table: string): boolean {
161
+ return kindOf(node) === 'BinaryOperationNode'
162
+ && read(read(node, 'operator'), 'operator') === '='
163
+ && isOwnColumn(read(node, 'leftOperand'), table)
164
+ && isBoundValue(read(node, 'rightOperand'))
165
+ }
166
+
167
+ /** `table.column in ($1, …)`: a Within's own values, or one container's. */
168
+ function isScopeMembership(node: unknown, table: string): boolean {
169
+ if (
170
+ kindOf(node) !== 'BinaryOperationNode' || read(read(node, 'operator'), 'operator') !== 'in' || !isOwnColumn(read(node, 'leftOperand'), table)
171
+ ) {
172
+ return false
173
+ }
174
+ const list = read(node, 'rightOperand')
175
+ const values = read(list, 'values')
176
+ if (kindOf(list) !== 'ValueListNode' || !isArray(values) || values.length === 0) {
177
+ return false
178
+ }
179
+ for (let index = 0; index < values.length; index++) {
180
+ if (!isBoundValue(values[index])) {
181
+ return false
182
+ }
183
+ }
184
+ return true
185
+ }
186
+
187
+ function isOwnColumn(node: unknown, table: string): boolean {
188
+ return kindOf(node) === 'ReferenceNode' && tableName(read(node, 'table')) === table && kindOf(read(node, 'column')) === 'ColumnNode'
189
+ }
190
+
191
+ function isBoundValue(node: unknown): boolean {
192
+ return kindOf(node) === 'ValueNode' && read(node, 'immediate') !== true
193
+ }
194
+
195
+ function soleFrom(from: unknown): unknown {
196
+ const froms = read(from, 'froms')
197
+ if (!isArray(froms) || froms.length !== 1) {
198
+ throw new QueryRefusedError('statement_shape', 'exactly one table in FROM')
199
+ }
200
+ return froms[0]
201
+ }
202
+
203
+ function walk(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
204
+ checkNode(node, parent, context)
205
+ if (node.kind === 'ValueNode') {
206
+ return
207
+ }
208
+ const keys = ownKeys(node)
209
+ for (let index = 0; index < keys.length; index++) {
210
+ const key = keys[index] ?? ''
211
+ const value = read(node, key)
212
+ const children: readonly unknown[] = isArray(value) ? value : [value]
213
+ for (let position = 0; position < children.length; position++) {
214
+ const child = children[position]
215
+ if (isOperationNode(child)) {
216
+ walk(child, { node, key, up: parent }, context)
217
+ } else if (typeof child === 'object' && child !== null) {
218
+ throw new QueryRefusedError('statement_shape', `unexpected structure under ${node.kind}.${key}`)
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ function checkNode(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
225
+ if (inSet(ROOT_KINDS, node.kind)) {
226
+ if (parent !== undefined) {
227
+ throw new QueryRefusedError('statement_shape', 'subqueries are not allowed')
228
+ }
229
+ return
230
+ }
231
+ if (!inSet(NODE_KINDS, node.kind)) {
232
+ throw new QueryRefusedError('statement_shape', `${String(node.kind)} is not allowed`)
233
+ }
234
+ switch (node.kind) {
235
+ case 'RawNode':
236
+ return checkRaw(node, parent)
237
+ case 'SelectModifierNode':
238
+ return checkLock(node, parent, context)
239
+ case 'CastNode':
240
+ return checkCast(node, parent, context)
241
+ case 'DataTypeNode':
242
+ if (parent?.node.kind !== 'CastNode' || parent.key !== 'dataType' || read(node, 'dataType') !== 'text') {
243
+ throw new QueryRefusedError('statement_shape', 'a data type appears only as the text of a selected column')
244
+ }
245
+ return
246
+ case 'SchemableIdentifierNode':
247
+ if (read(node, 'schema') !== undefined) {
248
+ throw new QueryRefusedError('statement_shape', 'schema-qualified tables are not allowed')
249
+ }
250
+ return
251
+ case 'IdentifierNode':
252
+ if (!isPlainAlias(read(node, 'name'))) {
253
+ throw new QueryRefusedError('statement_shape', 'identifiers must be plain names')
254
+ }
255
+ return
256
+ case 'TableNode':
257
+ return checkTable(node, parent, context)
258
+ case 'JoinNode':
259
+ return checkJoin(node, context)
260
+ case 'ReferenceNode':
261
+ if (read(node, 'table') === undefined) {
262
+ throw new QueryRefusedError('statement_shape', 'column references must be table-qualified')
263
+ }
264
+ return
265
+ case 'ColumnNode':
266
+ if (
267
+ parent === undefined
268
+ || !(parent.node.kind === 'ReferenceNode' || parent.node.kind === 'InsertQueryNode' || parent.node.kind === 'ColumnUpdateNode')
269
+ ) {
270
+ throw new QueryRefusedError('statement_shape', 'unqualified column')
271
+ }
272
+ return
273
+ case 'SelectAllNode':
274
+ if (parent?.node.kind !== 'AggregateFunctionNode' || read(parent.node, 'func') !== 'count') {
275
+ throw new QueryRefusedError('statement_shape', 'select * is not allowed; name the columns')
276
+ }
277
+ return
278
+ case 'OperatorNode':
279
+ return checkOperator(node, parent)
280
+ case 'AggregateFunctionNode':
281
+ if (
282
+ !inSet(AGGREGATES, read(node, 'func')) || read(node, 'distinct') === true || read(node, 'filter') !== undefined
283
+ || read(node, 'over') !== undefined
284
+ ) {
285
+ throw new QueryRefusedError('statement_shape', 'aggregate function not allowed')
286
+ }
287
+ return
288
+ case 'ValueNode':
289
+ return checkValue(node)
290
+ default:
291
+ return
292
+ }
293
+ }
294
+
295
+ function checkOperator(node: OperationNode, parent: Visit | undefined): void {
296
+ const operator = read(node, 'operator')
297
+ if (inSet(COMPARISON_OPERATORS, operator)) {
298
+ return
299
+ }
300
+ // The version bump `version = version + 1` is the one arithmetic the layer writes, as a SET value.
301
+ if (operator === '+' && parent?.up?.node.kind === 'ColumnUpdateNode') {
302
+ return
303
+ }
304
+ throw new QueryRefusedError('statement_shape', 'operator not allowed')
305
+ }
306
+
307
+ function checkValue(node: OperationNode): void {
308
+ const value = read(node, 'value')
309
+ if (read(node, 'immediate') === true) {
310
+ if (value !== null && typeof value !== 'boolean') {
311
+ throw new QueryRefusedError('statement_shape', 'inline values are allowed for null and booleans only')
312
+ }
313
+ return
314
+ }
315
+ const type = typeof value
316
+ const plain = type === 'string' || type === 'number' || type === 'bigint' || type === 'boolean' || value === null || value instanceof Date
317
+ if (!plain) {
318
+ // Bun.SQL mis-binds a JS array and stores a bound JSON string as a JSON string, so only scalars are bound.
319
+ throw new QueryRefusedError('statement_shape', 'only plain values are bound as parameters')
320
+ }
321
+ }
322
+
323
+ function checkLock(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
324
+ const modifiers = parent === undefined ? undefined : read(parent.node, 'endModifiers')
325
+ const onRoot = parent !== undefined && parent.up === undefined && parent.key === 'endModifiers' && isArray(modifiers) && modifiers.length === 1
326
+ const expected = context.lock === 'share' ? 'ForShare' : context.lock === 'update' ? 'ForUpdate' : undefined
327
+ const plain = expected !== undefined && read(node, 'modifier') === expected && read(node, 'of') === undefined
328
+ && read(node, 'rawModifier') === undefined
329
+ if (!onRoot || !plain) {
330
+ throw new QueryRefusedError('statement_shape', 'row locks are not allowed')
331
+ }
332
+ }
333
+
334
+ /** `cast(<own column> as text) as <alias>`, selected at the top of a select: nothing else casts. */
335
+ function checkCast(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
336
+ const selected = parent?.node.kind === 'AliasNode' && parent.key === 'node' && parent.up?.node.kind === 'SelectionNode'
337
+ && parent.up.up?.node.kind === 'SelectQueryNode' && parent.up.up.up === undefined && parent.up.up.key === 'selections'
338
+ if (context.root !== 'SelectQueryNode' || !selected || !isOwnColumn(read(node, 'expression'), context.table)) {
339
+ throw new QueryRefusedError('statement_shape', 'a cast appears only as the text of a selected column of the statement table')
340
+ }
341
+ }
342
+
343
+ function checkRaw(node: OperationNode, parent: Visit | undefined): void {
344
+ const fragments = read(node, 'sqlFragments')
345
+ const parameters = read(node, 'parameters')
346
+ const isDirection = parent !== undefined && parent.node.kind === 'OrderByItemNode' && parent.key === 'direction'
347
+ const isPlainDirection = isArray(fragments) && fragments.length === 1 && (fragments[0] === 'asc' || fragments[0] === 'desc')
348
+ const hasNoParameters = isArray(parameters) && parameters.length === 0
349
+ if (!isDirection || !isPlainDirection || !hasNoParameters) {
350
+ throw new QueryRefusedError('statement_shape', 'raw SQL is not allowed')
351
+ }
352
+ }
353
+
354
+ function checkTable(node: OperationNode, parent: Visit | undefined, context: GuardContext): void {
355
+ const name = tableName(node)
356
+ if (name === context.table) {
357
+ return
358
+ }
359
+ const isJoinTarget = parent?.node.kind === 'JoinNode' && parent.key === 'table'
360
+ const isColumnQualifier = parent?.node.kind === 'ReferenceNode'
361
+ if (name !== undefined && foreignKeyOf(context, name) !== undefined && (isJoinTarget || isColumnQualifier)) {
362
+ return
363
+ }
364
+ throw new QueryRefusedError('statement_shape', `table ${String(name)} is not reachable from ${context.table}`)
365
+ }
366
+
367
+ function checkJoin(node: OperationNode, context: GuardContext): void {
368
+ const child = tableName(read(node, 'table'))
369
+ const pairs = child === undefined ? undefined : foreignKeyOf(context, child)
370
+ if (context.root !== 'SelectQueryNode' || read(node, 'joinType') !== 'InnerJoin' || child === undefined || pairs === undefined) {
371
+ throw new QueryRefusedError('statement_shape', 'only inner joins to declared children are allowed')
372
+ }
373
+ const on = read(read(node, 'on'), 'on')
374
+ const withConditions = kindOf(on) === 'AndNode' && kindOf(read(on, 'right')) === 'ParensNode'
375
+ const matches = isForeignKey(on, child, context.table, pairs) || (withConditions && isForeignKey(read(on, 'left'), child, context.table, pairs))
376
+ if (!matches) {
377
+ throw new QueryRefusedError('statement_shape', `the join of ${child} must be ON its composite foreign key`)
378
+ }
379
+ }
380
+
381
+ function foreignKeyOf(context: GuardContext, child: string): readonly ForeignKeyPair[] | undefined {
382
+ return hasOwn(context.joins, child) ? context.joins[child] : undefined
383
+ }
384
+
385
+ /** `child.c1 = parent.p1 and child.c2 = parent.p2 …`, left-nested, in the declared order. */
386
+ function isForeignKey(node: unknown, child: string, parent: string, pairs: readonly ForeignKeyPair[]): boolean {
387
+ let rest = node
388
+ for (let index = pairs.length - 1; index > 0; index--) {
389
+ const pair = pairs[index]
390
+ if (
391
+ pair === undefined || kindOf(rest) !== 'AndNode' || !isColumnEquality(read(rest, 'right'), child, pair.childColumn, parent, pair.parentColumn)
392
+ ) {
393
+ return false
394
+ }
395
+ rest = read(rest, 'left')
396
+ }
397
+ const first = pairs[0]
398
+ return first !== undefined && isColumnEquality(rest, child, first.childColumn, parent, first.parentColumn)
399
+ }
400
+
401
+ function isColumnEquality(node: unknown, leftTable: string, leftColumn: string, rightTable: string, rightColumn: string): boolean {
402
+ return kindOf(node) === 'BinaryOperationNode'
403
+ && read(read(node, 'operator'), 'operator') === '='
404
+ && isReference(read(node, 'leftOperand'), leftTable, leftColumn)
405
+ && isReference(read(node, 'rightOperand'), rightTable, rightColumn)
406
+ }
407
+
408
+ function isReference(node: unknown, table: string, column: string): boolean {
409
+ return kindOf(node) === 'ReferenceNode' && tableName(read(node, 'table')) === table && read(read(read(node, 'column'), 'column'), 'name') === column
410
+ }
411
+
412
+ function isOperationNode(value: unknown): value is OperationNode {
413
+ return typeof value === 'object' && value !== null && typeof read(value, 'kind') === 'string'
414
+ }
415
+
416
+ function kindOf(node: unknown): unknown {
417
+ return read(node, 'kind')
418
+ }
419
+
420
+ /** An own data property, or undefined. An accessor is refused: the guard never runs a getter. */
421
+ function read(node: unknown, key: string): unknown {
422
+ if (typeof node !== 'object' || node === null) {
423
+ return undefined
424
+ }
425
+ const descriptor = ownDescriptor(node, key)
426
+ if (descriptor === undefined) {
427
+ return undefined
428
+ }
429
+ if (!hasOwn(descriptor, 'value')) {
430
+ throw new QueryRefusedError('statement_shape', `accessor properties are not allowed in an operation tree (${key})`)
431
+ }
432
+ return descriptor.value
433
+ }
434
+
435
+ /** TableNode → SchemableIdentifierNode → IdentifierNode.name */
436
+ function tableName(node: unknown): string | undefined {
437
+ const name = read(read(read(node, 'table'), 'identifier'), 'name')
438
+ return isPlainIdentifier(name) ? name : undefined
439
+ }