@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.
package/src/sql.ts ADDED
@@ -0,0 +1,732 @@
1
+ // `ctx.sql(Row)`…``: one statement of the application's own SQL, for a site that declares `sql: 'read' | 'write'`. The
2
+ // template's static text is classified before anything is sent: an interpolation is always a bound parameter (`$n`),
3
+ // never SQL. The statement runs in the site's session — the invocation's transaction, or a job's fenced one — so it
4
+ // commits and rolls back with every `ctx.db` handle. The name rules are defense in depth; the runtime role
5
+ // (`run_<id>`, DML only) is what bounds a statement they admit.
6
+
7
+ import {
8
+ dmlForeignSchema,
9
+ frameworkReference,
10
+ lexSql,
11
+ nameReferences,
12
+ nameText,
13
+ nestedStatement,
14
+ parenthesizedTarget,
15
+ refusedFunctionCall,
16
+ type SqlToken,
17
+ statementKind,
18
+ topLevelStatements,
19
+ unicodeIdentifier,
20
+ upperWord,
21
+ } from '@fabrika/platform-node/sql'
22
+ import { z } from 'zod/v4'
23
+ import { freeze, hasOwn, isArray, ownDescriptor, ownKeys, parseList } from './builtins'
24
+ import { runApplicationStatement, type Session, type SqlParameter } from './database'
25
+ import { type QueryRefusal, QueryRefusedError, SqlRowError } from './errors'
26
+ import { isPlainIdentifier } from './identifier'
27
+ import { dayText } from './temporal'
28
+
29
+ /** What a site declares: `'read'` runs queries only, `'write'` also `INSERT`, `UPDATE` and `DELETE`. */
30
+ export type SqlMode = 'read' | 'write'
31
+
32
+ /** A value an interpolation binds: a scalar, a `Date` (bound as ISO text), or a list of strings or numbers (an array). */
33
+ export type SqlValue = string | number | boolean | bigint | Date | null | readonly (string | number)[]
34
+
35
+ /** One test of a capability's scope on a table: the column holds the value, or one of the list. */
36
+ export interface ScopeTest {
37
+ readonly column: string
38
+ readonly value: string | readonly string[]
39
+ }
40
+
41
+ /** A capability's predicate on one table: every row (`'all'`), or an OR of AND groups; no group matches nothing. */
42
+ export type ScopeTerms = 'all' | readonly (readonly ScopeTest[])[]
43
+
44
+ /** What a fragment's table is to its statement: read, or the target of its UPDATE or DELETE. */
45
+ export type ScopeUse = 'read' | 'update' | 'delete'
46
+
47
+ /**
48
+ * The scope of `capability` on `table` for `use`, as the data layer predicates its handles; throws when it does not
49
+ * reach it for that use.
50
+ */
51
+ export type ScopeOf = (capability: unknown, table: string, use: ScopeUse) => ScopeTerms
52
+
53
+ /** The operation a write statement performs on one of its targets. */
54
+ export type WriteOperation = 'insert' | 'update' | 'delete'
55
+
56
+ /** Throws for a table no statement may write with `operation`: a reference table. */
57
+ export type WriteTarget = (table: string, operation: WriteOperation) => void
58
+
59
+ /**
60
+ * `ctx.sql.scope(capability, table, alias?)`: the capability's whole predicate on one table, as SQL with bound values.
61
+ * A fragment is a frozen object with no prototype; what it holds lives in this module's registry, read through
62
+ * methods captured at load, so no object an application builds or changes is ever taken for one.
63
+ */
64
+ export interface SqlScope {
65
+ readonly kind: 'sql-scope'
66
+ }
67
+
68
+ interface ScopeData {
69
+ readonly site: SqlSite
70
+ readonly capability: unknown
71
+ readonly table: string
72
+ readonly alias: string
73
+ }
74
+
75
+ const scopes = new WeakMap<object, ScopeData>()
76
+ // Bound once at load: a later patch of WeakMap.prototype or Function.prototype changes neither.
77
+ const registered = WeakMap.prototype.get.bind(scopes)
78
+ const register = WeakMap.prototype.set.bind(scopes)
79
+ const withoutPrototype = Object.setPrototypeOf
80
+
81
+ function newScope(data: ScopeData): SqlScope {
82
+ const scope: SqlScope = { kind: 'sql-scope' }
83
+ withoutPrototype(scope, null)
84
+ freeze(scope)
85
+ register(scope, freeze({ site: data.site, capability: data.capability, table: data.table, alias: data.alias }))
86
+ return scope
87
+ }
88
+
89
+ /** What `value` holds when it is a fragment this module built; `undefined` for anything else. */
90
+ function scopeOf(value: unknown): ScopeData | undefined {
91
+ return typeof value === 'object' && value !== null ? registered(value) : undefined
92
+ }
93
+
94
+ /** `ctx.sql(Row)`: the tag for rows of `Row`, and `scope` for the capability's predicate inside its text. */
95
+ export interface SqlTag {
96
+ <S extends z.core.$ZodType>(row: S): (text: TemplateStringsArray, ...values: readonly (SqlValue | SqlScope)[]) => Promise<z.output<S>[]>
97
+ scope(capability: unknown, table: string, alias?: string): SqlScope
98
+ }
99
+
100
+ /** The site a tag is made for: its mode, its binding to the invocation, and the session its statements run in. */
101
+ export interface SqlSite {
102
+ readonly mode: SqlMode
103
+ /** Throws unless the work running now is the invocation the tag was made for. */
104
+ check(): void
105
+ session(): Promise<Session>
106
+ /** Fails the site's transaction (the invocation's, or a job's fenced one), so it commits nothing. */
107
+ fail(): void
108
+ }
109
+
110
+ const READ_STATEMENTS: ReadonlySet<string> = new Set(['SELECT', 'WITH', 'VALUES', 'TABLE'])
111
+ const WRITE_STATEMENTS: ReadonlySet<string> = new Set(['INSERT', 'UPDATE', 'DELETE'])
112
+ const WRITE_WORDS: ReadonlySet<string> = new Set(['INSERT', 'UPDATE', 'DELETE', 'MERGE'])
113
+ /** `FOR UPDATE`, `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`: what follows `FOR` in a locking clause. */
114
+ const LOCK_WORDS: ReadonlySet<string> = new Set(['UPDATE', 'NO', 'SHARE', 'KEY'])
115
+ /** ctx.sql calls reviewed built-ins only: the functions the migrations declare are not known at runtime. */
116
+ const NO_DECLARED_FUNCTIONS: ReadonlySet<string> = new Set()
117
+
118
+ const ALLOWED_STATEMENTS = "SELECT, WITH, VALUES or TABLE, and INSERT, UPDATE or DELETE on a site that declares sql: 'write'"
119
+
120
+ function refuse(reason: QueryRefusal, message: string): never {
121
+ throw new QueryRefusedError(reason, `ctx.sql: ${message}`)
122
+ }
123
+
124
+ const isPunct = (token: SqlToken | undefined, char: string): boolean => token?.kind === 'punct' && token.char === char
125
+ const isDigit = (token: SqlToken | undefined): boolean => token?.kind === 'punct' && token.char >= '0' && token.char <= '9'
126
+
127
+ /** The template's static parts. A call with a plain array, or a text with an invalid escape, is refused. */
128
+ function templateParts(text: unknown): readonly string[] {
129
+ const raw: unknown = isArray(text) && hasOwn(text, 'raw') ? ownDescriptor(text, 'raw')?.value : undefined
130
+ if (!isArray(text) || !Object.isFrozen(text) || !isArray(raw) || !Object.isFrozen(raw) || raw.length !== text.length) {
131
+ refuse('sql_template', 'it is a tagged template: write ctx.sql(Row)`select …`, never call it with a string or an array')
132
+ }
133
+ const parts: string[] = []
134
+ for (let index = 0; index < text.length; index++) {
135
+ const part: unknown = text[index]
136
+ if (typeof part !== 'string') {
137
+ refuse('sql_template', 'the template has an invalid escape sequence')
138
+ }
139
+ parts.push(part)
140
+ }
141
+ return parts
142
+ }
143
+
144
+ /** The statement's text, each interpolation a `$n` placeholder between the static parts. */
145
+ function statementText(parts: readonly string[]): string {
146
+ let text = parts[0] ?? ''
147
+ for (let index = 1; index < parts.length; index++) {
148
+ const part = parts[index] ?? ''
149
+ if (/^[0-9]/.test(part)) {
150
+ refuse('sql_template', `a digit right after the value \${…} number ${index} would join its placeholder; put a space between them`)
151
+ }
152
+ text += `$${index}${part}`
153
+ }
154
+ return text
155
+ }
156
+
157
+ /** How many `$n` placeholders the lexer sees outside literals, quoted names, dollar-quoted bodies and comments. */
158
+ function placeholderCount(tokens: readonly SqlToken[]): number {
159
+ let count = 0
160
+ for (let index = 0; index + 1 < tokens.length; index++) {
161
+ if (isPunct(tokens[index], '$') && isDigit(tokens[index + 1])) count++
162
+ }
163
+ return count
164
+ }
165
+
166
+ /** The first `SELECT … INTO`: it creates a table. `INSERT INTO` is the one `INTO` a statement may hold. */
167
+ function selectInto(tokens: readonly SqlToken[]): SqlToken | undefined {
168
+ for (let index = 0; index < tokens.length; index++) {
169
+ if (upperWord(tokens[index]) === 'INTO' && upperWord(tokens[index - 1]) !== 'INSERT') return tokens[index]
170
+ }
171
+ return undefined
172
+ }
173
+
174
+ /** In a `'read'` statement: the first word that writes (a data-modifying CTE) or a locking clause. */
175
+ function readRefusal(tokens: readonly SqlToken[]): string | undefined {
176
+ for (let index = 0; index < tokens.length; index++) {
177
+ const word = upperWord(tokens[index])
178
+ if (word === 'FOR' && LOCK_WORDS.has(upperWord(tokens[index + 1]) ?? '')) {
179
+ return `a locking clause (FOR ${upperWord(tokens[index + 1])}) at line ${tokens[index]?.line}`
180
+ }
181
+ if (word !== undefined && WRITE_WORDS.has(word)) return `${word} at line ${tokens[index]?.line}`
182
+ }
183
+ return undefined
184
+ }
185
+
186
+ /**
187
+ * The SQL `parts` make, with `$1…$n` for the values, or a `QueryRefusedError` that names what is refused: more than
188
+ * one statement, any statement but a query (and `INSERT`/`UPDATE`/`DELETE` for `'write'`), a nested statement of
189
+ * another kind, a qualified name outside `schema`, a `fabrika_*` or `pg_*` name, a function call outside the reviewed
190
+ * built-ins, and a value placed where it would not be bound.
191
+ */
192
+ export function classifySql(parts: readonly string[], mode: SqlMode, schema: string): string {
193
+ for (const part of parts) {
194
+ if (/\$[0-9]/.test(part)) {
195
+ refuse('sql_template', 'the text holds $ and a digit, which reads as a positional parameter; interpolate the value with ${…}, a literal one too')
196
+ }
197
+ }
198
+ const text = statementText(parts)
199
+ const { tokens: all, unterminated } = lexSql(text)
200
+ if (unterminated !== undefined) {
201
+ refuse('sql_template', `a string, quoted name, dollar-quoted body or comment at line ${unterminated} never closes`)
202
+ }
203
+ if (placeholderCount(all) !== parts.length - 1) {
204
+ refuse(
205
+ 'sql_template',
206
+ 'a value ${…} inside a string literal, a quoted name, a dollar-quoted body or a comment is not bound; interpolate a whole value',
207
+ )
208
+ }
209
+ const statements = topLevelStatements(text)
210
+ const [statement] = statements
211
+ if (statement === undefined) {
212
+ refuse('sql_statement', 'the template holds no statement')
213
+ }
214
+ if (statements.length > 1) {
215
+ refuse('sql_statement', `it runs one statement, and the template holds ${statements.length}; call ctx.sql once for each`)
216
+ }
217
+ const tokens = statement.tokens
218
+ const first = upperWord(tokens[0]) ?? ''
219
+ if (!READ_STATEMENTS.has(first) && !WRITE_STATEMENTS.has(first)) {
220
+ refuse('sql_statement', `${statementKind(tokens)} is refused; it runs ${ALLOWED_STATEMENTS}`)
221
+ }
222
+ const writes = mode === 'read' ? (WRITE_STATEMENTS.has(first) ? `${first} at line ${statement.line}` : readRefusal(tokens)) : undefined
223
+ if (writes !== undefined) {
224
+ refuse('sql_read_only', `sql: 'read' runs queries only, and the statement holds ${writes}; declare sql: 'write' on a mutation, a job or a webhook`)
225
+ }
226
+ const nested = nestedStatement(tokens) ?? parenthesizedTarget(tokens)
227
+ if (nested !== undefined) {
228
+ refuse('sql_statement', `${nested.statement} at line ${nested.line} is refused inside another statement`)
229
+ }
230
+ const into = selectInto(tokens)
231
+ if (into !== undefined) {
232
+ refuse('sql_statement', `SELECT … INTO at line ${into.line} creates a table; select the rows and insert them with INSERT INTO … SELECT`)
233
+ }
234
+ if (unicodeIdentifier(tokens) !== undefined) {
235
+ refuse('sql_name', 'a U&"…" name is refused: write the name plainly')
236
+ }
237
+ const framework = frameworkReference(tokens)
238
+ if (framework !== undefined) {
239
+ refuse('sql_name', `'${framework.name}' at line ${framework.line} is the framework's; ctx.sql reaches only the application's own tables`)
240
+ }
241
+ for (const reference of nameReferences(tokens)) {
242
+ if (reference.role !== 'call' && reference.name.startsWith('pg_')) {
243
+ refuse('sql_name', `'${reference.name}' at line ${reference.line} is a catalog name; ctx.sql reaches only the application's own tables`)
244
+ }
245
+ }
246
+ const foreign = dmlForeignSchema(tokens, schema)
247
+ if (foreign !== undefined) {
248
+ refuse('sql_name', `the schema '${foreign}' is outside the application's; write its tables' names unqualified`)
249
+ }
250
+ const call = refusedFunctionCall(tokens, schema, NO_DECLARED_FUNCTIONS)
251
+ if (call !== undefined) {
252
+ refuse(
253
+ 'sql_function',
254
+ `${call.qualifier === undefined ? '' : `${call.qualifier}.`}${call.name}(…) at line ${call.line} is not a reviewed built-in function`,
255
+ )
256
+ }
257
+ return text
258
+ }
259
+
260
+ function arrayElement(item: unknown): string {
261
+ if (typeof item === 'string') {
262
+ return `"${item.replace(/["\\]/g, (char) => `\\${char}`)}"`
263
+ }
264
+ if (typeof item === 'number' && Number.isFinite(item)) {
265
+ return String(item)
266
+ }
267
+ return refuse('invalid_value', 'a list value holds only strings and finite numbers')
268
+ }
269
+
270
+ /** A value as the driver binds it. Unprepared, `Bun.SQL` mis-binds a JS array and sends a `Date` as `Date#toString()`. */
271
+ function parameterOf(value: unknown, position: number): SqlParameter {
272
+ if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'bigint') {
273
+ return value
274
+ }
275
+ if (typeof value === 'number') {
276
+ return Number.isFinite(value) ? value : refuse('invalid_value', `the value \${…} number ${position} is not a finite number`)
277
+ }
278
+ if (value instanceof Date) {
279
+ return Number.isNaN(value.getTime()) ? refuse('invalid_value', `the value \${…} number ${position} is an invalid Date`) : value.toISOString()
280
+ }
281
+ const items = parseList(value, arrayElement)
282
+ if (items !== undefined) {
283
+ return `{${items.join(',')}}`
284
+ }
285
+ return refuse(
286
+ 'invalid_value',
287
+ `the value \${…} number ${position} is ${
288
+ value === undefined ? 'undefined' : 'an object'
289
+ }; bind a string, number, boolean, bigint, Date, null or a plain list (JSON as JSON.stringify(v) with ::jsonb)`,
290
+ )
291
+ }
292
+
293
+ function camel(column: string): string {
294
+ return column.replace(/_([a-z0-9])/g, (_match, next: string) => next.toUpperCase())
295
+ }
296
+
297
+ /** A text Postgres sends for a `bigint` or `numeric` column. */
298
+ const NUMERIC_TEXT = /^-?[0-9]+(\.[0-9]+)?$/
299
+
300
+ /**
301
+ * One row as `row` parses it: snake_case columns as camelCase fields, a `Date` as ISO text (a day for a `z.iso.date()`
302
+ * field). No value reaches a message.
303
+ */
304
+ function parseRow<S extends z.core.$ZodType>(row: S, value: unknown, index: number): z.output<S> {
305
+ const fields: Record<string, unknown> = {}
306
+ const columnOf = new Map<string, string>()
307
+ const record = typeof value === 'object' && value !== null ? value : {}
308
+ for (const column of ownKeys(record)) {
309
+ const field = camel(column)
310
+ const earlier = columnOf.get(field)
311
+ if (earlier !== undefined) {
312
+ throw new SqlRowError(`ctx.sql: the columns '${earlier}' and '${column}' both become the field '${field}'; rename one with AS`)
313
+ }
314
+ columnOf.set(field, column)
315
+ const cell: unknown = ownDescriptor(record, column)?.value
316
+ Object.defineProperty(fields, field, { value: cell instanceof Date ? dateField(row, field, cell, column) : cell, enumerable: true })
317
+ }
318
+ const parsed = z.safeParse(row, fields)
319
+ if (parsed.success) {
320
+ return parsed.data
321
+ }
322
+ throw new SqlRowError(rowIssue(row, parsed.error.issues[0], fields, columnOf, index))
323
+ }
324
+
325
+ /**
326
+ * A `Date` cell as its field reads it: a day (`YYYY-MM-DD`) for a `z.iso.date()` field, ISO 8601 text otherwise. The
327
+ * driver decodes a `date` column to UTC midnight, which only the row schema tells apart from a timestamp.
328
+ */
329
+ function dateField(row: z.core.$ZodType, field: string, value: Date, column: string): string {
330
+ let schema: z.core.$ZodType | undefined = row instanceof z.ZodObject && hasOwn(row.shape, field) ? row.shape[field] : undefined
331
+ while (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) {
332
+ schema = schema.unwrap()
333
+ }
334
+ return schema instanceof z.ZodISODate ? dayText(value, `ctx.sql: the column '${column}'`) : value.toISOString()
335
+ }
336
+
337
+ /** A top-level field of an object row schema: the one part of an issue's path that is the schema's, not the data's. */
338
+ function schemaField(row: z.core.$ZodType, issue: z.core.$ZodIssue | undefined): string | undefined {
339
+ const field = issue?.path[0]
340
+ return typeof field === 'string' && row instanceof z.ZodObject && hasOwn(row.shape, field) ? field : undefined
341
+ }
342
+
343
+ /**
344
+ * The message of a refused row, from the issue's code, its expected type and the schema's own field only: an issue's
345
+ * message, a deeper path (a JSON key, an array index) and the input can all carry the row's values.
346
+ */
347
+ function rowIssue(
348
+ row: z.core.$ZodType,
349
+ issue: z.core.$ZodIssue | undefined,
350
+ fields: Readonly<Record<string, unknown>>,
351
+ columnOf: ReadonlyMap<string, string>,
352
+ index: number,
353
+ ): string {
354
+ const field = schemaField(row, issue)
355
+ const cell: unknown = field === undefined ? undefined : ownDescriptor(fields, field)?.value
356
+ if (
357
+ issue?.code === 'invalid_type' && issue.path.length === 1 && issue.expected === 'number' && typeof cell === 'string' && NUMERIC_TEXT.test(cell)
358
+ ) {
359
+ return `ctx.sql: the column '${
360
+ columnOf.get(field ?? '') ?? field
361
+ }' arrives as text (Postgres sends bigint and numeric as text); cast it in the SQL (count(*)::int, ::float8) or read it with z.coerce.number()`
362
+ }
363
+ const where = field === undefined ? '' : ` at '${field}'`
364
+ const kind = issue === undefined ? 'refused' : issue.code === 'invalid_type' ? `invalid_type (expected ${issue.expected})` : issue.code
365
+ return `ctx.sql: row ${index + 1} does not match the row schema${where}: ${kind}`
366
+ }
367
+
368
+ /** The predicate `terms` make over `alias`'s columns: static parts around one bound value per test. */
369
+ function scopeFragment(terms: ScopeTerms, alias: string): { readonly parts: readonly string[]; readonly values: readonly SqlValue[] } {
370
+ if (terms === 'all') return { parts: ['(true)'], values: [] }
371
+ if (terms.length === 0) return { parts: ['(false)'], values: [] }
372
+ const parts: string[] = []
373
+ const values: SqlValue[] = []
374
+ let text = '('
375
+ terms.forEach((group, groupIndex) => {
376
+ text += `${groupIndex === 0 ? '' : ' or '}(`
377
+ group.forEach((test, testIndex) => {
378
+ text += `${testIndex === 0 ? '' : ' and '}${alias}.${test.column} = ${typeof test.value === 'string' ? '' : 'any('}`
379
+ parts.push(text)
380
+ values.push(test.value)
381
+ text = typeof test.value === 'string' ? '' : ')'
382
+ })
383
+ text += ')'
384
+ })
385
+ parts.push(`${text})`)
386
+ return { parts, values }
387
+ }
388
+
389
+ /** One table a write statement writes, at any depth (a data-modifying CTE too), and how. */
390
+ interface Target {
391
+ readonly table: string
392
+ readonly operation: WriteOperation
393
+ }
394
+
395
+ /** The relation an UPDATE or DELETE writes: its table, under its alias (the table's name without one). */
396
+ interface Written {
397
+ readonly table: string
398
+ readonly alias: string
399
+ readonly operation: 'update' | 'delete'
400
+ }
401
+
402
+ /** What a statement writes, and for each interpolation, in order, the UPDATE or DELETE of the query block it stands in. */
403
+ interface StatementWrites {
404
+ readonly targets: readonly Target[]
405
+ readonly blocks: readonly (Written | undefined)[]
406
+ }
407
+
408
+ /** Words that cannot be an alias: what follows a target that has none. */
409
+ const NOT_ALIASES: ReadonlySet<string> = new Set(['SET', 'WHERE', 'USING', 'RETURNING', 'DEFAULT', 'VALUES', 'SELECT', 'ON', 'OVERRIDING'])
410
+
411
+ /** The target name at `index` (`ONLY` and a schema skipped) and its alias, or `undefined` when no name is there. */
412
+ function targetAt(tokens: readonly SqlToken[], index: number): { readonly table: string; readonly alias: string } | undefined {
413
+ const at = upperWord(tokens[index]) === 'ONLY' ? index + 1 : index
414
+ const qualified = isPunct(tokens[at + 1], '.')
415
+ const table = qualified ? nameText(tokens[at + 2]) : nameText(tokens[at])
416
+ if (table === undefined) return undefined
417
+ let next = qualified ? at + 3 : at + 1
418
+ if (upperWord(tokens[next]) === 'AS') next++
419
+ const word = upperWord(tokens[next])
420
+ const alias = tokens[next]?.kind === 'quoted' || (word !== undefined && !NOT_ALIASES.has(word)) ? nameText(tokens[next]) : undefined
421
+ return { table, alias: alias ?? table }
422
+ }
423
+
424
+ /**
425
+ * Every `INSERT INTO`, `UPDATE` and `DELETE FROM` target of the statement, and the query block of each interpolation.
426
+ * A block opens at a `SELECT`, `INSERT`, `UPDATE` or `DELETE` and closes with the parenthesis it stands in, or at the
427
+ * next block of its depth: a subquery, a data-modifying CTE and an `INSERT`'s `SELECT` are blocks of their own.
428
+ * `ON CONFLICT … DO UPDATE` is an UPDATE of the INSERT's target; a locking clause (`FOR UPDATE`) writes nothing.
429
+ */
430
+ function statementWrites(tokens: readonly SqlToken[]): StatementWrites {
431
+ const targets: Target[] = []
432
+ const blocks: (Written | undefined)[] = []
433
+ const open: { readonly depth: number; readonly write: Written | undefined }[] = []
434
+ const inserts = new Map<number, { readonly table: string; readonly alias: string }>()
435
+ let depth = 0
436
+ const enter = (write: Written | undefined) => {
437
+ while ((open[open.length - 1]?.depth ?? -1) >= depth) open.pop()
438
+ open.push({ depth, write })
439
+ }
440
+ for (let index = 0; index < tokens.length; index++) {
441
+ const token = tokens[index]
442
+ if (isPunct(token, '(')) {
443
+ depth++
444
+ continue
445
+ }
446
+ if (isPunct(token, ')')) {
447
+ depth--
448
+ while ((open[open.length - 1]?.depth ?? -1) > depth) open.pop()
449
+ for (const level of inserts.keys()) if (level > depth) inserts.delete(level)
450
+ continue
451
+ }
452
+ if (isPunct(token, '$') && isDigit(tokens[index + 1])) {
453
+ blocks.push(open[open.length - 1]?.write)
454
+ continue
455
+ }
456
+ const word = upperWord(token)
457
+ const next = upperWord(tokens[index + 1])
458
+ if (word === 'SELECT') {
459
+ enter(undefined)
460
+ } else if (word === 'INSERT' && next === 'INTO') {
461
+ const target = targetAt(tokens, index + 2)
462
+ enter(undefined)
463
+ if (target !== undefined) {
464
+ inserts.set(depth, target)
465
+ targets.push({ table: target.table, operation: 'insert' })
466
+ }
467
+ } else if (word === 'DELETE' && next === 'FROM') {
468
+ const target = targetAt(tokens, index + 2)
469
+ enter(target === undefined ? undefined : { ...target, operation: 'delete' })
470
+ if (target !== undefined) targets.push({ table: target.table, operation: 'delete' })
471
+ } else if (word === 'UPDATE') {
472
+ const before = upperWord(tokens[index - 1])
473
+ if (before === 'FOR' || before === 'KEY') continue
474
+ const target = before === 'DO' ? inserts.get(depth) : targetAt(tokens, index + 1)
475
+ enter(target === undefined ? undefined : { ...target, operation: 'update' })
476
+ if (target !== undefined) targets.push({ table: target.table, operation: 'update' })
477
+ }
478
+ }
479
+ return { targets, blocks }
480
+ }
481
+
482
+ /** What a fragment on `table` under `alias` is to its block: the relation its UPDATE or DELETE writes, or else read. */
483
+ function fragmentUse(written: Written | undefined, table: string, alias: string): ScopeUse {
484
+ return written !== undefined && written.table === table && written.alias === alias ? written.operation : 'read'
485
+ }
486
+
487
+ /** Words that open the clause a scope may stand in. */
488
+ const SCOPE_CLAUSES: ReadonlySet<string> = new Set(['WHERE', 'ON'])
489
+ /** Words that end a WHERE or ON clause at its own depth. */
490
+ const CLAUSE_ENDS: ReadonlySet<string> = new Set([
491
+ 'GROUP',
492
+ 'ORDER',
493
+ 'LIMIT',
494
+ 'OFFSET',
495
+ 'FETCH',
496
+ 'HAVING',
497
+ 'WINDOW',
498
+ 'UNION',
499
+ 'INTERSECT',
500
+ 'EXCEPT',
501
+ 'RETURNING',
502
+ 'FOR',
503
+ 'JOIN',
504
+ 'LEFT',
505
+ 'RIGHT',
506
+ 'INNER',
507
+ 'FULL',
508
+ 'CROSS',
509
+ 'NATURAL',
510
+ 'WHERE',
511
+ 'ON',
512
+ 'USING',
513
+ 'SET',
514
+ 'FROM',
515
+ 'SELECT',
516
+ 'VALUES',
517
+ ])
518
+ const CONJUNCT_FORM = 'write it as a conjunct of its WHERE or JOIN … ON clause: where (<filter>) and ${ctx.sql.scope(…)}'
519
+
520
+ /** Whether the token at `position` ends a WHERE or ON clause at its depth; `IS DISTINCT FROM` does not. */
521
+ function endsClause(tokens: readonly SqlToken[], position: number): boolean {
522
+ const token = tokens[position]
523
+ const word = upperWord(token)
524
+ if (isPunct(token, ';')) return true
525
+ if (word === undefined || !CLAUSE_ENDS.has(word)) return false
526
+ return word !== 'FROM' || upperWord(tokens[position - 1]) !== 'DISTINCT'
527
+ }
528
+
529
+ /** The index of the token after the `$n` placeholder at `index`. */
530
+ function afterPlaceholder(tokens: readonly SqlToken[], index: number): number {
531
+ let next = index + 1
532
+ while (isDigit(tokens[next])) next++
533
+ return next
534
+ }
535
+
536
+ /**
537
+ * A scope must restrict every row its clause admits: it stands alone, joined by AND, at the top level of a WHERE or
538
+ * JOIN … ON clause whose top level holds no OR (`where (<filter>) and ${scope}`). Anything else could reach past it.
539
+ */
540
+ function checkConjunct(tokens: readonly SqlToken[], index: number): void {
541
+ const line = tokens[index]?.line
542
+ let depth = 0
543
+ let start = -1
544
+ for (let position = index - 1; position >= 0 && start === -1; position--) {
545
+ const token = tokens[position]
546
+ const word = upperWord(token)
547
+ if (isPunct(token, ')')) depth++
548
+ else if (isPunct(token, '(')) {
549
+ if (depth === 0) refuse('sql_scope', `ctx.sql.scope(…) at line ${line} stands inside parentheses; ${CONJUNCT_FORM}`)
550
+ depth--
551
+ } else if (depth === 0 && word !== undefined && SCOPE_CLAUSES.has(word)) start = position
552
+ else if (depth === 0 && endsClause(tokens, position)) break
553
+ }
554
+ if (start === -1) refuse('sql_scope', `ctx.sql.scope(…) at line ${line} stands outside a WHERE or JOIN … ON clause; ${CONJUNCT_FORM}`)
555
+ const next = afterPlaceholder(tokens, index)
556
+ let end = tokens.length
557
+ depth = 0
558
+ for (let position = next; position < tokens.length && end === tokens.length; position++) {
559
+ const token = tokens[position]
560
+ if (isPunct(token, '(')) depth++
561
+ else if (isPunct(token, ')')) {
562
+ if (depth === 0) end = position
563
+ else depth--
564
+ } else if (depth === 0 && endsClause(tokens, position)) end = position
565
+ }
566
+ depth = 0
567
+ for (let position = start + 1; position < end; position++) {
568
+ const token = tokens[position]
569
+ if (isPunct(token, '(')) depth++
570
+ else if (isPunct(token, ')')) depth--
571
+ else if (depth === 0 && upperWord(token) === 'OR') {
572
+ refuse(
573
+ 'sql_scope',
574
+ `the clause that holds ctx.sql.scope(…) at line ${line} has an OR at its top level, which reaches past the scope; ${CONJUNCT_FORM}`,
575
+ )
576
+ } else if (depth === 0 && upperWord(token) === 'BETWEEN') {
577
+ refuse('sql_scope', `put BETWEEN … AND … in parentheses in the clause that holds ctx.sql.scope(…) at line ${line}`)
578
+ }
579
+ }
580
+ const before = upperWord(tokens[index - 1])
581
+ const after = next === end ? 'END' : upperWord(tokens[next])
582
+ if ((index - 1 !== start && before !== 'AND') || (after !== 'END' && after !== 'AND')) {
583
+ refuse('sql_scope', `ctx.sql.scope(…) at line ${line} must stand alone, joined by AND; ${CONJUNCT_FORM}`)
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Before any scope is spliced in, each interpolation must stand where a value binds (not in a literal, a quoted name,
589
+ * a dollar-quoted body or a comment), and each scope where it restricts every row of its clause.
590
+ */
591
+ function checkInterpolations(parts: readonly string[], values: readonly unknown[]): void {
592
+ for (const part of parts) {
593
+ if (/\$[0-9]/.test(part)) {
594
+ refuse('sql_template', 'the text holds $ and a digit, which reads as a positional parameter; interpolate the value with ${…}, a literal one too')
595
+ }
596
+ }
597
+ const { tokens, unterminated } = lexSql(statementText(parts))
598
+ if (unterminated !== undefined) {
599
+ refuse('sql_template', `a string, quoted name, dollar-quoted body or comment at line ${unterminated} never closes`)
600
+ }
601
+ if (placeholderCount(tokens) !== parts.length - 1) {
602
+ refuse(
603
+ 'sql_template',
604
+ 'a value ${…} inside a string literal, a quoted name, a dollar-quoted body or a comment is not bound; interpolate a whole value',
605
+ )
606
+ }
607
+ let interpolation = 0
608
+ for (let index = 0; index + 1 < tokens.length; index++) {
609
+ if (!isPunct(tokens[index], '$') || !isDigit(tokens[index + 1])) continue
610
+ if (scopeOf(values[interpolation]) !== undefined) checkConjunct(tokens, index)
611
+ interpolation++
612
+ }
613
+ }
614
+
615
+ /**
616
+ * The template with every scope spliced in as text around its own values, and the values a parameter each. Each
617
+ * fragment is rendered for what its relation is to its query block: the one its UPDATE or DELETE writes, or read.
618
+ */
619
+ function expandScopes(
620
+ site: SqlSite,
621
+ parts: readonly string[],
622
+ values: readonly unknown[],
623
+ termsOf: ScopeOf,
624
+ writeTarget: WriteTarget,
625
+ ): { parts: string[]; values: unknown[] } {
626
+ checkInterpolations(parts, values)
627
+ const writes: StatementWrites = site.mode === 'write' ? statementWrites(lexSql(statementText(parts)).tokens) : { targets: [], blocks: [] }
628
+ for (const target of writes.targets) writeTarget(target.table, target.operation)
629
+ const expanded: string[] = [parts[0] ?? '']
630
+ const bound: unknown[] = []
631
+ for (let index = 0; index < values.length; index++) {
632
+ const value = values[index]
633
+ const after = parts[index + 1] ?? ''
634
+ const fragment = scopeOf(value)
635
+ if (fragment === undefined) {
636
+ bound.push(value)
637
+ expanded.push(after)
638
+ continue
639
+ }
640
+ if (fragment.site !== site) {
641
+ refuse('sql_scope', 'a ctx.sql.scope(…) of another invocation or site is refused; build it with this ctx.sql')
642
+ }
643
+ const terms = termsOf(fragment.capability, fragment.table, fragmentUse(writes.blocks[index], fragment.table, fragment.alias))
644
+ const rendered = scopeFragment(terms, fragment.alias)
645
+ expanded[expanded.length - 1] += rendered.parts[0] ?? ''
646
+ for (let position = 0; position < rendered.values.length; position++) {
647
+ bound.push(rendered.values[position])
648
+ expanded.push(rendered.parts[position + 1] ?? '')
649
+ }
650
+ expanded[expanded.length - 1] += after
651
+ }
652
+ return { parts: expanded, values: bound }
653
+ }
654
+
655
+ /**
656
+ * `work`, failing the site's transaction when it throws: a handler that catches a refused statement, value or row must
657
+ * not commit what it wrote around it.
658
+ */
659
+ async function failing<T>(site: SqlSite, work: () => Promise<T>): Promise<T> {
660
+ try {
661
+ return await work()
662
+ } catch (error) {
663
+ site.fail()
664
+ throw error
665
+ }
666
+ }
667
+
668
+ const WRITE_USES: readonly ScopeUse[] = ['update', 'delete']
669
+
670
+ /**
671
+ * Throws unless `capability` reaches `table` for some use the site may make of it: reading, or on a `'write'` site
672
+ * the target of an UPDATE or a DELETE. The statement's own use is checked when the tag renders the fragment.
673
+ */
674
+ function reaches(site: SqlSite, termsOf: ScopeOf, capability: unknown, table: string): void {
675
+ try {
676
+ termsOf(capability, table, 'read')
677
+ } catch (error) {
678
+ if (site.mode !== 'write') throw error
679
+ for (const use of WRITE_USES) {
680
+ try {
681
+ termsOf(capability, table, use)
682
+ return
683
+ } catch {
684
+ // The read refusal names what a read needs, which is what most fragments are.
685
+ }
686
+ }
687
+ throw error
688
+ }
689
+ }
690
+
691
+ /**
692
+ * `ctx.sql` of one site. Every call first checks that the work running now is the site's invocation, then classifies
693
+ * the text, then binds the values; only then does it reach the session, so a refused statement sends nothing.
694
+ */
695
+ export function sqlTag(site: SqlSite, schema: string | undefined, termsOf: ScopeOf, writeTarget: WriteTarget): SqlTag {
696
+ const tag = <S extends z.core.$ZodType>(row: S) => {
697
+ site.check()
698
+ if (!(row instanceof z.core.$ZodType)) {
699
+ site.fail()
700
+ refuse('invalid_argument', 'ctx.sql(Row) takes the zod schema of one row')
701
+ }
702
+ return async (text: TemplateStringsArray, ...values: readonly (SqlValue | SqlScope)[]): Promise<z.output<S>[]> => {
703
+ site.check()
704
+ return failing(site, async () => {
705
+ const expanded = expandScopes(site, templateParts(text), values, termsOf, writeTarget)
706
+ const statement = classifySql(expanded.parts, site.mode, schema ?? '')
707
+ const parameters = expanded.values.map((value, index) => parameterOf(value, index + 1))
708
+ const session = await site.session()
709
+ if (session.readOnly && site.mode === 'write') {
710
+ refuse('read_only', 'this invocation is read-only')
711
+ }
712
+ const rows = await runApplicationStatement(session, statement, parameters)
713
+ return rows.map((value, index) => parseRow(row, value, index))
714
+ })
715
+ }
716
+ }
717
+ const scope = (capability: unknown, table: string, alias?: string): SqlScope => {
718
+ site.check()
719
+ try {
720
+ const name = alias ?? table
721
+ if (!isPlainIdentifier(table) || !isPlainIdentifier(name)) {
722
+ refuse('invalid_argument', 'ctx.sql.scope(capability, table, alias?) takes a table name and an alias of lowercase letters, digits and _')
723
+ }
724
+ reaches(site, termsOf, capability, table)
725
+ return newScope({ site, capability, table, alias: name })
726
+ } catch (error) {
727
+ site.fail()
728
+ throw error
729
+ }
730
+ }
731
+ return Object.freeze(Object.assign(tag, { scope }))
732
+ }