@crombolo/data 0.9.1 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/data",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,6 +28,6 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.9.1"
31
+ "@crombolo/core": "0.10.0"
32
32
  }
33
33
  }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The declarative builder a bundle writes its tables with. `docs/bundles.md`,
3
+ * Part 4.
4
+ *
5
+ * It is the only way a bundle writes schema: there is no SQL in it, and no
6
+ * escape hatch to some. A step's `up` returns the operations it wants, as plain
7
+ * data, which is what makes a step checkable before it runs — linted for
8
+ * expand and contract, hashed for its checksum, rendered for `upgrade --plan` —
9
+ * and what lets the final shape of every table be worked out from the
10
+ * declaration's types alone.
11
+ */
12
+
13
+ export type ColumnType =
14
+ | 'uuid'
15
+ | 'varchar'
16
+ | 'text'
17
+ | 'integer'
18
+ | 'bigint'
19
+ | 'boolean'
20
+ | 'timestamp'
21
+ | 'json'
22
+
23
+ /** A scalar a default may be: what DDL can carry as a literal. */
24
+ export type DefaultValue = string | number | boolean
25
+
26
+ export interface ColumnSpec {
27
+ type: ColumnType
28
+ /** For `varchar`. */
29
+ length?: number
30
+ nullable: boolean
31
+ default?: DefaultValue
32
+ primaryKey: boolean
33
+ indexed: boolean
34
+ }
35
+
36
+ /** What a column holds, as the capability reads and writes it. */
37
+ export type JsonValue =
38
+ | string
39
+ | number
40
+ | boolean
41
+ | null
42
+ | JsonValue[]
43
+ | { [key: string]: JsonValue }
44
+
45
+ interface ValueOf {
46
+ uuid: string
47
+ varchar: string
48
+ text: string
49
+ integer: number
50
+ bigint: number
51
+ boolean: boolean
52
+ timestamp: Date
53
+ json: JsonValue
54
+ }
55
+
56
+ /**
57
+ * A column being described. Its type parameters carry what the declaration
58
+ * says, so a table's row type can be read off it.
59
+ */
60
+ export interface Column<
61
+ T extends ColumnType = ColumnType,
62
+ N extends boolean = false,
63
+ D extends boolean = false,
64
+ > {
65
+ readonly spec: ColumnSpec
66
+ /** Allows null. */
67
+ nullable(): Column<T, true, D>
68
+ /** What a row gets when an insert leaves it out. */
69
+ default(
70
+ value: T extends 'json' ? never : ValueOf[T] extends Date ? never : ValueOf[T],
71
+ ): Column<T, N, true>
72
+ primaryKey(): Column<T, N, D>
73
+ /** Gives the column an index of its own. */
74
+ indexed(): Column<T, N, D>
75
+ /** Type-level only: what the column holds. */
76
+ readonly __value?: ValueOf[T]
77
+ readonly __nullable?: N
78
+ readonly __defaulted?: D
79
+ }
80
+
81
+ function column<T extends ColumnType>(type: T, spec: Partial<ColumnSpec> = {}): Column<T> {
82
+ const make = (s: ColumnSpec): Column<T, boolean, boolean> =>
83
+ Object.freeze({
84
+ spec: Object.freeze(s),
85
+ nullable: () => make({ ...s, nullable: true }),
86
+ default: (value: DefaultValue) => make({ ...s, default: value }),
87
+ primaryKey: () => make({ ...s, primaryKey: true }),
88
+ indexed: () => make({ ...s, indexed: true }),
89
+ }) as Column<T, boolean, boolean>
90
+ return make({ type, nullable: false, primaryKey: false, indexed: false, ...spec }) as Column<T>
91
+ }
92
+
93
+ export type Columns = Record<string, Column<ColumnType, boolean, boolean>>
94
+
95
+ export type Operation =
96
+ | { op: 'createTable'; table: string; columns: Record<string, ColumnSpec> }
97
+ | { op: 'addColumn'; table: string; column: string; spec: ColumnSpec }
98
+ | { op: 'addIndex'; table: string; columns: string[]; unique: boolean }
99
+ | { op: 'widenColumn'; table: string; column: string; spec: ColumnSpec }
100
+ | { op: 'dropTable'; table: string }
101
+ | { op: 'dropColumn'; table: string; column: string }
102
+ | { op: 'dropIndex'; table: string; columns: string[] }
103
+
104
+ /** An operation, carrying at the type level what it does to the schema. */
105
+ export type Op<Effect = never> = Operation & { readonly __effect?: Effect }
106
+
107
+ export interface CreateTable<T extends string, C extends Columns> {
108
+ kind: 'create'
109
+ table: T
110
+ columns: C
111
+ }
112
+ export interface AddColumn<
113
+ T extends string,
114
+ K extends string,
115
+ C extends Column<ColumnType, boolean, boolean>,
116
+ > {
117
+ kind: 'add'
118
+ table: T
119
+ column: K
120
+ spec: C
121
+ }
122
+ export interface DropTable<T extends string> {
123
+ kind: 'dropTable'
124
+ table: T
125
+ }
126
+ export interface DropColumn<T extends string, K extends string> {
127
+ kind: 'dropColumn'
128
+ table: T
129
+ column: K
130
+ }
131
+
132
+ /** What a step's `up` is handed. */
133
+ export interface SchemaBuilder {
134
+ uuid(): Column<'uuid'>
135
+ varchar(length: number): Column<'varchar'>
136
+ text(): Column<'text'>
137
+ integer(): Column<'integer'>
138
+ bigint(): Column<'bigint'>
139
+ boolean(): Column<'boolean'>
140
+ timestamp(): Column<'timestamp'>
141
+ json(): Column<'json'>
142
+
143
+ createTable<T extends string, C extends Columns>(table: T, columns: C): Op<CreateTable<T, C>>
144
+ /** A new column must be nullable or have a default, so it adds under live rows. */
145
+ addColumn<T extends string, K extends string, C extends Column<ColumnType, boolean, boolean>>(
146
+ table: T,
147
+ column: K,
148
+ spec: C,
149
+ ): Op<AddColumn<T, K, C>>
150
+ addIndex(table: string, columns: readonly string[], options?: { unique?: boolean }): Op
151
+ /** Longer text, or a wider integer; never narrower, never another kind. */
152
+ widenColumn(table: string, column: string, spec: Column<ColumnType, boolean, boolean>): Op
153
+ dropTable<T extends string>(table: T): Op<DropTable<T>>
154
+ dropColumn<T extends string, K extends string>(table: T, column: K): Op<DropColumn<T, K>>
155
+ dropIndex(table: string, columns: readonly string[]): Op
156
+ }
157
+
158
+ const specs = (columns: Columns): Record<string, ColumnSpec> => {
159
+ const out: Record<string, ColumnSpec> = {}
160
+ for (const [name, col] of Object.entries(columns)) out[name] = col.spec
161
+ return out
162
+ }
163
+
164
+ /** The builder: every method returns plain data. */
165
+ export const schemaBuilder: SchemaBuilder = Object.freeze<SchemaBuilder>({
166
+ uuid: () => column('uuid'),
167
+ varchar: (length: number) => column('varchar', { length }),
168
+ text: () => column('text'),
169
+ integer: () => column('integer'),
170
+ bigint: () => column('bigint'),
171
+ boolean: () => column('boolean'),
172
+ timestamp: () => column('timestamp'),
173
+ json: () => column('json'),
174
+ createTable: (table, columns) => ({ op: 'createTable', table, columns: specs(columns) }) as never,
175
+ addColumn: (table, name, spec) =>
176
+ ({ op: 'addColumn', table, column: name, spec: spec.spec }) as never,
177
+ addIndex: (table, columns, options = {}) => ({
178
+ op: 'addIndex',
179
+ table,
180
+ columns: [...columns],
181
+ unique: options.unique ?? false,
182
+ }),
183
+ widenColumn: (table, name, spec) => ({ op: 'widenColumn', table, column: name, spec: spec.spec }),
184
+ dropTable: (table) => ({ op: 'dropTable', table }) as never,
185
+ dropColumn: (table, name) => ({ op: 'dropColumn', table, column: name }) as never,
186
+ dropIndex: (table, columns) => ({ op: 'dropIndex', table, columns: [...columns] }),
187
+ })
188
+
189
+ // ------------------------------------------------------------ the row types
190
+
191
+ type ColumnValue<C> =
192
+ C extends Column<infer T, infer N, boolean>
193
+ ? N extends true
194
+ ? ValueOf[T] | null
195
+ : ValueOf[T]
196
+ : never
197
+
198
+ /** A table's row, from its columns. */
199
+ export type RowOf<C extends Columns> = { [K in keyof C]: ColumnValue<C[K]> }
200
+
201
+ type Optional<C extends Columns> = {
202
+ [K in keyof C]: C[K] extends Column<ColumnType, infer N, infer D>
203
+ ? N extends true
204
+ ? K
205
+ : D extends true
206
+ ? K
207
+ : never
208
+ : never
209
+ }[keyof C]
210
+
211
+ /** What an insert must and may supply: a nullable or defaulted column may be left out. */
212
+ export type InsertOf<C extends Columns> = Omit<RowOf<C>, Optional<C>> &
213
+ Partial<Pick<RowOf<C>, Optional<C>>>
214
+
215
+ /** Every table's columns, after a run of operations. */
216
+ export type TablesShape = Record<string, Columns>
217
+
218
+ type Effects<R> = R extends readonly unknown[] ? R[number] : R
219
+ type EffectOf<O> = O extends Op<infer E> ? E : never
220
+
221
+ type Apply<S extends TablesShape, E> =
222
+ E extends CreateTable<infer T, infer C>
223
+ ? Omit<S, T> & { [K in T]: C }
224
+ : E extends AddColumn<infer T, infer K, infer C>
225
+ ? T extends keyof S
226
+ ? Omit<S, T> & { [P in T]: S[T] & { [Q in K]: C } }
227
+ : S
228
+ : E extends DropTable<infer T>
229
+ ? Omit<S, T>
230
+ : E extends DropColumn<infer T, infer K>
231
+ ? T extends keyof S
232
+ ? Omit<S, T> & { [P in T]: Omit<S[T], K> }
233
+ : S
234
+ : S
235
+
236
+ type ApplyAll<S extends TablesShape, E> = [E] extends [never]
237
+ ? S
238
+ : // A union of effects within one step is applied one member at a time.
239
+ UnionToTuple<E> extends infer L
240
+ ? L extends readonly [infer H, ...infer Rest]
241
+ ? ApplyAll<Flatten<Apply<S, H>>, Rest[number]>
242
+ : S
243
+ : S
244
+
245
+ type Flatten<T> = { [K in keyof T]: T[K] } & {}
246
+
247
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
248
+ k: infer I,
249
+ ) => void
250
+ ? I
251
+ : never
252
+ type LastOf<U> =
253
+ UnionToIntersection<U extends unknown ? () => U : never> extends () => infer R ? R : never
254
+ type UnionToTuple<U, L = LastOf<U>> = [U] extends [never] ? [] : [...UnionToTuple<Exclude<U, L>>, L]
255
+
256
+ /** The shape after every step, in order: what `context.host.tables` is typed from. */
257
+ export type ShapeOf<
258
+ Steps extends readonly unknown[],
259
+ S extends TablesShape = Record<never, never>,
260
+ > = Steps extends readonly [infer First, ...infer Rest]
261
+ ? First extends { up: (s: SchemaBuilder) => infer R }
262
+ ? ShapeOf<Rest, Flatten<ApplyAll<S, EffectOf<Effects<R>>>>>
263
+ : ShapeOf<Rest, S>
264
+ : S
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The `tables` capability: a bundle's runtime access to its own tables, and
3
+ * nothing else. `docs/bundles.md`, Part 4.
4
+ *
5
+ * Three things keep it inside the bundle's tables:
6
+ *
7
+ * - **No identifier comes from the caller.** Every table and column name is
8
+ * looked up in the bundle's resolved schema and quoted from there, and an
9
+ * unknown one is refused before any SQL is built. Values are always bound.
10
+ * - **No `Db` is reachable.** The capability, each table handle and the
11
+ * transaction handle are frozen, null-prototype objects whose members are
12
+ * closures; the connection is never a property of any of them.
13
+ * - **Every statement passes the guard**, which refuses one naming any table
14
+ * that is not this bundle's.
15
+ */
16
+ import type { Db } from '../database.ts'
17
+ import { DbDate, fromDbBool } from '../dialect.ts'
18
+ import type { ColumnSpec, Columns, InsertOf, JsonValue, RowOf, TablesShape } from './builder.ts'
19
+ import { guardStatement } from './guard.ts'
20
+ import type { ResolvedSchema, ResolvedTable } from './schema.ts'
21
+
22
+ /** A request the capability refused: nothing reached the database. */
23
+ export class BundleQueryError extends Error {
24
+ constructor(
25
+ readonly bundle: string,
26
+ reason: string,
27
+ ) {
28
+ super(`Bundle "${bundle}": ${reason}.`)
29
+ }
30
+ }
31
+
32
+ type Scalar<V> = Exclude<V, null>
33
+
34
+ /** A condition on one column. A bare value means equality, and `null` means `isNull`. */
35
+ export type Condition<V> =
36
+ | V
37
+ | {
38
+ eq?: Scalar<V>
39
+ in?: readonly Scalar<V>[]
40
+ lt?: Scalar<V>
41
+ lte?: Scalar<V>
42
+ gt?: Scalar<V>
43
+ gte?: Scalar<V>
44
+ isNull?: true
45
+ notNull?: true
46
+ }
47
+
48
+ export type Where<R> = { [K in keyof R]?: Condition<R[K]> } & {
49
+ and?: readonly Where<R>[]
50
+ or?: readonly Where<R>[]
51
+ }
52
+
53
+ export interface SelectQuery<R> {
54
+ where?: Where<R>
55
+ orderBy?: { [K in keyof R]?: 'asc' | 'desc' }
56
+ /** At most 10,000; 1,000 when not given. */
57
+ limit?: number
58
+ offset?: number
59
+ }
60
+
61
+ export interface TableHandle<C extends Columns> {
62
+ select(query?: SelectQuery<RowOf<C>>): Promise<RowOf<C>[]>
63
+ count(query?: { where?: Where<RowOf<C>> }): Promise<number>
64
+ insert(rows: InsertOf<C> | readonly InsertOf<C>[]): Promise<void>
65
+ /** A `where` is required; an unfiltered write is `truncate`. */
66
+ update(query: { where: Where<RowOf<C>>; set: Partial<RowOf<C>> }): Promise<void>
67
+ delete(query: { where: Where<RowOf<C>> }): Promise<void>
68
+ /** Every row, gone. */
69
+ truncate(): Promise<void>
70
+ }
71
+
72
+ export type TableHandles<S extends TablesShape> = { readonly [T in keyof S]: TableHandle<S[T]> }
73
+
74
+ export type BundleTablesCapability<S extends TablesShape = TablesShape> = TableHandles<S> & {
75
+ /** Runs `fn` in one transaction, handing it the same tables bound to it. */
76
+ transaction<R>(fn: (tables: TableHandles<S>) => Promise<R>): Promise<R>
77
+ }
78
+
79
+ const MAX_LIMIT = 10_000
80
+ const DEFAULT_LIMIT = 1_000
81
+ const MAX_DEPTH = 8
82
+ const MAX_IN = 1_000
83
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
84
+ const OPERATORS = new Set(['eq', 'in', 'lt', 'lte', 'gt', 'gte', 'isNull', 'notNull'])
85
+ const COMPARISONS: Record<string, string> = { eq: '=', lt: '<', lte: '<=', gt: '>', gte: '>=' }
86
+
87
+ /** A plain object, and not a class instance or an array, read by its own keys only. */
88
+ function isPlain(value: unknown): value is Record<string, unknown> {
89
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
90
+ const proto = Object.getPrototypeOf(value)
91
+ return proto === Object.prototype || proto === null
92
+ }
93
+
94
+ function frozen<T extends object>(members: T): T {
95
+ return Object.freeze(Object.assign(Object.create(null), members)) as T
96
+ }
97
+
98
+ interface Compiler {
99
+ bundle: string
100
+ dialectName: Db['dialect']['name']
101
+ boolValue: Db['dialect']['boolValue']
102
+ quote: Db['dialect']['quote']
103
+ }
104
+
105
+ function refuse(bundle: string, reason: string): never {
106
+ throw new BundleQueryError(bundle, reason)
107
+ }
108
+
109
+ /** A value as the column stores it, or a refusal. */
110
+ function toDb(
111
+ c: Compiler,
112
+ table: ResolvedTable,
113
+ column: string,
114
+ spec: ColumnSpec,
115
+ value: unknown,
116
+ ): unknown {
117
+ const where = `"${table.name}.${column}"`
118
+ if (value === null || value === undefined) {
119
+ if (!spec.nullable) refuse(c.bundle, `${where} cannot be null`)
120
+ return null
121
+ }
122
+ switch (spec.type) {
123
+ case 'uuid':
124
+ // Lowercased, so a key compares equal however it was written, on both dialects.
125
+ if (typeof value !== 'string' || !UUID.test(value)) refuse(c.bundle, `${where} takes a uuid`)
126
+ return value.toLowerCase()
127
+ case 'text':
128
+ if (typeof value !== 'string') refuse(c.bundle, `${where} takes a string`)
129
+ return value
130
+ case 'varchar':
131
+ if (typeof value !== 'string') refuse(c.bundle, `${where} takes a string`)
132
+ if (value.length > (spec.length ?? 0))
133
+ refuse(c.bundle, `${where} holds at most ${spec.length} characters`)
134
+ return value
135
+ case 'integer':
136
+ case 'bigint':
137
+ if (typeof value !== 'number' || !Number.isSafeInteger(value))
138
+ refuse(c.bundle, `${where} takes a whole number`)
139
+ if (spec.type === 'integer' && Math.abs(value) > 2_147_483_647)
140
+ refuse(c.bundle, `${where} is an integer, and ${value} needs a bigint`)
141
+ return value
142
+ case 'boolean':
143
+ if (typeof value !== 'boolean') refuse(c.bundle, `${where} takes true or false`)
144
+ return c.boolValue(value)
145
+ case 'timestamp': {
146
+ const date =
147
+ value instanceof Date ? value : typeof value === 'string' ? new Date(value) : undefined
148
+ if (!date || Number.isNaN(date.getTime())) refuse(c.bundle, `${where} takes a date`)
149
+ return DbDate.toDb(date)
150
+ }
151
+ case 'json':
152
+ try {
153
+ return JSON.stringify(value)
154
+ } catch {
155
+ return refuse(c.bundle, `${where} takes a value JSON can hold`)
156
+ }
157
+ }
158
+ }
159
+
160
+ function fromDb(spec: ColumnSpec, value: unknown): unknown {
161
+ if (value === null || value === undefined) return null
162
+ switch (spec.type) {
163
+ case 'uuid':
164
+ return String(value).toLowerCase()
165
+ case 'varchar':
166
+ case 'text':
167
+ return String(value)
168
+ case 'integer':
169
+ case 'bigint':
170
+ return Number(value)
171
+ case 'boolean':
172
+ return fromDbBool(value)
173
+ case 'timestamp':
174
+ return DbDate.fromDb(value) ?? null
175
+ case 'json':
176
+ return JSON.parse(String(value)) as JsonValue
177
+ }
178
+ }
179
+
180
+ /** The column a caller named, from the schema, or a refusal. */
181
+ function columnOf(c: Compiler, table: ResolvedTable, name: string): ColumnSpec {
182
+ const column = table.columns.get(name)
183
+ if (!column) refuse(c.bundle, `"${table.name}" has no column ${JSON.stringify(name)}`)
184
+ return column.spec
185
+ }
186
+
187
+ function compileWhere(
188
+ c: Compiler,
189
+ table: ResolvedTable,
190
+ where: unknown,
191
+ params: unknown[],
192
+ depth: number,
193
+ ): string {
194
+ if (depth > MAX_DEPTH) refuse(c.bundle, `a filter is nested more than ${MAX_DEPTH} deep`)
195
+ if (!isPlain(where)) refuse(c.bundle, 'a filter must be a plain object')
196
+ const parts: string[] = []
197
+ for (const key of Object.keys(where)) {
198
+ const value = where[key]
199
+ if (key === 'and' || key === 'or') {
200
+ if (!Array.isArray(value) || value.length === 0)
201
+ refuse(c.bundle, `"${key}" takes a non-empty list of filters`)
202
+ const inner = value.map((item) => compileWhere(c, table, item, params, depth + 1))
203
+ parts.push(`(${inner.join(key === 'and' ? ' AND ' : ' OR ')})`)
204
+ continue
205
+ }
206
+ const spec = columnOf(c, table, key)
207
+ const col = c.quote(key)
208
+ if (value === null) {
209
+ parts.push(`${col} IS NULL`)
210
+ continue
211
+ }
212
+ if (isPlain(value)) {
213
+ const ops = Object.keys(value)
214
+ if (ops.length === 0) refuse(c.bundle, `the condition on "${key}" is empty`)
215
+ for (const op of ops) {
216
+ if (!OPERATORS.has(op)) refuse(c.bundle, `${JSON.stringify(op)} is not a condition`)
217
+ const operand = value[op]
218
+ if (op === 'isNull' || op === 'notNull') {
219
+ if (operand !== true) refuse(c.bundle, `"${op}" takes true`)
220
+ parts.push(`${col} ${op === 'isNull' ? 'IS NULL' : 'IS NOT NULL'}`)
221
+ continue
222
+ }
223
+ if (spec.type === 'json')
224
+ refuse(c.bundle, `"${key}" is json, which can only be tested for null`)
225
+ if (op === 'in') {
226
+ if (!Array.isArray(operand) || operand.length === 0 || operand.length > MAX_IN)
227
+ refuse(c.bundle, `"in" takes a list of 1 to ${MAX_IN} values`)
228
+ for (const item of operand)
229
+ params.push(toDb(c, table, key, { ...spec, nullable: false }, item))
230
+ parts.push(`${col} IN (${operand.map(() => '?').join(', ')})`)
231
+ continue
232
+ }
233
+ params.push(toDb(c, table, key, { ...spec, nullable: false }, operand))
234
+ parts.push(`${col} ${COMPARISONS[op]} ?`)
235
+ }
236
+ continue
237
+ }
238
+ if (spec.type === 'json')
239
+ refuse(c.bundle, `"${key}" is json, which can only be tested for null`)
240
+ params.push(toDb(c, table, key, { ...spec, nullable: false }, value))
241
+ parts.push(`${col} = ?`)
242
+ }
243
+ if (parts.length === 0) refuse(c.bundle, 'a filter names no condition')
244
+ return parts.join(' AND ')
245
+ }
246
+
247
+ function readRow(table: ResolvedTable, row: Record<string, unknown>): Record<string, unknown> {
248
+ const out: Record<string, unknown> = {}
249
+ for (const [name, column] of table.columns) out[name] = fromDb(column.spec, row[name])
250
+ return out
251
+ }
252
+
253
+ function tableHandle(db: Db, schema: ResolvedSchema, table: ResolvedTable): TableHandle<Columns> {
254
+ const c: Compiler = {
255
+ bundle: schema.id,
256
+ dialectName: db.dialect.name,
257
+ boolValue: (value) => db.dialect.boolValue(value),
258
+ quote: (identifier) => db.dialect.quote(identifier),
259
+ }
260
+ const name = c.quote(table.physical)
261
+ const guard = (sql: string) => guardStatement(schema.id, schema.prefix, sql)
262
+ const columnList = [...table.columns.keys()].map(c.quote).join(', ')
263
+
264
+ const whereClause = (where: unknown, params: unknown[], required: boolean): string => {
265
+ if (where === undefined) {
266
+ if (required)
267
+ refuse(c.bundle, 'an update or a delete needs a "where"; use truncate to empty a table')
268
+ return ''
269
+ }
270
+ return ` WHERE ${compileWhere(c, table, where, params, 0)}`
271
+ }
272
+
273
+ return frozen({
274
+ async select(query: SelectQuery<Record<string, unknown>> = {}) {
275
+ if (!isPlain(query)) refuse(c.bundle, 'a query must be a plain object')
276
+ const params: unknown[] = []
277
+ let sql = `SELECT ${columnList} FROM ${name}${whereClause(query.where, params, false)}`
278
+ if (query.orderBy !== undefined) {
279
+ if (!isPlain(query.orderBy)) refuse(c.bundle, '"orderBy" must be a plain object')
280
+ const order = Object.keys(query.orderBy).map((key) => {
281
+ columnOf(c, table, key)
282
+ const direction = (query.orderBy as Record<string, unknown>)[key]
283
+ if (direction !== 'asc' && direction !== 'desc')
284
+ refuse(c.bundle, `the order of "${key}" is "asc" or "desc"`)
285
+ return `${c.quote(key)} ${direction === 'asc' ? 'ASC' : 'DESC'}`
286
+ })
287
+ if (order.length > 0) sql += ` ORDER BY ${order.join(', ')}`
288
+ }
289
+ const limit: unknown = query.limit ?? DEFAULT_LIMIT
290
+ const offset: unknown = query.offset ?? 0
291
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 0 || limit > MAX_LIMIT)
292
+ refuse(c.bundle, `"limit" is a whole number from 0 to ${MAX_LIMIT}`)
293
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0)
294
+ refuse(c.bundle, '"offset" is a whole number from 0')
295
+ sql += ' LIMIT ? OFFSET ?'
296
+ params.push(limit, offset)
297
+ const rows = await db.query<Record<string, unknown>>(guard(sql), params)
298
+ return rows.map((row) => readRow(table, row))
299
+ },
300
+
301
+ async count(query: { where?: unknown } = {}) {
302
+ if (!isPlain(query)) refuse(c.bundle, 'a query must be a plain object')
303
+ const params: unknown[] = []
304
+ const sql = `SELECT COUNT(*) AS ${c.quote('n')} FROM ${name}${whereClause(query.where, params, false)}`
305
+ const rows = await db.query<{ n: unknown }>(guard(sql), params)
306
+ return Number(rows[0]?.n ?? 0)
307
+ },
308
+
309
+ async insert(input: unknown) {
310
+ const rows = Array.isArray(input) ? input : [input]
311
+ if (rows.length === 0) return
312
+ for (const row of rows) {
313
+ if (!isPlain(row)) refuse(c.bundle, 'a row must be a plain object')
314
+ for (const key of Object.keys(row)) columnOf(c, table, key)
315
+ const names: string[] = []
316
+ const params: unknown[] = []
317
+ for (const [column, resolved] of table.columns) {
318
+ const given = Object.hasOwn(row, column)
319
+ if (!given) {
320
+ if (resolved.spec.default !== undefined || resolved.spec.nullable) continue
321
+ refuse(c.bundle, `a row of "${table.name}" needs "${column}"`)
322
+ }
323
+ names.push(c.quote(column))
324
+ params.push(toDb(c, table, column, resolved.spec, row[column]))
325
+ }
326
+ await db.exec(
327
+ guard(
328
+ `INSERT INTO ${name} (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`,
329
+ ),
330
+ params,
331
+ )
332
+ }
333
+ },
334
+
335
+ async update(query: { where?: unknown; set?: unknown }) {
336
+ if (!isPlain(query)) refuse(c.bundle, 'an update must be a plain object')
337
+ if (!isPlain(query.set) || Object.keys(query.set).length === 0)
338
+ refuse(c.bundle, 'an update needs a "set" naming at least one column')
339
+ const set = query.set
340
+ const params: unknown[] = []
341
+ const assignments = Object.keys(set).map((column) => {
342
+ params.push(toDb(c, table, column, columnOf(c, table, column), set[column]))
343
+ return `${c.quote(column)} = ?`
344
+ })
345
+ const where = whereClause(query.where, params, true)
346
+ await db.exec(guard(`UPDATE ${name} SET ${assignments.join(', ')}${where}`), params)
347
+ },
348
+
349
+ async delete(query: { where?: unknown }) {
350
+ if (!isPlain(query)) refuse(c.bundle, 'a delete must be a plain object')
351
+ const params: unknown[] = []
352
+ await db.exec(guard(`DELETE FROM ${name}${whereClause(query.where, params, true)}`), params)
353
+ },
354
+
355
+ async truncate() {
356
+ await db.exec(guard(`DELETE FROM ${name}`))
357
+ },
358
+ }) as TableHandle<Columns>
359
+ }
360
+
361
+ function handlesOver(db: Db, schema: ResolvedSchema): TableHandles<TablesShape> {
362
+ const handles: Record<string, TableHandle<Columns>> = {}
363
+ for (const [name, table] of schema.tables) handles[name] = tableHandle(db, schema, table)
364
+ return frozen(handles) as TableHandles<TablesShape>
365
+ }
366
+
367
+ /** The capability over a bundle's resolved schema, bound to a connection it never exposes. */
368
+ export function createTablesCapability<S extends TablesShape = TablesShape>(
369
+ db: Db,
370
+ schema: ResolvedSchema,
371
+ ): BundleTablesCapability<S> {
372
+ return frozen({
373
+ ...handlesOver(db, schema),
374
+ transaction: <R>(fn: (tables: TableHandles<TablesShape>) => Promise<R>) =>
375
+ db.transaction((tx) => fn(handlesOver(tx, schema))),
376
+ }) as unknown as BundleTablesCapability<S>
377
+ }