@crombolo/data 0.9.1 → 0.11.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 +2 -2
- package/src/audit.ts +291 -0
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/cluster.ts +1 -1
- package/src/database.ts +39 -9
- package/src/dialect.ts +32 -0
- package/src/index.ts +9 -0
- package/src/migrations.ts +147 -31
- package/src/pending.ts +1 -1
- package/src/repositories/bundles.ts +36 -4
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/documents.ts +13 -3
- package/src/repositories/form-entries.ts +1 -1
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +15 -1
- package/src/repositories/nodes.ts +32 -7
- package/src/repositories/transfer-runs.ts +20 -2
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/bundle-components.ts +1 -1
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +1 -1
- package/src/schema/content.ts +2 -2
- package/src/schema/created-packages.ts +1 -1
- package/src/schema/form-permissions.ts +1 -1
- package/src/schema/form-workflows.ts +1 -1
- package/src/schema/forms.ts +1 -1
- package/src/schema/identity.ts +1 -1
- package/src/schema/plan.ts +10 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/upgrade-report.ts +1 -1
- package/src/schema-state.ts +1 -1
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundle's compiled steps as a migration chain, run by the same `migrate`,
|
|
3
|
+
* under the same lock and into the same ledger as core's — only its state key
|
|
4
|
+
* and its ledger rows' `bundle` differ. And purging one: dropping exactly the
|
|
5
|
+
* tables under its prefix, found in the database rather than the declaration,
|
|
6
|
+
* because a purged bundle is one the site no longer wires.
|
|
7
|
+
*/
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import { deleteKeyValue, type Migration, MigrationPlan, recordInLedger } from '../migrations.ts'
|
|
10
|
+
import { createTablesCapability } from './capability.ts'
|
|
11
|
+
import { renderStep } from './ddl.ts'
|
|
12
|
+
import { guardStatement } from './guard.ts'
|
|
13
|
+
import { bundlePrefix } from './names.ts'
|
|
14
|
+
import type { CompiledStep, CompiledTables } from './schema.ts'
|
|
15
|
+
|
|
16
|
+
function migrationOf(step: CompiledStep): Migration {
|
|
17
|
+
return {
|
|
18
|
+
from: step.from,
|
|
19
|
+
to: step.to,
|
|
20
|
+
name: step.name,
|
|
21
|
+
kind: step.kind,
|
|
22
|
+
release: step.release,
|
|
23
|
+
contracts: step.contracts,
|
|
24
|
+
checksum: step.checksum,
|
|
25
|
+
async up(db) {
|
|
26
|
+
for (const sql of renderStep(db.dialect, step)) await db.exec(sql)
|
|
27
|
+
},
|
|
28
|
+
backfill: step.backfill
|
|
29
|
+
? (db) =>
|
|
30
|
+
(step.backfill as NonNullable<CompiledStep['backfill']>)(
|
|
31
|
+
createTablesCapability(db, step.after),
|
|
32
|
+
)
|
|
33
|
+
: undefined,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The chain `migrate` walks. Throws when the declaration has problems: compile first. */
|
|
38
|
+
export function bundlePlan(compiled: CompiledTables): MigrationPlan {
|
|
39
|
+
if (compiled.problems.length > 0)
|
|
40
|
+
throw new Error(
|
|
41
|
+
`The bundle "${compiled.id}"'s tables cannot be planned:\n- ${compiled.problems.join('\n- ')}`,
|
|
42
|
+
)
|
|
43
|
+
return new MigrationPlan(compiled.steps.map(migrationOf), {
|
|
44
|
+
stateKey: compiled.stateKey,
|
|
45
|
+
initialState: compiled.initialState,
|
|
46
|
+
bundle: compiled.id,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a bundle's tables directly, step by step, without its ledger or its
|
|
52
|
+
* state. Only for proving what core's own migrations do around a bundle's
|
|
53
|
+
* tables: an install is always `migrate`.
|
|
54
|
+
*/
|
|
55
|
+
export async function applyBundleStepsUnledgered(db: Db, compiled: CompiledTables): Promise<void> {
|
|
56
|
+
for (const migration of bundlePlan(compiled).migrations)
|
|
57
|
+
await db.transaction(async (tx) => {
|
|
58
|
+
await migration.up(tx)
|
|
59
|
+
await migration.backfill?.(tx)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every table in the database under a bundle's prefix. */
|
|
64
|
+
export async function bundleTablesIn(db: Db, id: string): Promise<string[]> {
|
|
65
|
+
const prefix = bundlePrefix(id)
|
|
66
|
+
const rows =
|
|
67
|
+
db.dialect.name === 'sqlite'
|
|
68
|
+
? await db.query<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
69
|
+
: await db.query<{ name: string }>(
|
|
70
|
+
"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'",
|
|
71
|
+
)
|
|
72
|
+
// Compared as text rather than with LIKE, where `_` is a wildcard.
|
|
73
|
+
return rows
|
|
74
|
+
.map((row) => row.name)
|
|
75
|
+
.filter((name) => name.startsWith(prefix))
|
|
76
|
+
.sort()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drops every table under the bundle's prefix and forgets its state, so adding
|
|
81
|
+
* it again starts its chain from the beginning. Ledgered as a `purge`.
|
|
82
|
+
*/
|
|
83
|
+
export async function purgeBundleTables(
|
|
84
|
+
db: Db,
|
|
85
|
+
id: string,
|
|
86
|
+
options: { by?: string; stateKey: string },
|
|
87
|
+
): Promise<string[]> {
|
|
88
|
+
const prefix = bundlePrefix(id)
|
|
89
|
+
const tables = await bundleTablesIn(db, id)
|
|
90
|
+
const started = performance.now()
|
|
91
|
+
await db.transaction(async (tx) => {
|
|
92
|
+
for (const table of tables)
|
|
93
|
+
await tx.exec(guardStatement(id, prefix, `DROP TABLE ${tx.dialect.quote(table)}`))
|
|
94
|
+
})
|
|
95
|
+
await deleteKeyValue(db, options.stateKey)
|
|
96
|
+
await recordInLedger(db, {
|
|
97
|
+
name: `purge ${id}`,
|
|
98
|
+
kind: 'purge',
|
|
99
|
+
durationMs: Math.round(performance.now() - started),
|
|
100
|
+
appliedBy: options.by ?? null,
|
|
101
|
+
note: tables.length > 0 ? `dropped ${tables.join(', ')}` : 'no tables',
|
|
102
|
+
bundle: id,
|
|
103
|
+
})
|
|
104
|
+
return tables
|
|
105
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A step's operations as SQL, per dialect. Every identifier comes from the
|
|
3
|
+
* resolved schema and is quoted here, every default is a literal the builder
|
|
4
|
+
* already checked, and every statement passes the guard before it is returned.
|
|
5
|
+
*/
|
|
6
|
+
import type { Dialect } from '../dialect.ts'
|
|
7
|
+
import type { ColumnSpec, Operation } from './builder.ts'
|
|
8
|
+
import { guardStatement } from './guard.ts'
|
|
9
|
+
import type { ResolvedSchema } from './schema.ts'
|
|
10
|
+
|
|
11
|
+
/** The column type, from the dialect seam. Text compares case-sensitively on both. */
|
|
12
|
+
export function columnType(dialect: Dialect, spec: ColumnSpec): string {
|
|
13
|
+
const t = dialect.types
|
|
14
|
+
switch (spec.type) {
|
|
15
|
+
case 'uuid':
|
|
16
|
+
return t.uuid
|
|
17
|
+
case 'varchar':
|
|
18
|
+
return t.varcharExact(spec.length ?? 1)
|
|
19
|
+
case 'text':
|
|
20
|
+
return t.text
|
|
21
|
+
case 'integer':
|
|
22
|
+
return t.integer
|
|
23
|
+
case 'bigint':
|
|
24
|
+
return t.bigint
|
|
25
|
+
case 'boolean':
|
|
26
|
+
return t.boolean
|
|
27
|
+
case 'timestamp':
|
|
28
|
+
return t.timestamp
|
|
29
|
+
case 'json':
|
|
30
|
+
return t.json
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function literal(dialect: Dialect, value: string | number | boolean): string {
|
|
35
|
+
if (typeof value === 'boolean')
|
|
36
|
+
return dialect.name === 'postgres' ? (value ? 'TRUE' : 'FALSE') : value ? '1' : '0'
|
|
37
|
+
if (typeof value === 'number') return String(value)
|
|
38
|
+
return `'${value.replaceAll("'", "''")}'`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function columnDefinition(dialect: Dialect, name: string, spec: ColumnSpec): string {
|
|
42
|
+
const parts = [dialect.quote(name), columnType(dialect, spec)]
|
|
43
|
+
if (!spec.nullable) parts.push('NOT NULL')
|
|
44
|
+
if (spec.default !== undefined) parts.push(`DEFAULT ${literal(dialect, spec.default)}`)
|
|
45
|
+
return parts.join(' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The statements one operation runs, against the schema as it stood before it. */
|
|
49
|
+
export function renderOperation(
|
|
50
|
+
dialect: Dialect,
|
|
51
|
+
before: ResolvedSchema,
|
|
52
|
+
after: ResolvedSchema,
|
|
53
|
+
op: Operation,
|
|
54
|
+
): string[] {
|
|
55
|
+
const q = (identifier: string) => dialect.quote(identifier)
|
|
56
|
+
const guard = (sql: string) => guardStatement(before.id, before.prefix, sql)
|
|
57
|
+
const table = after.tables.get(op.table) ?? before.tables.get(op.table)
|
|
58
|
+
if (!table) throw new Error(`no table "${op.table}" to render ${op.op} against`)
|
|
59
|
+
const statements: string[] = []
|
|
60
|
+
const indexFor = (columns: readonly string[]) => {
|
|
61
|
+
const index = (after.tables.get(op.table) ?? table).indexes.get(columns.join(','))
|
|
62
|
+
if (!index) throw new Error(`no index on "${op.table}" (${columns.join(',')})`)
|
|
63
|
+
return index
|
|
64
|
+
}
|
|
65
|
+
const createIndex = (columns: readonly string[]) => {
|
|
66
|
+
const index = indexFor(columns)
|
|
67
|
+
statements.push(
|
|
68
|
+
`CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${q(index.name)} ON ${q(table.physical)} (${index.columns.map(q).join(', ')})`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
switch (op.op) {
|
|
73
|
+
case 'createTable': {
|
|
74
|
+
const columns = Object.entries(op.columns).map(([name, spec]) =>
|
|
75
|
+
columnDefinition(dialect, name, spec),
|
|
76
|
+
)
|
|
77
|
+
const key = Object.entries(op.columns).find(([, spec]) => spec.primaryKey)?.[0] as string
|
|
78
|
+
statements.push(
|
|
79
|
+
`CREATE TABLE ${q(table.physical)} (${columns.join(', ')}, PRIMARY KEY (${q(key)}))`,
|
|
80
|
+
)
|
|
81
|
+
for (const [name, spec] of Object.entries(op.columns))
|
|
82
|
+
if (spec.indexed && !spec.primaryKey) createIndex([name])
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
case 'addColumn':
|
|
86
|
+
statements.push(
|
|
87
|
+
`ALTER TABLE ${q(table.physical)} ADD COLUMN ${columnDefinition(dialect, op.column, op.spec)}`,
|
|
88
|
+
)
|
|
89
|
+
if (op.spec.indexed) createIndex([op.column])
|
|
90
|
+
break
|
|
91
|
+
case 'addIndex':
|
|
92
|
+
createIndex(op.columns)
|
|
93
|
+
break
|
|
94
|
+
case 'widenColumn':
|
|
95
|
+
// SQLite stores by affinity and enforces no length, so a wider type is
|
|
96
|
+
// already what it holds; only Postgres has anything to change.
|
|
97
|
+
if (dialect.name === 'postgres')
|
|
98
|
+
statements.push(
|
|
99
|
+
`ALTER TABLE ${q(table.physical)} ALTER COLUMN ${q(op.column)} TYPE ${columnType(dialect, op.spec)}`,
|
|
100
|
+
)
|
|
101
|
+
break
|
|
102
|
+
case 'dropTable':
|
|
103
|
+
statements.push(`DROP TABLE ${q(table.physical)}`)
|
|
104
|
+
break
|
|
105
|
+
case 'dropColumn':
|
|
106
|
+
statements.push(`ALTER TABLE ${q(table.physical)} DROP COLUMN ${q(op.column)}`)
|
|
107
|
+
break
|
|
108
|
+
case 'dropIndex': {
|
|
109
|
+
const index = before.tables.get(op.table)?.indexes.get(op.columns.join(','))
|
|
110
|
+
if (!index) throw new Error(`no index on "${op.table}" (${op.columns.join(',')})`)
|
|
111
|
+
statements.push(`DROP INDEX ${q(index.name)}`)
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return statements.map(guard)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Every statement a step runs, in order. */
|
|
119
|
+
export function renderStep(
|
|
120
|
+
dialect: Dialect,
|
|
121
|
+
step: { before: ResolvedSchema; after: ResolvedSchema; operations: readonly Operation[] },
|
|
122
|
+
): string[] {
|
|
123
|
+
return step.operations.flatMap((op) => renderOperation(dialect, step.before, step.after, op))
|
|
124
|
+
}
|