Skip to content

Commit 1beb436

Browse files
committed
fix(db): make file ownership provisioning replay safe
1 parent b676fc0 commit 1beb436

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ALTER TYPE "public"."upload_session_purpose" ADD VALUE 'project_file' BEFORE 'table_import';
1+
ALTER TYPE "public"."upload_session_purpose" ADD VALUE IF NOT EXISTS 'project_file' BEFORE 'table_import';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { execFile } from 'node:child_process'
2+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { promisify } from 'node:util'
6+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
7+
import { getPostgresErrorCode } from '@sim/utils/errors'
8+
import { generateId } from '@sim/utils/id'
9+
import { drizzle } from 'drizzle-orm/postgres-js'
10+
import { migrate } from 'drizzle-orm/postgres-js/migrator'
11+
import postgres, { type Sql } from 'postgres'
12+
import { describe, expect, it } from 'vitest'
13+
14+
const databaseUrl = readTestDatabaseUrl()
15+
16+
async function withDatabase(run: (sql: Sql, url: string) => Promise<void>) {
17+
const admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined })
18+
const name = `file_reconciliation_test_${generateId().replaceAll('-', '')}`
19+
let sql: Sql | undefined
20+
try {
21+
await admin`CREATE DATABASE ${admin(name)}`
22+
const url = new URL(databaseUrl)
23+
url.pathname = `/${name}`
24+
sql = postgres(url.toString(), { max: 1, onnotice: () => undefined })
25+
await run(sql, url.toString())
26+
} finally {
27+
await sql?.end()
28+
await admin`DROP DATABASE IF EXISTS ${admin(name)}`
29+
await admin.end()
30+
}
31+
}
32+
33+
describe('file ownership provisioning and interrupted migration recovery', () => {
34+
it('retries an enum addition that committed before its journal insert failed', async () => {
35+
const directory = await mkdtemp(join(tmpdir(), 'file-enum-replay-'))
36+
try {
37+
await mkdir(join(directory, 'meta'))
38+
await writeFile(join(directory, '0000_commit.sql'), 'COMMIT;')
39+
await writeFile(
40+
join(directory, '0001_upload_purpose.sql'),
41+
await readFile(
42+
new URL('../migrations/0406_project_file_upload_sessions.sql', import.meta.url)
43+
)
44+
)
45+
await writeFile(
46+
join(directory, 'meta/_journal.json'),
47+
JSON.stringify({
48+
version: '7',
49+
dialect: 'postgresql',
50+
entries: [
51+
{ idx: 0, version: '7', when: 1, tag: '0000_commit', breakpoints: true },
52+
{ idx: 1, version: '7', when: 2, tag: '0001_upload_purpose', breakpoints: true },
53+
],
54+
})
55+
)
56+
await withDatabase(async (sql) => {
57+
await sql`CREATE TYPE upload_session_purpose AS ENUM ('workspace_file', 'table_import')`
58+
await sql`CREATE SCHEMA drizzle`
59+
await sql`CREATE TABLE drizzle.__drizzle_migrations (
60+
id serial PRIMARY KEY, hash text NOT NULL, created_at bigint,
61+
CONSTRAINT reject_enum_journal CHECK (created_at <> 2)
62+
)`
63+
const run = () => migrate(drizzle(sql), { migrationsFolder: directory })
64+
await expect(run()).rejects.toSatisfy(
65+
(error: unknown) => getPostgresErrorCode(error) === '23514'
66+
)
67+
expect(await sql`SELECT enum_range(NULL::upload_session_purpose)::text AS labels`).toEqual([
68+
{ labels: '{workspace_file,project_file,table_import}' },
69+
])
70+
expect(await sql`SELECT created_at::int FROM drizzle.__drizzle_migrations`).toEqual([
71+
{ created_at: 1 },
72+
])
73+
await sql`ALTER TABLE drizzle.__drizzle_migrations DROP CONSTRAINT reject_enum_journal`
74+
await run()
75+
await run()
76+
expect(
77+
await sql`SELECT created_at::int FROM drizzle.__drizzle_migrations ORDER BY created_at`
78+
).toEqual([{ created_at: 1 }, { created_at: 2 }])
79+
expect(await sql`SELECT enum_range(NULL::upload_session_purpose)::text AS labels`).toEqual([
80+
{ labels: '{workspace_file,project_file,table_import}' },
81+
])
82+
})
83+
} finally {
84+
await rm(directory, { recursive: true, force: true })
85+
}
86+
})
87+
88+
it('reconciles missing precision triggers while preserving legacy no-op fences', async () => {
89+
await withDatabase(async (sql, url) => {
90+
const run = (script: string) =>
91+
promisify(execFile)('bun', ['--no-env-file', script], {
92+
cwd: new URL('..', import.meta.url),
93+
env: { ...process.env, DATABASE_URL: url, MIGRATION_DATABASE_URL: url },
94+
timeout: 120_000,
95+
})
96+
await run('scripts/migrate.ts')
97+
await sql`DROP TRIGGER workspace_files_content_version_millisecond ON workspace_files`
98+
await sql`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
99+
VALUES ('owner', 'Owner', 'owner@example.com', false, now(), now())`
100+
await sql`INSERT INTO workspace_files
101+
(id, key, user_id, context, original_name, content_type, content_updated_at)
102+
VALUES ('legacy', 'legacy-key', 'owner', 'copilot', 'legacy.txt', 'text/plain',
103+
'2026-08-01 03:30:51.566952')`
104+
await run('scripts/reconcile-file-ownership.ts')
105+
await run('scripts/reconcile-file-ownership.ts')
106+
await sql`UPDATE workspace_files SET id = id WHERE id = 'legacy'`
107+
expect(await sql`SELECT content_updated_at::text AS revision FROM workspace_files`).toEqual([
108+
{ revision: '2026-08-01 03:30:51.566952' },
109+
])
110+
await sql`UPDATE workspace_files SET original_name = 'renamed.txt' WHERE id = 'legacy'`
111+
expect(await sql`SELECT content_updated_at::text AS revision FROM workspace_files`).toEqual([
112+
{ revision: '2026-08-01 03:30:51.566' },
113+
])
114+
await sql`INSERT INTO workspace_files
115+
(id, key, user_id, context, original_name, content_type, content_updated_at)
116+
VALUES ('raw', 'raw-key', 'owner', 'copilot', 'raw.txt', 'text/plain',
117+
'2026-08-01 03:30:51.123456')`
118+
expect(
119+
await sql`SELECT content_updated_at::text AS revision FROM workspace_files WHERE id = 'raw'`
120+
).toEqual([{ revision: '2026-08-01 03:30:51.123' }])
121+
})
122+
})
123+
})

‎packages/db/scripts/reconcile-file-ownership.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const sql = postgres(url, { max: 1, connect_timeout: 10 })
99

1010
const migrations = [
1111
'0313_puzzling_zodiak.sql',
12+
'0358_workspace_file_content_version_precision.sql',
1213
'0359_workspace_file_search_chunks.sql',
1314
'0403_file_entity_ownership.sql',
1415
'0404_file_folder_version_ownership.sql',

0 commit comments

Comments
 (0)