@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/LICENSE +21 -0
- package/README.md +197 -0
- package/package.json +51 -0
- package/src/compile.ts +177 -0
- package/src/driver.ts +386 -0
- package/src/errors.ts +100 -0
- package/src/fixtures.ts +59 -0
- package/src/index.ts +19 -0
- package/src/migrations.ts +123 -0
- package/src/normalize.ts +50 -0
- package/src/schema.ts +123 -0
- package/src/validate.ts +236 -0
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
|
package/src/fixtures.ts
ADDED
|
@@ -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
|
+
}
|