@o/database 0.16.18 → 0.16.19-canary.1790191946359

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.
Files changed (121) hide show
  1. package/dist/takeout/better-auth.d.ts +19 -0
  2. package/dist/takeout/better-auth.d.ts.map +1 -0
  3. package/dist/takeout/better-auth.js +59 -0
  4. package/dist/takeout/better-auth.js.map +1 -0
  5. package/dist/takeout/build.d.ts +2 -0
  6. package/dist/takeout/build.d.ts.map +1 -0
  7. package/dist/takeout/build.js +3 -0
  8. package/dist/takeout/build.js.map +1 -0
  9. package/dist/takeout/chunkedQuery.d.ts +17 -0
  10. package/dist/takeout/chunkedQuery.d.ts.map +1 -0
  11. package/dist/takeout/chunkedQuery.js +55 -0
  12. package/dist/takeout/chunkedQuery.js.map +1 -0
  13. package/dist/takeout/cli.d.ts +3 -0
  14. package/dist/takeout/cli.d.ts.map +1 -0
  15. package/dist/takeout/cli.js +114 -0
  16. package/dist/takeout/cli.js.map +1 -0
  17. package/dist/takeout/createDatabase.d.ts +49 -0
  18. package/dist/takeout/createDatabase.d.ts.map +1 -0
  19. package/dist/takeout/createDatabase.js +100 -0
  20. package/dist/takeout/createDatabase.js.map +1 -0
  21. package/dist/takeout/createDb.d.ts +3 -0
  22. package/dist/takeout/createDb.d.ts.map +1 -0
  23. package/dist/takeout/createDb.js +11 -0
  24. package/dist/takeout/createDb.js.map +1 -0
  25. package/dist/takeout/createDrizzle.d.ts +38 -0
  26. package/dist/takeout/createDrizzle.d.ts.map +1 -0
  27. package/dist/takeout/createDrizzle.js +60 -0
  28. package/dist/takeout/createDrizzle.js.map +1 -0
  29. package/dist/takeout/createPool.d.ts +11 -0
  30. package/dist/takeout/createPool.d.ts.map +1 -0
  31. package/dist/takeout/createPool.js +36 -0
  32. package/dist/takeout/createPool.js.map +1 -0
  33. package/dist/takeout/createServerHelpers.d.ts +9 -0
  34. package/dist/takeout/createServerHelpers.d.ts.map +1 -0
  35. package/dist/takeout/createServerHelpers.js +11 -0
  36. package/dist/takeout/createServerHelpers.js.map +1 -0
  37. package/dist/takeout/getDBClient.d.ts +11 -0
  38. package/dist/takeout/getDBClient.d.ts.map +1 -0
  39. package/dist/takeout/getDBClient.js +169 -0
  40. package/dist/takeout/getDBClient.js.map +1 -0
  41. package/dist/takeout/index.d.ts +13 -0
  42. package/dist/takeout/index.d.ts.map +1 -0
  43. package/dist/takeout/index.js +13 -0
  44. package/dist/takeout/index.js.map +1 -0
  45. package/dist/takeout/migrate.d.ts +33 -0
  46. package/dist/takeout/migrate.d.ts.map +1 -0
  47. package/dist/takeout/migrate.js +214 -0
  48. package/dist/takeout/migrate.js.map +1 -0
  49. package/dist/takeout/scripts/build-migrations.d.ts +8 -0
  50. package/dist/takeout/scripts/build-migrations.d.ts.map +1 -0
  51. package/dist/takeout/scripts/build-migrations.js +51 -0
  52. package/dist/takeout/scripts/build-migrations.js.map +1 -0
  53. package/dist/takeout/scripts/drizzle-migrations-sync.d.ts +17 -0
  54. package/dist/takeout/scripts/drizzle-migrations-sync.d.ts.map +1 -0
  55. package/dist/takeout/scripts/drizzle-migrations-sync.js +69 -0
  56. package/dist/takeout/scripts/drizzle-migrations-sync.js.map +1 -0
  57. package/dist/takeout/scripts/dump-database.d.ts +22 -0
  58. package/dist/takeout/scripts/dump-database.d.ts.map +1 -0
  59. package/dist/takeout/scripts/dump-database.js +64 -0
  60. package/dist/takeout/scripts/dump-database.js.map +1 -0
  61. package/dist/takeout/scripts/migration-add.d.ts +6 -0
  62. package/dist/takeout/scripts/migration-add.d.ts.map +1 -0
  63. package/dist/takeout/scripts/migration-add.js +39 -0
  64. package/dist/takeout/scripts/migration-add.js.map +1 -0
  65. package/dist/takeout/scripts/pg_dump.d.ts +11 -0
  66. package/dist/takeout/scripts/pg_dump.d.ts.map +1 -0
  67. package/dist/takeout/scripts/pg_dump.js +37 -0
  68. package/dist/takeout/scripts/pg_dump.js.map +1 -0
  69. package/dist/takeout/scripts/prune-backups.d.ts +8 -0
  70. package/dist/takeout/scripts/prune-backups.d.ts.map +1 -0
  71. package/dist/takeout/scripts/prune-backups.js +166 -0
  72. package/dist/takeout/scripts/prune-backups.js.map +1 -0
  73. package/dist/takeout/scripts/psql.d.ts +11 -0
  74. package/dist/takeout/scripts/psql.d.ts.map +1 -0
  75. package/dist/takeout/scripts/psql.js +41 -0
  76. package/dist/takeout/scripts/psql.js.map +1 -0
  77. package/dist/takeout/seed.d.ts +10 -0
  78. package/dist/takeout/seed.d.ts.map +1 -0
  79. package/dist/takeout/seed.js +22 -0
  80. package/dist/takeout/seed.js.map +1 -0
  81. package/dist/takeout/sql.d.ts +9 -0
  82. package/dist/takeout/sql.d.ts.map +1 -0
  83. package/dist/takeout/sql.js +24 -0
  84. package/dist/takeout/sql.js.map +1 -0
  85. package/dist/takeout/sqlite.d.ts +66 -0
  86. package/dist/takeout/sqlite.d.ts.map +1 -0
  87. package/dist/takeout/sqlite.js +110 -0
  88. package/dist/takeout/sqlite.js.map +1 -0
  89. package/dist/takeout/waitForDatabase.d.ts +2 -0
  90. package/dist/takeout/waitForDatabase.d.ts.map +1 -0
  91. package/dist/takeout/waitForDatabase.js +26 -0
  92. package/dist/takeout/waitForDatabase.js.map +1 -0
  93. package/dist/takeout/zero.d.ts +44 -0
  94. package/dist/takeout/zero.d.ts.map +1 -0
  95. package/dist/takeout/zero.js +177 -0
  96. package/dist/takeout/zero.js.map +1 -0
  97. package/package.json +54 -4
  98. package/src/takeout/better-auth.ts +110 -0
  99. package/src/takeout/build.ts +2 -0
  100. package/src/takeout/chunkedQuery.ts +91 -0
  101. package/src/takeout/cli.ts +126 -0
  102. package/src/takeout/createDatabase.ts +164 -0
  103. package/src/takeout/createDb.ts +15 -0
  104. package/src/takeout/createDrizzle.ts +127 -0
  105. package/src/takeout/createPool.ts +58 -0
  106. package/src/takeout/createServerHelpers.ts +21 -0
  107. package/src/takeout/getDBClient.ts +254 -0
  108. package/src/takeout/index.ts +38 -0
  109. package/src/takeout/migrate.ts +272 -0
  110. package/src/takeout/scripts/build-migrations.ts +66 -0
  111. package/src/takeout/scripts/drizzle-migrations-sync.ts +81 -0
  112. package/src/takeout/scripts/dump-database.ts +103 -0
  113. package/src/takeout/scripts/migration-add.ts +54 -0
  114. package/src/takeout/scripts/pg_dump.ts +46 -0
  115. package/src/takeout/scripts/prune-backups.ts +211 -0
  116. package/src/takeout/scripts/psql.ts +51 -0
  117. package/src/takeout/seed.ts +45 -0
  118. package/src/takeout/sql.ts +39 -0
  119. package/src/takeout/sqlite.ts +237 -0
  120. package/src/takeout/waitForDatabase.ts +27 -0
  121. package/src/takeout/zero.ts +281 -0
@@ -0,0 +1,21 @@
1
+ import { getDBClient, type GetDBClientOptions } from './getDBClient.js'
2
+ import { createSql, setDefaultPool } from './sql.js'
3
+
4
+ import type { Pool } from 'pg'
5
+
6
+ export type ServerHelpers = {
7
+ sql: ReturnType<typeof createSql>
8
+ getDBClient: (
9
+ options?: Omit<GetDBClientOptions, 'pool' | 'connectionString'>
10
+ ) => ReturnType<typeof getDBClient>
11
+ }
12
+
13
+ export function createServerHelpers(pool: Pool): ServerHelpers {
14
+ const sql = createSql(pool)
15
+ setDefaultPool(pool)
16
+
17
+ return {
18
+ sql,
19
+ getDBClient: (options = {}) => getDBClient({ pool, ...options }),
20
+ }
21
+ }
@@ -0,0 +1,254 @@
1
+ import pg, { type Pool, type PoolClient } from 'pg'
2
+
3
+ // some of this file retry logic taken from:
4
+ // https://github.com/brianc/node-postgres/issues/2718#issuecomment-1074019993
5
+
6
+ export type GetDBClientOptions = {
7
+ pool?: Pool
8
+ connectionString?: string
9
+ retries?: number
10
+ onRetry?: (error: Error, attempt: number) => void
11
+ }
12
+
13
+ const cache = new Map<
14
+ string,
15
+ {
16
+ pool: pg.Pool
17
+ maxConnections: number | null
18
+ reservedConnections: number | null
19
+ openedConnections: number | null
20
+ openedConnectionsLastUpdate: number | null
21
+ }
22
+ >()
23
+
24
+ const createPoolKey = (connectionString: string) => connectionString
25
+
26
+ const getOrCreatePoolCache = (connectionString: string, config: pg.PoolConfig) => {
27
+ const key = createPoolKey(connectionString)
28
+
29
+ if (!cache.has(key)) {
30
+ cache.set(key, {
31
+ pool: new pg.Pool(config),
32
+ maxConnections: null,
33
+ reservedConnections: null,
34
+ openedConnections: null,
35
+ openedConnectionsLastUpdate: null,
36
+ })
37
+ }
38
+
39
+ return cache.get(key)!
40
+ }
41
+
42
+ export async function getDBClient(options: GetDBClientOptions = {}): Promise<PoolClient> {
43
+ const { pool, connectionString, retries = 8 } = options
44
+
45
+ if (!pool && !connectionString) {
46
+ throw new Error('Either pool or connectionString must be provided')
47
+ }
48
+
49
+ let client: PoolClient | null = null
50
+
51
+ try {
52
+ client = await tryToGetNewClientFromPool(pool, connectionString, retries)
53
+ return client
54
+ } catch (error) {
55
+ console.error(`Failed to get DB client:`, error)
56
+ throw error
57
+ }
58
+ }
59
+
60
+ async function tryToGetNewClientFromPool(
61
+ providedPool: Pool | undefined,
62
+ connectionString: string | undefined,
63
+ retries: number
64
+ ): Promise<PoolClient> {
65
+ const { default: retry } = await import('async-retry')
66
+ const clientFromPool = await retry(
67
+ async () => {
68
+ if (providedPool) {
69
+ const client = await providedPool.connect()
70
+ return client
71
+ }
72
+
73
+ if (!connectionString) {
74
+ throw new Error('No connection string provided')
75
+ }
76
+
77
+ const configurations: pg.PoolConfig = {
78
+ connectionString,
79
+ connectionTimeoutMillis: 5_000,
80
+ // idle_session_timeout set to 35s on server, client timeout at 30s
81
+ // fix via https://github.com/brianc/node-postgres/issues/2718#issuecomment-2094885323
82
+ idleTimeoutMillis: 30_000,
83
+ allowExitOnIdle: true,
84
+ }
85
+
86
+ const poolCache = getOrCreatePoolCache(connectionString, configurations)
87
+
88
+ const client = await poolCache.pool.connect()
89
+ return client
90
+ },
91
+ {
92
+ retries,
93
+ minTimeout: 300,
94
+ factor: 2,
95
+ maxTimeout: 8000,
96
+ }
97
+ )
98
+
99
+ return clientFromPool
100
+ }
101
+
102
+ export async function queryDb(
103
+ queryText: string,
104
+ params?: any[],
105
+ options: GetDBClientOptions = {}
106
+ ): Promise<pg.QueryResult> {
107
+ let client: PoolClient | null = null
108
+
109
+ try {
110
+ client = await tryToGetNewClientFromPool(
111
+ options.pool,
112
+ options.connectionString,
113
+ options.retries || 8
114
+ )
115
+ return await client.query(queryText, params)
116
+ } catch (error) {
117
+ console.error(`Database query failed:`, {
118
+ query: queryText,
119
+ error: error instanceof Error ? error.message : String(error),
120
+ })
121
+ throw error
122
+ } finally {
123
+ if (client && options.connectionString) {
124
+ const tooManyConnections = await checkForTooManyConnections(
125
+ client,
126
+ options.connectionString
127
+ )
128
+
129
+ if (tooManyConnections) {
130
+ const poolCache = cache.get(createPoolKey(options.connectionString))
131
+ client.release()
132
+ await poolCache?.pool.end()
133
+ if (poolCache) {
134
+ cache.delete(createPoolKey(options.connectionString))
135
+ }
136
+ } else {
137
+ client.release()
138
+ }
139
+ } else if (client) {
140
+ client.release()
141
+ }
142
+ }
143
+ }
144
+
145
+ async function checkForTooManyConnections(
146
+ client: PoolClient,
147
+ connectionString: string
148
+ ): Promise<boolean> {
149
+ const poolCache = cache.get(createPoolKey(connectionString))
150
+ if (!poolCache) return false
151
+
152
+ const currentTime = Date.now()
153
+ const openedConnectionsMaxAge = 10000
154
+ const maxConnectionsTolerance = 0.9
155
+
156
+ if (poolCache.maxConnections === null || poolCache.reservedConnections === null) {
157
+ const [maxConnections, reservedConnections] = await getConnectionLimits(client)
158
+ poolCache.maxConnections = maxConnections
159
+ poolCache.reservedConnections = reservedConnections
160
+ }
161
+
162
+ if (
163
+ poolCache.openedConnections === null ||
164
+ poolCache.openedConnectionsLastUpdate === null ||
165
+ currentTime - poolCache.openedConnectionsLastUpdate > openedConnectionsMaxAge
166
+ ) {
167
+ const openedConnections = await getOpenedConnections(client, connectionString)
168
+ poolCache.openedConnections = openedConnections
169
+ poolCache.openedConnectionsLastUpdate = currentTime
170
+ }
171
+
172
+ if (
173
+ poolCache.openedConnections >
174
+ (poolCache.maxConnections - poolCache.reservedConnections) * maxConnectionsTolerance
175
+ ) {
176
+ console.warn(
177
+ `Too many connections detected: ${poolCache.openedConnections}/${poolCache.maxConnections - poolCache.reservedConnections}`
178
+ )
179
+ return true
180
+ }
181
+
182
+ return false
183
+ }
184
+
185
+ async function getConnectionLimits(client: PoolClient): Promise<[number, number]> {
186
+ const maxConnectionsResult = await client.query('SHOW max_connections')
187
+ const reservedConnectionResult = await client.query(
188
+ 'SHOW superuser_reserved_connections'
189
+ )
190
+
191
+ return [
192
+ Number.parseInt(maxConnectionsResult.rows[0].max_connections, 10),
193
+ Number.parseInt(reservedConnectionResult.rows[0].superuser_reserved_connections, 10),
194
+ ]
195
+ }
196
+
197
+ async function getOpenedConnections(
198
+ client: PoolClient,
199
+ connectionString: string
200
+ ): Promise<number> {
201
+ // For Aurora/RDS, we need to get the database name from connection string
202
+ const dbName = new URL(connectionString).pathname.slice(1)
203
+ const openConnectionsResult = await client.query(
204
+ 'SELECT numbackends as opened_connections FROM pg_stat_database WHERE datname = $1',
205
+ [dbName]
206
+ )
207
+ const result = Number.parseInt(
208
+ openConnectionsResult.rows[0]?.opened_connections || 0,
209
+ 10
210
+ )
211
+ return result
212
+ }
213
+
214
+ export async function getNewClient(options: GetDBClientOptions = {}): Promise<pg.Client> {
215
+ const { connectionString } = options
216
+
217
+ if (!connectionString) {
218
+ throw new Error('connectionString is required for getNewClient')
219
+ }
220
+
221
+ try {
222
+ const client = await tryToGetNewClient(connectionString)
223
+ return client
224
+ } catch (error) {
225
+ console.error(`Failed to get new client:`, error)
226
+ throw error
227
+ }
228
+ }
229
+
230
+ async function tryToGetNewClient(connectionString: string): Promise<pg.Client> {
231
+ const configurations: pg.PoolConfig = {
232
+ connectionString,
233
+ connectionTimeoutMillis: 5_000,
234
+ idleTimeoutMillis: 30_000,
235
+ allowExitOnIdle: true,
236
+ }
237
+
238
+ const { default: retry } = await import('async-retry')
239
+ const client = await retry(
240
+ async () => {
241
+ const newClient = new pg.Client(configurations)
242
+ await newClient.connect()
243
+ return newClient
244
+ },
245
+ {
246
+ retries: 10,
247
+ minTimeout: 100,
248
+ factor: 2,
249
+ maxTimeout: 5000,
250
+ }
251
+ )
252
+
253
+ return client
254
+ }
@@ -0,0 +1,38 @@
1
+ export {
2
+ createDatabase,
3
+ type DatabaseConfig,
4
+ type MigrateOptions,
5
+ } from './createDatabase.js'
6
+ export { createPool, type CreatePoolOptions } from './createPool.js'
7
+ export {
8
+ createDrizzle,
9
+ type CreateDrizzleOptions,
10
+ type DrizzleHelpers,
11
+ } from './createDrizzle.js'
12
+ export { createSql, type SqlQuery } from './sql.js'
13
+ export { createServerHelpers, type ServerHelpers } from './createServerHelpers.js'
14
+ export { getDBClient, queryDb, type GetDBClientOptions } from './getDBClient.js'
15
+ export { processInChunks, updateInChunks } from './chunkedQuery.js'
16
+ export { createDb } from './createDb.js'
17
+ export { migrate, loadMigrationsFromDir, type Migration } from './migrate.js'
18
+ export { waitForDatabase } from './waitForDatabase.js'
19
+ export {
20
+ buildInitSql,
21
+ defineSeed,
22
+ defineTrigger,
23
+ derivePublicTables,
24
+ ensureZeroDatabases,
25
+ ensureZeroPublication,
26
+ ensureZeroSeeds,
27
+ ensureZeroTriggers,
28
+ isPrivateTable,
29
+ loadSeeds,
30
+ loadTriggers,
31
+ privateTable,
32
+ type InitSqlOptions,
33
+ type SeedDefinition,
34
+ type TriggerDefinition,
35
+ type ZeroDatabaseOptions,
36
+ type ZeroPublicationOptions,
37
+ } from './zero.js'
38
+ export { applySeed, type SeedClient, type SeedData, type SeedRows } from './seed.js'
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Custom migration script - why?
3
+ * Well migration scripts aren't so complex, and we haven't found a simple enough library
4
+ * to deserve a new dependency. We like this setup because we can customize it easily
5
+ * and it's pretty easy to understand.
6
+ *
7
+ * This migration system handles both custom TypeScript migrations and
8
+ * SQL migrations generated by Drizzle.
9
+ */
10
+
11
+ import { readdirSync, readFileSync } from 'node:fs'
12
+ import { basename, resolve } from 'node:path'
13
+
14
+ import { getDBClient } from './getDBClient.js'
15
+ import { ensureZeroDatabases } from './zero.js'
16
+
17
+ import type { PoolClient } from 'pg'
18
+
19
+ export type Migration = {
20
+ name: string
21
+ up?: (client: PoolClient) => Promise<void>
22
+ /** per-migration timeout in ms, overrides defaultTimeout */
23
+ timeout?: number
24
+ }
25
+
26
+ /** default timeout for migrations (5 seconds) */
27
+ const DEFAULT_MIGRATION_TIMEOUT = 5_000
28
+
29
+ export type MigrateOptions = {
30
+ connectionString: string
31
+ migrations: Record<string, () => Promise<unknown>>
32
+ createDatabases?: string[]
33
+ onMigrationComplete?: () => Promise<void>
34
+ gitSha?: string
35
+ cvrDb?: string
36
+ changeDb?: string
37
+ /** call process.exit(0) after migrations complete (default: false) */
38
+ forceExit?: boolean
39
+ /** default timeout for all migrations in ms (default: 5000) */
40
+ defaultTimeout?: number
41
+ }
42
+
43
+ async function withTimeout<T>(promise: Promise<T>, ms: number, name: string): Promise<T> {
44
+ let timeoutId: ReturnType<typeof setTimeout>
45
+ const timeoutPromise = new Promise<never>((_, reject) => {
46
+ timeoutId = setTimeout(() => {
47
+ reject(new Error(`migration "${name}" timed out after ${ms}ms`))
48
+ }, ms)
49
+ })
50
+ try {
51
+ return await Promise.race([promise, timeoutPromise])
52
+ } finally {
53
+ clearTimeout(timeoutId!)
54
+ }
55
+ }
56
+
57
+ export async function migrate(options: MigrateOptions) {
58
+ const {
59
+ connectionString,
60
+ migrations,
61
+ createDatabases = [],
62
+ onMigrationComplete,
63
+ forceExit = false,
64
+ gitSha,
65
+ cvrDb,
66
+ changeDb,
67
+ defaultTimeout = DEFAULT_MIGRATION_TIMEOUT,
68
+ } = options
69
+
70
+ // create zero + additional databases before running migrations
71
+ await ensureZeroDatabases({ connectionString, cvrDb, changeDb })
72
+ for (const dbUrl of createDatabases) {
73
+ await ensureZeroDatabases({ connectionString, cvrDb: dbUrl })
74
+ }
75
+
76
+ const client = await getDBClient({ connectionString })
77
+
78
+ try {
79
+ await client.query('BEGIN')
80
+ // force public schema - if a schema matching the db user exists (e.g. startchat),
81
+ // postgres resolves unqualified names there first, causing tables to land in the
82
+ // wrong schema. setting search_path ensures all CREATE TABLE etc go to public.
83
+ await client.query(`SET search_path = public`)
84
+ await client.query(`
85
+ CREATE TABLE IF NOT EXISTS public.migrations (
86
+ id SERIAL PRIMARY KEY,
87
+ name VARCHAR(255) NOT NULL,
88
+ run_on TIMESTAMP NOT NULL DEFAULT NOW()
89
+ )
90
+ `)
91
+
92
+ const appliedMigrations = await client.query('SELECT name FROM public.migrations')
93
+ const appliedMigrationNames = new Set(appliedMigrations.rows.map((row) => row.name))
94
+
95
+ // get TypeScript migrations
96
+ const tsMigrationsSorted = Object.entries(migrations)
97
+ .sort(([a], [b]) => a.localeCompare(b))
98
+ .map(([file, run]) => ({
99
+ name: basename(file).replace(/\.(ts|sql)$/, ''),
100
+ run,
101
+ }))
102
+ .filter(({ name }) => /^[\d]+/.test(name))
103
+
104
+ // process TypeScript migrations
105
+ const tsMigrations: Migration[] = await Promise.all(
106
+ tsMigrationsSorted.map(async ({ name, run }) => {
107
+ if (appliedMigrationNames.has(name)) {
108
+ return null
109
+ }
110
+ try {
111
+ const migration = (await run()) as object
112
+ return { ...migration, name }
113
+ } catch (error) {
114
+ console.error(`Failed to load TypeScript migration ${name}:`, error)
115
+ throw error
116
+ }
117
+ })
118
+ ).then((migrations) => migrations.filter(Boolean) as Migration[])
119
+
120
+ // combine TypeScript and SQL migrations, sorted by name to ensure correct order
121
+ const pendingMigrations = [...tsMigrations].sort((a, b) =>
122
+ a.name.localeCompare(b.name)
123
+ )
124
+
125
+ if (!pendingMigrations.length) {
126
+ await client.query('COMMIT')
127
+ } else {
128
+ for (const migration of pendingMigrations) {
129
+ if (migration.up) {
130
+ // use savepoints so "already exists" errors don't abort the transaction.
131
+ // this handles cases where tables/columns exist but migration tracking
132
+ // was lost (e.g. db restore, pglite data loss)
133
+ const sp = `sp_${migration.name.replace(/\W/g, '_')}`
134
+ await client.query(`SAVEPOINT ${sp}`)
135
+ try {
136
+ const timeout = migration.timeout ?? defaultTimeout
137
+ await withTimeout(migration.up(client), timeout, migration.name)
138
+ await client.query(`RELEASE SAVEPOINT ${sp}`)
139
+ } catch (e: any) {
140
+ // ROLLBACK TO SAVEPOINT can fail in its own right (e.g. when running
141
+ // against pglite via orez's wire-protocol proxy, a parallel client
142
+ // sharing the same backend session can corrupt the transaction
143
+ // state, leaving us with no active transaction). if we don't catch
144
+ // the rollback failure separately, the original migration error `e`
145
+ // is replaced by the rollback's error, hiding what actually went
146
+ // wrong. log the rollback failure and re-throw the original.
147
+ try {
148
+ await client.query(`ROLLBACK TO SAVEPOINT ${sp}`)
149
+ } catch (rollbackErr) {
150
+ console.error(
151
+ `[migrate] ${migration.name}: ROLLBACK TO SAVEPOINT failed:`,
152
+ rollbackErr
153
+ )
154
+ console.error(
155
+ `[migrate] ${migration.name}: original migration error was:`,
156
+ e
157
+ )
158
+ throw e
159
+ }
160
+ // only ignore idempotency errors - NOT all 42xxx errors!
161
+ // 42xxx is the entire "Syntax Error or Access Rule Violation" class
162
+ // which includes undefined_table (42P01), syntax_error (42601), etc.
163
+ // we only want to skip migrations where objects already/don't exist:
164
+ const idempotentErrorCodes = [
165
+ '42710', // duplicate_object
166
+ '42P07', // duplicate_table
167
+ '42701', // duplicate_column
168
+ '42P16', // invalid_table_definition (e.g. constraint already exists)
169
+ '42704', // undefined_object (e.g. DROP CONSTRAINT on non-existent constraint)
170
+ ]
171
+ if (idempotentErrorCodes.includes(e?.code)) {
172
+ console.info(
173
+ `[migrate] ${migration.name}: ${e.message} (${e.code}), recording as applied`
174
+ )
175
+ } else {
176
+ // any other error should fail the migration
177
+ throw e
178
+ }
179
+ }
180
+ }
181
+
182
+ await client.query('INSERT INTO public.migrations (name) VALUES ($1)', [
183
+ migration.name,
184
+ ])
185
+ }
186
+
187
+ await client.query('COMMIT')
188
+ }
189
+ } catch (e) {
190
+ console.error(`Migration failed, rolling back:`, e)
191
+ await client.query('ROLLBACK')
192
+ try {
193
+ client.release(false)
194
+ } catch (releaseErr) {
195
+ console.error(`Error releasing connection after rollback:`, releaseErr)
196
+ }
197
+ throw e
198
+ }
199
+
200
+ if (onMigrationComplete) {
201
+ await onMigrationComplete()
202
+ }
203
+
204
+ try {
205
+ // don't destroy the connection forcefully - let it return to pool gracefully
206
+ client.release(false)
207
+ } catch (err) {
208
+ console.error(`Error releasing connection gracefully, trying to destroy:`, err)
209
+ try {
210
+ client.release(true)
211
+ } catch (destroyErr) {
212
+ console.error(`Error destroying connection:`, destroyErr)
213
+ }
214
+ }
215
+
216
+ if (forceExit) {
217
+ // flush stdio before exiting — process.exit() doesn't wait for streams to drain
218
+ await new Promise<void>((resolve) => {
219
+ process.stderr.write('', () => {
220
+ process.stdout.write('', () => resolve())
221
+ })
222
+ })
223
+ process.exit(0)
224
+ }
225
+ }
226
+
227
+ /** load migration files from a directory (for non-vite contexts like bun scripts) */
228
+ export function loadMigrationsFromDir(
229
+ dir: string
230
+ ): Record<string, () => Promise<unknown>> {
231
+ const files = readdirSync(dir)
232
+ .filter((f) => /^\d+/.test(f) && (f.endsWith('.ts') || f.endsWith('.sql')))
233
+ .sort()
234
+
235
+ // deduplicate: prefer .ts over .sql for same base name
236
+ const seen = new Set<string>()
237
+ const deduped: string[] = []
238
+ for (const f of files) {
239
+ if (f.endsWith('.ts')) {
240
+ seen.add(f.replace(/\.ts$/, ''))
241
+ deduped.push(f)
242
+ }
243
+ }
244
+ for (const f of files) {
245
+ if (f.endsWith('.sql') && !seen.has(f.replace(/\.sql$/, ''))) {
246
+ deduped.push(f)
247
+ }
248
+ }
249
+ deduped.sort()
250
+
251
+ return Object.fromEntries(
252
+ deduped.map((f) => {
253
+ if (f.endsWith('.sql')) {
254
+ return [
255
+ `./${f}`,
256
+ async () => {
257
+ const sql = readFileSync(resolve(dir, f), 'utf-8')
258
+ return {
259
+ up: async (client: PoolClient) => {
260
+ for (const stmt of sql.split('--> statement-breakpoint')) {
261
+ const trimmed = stmt.trim()
262
+ if (trimmed) await client.query(trimmed)
263
+ }
264
+ },
265
+ }
266
+ },
267
+ ]
268
+ }
269
+ return [`./${f}`, () => import(/* @vite-ignore */ resolve(dir, f))]
270
+ })
271
+ )
272
+ }
@@ -0,0 +1,66 @@
1
+ import { execSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+
4
+ export type BuildMigrationsOptions = {
5
+ migrationsDir: string
6
+ outFile?: string
7
+ target?: string
8
+ aliases?: Record<string, string>
9
+ }
10
+
11
+ export async function buildMigrations(options: BuildMigrationsOptions) {
12
+ const {
13
+ migrationsDir,
14
+ outFile = 'migrate-dist.js',
15
+ target = 'node22',
16
+ aliases = {},
17
+ } = options
18
+
19
+ const { build } = await import('vite')
20
+
21
+ const migrateFile = path.join(migrationsDir, '..', 'migrate.ts')
22
+
23
+ const result = await build({
24
+ configFile: false,
25
+ resolve: {
26
+ alias: aliases,
27
+ },
28
+ define: {
29
+ 'process.env.GIT_SHA': JSON.stringify(
30
+ execSync('git rev-parse HEAD').toString().trim()
31
+ ),
32
+ },
33
+ build: {
34
+ outDir: path.dirname(migrateFile),
35
+ target,
36
+ minify: false,
37
+ emptyOutDir: false,
38
+ copyPublicDir: false,
39
+ lib: {
40
+ name: 'migrate',
41
+ formats: ['es'],
42
+ entry: migrateFile,
43
+ },
44
+ rollupOptions: {
45
+ external: (id) => {
46
+ // externalize all node modules and node: imports
47
+ if (id.startsWith('node:') || id === 'pg') return true
48
+ // externalize all absolute paths (like aliases)
49
+ if (id.startsWith('/')) return false
50
+ // externalize node_modules
51
+ if (!id.startsWith('.') && !id.startsWith('/')) return true
52
+ return false
53
+ },
54
+ output: {
55
+ format: 'es',
56
+ inlineDynamicImports: true,
57
+ exports: 'named',
58
+ entryFileNames: outFile,
59
+ },
60
+ },
61
+ },
62
+ })
63
+
64
+ console.info(`✓ Built migration bundle: ${outFile}`)
65
+ return result
66
+ }