@avelonjs/postgres 0.1.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/driver.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { SQL } from 'bun'
2
+ import {
3
+ Invalid,
4
+ type DatabaseDriver,
5
+ type DatabaseTransaction,
6
+ type MigrationPlan,
7
+ type MigrationStatus,
8
+ type Predicate,
9
+ type QueryIR,
10
+ type QueryResult,
11
+ type RelationLoad,
12
+ type TransactionSurface,
13
+ } from '@avelonjs/core'
14
+ import { compilePostgres } from './compile'
15
+ import { mapPostgresError } from './errors'
16
+ import { resetAssayFixtures } from './fixtures'
17
+ import {
18
+ applyMigrations,
19
+ planMigrations,
20
+ rollbackMigrations,
21
+ statusMigrations,
22
+ type PostgresMigration,
23
+ } from './migrations'
24
+ import { combinedPredicate } from './normalize'
25
+ import { assertQueryAgainstSchema, loadSchemaCache, type SchemaCache } from './schema'
26
+ import { validateQueryIR } from './validate'
27
+
28
+ /** Exact capability declaration for the Postgres database driver. */
29
+ export const postgresDatabaseCapabilities = {
30
+ transactions: true,
31
+ rowSecurity: false,
32
+ /** Measured via nested application-side relation loads; not a PostgREST embed limit. */
33
+ maxRelationDepth: 8,
34
+ fullTextSearch: false,
35
+ upsert: true,
36
+ returning: true,
37
+ windowFunctions: true,
38
+ jsonOperators: true,
39
+ } as const
40
+
41
+ /** Construction options for {@link PostgresDatabase}. */
42
+ export interface PostgresDatabaseOptions {
43
+ /** Postgres connection URL. Defaults to `POSTGRES_URL` or `DATABASE_URL`. */
44
+ url?: string
45
+ /** Configured connection name reported on the driver. */
46
+ instance?: string
47
+ /** Optional driver-owned migrations registered with this instance. */
48
+ migrations?: readonly PostgresMigration[]
49
+ }
50
+
51
+ type Row = Record<string, unknown>
52
+ type SqlClient = SQL
53
+
54
+ function defaultUrl(): string {
55
+ return (
56
+ process.env.POSTGRES_URL ??
57
+ process.env.DATABASE_URL ??
58
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_test'
59
+ )
60
+ }
61
+
62
+ function includeColumns(select: string[] | '*', columns: string[]): string[] | '*' {
63
+ if (select === '*') return '*'
64
+ return [...new Set([...select, ...columns])]
65
+ }
66
+
67
+ function projectRow(row: Row, select: string[] | '*', relations: readonly RelationLoad[]): Row {
68
+ const output: Row = {}
69
+ const columns =
70
+ select === '*'
71
+ ? Object.keys(row).filter((column) => !relations.some((relation) => relation.relation === column))
72
+ : select
73
+ for (const column of columns) output[column] = row[column]
74
+ for (const relation of relations) output[relation.relation] = row[relation.relation]
75
+ return output
76
+ }
77
+
78
+ function unique(values: unknown[]): unknown[] {
79
+ const output: unknown[] = []
80
+ for (const value of values) {
81
+ if (!output.some((existing) => sqlEqual(existing, value))) output.push(value)
82
+ }
83
+ return output
84
+ }
85
+
86
+ function sqlEqual(left: unknown, right: unknown): boolean {
87
+ if (left === null || left === undefined || right === null || right === undefined) return false
88
+ return left === right
89
+ }
90
+
91
+ function asRows(result: unknown): Row[] {
92
+ if (!Array.isArray(result)) return []
93
+ return result.map((row) => ({ ...(row as Row) }))
94
+ }
95
+
96
+ function resultCount(result: unknown): number {
97
+ if (!Array.isArray(result)) return 0
98
+ const count = Reflect.get(result, 'count')
99
+ return typeof count === 'number' ? count : result.length
100
+ }
101
+
102
+ /**
103
+ * Postgres implementation of the frozen database contract.
104
+ *
105
+ * Vendor access stays inside this package through Bun's SQL client. Application code reaches the
106
+ * driver only through the database facade and never imports `bun` SQL APIs.
107
+ */
108
+ export class PostgresDatabase
109
+ implements
110
+ DatabaseDriver<typeof postgresDatabaseCapabilities, SQL>,
111
+ TransactionSurface
112
+ {
113
+ /** Driver implementation name. */
114
+ readonly name = 'postgres'
115
+
116
+ /** Configured connection name. */
117
+ readonly instance: string
118
+
119
+ /** Exact optional-feature declaration. */
120
+ readonly capabilities: typeof postgresDatabaseCapabilities
121
+
122
+ readonly #sql: SQL
123
+ readonly #migrations: readonly PostgresMigration[]
124
+ #schema: SchemaCache | undefined
125
+ #roundTrips = 0
126
+
127
+ /** Creates a driver bound to one Postgres connection URL. */
128
+ constructor(options: PostgresDatabaseOptions = {}) {
129
+ this.instance = options.instance ?? 'default'
130
+ this.#migrations = options.migrations ?? []
131
+ this.capabilities = postgresDatabaseCapabilities
132
+ this.#sql = new SQL(options.url ?? defaultUrl())
133
+ }
134
+
135
+ /** Number of statements that reached Postgres. Used by conformance short-circuit checks. */
136
+ get roundTrips(): number {
137
+ return this.#roundTrips
138
+ }
139
+
140
+ /** Returns the underlying Bun SQL client. */
141
+ raw(): SQL {
142
+ return this.#sql
143
+ }
144
+
145
+ /** Drops and recreates the assay fixture schema used by live conformance. */
146
+ async resetFixtures(): Promise<void> {
147
+ await resetAssayFixtures(this.#sql)
148
+ this.#schema = undefined
149
+ }
150
+
151
+ /** Closes the underlying SQL client pool. */
152
+ async close(): Promise<void> {
153
+ await this.#sql.close()
154
+ }
155
+
156
+ /** Compiles and executes one query IR operation. */
157
+ async execute<TRow = Record<string, unknown>>(query: QueryIR): Promise<QueryResult<TRow>> {
158
+ return this.#execute(this.#sql, query) as Promise<QueryResult<TRow>>
159
+ }
160
+
161
+ /** Invokes a named Postgres routine. Missing routines map to `Invalid`. */
162
+ async rpc<TResult = unknown>(
163
+ routine: string,
164
+ args: Readonly<Record<string, unknown>>,
165
+ ): Promise<TResult> {
166
+ try {
167
+ this.#roundTrips += 1
168
+ const rows = asRows(
169
+ await this.#sql.unsafe(`SELECT ${quoteRoutine(routine)}($1::jsonb) AS result`, [
170
+ JSON.stringify(args),
171
+ ]),
172
+ )
173
+ return decodeJsonResult(rows[0]?.result) as TResult
174
+ } catch (error) {
175
+ mapPostgresError(error, 'rpc')
176
+ }
177
+ }
178
+
179
+ /** Produces the pending migration plan. */
180
+ async plan(): Promise<MigrationPlan> {
181
+ return planMigrations(this.#sql, this.#migrations)
182
+ }
183
+
184
+ /** Applies pending migrations. */
185
+ async apply(): Promise<readonly MigrationStatus[]> {
186
+ return applyMigrations(this.#sql, this.#migrations)
187
+ }
188
+
189
+ /** Rolls back applied migration batches. */
190
+ async rollback(steps?: number): Promise<readonly MigrationStatus[]> {
191
+ return rollbackMigrations(this.#sql, this.#migrations, steps)
192
+ }
193
+
194
+ /** Returns all migration states. */
195
+ async status(): Promise<readonly MigrationStatus[]> {
196
+ return statusMigrations(this.#sql, this.#migrations)
197
+ }
198
+
199
+ /** Runs a callback inside a Postgres transaction. */
200
+ async transaction<TResult>(
201
+ callback: (transaction: DatabaseTransaction) => Promise<TResult>,
202
+ ): Promise<TResult> {
203
+ try {
204
+ return await this.#sql.begin(async (tx) => {
205
+ const transaction: DatabaseTransaction = {
206
+ execute: <TRow = Record<string, unknown>>(query: QueryIR) =>
207
+ this.#execute(tx as SqlClient, query) as Promise<QueryResult<TRow>>,
208
+ rpc: async <TResultRpc = unknown>(
209
+ routine: string,
210
+ args: Readonly<Record<string, unknown>>,
211
+ ) => {
212
+ try {
213
+ this.#roundTrips += 1
214
+ const rows = asRows(
215
+ await (tx as SqlClient).unsafe(`SELECT ${quoteRoutine(routine)}($1::jsonb) AS result`, [
216
+ JSON.stringify(args),
217
+ ]),
218
+ )
219
+ return decodeJsonResult(rows[0]?.result) as TResultRpc
220
+ } catch (error) {
221
+ mapPostgresError(error, 'rpc')
222
+ }
223
+ },
224
+ }
225
+ return callback(transaction)
226
+ })
227
+ } catch (error) {
228
+ if (
229
+ error instanceof Invalid ||
230
+ (error instanceof Error &&
231
+ (error.name === 'Conflict' ||
232
+ error.name === 'DriverFault' ||
233
+ error.name === 'Unavailable'))
234
+ ) {
235
+ throw error
236
+ }
237
+ mapPostgresError(error, 'transaction')
238
+ }
239
+ }
240
+
241
+ async #schemaCache(): Promise<SchemaCache> {
242
+ if (this.#schema === undefined) this.#schema = await loadSchemaCache(this.#sql)
243
+ return this.#schema
244
+ }
245
+
246
+ async #execute(sql: SqlClient, ir: QueryIR): Promise<QueryResult> {
247
+ validateQueryIR(ir, this.capabilities.maxRelationDepth)
248
+ assertQueryAgainstSchema(await this.#schemaCache(), ir)
249
+ const predicate = combinedPredicate(ir)
250
+
251
+ if (predicate.kind === 'const' && !predicate.value) {
252
+ return ir.mode === 'count' ? { rows: [], affected: 0, count: 0 } : { rows: [], affected: 0 }
253
+ }
254
+
255
+ if ((ir.mode === 'insert' || ir.mode === 'upsert') && predicate.kind !== 'const') {
256
+ throw new Invalid(`${ir.mode} with a non-constant where or ward has no documented row scope.`, {
257
+ metadata: { fields: { where: [`${ir.mode} predicates are not documented`] } },
258
+ })
259
+ }
260
+
261
+ if (ir.mode === 'select') {
262
+ const primitive: QueryIR = {
263
+ ...ir,
264
+ select: includeColumns(
265
+ ir.select,
266
+ ir.relations.map((relation) => relation.localKey),
267
+ ),
268
+ relations: [],
269
+ }
270
+ const rows = await this.#executePrimitive(sql, primitive, predicate)
271
+ await this.#loadRelations(sql, rows, ir.relations)
272
+ return {
273
+ rows: rows.map((row) => projectRow(row, ir.select, ir.relations)),
274
+ affected: 0,
275
+ }
276
+ }
277
+
278
+ if (ir.mode === 'count') {
279
+ const rows = await this.#executePrimitive(sql, ir, predicate)
280
+ const count = Number(rows[0]?.count ?? 0)
281
+ return { rows: [], affected: 0, count }
282
+ }
283
+
284
+ const rows = await this.#executePrimitive(sql, ir, predicate)
285
+ const affected = resultCount(rows)
286
+ return {
287
+ rows: ir.returning === undefined ? [] : rows,
288
+ affected,
289
+ }
290
+ }
291
+
292
+ async #executePrimitive(sql: SqlClient, ir: QueryIR, predicate: Predicate): Promise<Row[]> {
293
+ const compiled = compilePostgres(ir, predicate)
294
+ try {
295
+ this.#roundTrips += 1
296
+ const result = await sql.unsafe(compiled.text, compiled.parameters)
297
+ const rows = asRows(result)
298
+ // Preserve Bun's statement count for writes without RETURNING (empty row arrays).
299
+ Object.defineProperty(rows, 'count', {
300
+ value: resultCount(result),
301
+ enumerable: false,
302
+ })
303
+ return rows
304
+ } catch (error) {
305
+ mapPostgresError(error, 'execute')
306
+ }
307
+ }
308
+
309
+ async #loadRelations(
310
+ sql: SqlClient,
311
+ parents: Row[],
312
+ relations: readonly RelationLoad[],
313
+ ): Promise<void> {
314
+ for (const relation of relations) {
315
+ const keys = unique(
316
+ parents
317
+ .map((parent) => parent[relation.localKey])
318
+ .filter((value) => value !== null && value !== undefined),
319
+ )
320
+
321
+ if (keys.length === 0) {
322
+ for (const parent of parents) {
323
+ parent[relation.relation] = relation.kind === 'hasMany' ? [] : null
324
+ }
325
+ continue
326
+ }
327
+
328
+ const childSelect = includeColumns(relation.select, [
329
+ relation.foreignKey,
330
+ ...relation.relations.map((nested) => nested.localKey),
331
+ ])
332
+ const childWhere: Predicate[] = [
333
+ ...(relation.where === null ? [] : [relation.where]),
334
+ { kind: 'in', column: relation.foreignKey, values: keys, negated: false },
335
+ ]
336
+ const childIR: QueryIR = {
337
+ table: relation.table,
338
+ mode: 'select',
339
+ select: childSelect,
340
+ where: childWhere,
341
+ relations: [],
342
+ order: relation.order,
343
+ }
344
+ const predicate = combinedPredicate(childIR)
345
+ const children =
346
+ predicate.kind === 'const' && !predicate.value
347
+ ? []
348
+ : await this.#executePrimitive(sql, childIR, predicate)
349
+ await this.#loadRelations(sql, children, relation.relations)
350
+
351
+ for (const parent of parents) {
352
+ const matches = children.filter((child) =>
353
+ sqlEqual(child[relation.foreignKey], parent[relation.localKey]),
354
+ )
355
+ const limited = relation.limit === undefined ? matches : matches.slice(0, relation.limit)
356
+ const projected = limited.map((child) =>
357
+ projectRow(child, relation.select, relation.relations),
358
+ )
359
+ parent[relation.relation] = relation.kind === 'hasMany' ? projected : (projected[0] ?? null)
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ function quoteRoutine(routine: string): string {
366
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(routine)) {
367
+ throw new Invalid('Routine name must be a simple identifier.', {
368
+ metadata: { fields: { routine: [routine] } },
369
+ })
370
+ }
371
+ return `"${routine}"`
372
+ }
373
+
374
+ function decodeJsonResult(value: unknown): unknown {
375
+ if (typeof value !== 'string') return value
376
+ try {
377
+ return JSON.parse(value)
378
+ } catch {
379
+ return value
380
+ }
381
+ }
382
+
383
+ /** Creates a Postgres database driver from options or environment defaults. */
384
+ export function createPostgresDatabase(options: PostgresDatabaseOptions = {}): PostgresDatabase {
385
+ return new PostgresDatabase(options)
386
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { Conflict, DriverFault, Invalid, Unavailable } from '@avelonjs/core'
2
+
3
+ interface PostgresErrorLike {
4
+ readonly name?: string
5
+ readonly message?: string
6
+ readonly errno?: string | number
7
+ readonly code?: string
8
+ readonly detail?: string
9
+ readonly table?: string
10
+ readonly constraint?: string
11
+ readonly routine?: string
12
+ }
13
+
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === 'object' && value !== null
16
+ }
17
+
18
+ function asPostgresError(error: unknown): PostgresErrorLike | undefined {
19
+ if (!isRecord(error)) return undefined
20
+ return error as PostgresErrorLike
21
+ }
22
+
23
+ function sqlState(error: PostgresErrorLike): string | undefined {
24
+ if (typeof error.errno === 'string') return error.errno
25
+ if (typeof error.errno === 'number') return String(error.errno)
26
+ return undefined
27
+ }
28
+
29
+ /**
30
+ * Maps a Bun/Postgres vendor failure into the framework error taxonomy.
31
+ *
32
+ * Unknown codes become `DriverFault` rather than leaking vendor shapes into application code.
33
+ */
34
+ export function mapPostgresError(error: unknown, operation: string): never {
35
+ const pg = asPostgresError(error)
36
+ const state = pg === undefined ? undefined : sqlState(pg)
37
+ const message = pg?.message ?? (error instanceof Error ? error.message : 'Postgres driver failure')
38
+
39
+ if (state === '23505') {
40
+ throw new Conflict(message, {
41
+ metadata: {
42
+ resource: pg?.table,
43
+ key: pg?.constraint,
44
+ },
45
+ cause: error,
46
+ })
47
+ }
48
+
49
+ if (state === '42P01' || state === '42703') {
50
+ throw new Invalid(message, {
51
+ metadata: {
52
+ fields: {
53
+ [state === '42P01' ? 'table' : 'select']: [message],
54
+ },
55
+ },
56
+ cause: error,
57
+ })
58
+ }
59
+
60
+ if (state === '42883') {
61
+ const routine = extractRoutineName(message) ?? 'unknown'
62
+ throw new Invalid(message, {
63
+ metadata: { fields: { routine: [routine] } },
64
+ cause: error,
65
+ })
66
+ }
67
+
68
+ if (state === '57P01' || state === '57P03' || state === '08006' || state === '08001') {
69
+ throw new Unavailable(message, {
70
+ metadata: { service: 'postgres' },
71
+ cause: error,
72
+ })
73
+ }
74
+
75
+ throw new DriverFault(message, {
76
+ metadata: { driver: 'database', operation },
77
+ cause: error,
78
+ })
79
+ }
80
+
81
+ function extractRoutineName(message: string): string | undefined {
82
+ const match = /function ([a-zA-Z0-9_.]+)\(/.exec(message)
83
+ return match?.[1]
84
+ }
85
+
86
+ /**
87
+ * Vendor SQLSTATE codes this driver maps, with the framework class they become.
88
+ *
89
+ * Kept as a table so live error-mapping tests and docs stay aligned.
90
+ */
91
+ export const POSTGRES_ERROR_MAP = [
92
+ { sqlstate: '23505', framework: 'Conflict', meaning: 'unique_violation' },
93
+ { sqlstate: '42P01', framework: 'Invalid', meaning: 'undefined_table' },
94
+ { sqlstate: '42703', framework: 'Invalid', meaning: 'undefined_column' },
95
+ { sqlstate: '42883', framework: 'Invalid', meaning: 'undefined_function' },
96
+ { sqlstate: '57P01', framework: 'Unavailable', meaning: 'admin_shutdown' },
97
+ { sqlstate: '57P03', framework: 'Unavailable', meaning: 'cannot_connect_now' },
98
+ { sqlstate: '08006', framework: 'Unavailable', meaning: 'connection_failure' },
99
+ { sqlstate: '08001', framework: 'Unavailable', meaning: 'sqlclient_unable_to_establish_sqlconnection' },
100
+ ] as const
@@ -0,0 +1,59 @@
1
+ import type { SQL } from 'bun'
2
+
3
+ /** SQL that drops and recreates the database conformance fixtures. */
4
+ export const ASSAY_FIXTURE_SQL = `
5
+ DROP TABLE IF EXISTS assay_reactions CASCADE;
6
+ DROP TABLE IF EXISTS assay_comments CASCADE;
7
+ DROP TABLE IF EXISTS assay_posts CASCADE;
8
+ DROP TABLE IF EXISTS assay_profiles CASCADE;
9
+ DROP TABLE IF EXISTS assay_users CASCADE;
10
+ DROP FUNCTION IF EXISTS assay_echo(jsonb);
11
+
12
+ CREATE TABLE assay_users (
13
+ id text PRIMARY KEY,
14
+ email text NOT NULL UNIQUE,
15
+ name text NOT NULL,
16
+ age integer NOT NULL,
17
+ nickname text
18
+ );
19
+
20
+ CREATE TABLE assay_profiles (
21
+ id text PRIMARY KEY,
22
+ user_id text NOT NULL UNIQUE REFERENCES assay_users(id) ON DELETE CASCADE,
23
+ bio text NOT NULL
24
+ );
25
+
26
+ CREATE TABLE assay_posts (
27
+ id text PRIMARY KEY,
28
+ user_id text NOT NULL REFERENCES assay_users(id) ON DELETE CASCADE,
29
+ title text NOT NULL,
30
+ score integer NOT NULL,
31
+ published_at timestamptz
32
+ );
33
+
34
+ CREATE TABLE assay_comments (
35
+ id text PRIMARY KEY,
36
+ post_id text NOT NULL REFERENCES assay_posts(id) ON DELETE CASCADE,
37
+ body text NOT NULL,
38
+ position integer NOT NULL
39
+ );
40
+
41
+ CREATE TABLE assay_reactions (
42
+ id text PRIMARY KEY,
43
+ comment_id text NOT NULL REFERENCES assay_comments(id) ON DELETE CASCADE,
44
+ kind text NOT NULL
45
+ );
46
+
47
+ CREATE OR REPLACE FUNCTION assay_echo(args jsonb)
48
+ RETURNS jsonb
49
+ LANGUAGE sql
50
+ IMMUTABLE
51
+ AS $$
52
+ SELECT args;
53
+ $$;
54
+ `
55
+
56
+ /** Provisions empty assay fixture tables and the `assay_echo` routine. */
57
+ export async function resetAssayFixtures(sql: SQL): Promise<void> {
58
+ await sql.unsafe(ASSAY_FIXTURE_SQL)
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { compilePostgres, compileSqlPredicate, type CompiledSql } from './compile'
2
+ export {
3
+ createPostgresDatabase,
4
+ PostgresDatabase,
5
+ postgresDatabaseCapabilities,
6
+ type PostgresDatabaseOptions,
7
+ } from './driver'
8
+ export { mapPostgresError, POSTGRES_ERROR_MAP } from './errors'
9
+ export { ASSAY_FIXTURE_SQL, resetAssayFixtures } from './fixtures'
10
+ export {
11
+ applyMigrations,
12
+ planMigrations,
13
+ rollbackMigrations,
14
+ statusMigrations,
15
+ type PostgresMigration,
16
+ } from './migrations'
17
+ export { combinedPredicate, normalizePredicate } from './normalize'
18
+ export { assertQueryAgainstSchema, loadSchemaCache, type SchemaCache } from './schema'
19
+ export { assertIdentifier, validateQueryIR } from './validate'
@@ -0,0 +1,123 @@
1
+ import type { SQL } from 'bun'
2
+ import type { MigrationPlan, MigrationStatus } from '@avelonjs/core'
3
+ import { mapPostgresError } from './errors'
4
+
5
+ /** One driver-owned SQL migration pair. */
6
+ export interface PostgresMigration {
7
+ /** Stable migration identifier. */
8
+ id: string
9
+ /** Statements applied in order. */
10
+ up: readonly string[]
11
+ /** Statements used to roll the migration back. */
12
+ down: readonly string[]
13
+ }
14
+
15
+ const HISTORY_TABLE = 'avelon_migrations'
16
+
17
+ async function ensureHistory(sql: SQL): Promise<void> {
18
+ try {
19
+ await sql.unsafe(`
20
+ CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
21
+ id text PRIMARY KEY,
22
+ applied_at timestamptz NOT NULL DEFAULT now()
23
+ )
24
+ `)
25
+ } catch (error) {
26
+ mapPostgresError(error, 'migrations.ensureHistory')
27
+ }
28
+ }
29
+
30
+ async function appliedIds(sql: SQL): Promise<Set<string>> {
31
+ await ensureHistory(sql)
32
+ try {
33
+ const rows = (await sql.unsafe(`SELECT id FROM ${HISTORY_TABLE}`)) as Array<{ id: string }>
34
+ return new Set(rows.map((row) => row.id))
35
+ } catch (error) {
36
+ mapPostgresError(error, 'migrations.status')
37
+ }
38
+ }
39
+
40
+ /** Builds a pending migration plan from registered migrations and history. */
41
+ export async function planMigrations(
42
+ sql: SQL,
43
+ migrations: readonly PostgresMigration[],
44
+ ): Promise<MigrationPlan> {
45
+ const applied = await appliedIds(sql)
46
+ const pending = migrations.filter((migration) => !applied.has(migration.id))
47
+ return {
48
+ id: `postgres-${pending.map((migration) => migration.id).join('+') || 'empty'}`,
49
+ migrations: pending.map((migration) => migration.id),
50
+ steps: pending.flatMap((migration) => migration.up),
51
+ }
52
+ }
53
+
54
+ /** Applies every pending migration and returns the resulting statuses. */
55
+ export async function applyMigrations(
56
+ sql: SQL,
57
+ migrations: readonly PostgresMigration[],
58
+ ): Promise<readonly MigrationStatus[]> {
59
+ const plan = await planMigrations(sql, migrations)
60
+ for (const id of plan.migrations) {
61
+ const migration = migrations.find((entry) => entry.id === id)
62
+ if (migration === undefined) continue
63
+ try {
64
+ await sql.begin(async (tx) => {
65
+ for (const statement of migration.up) await tx.unsafe(statement)
66
+ await tx.unsafe(`INSERT INTO ${HISTORY_TABLE} (id) VALUES ($1)`, [id])
67
+ })
68
+ } catch (error) {
69
+ mapPostgresError(error, 'migrations.apply')
70
+ }
71
+ }
72
+ return statusMigrations(sql, migrations)
73
+ }
74
+
75
+ /** Rolls back the newest applied migration batches. */
76
+ export async function rollbackMigrations(
77
+ sql: SQL,
78
+ migrations: readonly PostgresMigration[],
79
+ steps = 1,
80
+ ): Promise<readonly MigrationStatus[]> {
81
+ await ensureHistory(sql)
82
+ const count = Number.isInteger(steps) && steps > 0 ? steps : 1
83
+ try {
84
+ const applied = (await sql.unsafe(
85
+ `SELECT id, applied_at FROM ${HISTORY_TABLE} ORDER BY applied_at DESC LIMIT $1`,
86
+ [count],
87
+ )) as Array<{ id: string; applied_at: Date }>
88
+
89
+ for (const row of applied) {
90
+ const migration = migrations.find((entry) => entry.id === row.id)
91
+ if (migration === undefined) continue
92
+ await sql.begin(async (tx) => {
93
+ for (const statement of migration.down) await tx.unsafe(statement)
94
+ await tx.unsafe(`DELETE FROM ${HISTORY_TABLE} WHERE id = $1`, [row.id])
95
+ })
96
+ }
97
+ } catch (error) {
98
+ mapPostgresError(error, 'migrations.rollback')
99
+ }
100
+ return statusMigrations(sql, migrations)
101
+ }
102
+
103
+ /** Returns applied/pending status for every registered migration. */
104
+ export async function statusMigrations(
105
+ sql: SQL,
106
+ migrations: readonly PostgresMigration[],
107
+ ): Promise<readonly MigrationStatus[]> {
108
+ await ensureHistory(sql)
109
+ try {
110
+ const rows = (await sql.unsafe(
111
+ `SELECT id, applied_at FROM ${HISTORY_TABLE}`,
112
+ )) as Array<{ id: string; applied_at: Date }>
113
+ const byId = new Map(rows.map((row) => [row.id, row.applied_at]))
114
+ return migrations.map((migration) => {
115
+ const appliedAt = byId.get(migration.id)
116
+ return appliedAt === undefined
117
+ ? { id: migration.id, applied: false }
118
+ : { id: migration.id, applied: true, appliedAt }
119
+ })
120
+ } catch (error) {
121
+ mapPostgresError(error, 'migrations.status')
122
+ }
123
+ }