Skip to content

Commit c394c17

Browse files
committed
fix(db): prevent schema push from bypassing column-drop migrations
1 parent e561cf1 commit c394c17

6 files changed

Lines changed: 120 additions & 4 deletions

File tree

‎.github/CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ If you prefer not to use Docker. **All commands run from the repository root unl
254254
cd packages/db && bun run db:migrate && cd ../..
255255
```
256256

257-
For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for both local and CI/CD setups.
257+
For ad-hoc schema iteration during development you can also use `bun run db:push` from `packages/db`, but `db:migrate` is the canonical command for both local and CI/CD setups. `db:push` rejects column removals so it cannot bypass a versioned migration's prerequisites or backfills; use `db:migrate` for those changes.
258258

259259
4. **Run the Development Servers:**
260260

‎.github/workflows/migrations.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ jobs:
7171
7272
if [ "${ENVIRONMENT}" = "dev" ]; then
7373
echo "Dev environment — pushing schema directly (db:push)"
74+
# db:push validates column removals before invoking drizzle-kit; --force
75+
# cannot bypass the versioned migration prerequisites for a column drop.
7476
# drizzle-kit push needs a TTY to resolve ambiguous renames (--force only
7577
# covers data-loss). In CI it throws "Interactive prompts require a TTY
7678
# terminal" but still exits 0, so the job goes green without applying the

‎packages/db/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
},
2929
"scripts": {
3030
"db:generate": "bunx drizzle-kit generate --config=./drizzle.config.ts",
31-
"db:push": "bunx drizzle-kit push --config=./drizzle.config.ts && bun --env-file=.env run ./scripts/reconcile-credential-group-resource-policies.ts && bun --env-file=.env run ./scripts/reconcile-oauth-provider.ts && bun --env-file=.env run ./script-migrations/0016_backfill_search_vectors.ts",
31+
"db:push": "bun --env-file=.env run ./scripts/check-schema-push-safety.ts && bunx drizzle-kit push --config=./drizzle.config.ts && bun --env-file=.env run ./scripts/reconcile-credential-group-resource-policies.ts && bun --env-file=.env run ./scripts/reconcile-oauth-provider.ts && bun --env-file=.env run ./script-migrations/0016_backfill_search_vectors.ts",
3232
"db:migrate": "bun --env-file=.env run ./scripts/migrate.ts",
3333
"db:reconcile-fork-kb-file-ownership": "bun --env-file=.env run ./scripts/reconcile-fork-kb-file-ownership.ts",
3434
"db:reconcile-workspace-storage": "bun --env-file=.env run ./scripts/reconcile-workspace-storage.ts",

‎packages/db/schema-push-safety.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as schema from '@sim/db/schema'
2+
import { is } from 'drizzle-orm'
3+
import { getTableConfig, PgTable } from 'drizzle-orm/pg-core'
4+
import type { Sql } from 'postgres'
5+
6+
/**
7+
* Direct schema pushes bypass versioned migration guards and backfills. Require
8+
* db:migrate for column removals, including empty tables that can receive writes
9+
* between this check and the push. Fresh installs and additive pushes remain valid.
10+
*/
11+
export async function assertSafeSchemaPush(
12+
sql: Sql,
13+
tables: readonly PgTable[] = Object.values<unknown>(schema).filter((value): value is PgTable =>
14+
is(value, PgTable)
15+
)
16+
): Promise<void> {
17+
const definitions = tables.map((table) => getTableConfig(table))
18+
const expectedColumns = new Map(
19+
definitions.map((table) => [
20+
`${table.schema ?? 'public'}\0${table.name}`,
21+
new Set(table.columns.map((column) => column.name)),
22+
])
23+
)
24+
const schemaNames = [...new Set(definitions.map((table) => table.schema ?? 'public'))]
25+
const tableNames = [...new Set(definitions.map((table) => table.name))]
26+
const existing = await sql<
27+
Array<{ schema_name: string; table_name: string; column_name: string }>
28+
>`
29+
SELECT n.nspname AS schema_name, c.relname AS table_name, a.attname AS column_name
30+
FROM pg_attribute a
31+
JOIN pg_class c ON c.oid = a.attrelid
32+
JOIN pg_namespace n ON n.oid = c.relnamespace
33+
WHERE n.nspname = ANY(${schemaNames}::text[])
34+
AND c.relname = ANY(${tableNames}::text[])
35+
AND c.relkind IN ('r', 'p')
36+
AND a.attnum > 0
37+
AND NOT a.attisdropped
38+
ORDER BY n.nspname, c.relname, a.attnum
39+
`
40+
const removed = existing.filter((column) => {
41+
const expected = expectedColumns.get(`${column.schema_name}\0${column.table_name}`)
42+
return expected && !expected.has(column.column_name)
43+
})
44+
if (removed.length === 0) return
45+
46+
throw new Error(
47+
'db:push cannot remove existing columns: ' +
48+
removed
49+
.map((column) => `${column.schema_name}.${column.table_name}.${column.column_name}`)
50+
.join(', ') +
51+
'. Run db:migrate so versioned migration prerequisites and backfills are enforced.'
52+
)
53+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { assertSafeSchemaPush } from '@sim/db/schema-push-safety'
2+
import { createLogger } from '@sim/logger'
3+
import postgres from 'postgres'
4+
5+
const logger = createLogger('SchemaPushSafety')
6+
const url = process.env.DATABASE_URL
7+
if (!url) throw new Error('Missing DATABASE_URL')
8+
9+
const sql = postgres(url, {
10+
max: 1,
11+
connect_timeout: 10,
12+
connection: { application_name: 'sim-schema-push-safety', statement_timeout: 10_000 },
13+
})
14+
15+
try {
16+
await assertSafeSchemaPush(sql)
17+
logger.info('Schema push does not remove existing columns')
18+
} finally {
19+
await sql.end()
20+
}

‎packages/db/scripts/retired-columns.postgres.test.ts‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { readFileSync } from 'node:fs'
2+
import { assertSafeSchemaPush } from '@sim/db/schema-push-safety'
23
import { generateId } from '@sim/utils/id'
4+
import { bigint, numeric, pgSchema, text } from 'drizzle-orm/pg-core'
35
import postgres from 'postgres'
46
import { describe, expect, it } from 'vitest'
57

@@ -38,7 +40,9 @@ const receipts = [
3840
'0009_backfill_wel_residual_cost_total',
3941
]
4042

41-
async function fixture(run: (sql: postgres.Sql) => Promise<void>): Promise<void> {
43+
async function fixture(
44+
run: (sql: postgres.Sql, tables: Parameters<typeof assertSafeSchemaPush>[1]) => Promise<void>
45+
): Promise<void> {
4246
const sql = postgres(databaseUrl!, { max: 1, onnotice: () => {} })
4347
const schemaName = `retired_columns_${generateId().replaceAll('-', '')}`
4448
try {
@@ -53,7 +57,20 @@ async function fixture(run: (sql: postgres.Sql) => Promise<void>): Promise<void>
5357
await sql`CREATE TABLE workflow_execution_logs (id text, cost jsonb, cost_total numeric)`
5458
await sql`CREATE TABLE workspace_files (id text, size integer NOT NULL, size_bytes bigint)`
5559
await sql.unsafe(bridge)
56-
await run(sql)
60+
const namespace = pgSchema(schemaName)
61+
const tables = [
62+
namespace.table('organization', { id: text('id'), creditBalance: numeric('credit_balance') }),
63+
namespace.table('user_stats', { id: text('id'), creditBalance: numeric('credit_balance') }),
64+
namespace.table('workflow_execution_logs', {
65+
id: text('id'),
66+
costTotal: numeric('cost_total'),
67+
}),
68+
namespace.table('workspace_files', {
69+
id: text('id'),
70+
sizeBytes: bigint('size_bytes', { mode: 'number' }),
71+
}),
72+
]
73+
await run(sql, tables)
5774
} finally {
5875
try {
5976
await sql`DROP SCHEMA IF EXISTS ${sql(schemaName)} CASCADE`
@@ -70,6 +87,30 @@ async function apply(sql: postgres.Sql): Promise<void> {
7087
}
7188

7289
describe.skipIf(!databaseUrl)('retired-column contract migration', () => {
90+
it('blocks schema push before it can discard legacy values', async () => {
91+
await fixture(async (sql, tables) => {
92+
await sql`INSERT INTO workspace_files (id, size) VALUES ('file', 123)`
93+
await sql`INSERT INTO workflow_execution_logs (id, cost) VALUES ('log', '{"total": 1.25}')`
94+
await expect(assertSafeSchemaPush(sql, tables)).rejects.toThrow(
95+
'db:push cannot remove existing columns'
96+
)
97+
expect(await sql`SELECT size FROM workspace_files`).toEqual([{ size: 123 }])
98+
expect(await sql`SELECT cost, cost_total FROM workflow_execution_logs`).toEqual([
99+
{ cost: { total: 1.25 }, cost_total: null },
100+
])
101+
})
102+
})
103+
104+
it('also blocks empty legacy tables and allows pushes after the guarded contract', async () => {
105+
await fixture(async (sql, tables) => {
106+
await expect(assertSafeSchemaPush(sql, tables)).rejects.toThrow('Run db:migrate')
107+
await apply(sql)
108+
await expect(assertSafeSchemaPush(sql, tables)).resolves.toBeUndefined()
109+
await sql`DROP TABLE workspace_files`
110+
await expect(assertSafeSchemaPush(sql, tables)).resolves.toBeUndefined()
111+
})
112+
})
113+
73114
it('allows a fresh database and replays after the columns are gone', async () => {
74115
await fixture(async (sql) => {
75116
await apply(sql)

0 commit comments

Comments
 (0)