Skip to content

Commit b5f194c

Browse files
committed
fix(db): retire file size bridge before forced schema push
1 parent 47c70e9 commit b5f194c

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ jobs:
8383
- name: Install dependencies
8484
run: bun install --frozen-lockfile --ignore-scripts
8585

86+
- name: Verify direct schema push compatibility
87+
working-directory: packages/db
88+
env:
89+
DB_PUSH_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
90+
run: bunx vitest run scripts/push.postgres.test.ts
91+
8692
- name: Provision a fresh database through the supported command
8793
working-directory: packages/db
8894
run: |
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createLogger } from '@sim/logger'
2+
import postgres, { type Sql } from 'postgres'
3+
4+
/**
5+
* Direct schema push does not run versioned SQL migrations. Retire the known
6+
* size compatibility bridge atomically with its column, preserving canonical
7+
* bigint values and backfilling legacy-only rows before Drizzle inspects them.
8+
* Only the explicitly forced push path invokes this preparation; ordinary
9+
* migrations continue to use the guarded release cutover in migration 0348.
10+
*/
11+
export async function prepareForcedPush(sql: Sql): Promise<void> {
12+
await sql.begin(async (tx) => {
13+
await tx`SET LOCAL lock_timeout = '5s'`
14+
const columns = await tx<{ attname: string }[]>`
15+
SELECT attname FROM pg_attribute
16+
WHERE attrelid = to_regclass('public.workspace_files')
17+
AND attname IN ('size', 'size_bytes') AND NOT attisdropped
18+
`
19+
if (!columns.some((column) => column.attname === 'size')) return
20+
21+
await tx`LOCK TABLE public.workspace_files IN ACCESS EXCLUSIVE MODE`
22+
await tx`ALTER TABLE public.workspace_files ADD COLUMN IF NOT EXISTS size_bytes bigint`
23+
await tx`UPDATE public.workspace_files SET size_bytes = size WHERE size_bytes IS NULL`
24+
await tx`DROP TRIGGER IF EXISTS workspace_files_sync_size_columns ON public.workspace_files`
25+
await tx`DROP FUNCTION IF EXISTS public.sync_workspace_file_size_columns()`
26+
await tx`ALTER TABLE public.workspace_files DROP COLUMN size`
27+
})
28+
}
29+
30+
if (import.meta.main) {
31+
const url = process.env.DATABASE_URL
32+
if (!url) throw new Error('Missing DATABASE_URL')
33+
const sql = postgres(url, { max: 1, connect_timeout: 10 })
34+
try {
35+
await prepareForcedPush(sql)
36+
createLogger('DatabasePush').info('Forced push compatibility preparation completed')
37+
} finally {
38+
await sql.end()
39+
}
40+
}

‎packages/db/scripts/push.postgres.test.ts‎

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { spawnSync } from 'node:child_process'
2-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
5+
import { prepareForcedPush } from '@sim/db/scripts/prepare-push'
56
import { generateId } from '@sim/utils/id'
67
import postgres, { type Sql } from 'postgres'
78
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
@@ -54,7 +55,7 @@ describe.skipIf(!databaseUrl)('patched Drizzle push against PostgreSQL', () => {
5455
async function schema(source: string) {
5556
await writeFile(
5657
join(directory, 'schema.ts'),
57-
`import { pgTable, pgSchema, pgEnum, text, integer, boolean, check } from ${JSON.stringify(import.meta.resolve('drizzle-orm/pg-core'))}
58+
`import { pgTable, pgSchema, pgEnum, text, integer, bigint, boolean, check } from ${JSON.stringify(import.meta.resolve('drizzle-orm/pg-core'))}
5859
import { sql } from ${JSON.stringify(import.meta.resolve('drizzle-orm'))}
5960
${source}`
6061
)
@@ -90,6 +91,58 @@ ${source}`
9091
})`)
9192
}
9293

94+
it('retires the legacy size bridge without losing bigint or unbackfilled values', async () => {
95+
await sql`CREATE TABLE workspace_files (id text PRIMARY KEY, size integer NOT NULL, size_bytes bigint)`
96+
await sql`INSERT INTO workspace_files VALUES ('legacy', 123, NULL), ('large', 2147483647, 5000000000)`
97+
await sql.unsafe(
98+
await readFile(
99+
new URL('../migrations/0308_workspace_file_size_cutover.sql', import.meta.url),
100+
'utf8'
101+
)
102+
)
103+
await prepareForcedPush(sql)
104+
await prepareForcedPush(sql)
105+
await schema(`export const files = pgTable('workspace_files', {
106+
id: text('id').primaryKey(), sizeBytes: bigint('size_bytes', { mode: 'number' }),
107+
})`)
108+
const result = push()
109+
expect(result.status, result.stdout + result.stderr).toBe(0)
110+
expect(await sql`SELECT id, size_bytes::text FROM workspace_files ORDER BY id`).toEqual([
111+
{ id: 'large', size_bytes: '5000000000' },
112+
{ id: 'legacy', size_bytes: '123' },
113+
])
114+
expect(
115+
await sql`SELECT to_regprocedure('sync_workspace_file_size_columns()') AS bridge`
116+
).toEqual([{ bridge: null }])
117+
}, 30_000)
118+
119+
it('rolls back preparation instead of cascading unknown dependencies', async () => {
120+
await sql`CREATE TABLE workspace_files (id text PRIMARY KEY, size integer NOT NULL, size_bytes bigint)`
121+
await sql`INSERT INTO workspace_files VALUES ('legacy', 123, NULL)`
122+
await sql.unsafe(
123+
await readFile(
124+
new URL('../migrations/0308_workspace_file_size_cutover.sql', import.meta.url),
125+
'utf8'
126+
)
127+
)
128+
await sql`CREATE VIEW retained_sizes AS SELECT size FROM workspace_files`
129+
await expect(prepareForcedPush(sql)).rejects.toThrow('depend')
130+
expect(await sql`SELECT size, size_bytes FROM workspace_files`).toEqual([
131+
{ size: 123, size_bytes: null },
132+
])
133+
expect(
134+
await sql`SELECT tgname FROM pg_trigger WHERE tgrelid = 'workspace_files'::regclass AND NOT tgisinternal`
135+
).toEqual([{ tgname: 'workspace_files_sync_size_columns' }])
136+
})
137+
138+
it('prepares both fresh databases and legacy schemas without the new column', async () => {
139+
await prepareForcedPush(sql)
140+
await sql`CREATE TABLE workspace_files (id text PRIMARY KEY, size integer NOT NULL)`
141+
await sql`INSERT INTO workspace_files VALUES ('legacy', 456)`
142+
await prepareForcedPush(sql)
143+
expect(await sql`SELECT * FROM workspace_files`).toEqual([{ id: 'legacy', size_bytes: '456' }])
144+
})
145+
93146
it('initializes a fresh database', async () => {
94147
await schema(`export const records = pgTable('records', {
95148
id: text('id').primaryKey(), enabled: boolean('enabled').notNull().default(false),

‎packages/db/scripts/push.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,26 @@ describe('db:push policy and process boundaries', () => {
3838
it('sets create/drop only on the Drizzle child and forwards force independently', async () => {
3939
vi.stubEnv('SIM_DB_PUSH_RENAME_MODE', undefined)
4040
expect(await runPush(['--force'])).toBe(0)
41-
expect(spawn).toHaveBeenCalledTimes(4)
42-
expect(spawn.mock.calls[0][0]).toEqual([
41+
expect(spawn).toHaveBeenCalledTimes(5)
42+
expect(spawn.mock.calls[0][0]).toContain('./scripts/prepare-push.ts')
43+
expect(spawn.mock.calls[1][0]).toEqual([
4344
'bunx',
4445
'--no-install',
4546
'drizzle-kit',
4647
'push',
4748
'--config=./drizzle.config.ts',
4849
'--force',
4950
])
50-
expect(spawn.mock.calls[0][1].env?.SIM_DB_PUSH_RENAME_MODE).toBe('create')
51+
expect(spawn.mock.calls[1][1].env?.SIM_DB_PUSH_RENAME_MODE).toBe('create')
5152
expect(process.env.SIM_DB_PUSH_RENAME_MODE).toBeUndefined()
52-
for (const [, options] of spawn.mock.calls.slice(1)) expect(options.env).toBeUndefined()
53+
for (const [, options] of [spawn.mock.calls[0], ...spawn.mock.calls.slice(2)])
54+
expect(options.env).toBeUndefined()
55+
})
56+
57+
it('does not start Drizzle when compatibility preparation fails', async () => {
58+
spawn.mockReturnValueOnce({ exited: Promise.resolve(44) })
59+
expect(await runPush(['--force'])).toBe(44)
60+
expect(spawn).toHaveBeenCalledTimes(1)
5361
})
5462

5563
it('does not implicitly approve data loss', async () => {
@@ -58,9 +66,10 @@ describe('db:push policy and process boundaries', () => {
5866
})
5967

6068
it('stops reconciliation when Drizzle fails', async () => {
69+
spawn.mockReturnValueOnce({ exited: Promise.resolve(0) })
6170
spawn.mockReturnValueOnce({ exited: Promise.resolve(42) })
6271
expect(await runPush(['--force'])).toBe(42)
63-
expect(spawn).toHaveBeenCalledTimes(1)
72+
expect(spawn).toHaveBeenCalledTimes(2)
6473
})
6574

6675
it('stops after the first failed reconciliation', async () => {

‎packages/db/scripts/push.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ export async function runPush(args: string[]): Promise<number> {
2626
return 1
2727
}
2828

29+
if (!help && args.includes('--force')) {
30+
const preparation = Bun.spawn(['bun', '--env-file=.env', 'run', './scripts/prepare-push.ts'], {
31+
stdin: 'inherit',
32+
stdout: 'inherit',
33+
stderr: 'inherit',
34+
})
35+
const preparationExit = await preparation.exited
36+
if (preparationExit !== 0) return preparationExit
37+
}
38+
2939
const pushArgs = args.filter((arg) => arg !== '--interactive-renames')
3040
const child = Bun.spawn(
3141
['bunx', '--no-install', 'drizzle-kit', 'push', '--config=./drizzle.config.ts', ...pushArgs],

0 commit comments

Comments
 (0)