Skip to content

Commit fbbd6df

Browse files
committed
fix(db): push the schema before the Project migration reads it
The bootstrap ran 0032_project_membership before Drizzle pushed anything, so a development database from before a column the migration reads (such as the fork parent) failed after committing the expansion, on every retry. --prepare now only replays the expansion. push.ts then runs one Drizzle push with workspace.project_id still nullable (SIM_DB_PUSH_PROJECT_BOOTSTRAP=1), runs the migration through reconcile-project-membership.ts --bootstrap, and finishes with the ordinary push. The connector and rollout marker are kept out of Drizzle's diff until the migration retires them. A fresh bootstrap also requires that no public table carries a project_id column.
1 parent 3a88371 commit fbbd6df

5 files changed

Lines changed: 141 additions & 45 deletions

File tree

‎packages/db/drizzle.config.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@ export default {
1515
dbCredentials: {
1616
url: process.env.DATABASE_URL!,
1717
},
18-
/** Runner-owned journals and resumable cleanup cursors must survive a development schema push. */
18+
/**
19+
* Runner-owned journals, resumable cleanup cursors, and a Project bootstrap's connector and
20+
* rollout marker (until its migration retires them) must survive a development schema push.
21+
*/
1922
tablesFilter: [
2023
'!script_migrations',
24+
'!project_workspace',
25+
'!project_membership_rollout',
2126
'!project_backfill_archive_repairs',
2227
'!search_embedding_cleanup_progress',
2328
'!search_embedding_cleanup_targets',

‎packages/db/schema.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2171,6 +2171,20 @@ export const workspaceModeEnum = pgEnum('workspace_mode', [
21712171

21722172
export type WorkspaceMode = (typeof workspaceModeEnum.enumValues)[number]
21732173

2174+
/**
2175+
* A development push that bootstraps Projects first pushes this column nullable, so the columns
2176+
* `0032_project_membership` reads exist before it fills the column and enforces NOT NULL; the
2177+
* push that follows uses the required column. Every other push and generation reads it required.
2178+
*/
2179+
function workspaceProjectIdColumn() {
2180+
const column = text('project_id').references((): AnyPgColumn => project.id, {
2181+
onDelete: 'restrict',
2182+
})
2183+
return process.env.SIM_DB_PUSH_PROJECT_BOOTSTRAP === '1'
2184+
? (column as ReturnType<typeof column.notNull>)
2185+
: column.notNull()
2186+
}
2187+
21742188
export const workspace = pgTable(
21752189
'workspace',
21762190
{
@@ -2198,9 +2212,7 @@ export const workspace = pgTable(
21982212
organizationScopeKey: text('organization_scope_key').generatedAlwaysAs(
21992213
sql`CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END`
22002214
),
2201-
projectId: text('project_id')
2202-
.notNull()
2203-
.references((): AnyPgColumn => project.id, { onDelete: 'restrict' }),
2215+
projectId: workspaceProjectIdColumn(),
22042216
workspaceMode: workspaceModeEnum('workspace_mode').notNull().default('grandfathered_shared'),
22052217
billedAccountUserId: text('billed_account_user_id')
22062218
.notNull()

‎packages/db/scripts/project-push-bootstrap.integration.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,30 @@ describe('development schema push of a database that predates Projects', () => {
185185
})
186186
}, 240_000)
187187

188+
it('pushes the columns the Project migration reads before running it', async () => {
189+
await preProjectDatabase(async (sql, url) => {
190+
await sql`ALTER TABLE workspace DROP COLUMN forked_from_workspace_id CASCADE`
191+
const pushed = runPush(url, true)
192+
expect(pushed.status, pushed.output).toBe(0)
193+
expect(await projectSchema(sql)).toEqual({
194+
project: true,
195+
connector: false,
196+
rollout: false,
197+
required: true,
198+
})
199+
expect(
200+
await sql`SELECT count(*)::int AS unassigned FROM workspace WHERE project_id IS NULL`
201+
).toEqual([{ unassigned: 0 }])
202+
expect(
203+
await sql`SELECT count(*)::int AS restored FROM pg_attribute WHERE attrelid = 'workspace'::regclass
204+
AND attname = 'forked_from_workspace_id' AND NOT attisdropped`
205+
).toEqual([{ restored: 1 }])
206+
expect(
207+
await sql`SELECT name FROM script_migrations WHERE name = '0032_project_membership'`
208+
).toEqual([{ name: '0032_project_membership' }])
209+
})
210+
}, 240_000)
211+
188212
it('resumes after the Project migration reports conflicts', async () => {
189213
await preProjectDatabase(async (sql, url) => {
190214
await sql`INSERT INTO workspace (id, name, owner_id, billed_account_user_id, forked_from_workspace_id)
@@ -298,6 +322,13 @@ describe('development schema push of a database that predates Projects', () => {
298322
await sql`ALTER TABLE workspace ADD COLUMN project_id text`
299323
},
300324
},
325+
{
326+
state: 'folder Project ownership without Projects',
327+
async build(sql: Sql) {
328+
await sql`ALTER TABLE folder ADD COLUMN project_id text`
329+
await sql`UPDATE folder SET project_id = 'orphaned'`
330+
},
331+
},
301332
{
302333
state: 'a versioned expansion awaiting its script migration',
303334
async build(sql: Sql) {

‎packages/db/scripts/push.ts‎

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ const RECONCILIATION_COMMANDS = [
1717
['bun', '--env-file=.env', 'run', './script-migrations/0031_drop_keyword_tin.ts'],
1818
]
1919

20+
/** Reads, on the fence session, whether `--prepare` left a Project bootstrap for this push to finish. */
21+
type ProjectBootstrapProbe = () => Promise<boolean>
22+
2023
/** Fences preparation, Drizzle and reconcilers from a concurrent migration run. */
21-
async function withMigrationFence(run: (signal: AbortSignal) => Promise<number>): Promise<number> {
24+
async function withMigrationFence(
25+
run: (signal: AbortSignal, projectBootstrapPending: ProjectBootstrapProbe) => Promise<number>
26+
): Promise<number> {
2227
const rawUrl = process.env.DATABASE_URL
2328
if (!rawUrl) {
2429
logger.error('DATABASE_URL is required for schema push')
@@ -48,7 +53,11 @@ async function withMigrationFence(run: (signal: AbortSignal) => Promise<number>)
4853
logger.error('A migration is running; retry schema push later')
4954
return 1
5055
}
51-
return run(abort.signal)
56+
return run(abort.signal, async () => {
57+
const [state] = await tx<{ pending: boolean }[]>`
58+
SELECT to_regclass('public.project_membership_rollout') IS NOT NULL AS pending`.simple()
59+
return state.pending
60+
})
5261
})) as number
5362
} catch (error) {
5463
logger.error(
@@ -82,11 +91,21 @@ export async function runPush(args: string[]): Promise<number> {
8291
return 1
8392
}
8493

85-
async function runCommands(signal?: AbortSignal): Promise<number> {
86-
async function runCommand(command: string[]): Promise<number> {
94+
async function runCommands(
95+
signal?: AbortSignal,
96+
projectBootstrapPending?: ProjectBootstrapProbe
97+
): Promise<number> {
98+
async function runCommand(
99+
command: string[],
100+
env: Record<string, string> = {}
101+
): Promise<number> {
87102
signal?.throwIfAborted()
88103
const child = Bun.spawn(command, {
89-
env: { ...process.env, SIM_DB_PUSH_RENAME_MODE: interactiveRenames ? 'prompt' : 'create' },
104+
env: {
105+
...process.env,
106+
SIM_DB_PUSH_RENAME_MODE: interactiveRenames ? 'prompt' : 'create',
107+
...env,
108+
},
90109
stdin: 'inherit',
91110
stdout: 'inherit',
92111
stderr: 'inherit',
@@ -119,13 +138,21 @@ export async function runPush(args: string[]): Promise<number> {
119138
const pushArgs = args.filter((arg) => arg !== '--interactive-renames')
120139
// Launch the installed CLI directly so losing the fence also terminates its database connection.
121140
const cli = fileURLToPath(new URL('bin.cjs', import.meta.resolve('drizzle-kit')))
122-
const exitCode = await runCommand([
123-
'bun',
124-
cli,
125-
'push',
126-
'--config=./drizzle.config.ts',
127-
...pushArgs,
128-
])
141+
const drizzlePush = ['bun', cli, 'push', '--config=./drizzle.config.ts', ...pushArgs]
142+
if (!help && process.env.SIM_DEV_DB_PUSH === '1' && (await projectBootstrapPending?.())) {
143+
/** The Project migration reads columns an older database gains only from this push. */
144+
const code = await runCommand(drizzlePush, { SIM_DB_PUSH_PROJECT_BOOTSTRAP: '1' })
145+
if (code !== 0) return code
146+
const bootstrapCode = await runCommand([
147+
'bun',
148+
'--env-file=.env',
149+
'run',
150+
'./scripts/reconcile-project-membership.ts',
151+
'--bootstrap',
152+
])
153+
if (bootstrapCode !== 0) return bootstrapCode
154+
}
155+
const exitCode = await runCommand(drizzlePush)
129156
if (exitCode !== 0 || help) return exitCode
130157

131158
for (const command of RECONCILIATION_COMMANDS) {

‎packages/db/scripts/reconcile-project-membership.ts‎

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const url = process.env.MIGRATION_DATABASE_URL || process.env.DATABASE_URL
1212
if (!url) throw new Error('A database URL is required for Project schema push reconciliation')
1313
const sql = postgres(url, { max: 1, prepare: false, connect_timeout: 10, max_lifetime: null })
1414

15+
const REFUSAL =
16+
'Existing Project databases must complete the Project script migration 0032_project_membership before schema push. Use the phased migration path for retained data; schema push cannot bypass the writer-drain requirement'
17+
1518
interface ProjectSchemaState {
1619
workspace: boolean
1720
project: boolean
@@ -20,6 +23,8 @@ interface ProjectSchemaState {
2023
journal: boolean
2124
/** Null when `workspace.project_id` does not exist; otherwise whether it is NOT NULL. */
2225
projectIdNotNull: boolean | null
26+
/** Whether any public table carries a `project_id` column. */
27+
projectColumns: boolean
2328
}
2429

2530
async function readProjectSchemaState(): Promise<ProjectSchemaState> {
@@ -30,25 +35,29 @@ async function readProjectSchemaState(): Promise<ProjectSchemaState> {
3035
to_regclass('public.project_membership_rollout') IS NOT NULL AS rollout,
3136
to_regclass('drizzle.__drizzle_migrations') IS NOT NULL AS journal,
3237
(SELECT attnotnull FROM pg_attribute WHERE attrelid = to_regclass('public.workspace')
33-
AND attname = 'project_id' AND NOT attisdropped) AS "projectIdNotNull"
38+
AND attname = 'project_id' AND NOT attisdropped) AS "projectIdNotNull",
39+
EXISTS (SELECT 1 FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
40+
WHERE c.relnamespace = 'public'::regnamespace AND c.relkind IN ('r', 'p')
41+
AND a.attname = 'project_id' AND NOT a.attisdropped) AS "projectColumns"
3442
`
3543
return state
3644
}
3745

3846
/**
3947
* Classifies a development push database that has no connector writer to drain: either it
40-
* predates Projects entirely, or an earlier bootstrap committed the expansion and stopped before
41-
* enforcement. Only the versioned 0406 creates the rollout marker, so a marker that the migration
42-
* journal does not account for, beside a connector nothing has written, is a bootstrap to resume.
43-
* Every other shape returns null and keeps the writer-drain refusal.
48+
* predates Projects entirely (no Project table, connector, marker, or `project_id` column
49+
* anywhere), or an earlier bootstrap committed the expansion and has not finished. Only the
50+
* versioned 0406 creates the rollout marker, so a marker that the migration journal does not
51+
* account for, beside a connector nothing has written, is a bootstrap in progress. Every other
52+
* shape returns null and keeps the writer-drain refusal.
4453
*/
4554
async function classifyProjectBootstrap(
4655
state: ProjectSchemaState
4756
): Promise<'fresh' | 'resume' | null> {
48-
if (!state.workspace || state.projectIdNotNull) return null
49-
const parts = [state.project, state.connector, state.rollout, state.projectIdNotNull !== null]
50-
if (parts.every((present) => !present)) return 'fresh'
51-
if (!parts.every(Boolean)) return null
57+
if (process.env.SIM_DEV_DB_PUSH !== '1' || !state.workspace) return null
58+
if (!state.project && !state.connector && !state.rollout && !state.projectColumns) return 'fresh'
59+
if (!state.project || !state.connector || !state.rollout || state.projectIdNotNull !== false)
60+
return null
5261
const [connector] = await sql<{ populated: boolean }[]>`
5362
SELECT EXISTS (SELECT 1 FROM public.project_workspace) AS populated`
5463
if (connector.populated) return null
@@ -70,13 +79,12 @@ async function migrationStatements(tag: string): Promise<string[]> {
7079
}
7180

7281
/**
73-
* Applies the versioned Project expansion, then the registered script migration on this
74-
* single-session connection, recording it as the migration runner does. 0406 opens by committing
75-
* the migrator's batch; leaving that out commits the 0394 foundation together with the rollout
76-
* marker, so an interrupted bootstrap leaves either no Project schema or one it can resume.
82+
* Replays the versioned Project expansion. 0406 opens by committing the migrator's batch; leaving
83+
* that out commits the 0394 foundation together with the rollout marker, so an interrupted
84+
* expansion leaves either no Project schema or one the next push resumes.
7785
*/
78-
async function bootstrapProjects(mode: 'fresh' | 'resume'): Promise<void> {
79-
logger.info('Bootstrapping Projects for a development push database without them', { mode })
86+
async function expandProjects(mode: 'fresh' | 'resume'): Promise<void> {
87+
logger.info('Expanding Projects for a development push database without them', { mode })
8088
const foundation = mode === 'fresh' ? await migrationStatements('0394_project_foundation') : []
8189
const [batchCommit, ...expansion] = await migrationStatements('0406_workspace_project_column')
8290
if (batchCommit.trim() !== 'COMMIT;') {
@@ -89,28 +97,41 @@ async function bootstrapProjects(mode: 'fresh' | 'resume'): Promise<void> {
8997
await sql.unsafe('ROLLBACK').catch(() => undefined)
9098
throw error
9199
}
92-
await runScriptMigrations(sql, [projectMembershipMigration])
93100
}
94101

95-
const prepare = process.argv.includes('--prepare')
102+
function assertProjectMembershipComplete(state: ProjectSchemaState): void {
103+
if (state.workspace && (state.connector || !state.projectIdNotNull || !state.project)) {
104+
throw new Error(REFUSAL)
105+
}
106+
}
107+
108+
/**
109+
* `--prepare` runs before the first Drizzle push and expands an eligible bootstrap. `push.ts`
110+
* then pushes the schema with `workspace.project_id` still nullable, so the columns the Project
111+
* migration reads exist, and calls `--bootstrap` to run the registered migration on this
112+
* single-session connection, recorded as the migration runner records it. Without a flag, the
113+
* post-push pass installs the native constraints.
114+
*/
96115
try {
97116
let state = await readProjectSchemaState()
98-
if (prepare && process.env.SIM_DEV_DB_PUSH === '1') {
117+
if (process.argv.includes('--prepare')) {
99118
const bootstrap = await classifyProjectBootstrap(state)
100119
if (bootstrap) {
101-
await bootstrapProjects(bootstrap)
120+
await expandProjects(bootstrap)
102121
state = await readProjectSchemaState()
103122
}
104-
}
105-
if (state.workspace && (state.connector || !state.projectIdNotNull || !state.project)) {
106-
throw new Error(
107-
'Existing Project databases must complete the Project script migration 0032_project_membership before schema push. Use the phased migration path for retained data; schema push cannot bypass the writer-drain requirement'
108-
)
109-
}
110-
if (!prepare && state.workspace && state.project) {
111-
/** Drizzle cannot express deferred FK timing; fresh push shares the migration finalizer. */
112-
await enforceProjectMembership(sql)
113-
logger.info('Project membership validation and native constraints completed')
123+
if ((await classifyProjectBootstrap(state)) !== 'resume') assertProjectMembershipComplete(state)
124+
} else if (process.argv.includes('--bootstrap')) {
125+
if ((await classifyProjectBootstrap(state)) !== 'resume') throw new Error(REFUSAL)
126+
await runScriptMigrations(sql, [projectMembershipMigration])
127+
assertProjectMembershipComplete(await readProjectSchemaState())
128+
} else {
129+
assertProjectMembershipComplete(state)
130+
if (state.workspace && state.project) {
131+
/** Drizzle cannot express deferred FK timing; fresh push shares the migration finalizer. */
132+
await enforceProjectMembership(sql)
133+
logger.info('Project membership validation and native constraints completed')
134+
}
114135
}
115136
} catch (error) {
116137
logger.error('Project schema push stopped; resolve the error and retry', {

0 commit comments

Comments
 (0)