Skip to content

Commit 87dd2ca

Browse files
committed
fix(db): keep a deferred Tin projection from failing a direct run
`db:push` runs the migration file directly, where a deferral has no migration record to leave unwritten, so a refused extension aborted the push. The direct entry point now adopts the projection where the database allows it and logs the refusal otherwise, while the registered runner still sees the deferral. Also covers continuation after a deferral with a synthetic migration list, which the registry cannot express while the only deferring migration is its last entry.
1 parent 0a3c08a commit 87dd2ca

4 files changed

Lines changed: 68 additions & 11 deletions

File tree

‎packages/db/script-migrations/0019_tin_keyword_projection.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
*/
44
import type { Sql } from 'postgres'
55
import { describe, expect, it, vi } from 'vitest'
6-
import { installTinKeywordProjection } from './0019_tin_keyword_projection'
6+
import {
7+
adoptTinKeywordProjection,
8+
installTinKeywordProjection,
9+
} from './0019_tin_keyword_projection'
710
import { ScriptMigrationDeferred } from './types'
811

912
/** A session that offers `tin` or not, and answers `CREATE EXTENSION` with `createError`. */
@@ -42,6 +45,20 @@ describe('installTinKeywordProjection', () => {
4245
}
4346
)
4447

48+
it('is a no-op when run directly against a database that refuses the extension', async () => {
49+
const { sql, statements } = createSqlHarness({
50+
available: true,
51+
createError: { code: '42501' },
52+
})
53+
await expect(adoptTinKeywordProjection(sql)).resolves.toBeUndefined()
54+
expect(statements.at(-1)).toBe('CREATE EXTENSION IF NOT EXISTS tin')
55+
})
56+
57+
it('fails a direct run on any other extension error', async () => {
58+
const { sql } = createSqlHarness({ available: true, createError: { code: '53100' } })
59+
await expect(adoptTinKeywordProjection(sql)).rejects.toEqual({ code: '53100' })
60+
})
61+
4562
it('fails the migration on any other extension error', async () => {
4663
const { sql } = createSqlHarness({ available: true, createError: { code: '53100' } })
4764
await expect(installTinKeywordProjection(sql)).rejects.toEqual({ code: '53100' })

‎packages/db/script-migrations/0019_tin_keyword_projection.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,28 @@ export const tinKeywordProjectionMigration: ScriptMigration = {
233233
up: installTinKeywordProjection,
234234
}
235235

236+
/**
237+
* Installs the projection where this database allows it, treating a refused extension as a no-op:
238+
* run directly — `db:push`, or adopting Tin after a cluster gains it — there is no migration
239+
* record to leave unwritten, and keyword search keeps the GIN projection either way.
240+
*/
241+
export async function adoptTinKeywordProjection(sql: Sql): Promise<void> {
242+
try {
243+
await installTinKeywordProjection(sql)
244+
} catch (error) {
245+
if (!(error instanceof ScriptMigrationDeferred)) throw error
246+
logger.warn('Tin projection deferred; keyword search keeps the GIN projection', {
247+
reason: error.message,
248+
})
249+
}
250+
}
251+
236252
if (import.meta.main) {
237253
const url = process.env.MIGRATION_DATABASE_URL ?? process.env.DATABASE_URL
238254
if (!url) throw new Error('DATABASE_URL is required to install the Tin keyword projection')
239255
const sql = postgres(url, { max: 1, max_lifetime: null, onnotice: () => undefined })
240256
try {
241-
await installTinKeywordProjection(sql)
257+
await adoptTinKeywordProjection(sql)
242258
} finally {
243259
await sql.end()
244260
}

‎packages/db/script-migrations/index.test.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,22 @@
44
import type { Sql } from 'postgres'
55
import { describe, expect, it, vi } from 'vitest'
66
import { runScriptMigrations, scriptMigrations } from './index'
7+
import { type ScriptMigration, ScriptMigrationDeferred } from './types'
78

89
const TIN = '0019_tin_keyword_projection'
910

10-
/** A session where every migration but Tin's is recorded and the database refuses `tin`. */
11-
function createSqlHarness() {
11+
/** Every registered migration but Tin's, so Tin is the only pending one. */
12+
const APPLIED_BEFORE_TIN = scriptMigrations
13+
.filter(({ name }) => name !== TIN)
14+
.map(({ name }) => name)
15+
16+
/** A session where `applied` is already recorded and the database refuses `tin`. */
17+
function createSqlHarness(applied: readonly string[]) {
1218
const recorded: string[] = []
1319
const run = (strings: TemplateStringsArray, ...values: unknown[]) => {
1420
const text = strings.join('?').replace(/\s+/g, ' ').trim()
1521
if (text.startsWith('SELECT name FROM script_migrations')) {
16-
return Promise.resolve(
17-
scriptMigrations.filter(({ name }) => name !== TIN).map(({ name }) => ({ name }))
18-
)
22+
return Promise.resolve(applied.map((name) => ({ name })))
1923
}
2024
if (text.includes('pg_available_extensions')) return Promise.resolve([{ '?column?': 1 }])
2125
if (text.startsWith('INSERT INTO script_migrations')) recorded.push(values[0] as string)
@@ -32,8 +36,21 @@ function createSqlHarness() {
3236

3337
describe('runScriptMigrations', () => {
3438
it('leaves a deferred migration unrecorded without failing the upgrade', async () => {
35-
const { sql, recorded } = createSqlHarness()
39+
const { sql, recorded } = createSqlHarness(APPLIED_BEFORE_TIN)
3640
await expect(runScriptMigrations(sql)).resolves.toBeUndefined()
3741
expect(recorded).toEqual([])
3842
})
43+
44+
it('applies and records the migrations that follow a deferred one', async () => {
45+
const deferring: ScriptMigration = {
46+
name: 'test_deferring',
47+
up: async () => {
48+
throw new ScriptMigrationDeferred('the database refused the test migration')
49+
},
50+
}
51+
const following: ScriptMigration = { name: 'test_following', up: async () => {} }
52+
const { sql, recorded } = createSqlHarness([])
53+
await expect(runScriptMigrations(sql, [deferring, following])).resolves.toBeUndefined()
54+
expect(recorded).toEqual(['test_following'])
55+
})
3956
})

‎packages/db/script-migrations/index.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,17 @@ export const scriptMigrations: readonly ScriptMigration[] = [
5555
* Fails fast: a missing required env var or a throwing `up` aborts the run
5656
* before the name is recorded, so the migration retries on the next upgrade.
5757
* A deferred `up` is not recorded either, but lets the later migrations run.
58+
*
59+
* `migrations` defaults to the registry and exists so a test can apply a
60+
* synthetic list: a deferral followed by a later migration is otherwise
61+
* uncoverable while the only deferring migration is the last registered entry.
5862
*/
59-
export async function runScriptMigrations(sql: Sql): Promise<void> {
63+
export async function runScriptMigrations(
64+
sql: Sql,
65+
migrations: readonly ScriptMigration[] = scriptMigrations
66+
): Promise<void> {
6067
const names = new Set<string>()
61-
for (const migration of scriptMigrations) {
68+
for (const migration of migrations) {
6269
if (names.has(migration.name)) {
6370
throw new Error(`Duplicate script migration name: ${migration.name}`)
6471
}
@@ -86,7 +93,7 @@ export async function runScriptMigrations(sql: Sql): Promise<void> {
8693
const appliedRows = await sql<{ name: string }[]>`SELECT name FROM script_migrations`
8794
const applied = new Set(appliedRows.map((row) => row.name))
8895

89-
const pending = scriptMigrations.filter((migration) => !applied.has(migration.name))
96+
const pending = migrations.filter((migration) => !applied.has(migration.name))
9097
if (pending.length === 0) {
9198
console.log('No pending script migrations.')
9299
return

0 commit comments

Comments
 (0)