Skip to content

Commit e137d82

Browse files
authored
fix(db): bootstrap Projects for development push databases that predate them (#9611)
* 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. * 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. * fix(db): pin the Project bootstrap to the public schema The replayed migration SQL and the script runner name tables unqualified, so a connection search path that lists another schema first created the Project foundation outside public. The refusal snapshot now also covers folders and Projects.
1 parent 0a2970f commit e137d82

8 files changed

Lines changed: 623 additions & 31 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()

0 commit comments

Comments
 (0)