Skip to content

Commit 3a88371

Browse files
committed
fix(db): bootstrap Projects for development push databases that predate them
A push-managed database with workspaces but no Project schema could never pass the schema-push guard: push cannot backfill workspace.project_id, and the guard refuses every unenforced Project state. Under SIM_DEV_DB_PUSH=1 the pre-push reconciler now replays migrations 0394 and 0406 (the foundation commits atomically with the rollout marker) and runs the registered 0032_project_membership script migration on its single session, recording it as the migration runner does. An interrupted bootstrap resumes; every other unenforced state still refuses with the same message. The pinned drizzle-kit patch also adds unique constraints before foreign keys, so a composite foreign key on a new unique constraint of an existing table no longer fails, and in the noninteractive push policy it answers the unique-constraint truncate prompt with its non-destructive choice.
1 parent 133c671 commit 3a88371

5 files changed

Lines changed: 478 additions & 9 deletions

File tree

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
import { spawnSync } from 'node:child_process'
2+
import { readFile } from 'node:fs/promises'
3+
import { fileURLToPath } from 'node:url'
4+
import journal from '@sim/db/migrations/meta/_journal.json'
5+
import { applyMigration } from '@sim/db/scripts/migration-fixture'
6+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
7+
import { generateId } from '@sim/utils/id'
8+
import postgres, { type Sql } from 'postgres'
9+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
10+
11+
const REFUSAL =
12+
'Existing Project databases must complete the Project script migration 0032_project_membership before schema push'
13+
const MIGRATION_LOCK_KEY = 4_961_002_270n
14+
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
15+
const databaseUrl = readTestDatabaseUrl()
16+
17+
function databaseUrlFor(name: string): string {
18+
const url = new URL(databaseUrl)
19+
url.pathname = `/${name}`
20+
return url.toString()
21+
}
22+
23+
/** Runs the supported `db:push --force` entrypoint, fence included, against one database. */
24+
function runPush(url: string, devPush: boolean) {
25+
const result = spawnSync('bun', ['--no-env-file', 'scripts/push.ts', '--force'], {
26+
cwd: packageRoot,
27+
env: {
28+
...process.env,
29+
DATABASE_URL: url,
30+
MIGRATION_DATABASE_URL: url,
31+
/** The test runner's NODE_ENV silences the child loggers whose messages these tests read. */
32+
NODE_ENV: 'development',
33+
SIM_DEV_DB_PUSH: devPush ? '1' : undefined,
34+
},
35+
encoding: 'utf8',
36+
timeout: 120_000,
37+
})
38+
expect(result.error).toBeUndefined()
39+
return { status: result.status, output: result.stdout + result.stderr }
40+
}
41+
42+
async function migrationSql(tag: string): Promise<string> {
43+
return readFile(new URL(`../migrations/${tag}.sql`, import.meta.url), 'utf8')
44+
}
45+
46+
/** Replays a versioned migration on one session, as the migrator does. */
47+
async function replayMigration(sql: Sql, tag: string): Promise<void> {
48+
const session = await sql.reserve()
49+
try {
50+
await applyMigration(session, await migrationSql(tag))
51+
} finally {
52+
session.release()
53+
}
54+
}
55+
56+
async function projectSchema(sql: Sql) {
57+
const [state] = await sql`SELECT
58+
to_regclass('public.project') IS NOT NULL AS project,
59+
to_regclass('public.project_workspace') IS NOT NULL AS connector,
60+
to_regclass('public.project_membership_rollout') IS NOT NULL AS rollout,
61+
(SELECT attnotnull FROM pg_attribute WHERE attrelid = 'public.workspace'::regclass
62+
AND attname = 'project_id' AND NOT attisdropped) AS required`
63+
return state
64+
}
65+
66+
/** Everything a refused push must leave exactly as it found it. */
67+
async function retainedState(sql: Sql) {
68+
const schema = await projectSchema(sql)
69+
return {
70+
schema,
71+
workspaces: await sql`SELECT to_jsonb(w) AS row FROM workspace w ORDER BY id`,
72+
connector: schema.connector
73+
? await sql`SELECT * FROM project_workspace ORDER BY workspace_id`
74+
: [],
75+
}
76+
}
77+
78+
describe('development schema push of a database that predates Projects', () => {
79+
const template = `project_push_template_test_${generateId().replaceAll('-', '')}`
80+
let admin: Sql
81+
82+
beforeAll(async () => {
83+
admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined })
84+
await admin.unsafe(`CREATE DATABASE "${template}"`)
85+
const sql = postgres(databaseUrlFor(template), { max: 1, onnotice: () => undefined })
86+
try {
87+
for (const extension of ['vector', 'btree_gin', 'pg_trgm']) {
88+
await sql`CREATE EXTENSION IF NOT EXISTS ${sql(extension)}`
89+
}
90+
const fresh = runPush(databaseUrlFor(template), true)
91+
expect(fresh.status, fresh.output).toBe(0)
92+
/** Removes every Project object so the template matches a push-managed schema from before Projects. */
93+
await sql.unsafe(`
94+
DROP TABLE project CASCADE;
95+
ALTER TABLE workspace DROP COLUMN project_id CASCADE, DROP COLUMN organization_scope_key CASCADE;
96+
ALTER TABLE folder DROP COLUMN project_id CASCADE;
97+
ALTER TABLE workspace_files DROP COLUMN project_id CASCADE;
98+
DELETE FROM script_migrations WHERE name = '0032_project_membership';
99+
`)
100+
} finally {
101+
await sql.end()
102+
}
103+
}, 180_000)
104+
105+
afterAll(async () => {
106+
if (!admin) return
107+
await admin.unsafe(`DROP DATABASE IF EXISTS "${template}" WITH (FORCE)`)
108+
await admin.end()
109+
})
110+
111+
/** Copies the pre-Project template with seeded tenants into a database owned by one test. */
112+
async function preProjectDatabase(run: (sql: Sql, url: string) => Promise<void>) {
113+
const name = `project_push_test_${generateId().replaceAll('-', '')}`
114+
await admin.unsafe(`CREATE DATABASE "${name}" TEMPLATE "${template}"`)
115+
const url = databaseUrlFor(name)
116+
const sql = postgres(url, { max: 2, onnotice: () => undefined })
117+
try {
118+
await sql.unsafe(`
119+
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) VALUES
120+
('owner', 'Owner', 'owner@example.test', true, now(), now()),
121+
('member', 'Member', 'member@example.test', true, now(), now());
122+
INSERT INTO organization (id, name, slug) VALUES ('org', 'Org', 'org');
123+
INSERT INTO workspace (id, name, owner_id, billed_account_user_id, organization_id, forked_from_workspace_id, archived_at) VALUES
124+
('personal', 'Personal', 'owner', 'owner', NULL, NULL, NULL),
125+
('team', 'Team', 'owner', 'owner', 'org', NULL, NULL),
126+
('team-fork', 'Team fork', 'member', 'owner', 'org', 'team', NULL),
127+
('retired', 'Retired', 'member', 'member', NULL, NULL, '2026-01-01');
128+
INSERT INTO folder (id, resource_type, name, user_id, workspace_id) VALUES
129+
('folder', 'workflow', 'Folder', 'owner', 'team');
130+
`)
131+
await run(sql, url)
132+
} finally {
133+
await sql.end({ timeout: 2 })
134+
await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`)
135+
}
136+
}
137+
138+
async function expectBootstrapped(sql: Sql) {
139+
expect(await projectSchema(sql)).toEqual({
140+
project: true,
141+
connector: false,
142+
rollout: false,
143+
required: true,
144+
})
145+
const memberships = await sql<{ id: string; project_id: string; archived: boolean }[]>`
146+
SELECT w.id, w.project_id, p.archived_at IS NOT NULL AS archived
147+
FROM workspace w JOIN project p ON p.id = w.project_id ORDER BY w.id`
148+
expect(memberships.map(({ id, archived }) => ({ id, archived }))).toEqual([
149+
{ id: 'personal', archived: false },
150+
{ id: 'retired', archived: true },
151+
{ id: 'team', archived: false },
152+
{ id: 'team-fork', archived: false },
153+
])
154+
const projectOf = new Map(memberships.map((row) => [row.id, row.project_id]))
155+
expect(projectOf.get('team-fork')).toBe(projectOf.get('team'))
156+
expect(new Set(projectOf.values()).size).toBe(3)
157+
expect(
158+
await sql`SELECT conname, convalidated, condeferrable FROM pg_constraint
159+
WHERE conrelid = 'workspace'::regclass AND conname IN ('workspace_project_id_project_id_fk',
160+
'workspace_project_organization_fk', 'workspace_fork_project_fk')
161+
ORDER BY conname`
162+
).toEqual([
163+
{ conname: 'workspace_fork_project_fk', convalidated: true, condeferrable: true },
164+
{ conname: 'workspace_project_id_project_id_fk', convalidated: true, condeferrable: false },
165+
{ conname: 'workspace_project_organization_fk', convalidated: true, condeferrable: true },
166+
])
167+
expect(
168+
await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'`
169+
).toEqual([{ name: '0032_project_membership' }])
170+
await expect(
171+
sql`UPDATE workspace SET organization_id = NULL WHERE id = 'team-fork'`
172+
).rejects.toMatchObject({ code: '23503' })
173+
}
174+
175+
it('bootstraps Projects, completes the push, and keeps memberships and constraints on replay', async () => {
176+
await preProjectDatabase(async (sql, url) => {
177+
const pushed = runPush(url, true)
178+
expect(pushed.status, pushed.output).toBe(0)
179+
await expectBootstrapped(sql)
180+
const before = await sql`SELECT id, project_id FROM workspace ORDER BY id`
181+
const replayed = runPush(url, true)
182+
expect(replayed.status, replayed.output).toBe(0)
183+
expect(await sql`SELECT id, project_id FROM workspace ORDER BY id`).toEqual(before)
184+
await expectBootstrapped(sql)
185+
})
186+
}, 240_000)
187+
188+
it('resumes after the Project migration reports conflicts', async () => {
189+
await preProjectDatabase(async (sql, url) => {
190+
await sql`INSERT INTO workspace (id, name, owner_id, billed_account_user_id, forked_from_workspace_id)
191+
VALUES ('cycle-a', 'Cycle A', 'owner', 'owner', NULL), ('cycle-b', 'Cycle B', 'owner', 'owner', 'cycle-a')`
192+
await sql`UPDATE workspace SET forked_from_workspace_id = 'cycle-b' WHERE id = 'cycle-a'`
193+
const refused = runPush(url, true)
194+
expect(refused.status, refused.output).toBe(1)
195+
expect(refused.output).toContain('Project preparation requires operator remediation')
196+
expect(await projectSchema(sql)).toEqual({
197+
project: true,
198+
connector: true,
199+
rollout: true,
200+
required: false,
201+
})
202+
expect(await sql`SELECT phase FROM project_membership_rollout`).toEqual([{ phase: 'column' }])
203+
expect(
204+
await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'`
205+
).toEqual([])
206+
207+
await sql`UPDATE workspace SET forked_from_workspace_id = NULL WHERE id = 'cycle-a'`
208+
const resumed = runPush(url, true)
209+
expect(resumed.status, resumed.output).toBe(0)
210+
await sql`DELETE FROM workspace WHERE id IN ('cycle-a', 'cycle-b')`
211+
await expectBootstrapped(sql)
212+
})
213+
}, 240_000)
214+
215+
it('rolls the foundation back with the expansion when the workspace barrier is busy', async () => {
216+
await preProjectDatabase(async (sql, url) => {
217+
const holder = await sql.reserve()
218+
try {
219+
await holder`BEGIN`
220+
await holder`SELECT 1 FROM workspace LIMIT 1`
221+
const busy = runPush(url, true)
222+
expect(busy.status, busy.output).toBe(1)
223+
expect(busy.output).toContain('55P03')
224+
await holder`ROLLBACK`
225+
} finally {
226+
holder.release()
227+
}
228+
expect(await projectSchema(sql)).toEqual({
229+
project: false,
230+
connector: false,
231+
rollout: false,
232+
required: null,
233+
})
234+
const pushed = runPush(url, true)
235+
expect(pushed.status, pushed.output).toBe(0)
236+
await expectBootstrapped(sql)
237+
})
238+
}, 240_000)
239+
240+
it('leaves the database untouched outside development push mode', async () => {
241+
await preProjectDatabase(async (sql, url) => {
242+
const refused = runPush(url, false)
243+
expect(refused.status, refused.output).toBe(1)
244+
expect(refused.output).toContain(REFUSAL)
245+
expect(await projectSchema(sql)).toEqual({
246+
project: false,
247+
connector: false,
248+
rollout: false,
249+
required: null,
250+
})
251+
expect(
252+
await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'`
253+
).toEqual([])
254+
})
255+
}, 120_000)
256+
257+
it('refuses to bootstrap while a migration holds the schema lock', async () => {
258+
await preProjectDatabase(async (sql, url) => {
259+
const migrator = await sql.reserve()
260+
try {
261+
await migrator`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})`
262+
const fenced = runPush(url, true)
263+
expect(fenced.status, fenced.output).toBe(1)
264+
expect(fenced.output).toContain('A migration is running')
265+
await migrator`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`
266+
} finally {
267+
migrator.release()
268+
}
269+
expect((await projectSchema(sql)).project).toBe(false)
270+
})
271+
}, 120_000)
272+
273+
it.each([
274+
{
275+
state: 'a connector with live memberships',
276+
async build(sql: Sql) {
277+
await sql.unsafe(await migrationSql('0394_project_foundation'))
278+
await sql`INSERT INTO project (id, name, owner_id) VALUES ('legacy', 'Legacy', 'owner')`
279+
await sql`INSERT INTO project_workspace (project_id, workspace_id) VALUES ('legacy', 'personal')`
280+
},
281+
},
282+
{
283+
state: 'an empty connector without the column expansion',
284+
async build(sql: Sql) {
285+
await sql.unsafe(await migrationSql('0394_project_foundation'))
286+
},
287+
},
288+
{
289+
state: 'Projects without a connector or required column',
290+
async build(sql: Sql) {
291+
await sql.unsafe(await migrationSql('0394_project_foundation'))
292+
await sql`DROP TABLE project_workspace`
293+
},
294+
},
295+
{
296+
state: 'a workspace Project column without Projects',
297+
async build(sql: Sql) {
298+
await sql`ALTER TABLE workspace ADD COLUMN project_id text`
299+
},
300+
},
301+
{
302+
state: 'a versioned expansion awaiting its script migration',
303+
async build(sql: Sql) {
304+
await sql.unsafe(await migrationSql('0394_project_foundation'))
305+
await replayMigration(sql, '0406_workspace_project_column')
306+
const expansion = journal.entries.find(
307+
(entry) => entry.tag === '0406_workspace_project_column'
308+
)
309+
await sql`CREATE SCHEMA drizzle`
310+
await sql`CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint)`
311+
await sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('fixture', ${expansion?.when ?? 0})`
312+
},
313+
},
314+
{
315+
state: 'an expansion whose connector holds memberships',
316+
async build(sql: Sql) {
317+
await sql.unsafe(await migrationSql('0394_project_foundation'))
318+
await replayMigration(sql, '0406_workspace_project_column')
319+
await sql`INSERT INTO project (id, name, owner_id) VALUES ('legacy', 'Legacy', 'owner')`
320+
await sql`INSERT INTO project_workspace (project_id, workspace_id) VALUES ('legacy', 'personal')`
321+
},
322+
},
323+
])(
324+
'refuses development push of $state',
325+
async ({ build }) => {
326+
await preProjectDatabase(async (sql, url) => {
327+
await build(sql)
328+
const before = await retainedState(sql)
329+
const refused = runPush(url, true)
330+
expect(refused.status, refused.output).toBe(1)
331+
expect(refused.output).toContain(REFUSAL)
332+
expect(await retainedState(sql)).toEqual(before)
333+
expect(
334+
await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'`
335+
).toEqual([])
336+
})
337+
},
338+
120_000
339+
)
340+
})

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,25 @@ export const workflows = pgTable('workflow', {
330330
).rejects.toMatchObject({ code: '23503' })
331331
}, 60_000)
332332

333+
it('adds a composite unique constraint before the foreign keys that reference it', async () => {
334+
await sql`CREATE TABLE folder (id text PRIMARY KEY, resource_type text NOT NULL, parent_id text)`
335+
await sql`INSERT INTO folder VALUES ('parent', 'file', NULL), ('child', 'file', 'parent')`
336+
await schema(`export const folders = pgTable('folder', {
337+
id: text('id').primaryKey(), resourceType: text('resource_type').notNull(),
338+
projectId: text('project_id'), parentId: text('parent_id'),
339+
}, (t) => [
340+
unique('folder_project_owner_type_unique').on(t.id, t.projectId, t.resourceType),
341+
foreignKey({ name: 'folder_project_parent_owner_fk', columns: [t.parentId, t.projectId, t.resourceType], foreignColumns: [t.id, t.projectId, t.resourceType] }),
342+
])`)
343+
const result = push()
344+
expect(result.error).toBeUndefined()
345+
expect(result.status, result.stdout + result.stderr).toBe(0)
346+
await sql`UPDATE folder SET project_id = 'project' WHERE id = 'parent'`
347+
await expect(
348+
sql`UPDATE folder SET project_id = 'other' WHERE id = 'child'`
349+
).rejects.toMatchObject({ code: '23503' })
350+
}, 30_000)
351+
333352
it('retires the legacy size bridge without losing bigint or unbackfilled values', async () => {
334353
await sql`CREATE TABLE workspace_files (id text PRIMARY KEY, size integer NOT NULL, size_bytes bigint)`
335354
await sql`INSERT INTO workspace_files VALUES ('legacy', 123, NULL), ('large', 2147483647, 5000000000)`

0 commit comments

Comments
 (0)