Skip to content

Commit f51be95

Browse files
committed
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 fbbd6df commit f51be95

2 files changed

Lines changed: 29 additions & 2 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ async function retainedState(sql: Sql) {
6969
return {
7070
schema,
7171
workspaces: await sql`SELECT to_jsonb(w) AS row FROM workspace w ORDER BY id`,
72+
folders: await sql`SELECT to_jsonb(f) AS row FROM folder f ORDER BY id`,
73+
projects: schema.project ? await sql`SELECT to_jsonb(p) AS row FROM project p ORDER BY id` : [],
7274
connector: schema.connector
7375
? await sql`SELECT * FROM project_workspace ORDER BY workspace_id`
7476
: [],
@@ -261,6 +263,20 @@ describe('development schema push of a database that predates Projects', () => {
261263
})
262264
}, 240_000)
263265

266+
it('bootstraps into public when the connection search path lists another schema first', async () => {
267+
await preProjectDatabase(async (sql, url) => {
268+
await sql`CREATE SCHEMA shadow`
269+
const shadowed = new URL(url)
270+
shadowed.searchParams.set('options', '-c search_path=shadow,public')
271+
const pushed = runPush(shadowed.toString(), true)
272+
expect(pushed.status, pushed.output).toBe(0)
273+
const shadowTables = await sql`SELECT tablename FROM pg_tables WHERE schemaname = 'shadow'
274+
AND tablename IN ('project', 'project_workspace', 'project_membership_rollout')`
275+
await expectBootstrapped(sql)
276+
expect(shadowTables).toEqual([])
277+
})
278+
}, 240_000)
279+
264280
it('leaves the database untouched outside development push mode', async () => {
265281
await preProjectDatabase(async (sql, url) => {
266282
const refused = runPush(url, false)

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ async function expandProjects(mode: 'fresh' | 'resume'): Promise<void> {
9999
}
100100
}
101101

102+
/** Migration SQL and the script runner name tables unqualified; pin them to `public`, as the migrator's default session does. */
103+
async function inPublicSchema(run: () => Promise<void>): Promise<void> {
104+
const [settings] = await sql<{ search_path: string }[]>`SHOW search_path`
105+
await sql`SELECT set_config('search_path', 'public, pg_temp', false)`
106+
try {
107+
await run()
108+
} finally {
109+
await sql`SELECT set_config('search_path', ${settings.search_path}, false)`
110+
}
111+
}
112+
102113
function assertProjectMembershipComplete(state: ProjectSchemaState): void {
103114
if (state.workspace && (state.connector || !state.projectIdNotNull || !state.project)) {
104115
throw new Error(REFUSAL)
@@ -117,13 +128,13 @@ try {
117128
if (process.argv.includes('--prepare')) {
118129
const bootstrap = await classifyProjectBootstrap(state)
119130
if (bootstrap) {
120-
await expandProjects(bootstrap)
131+
await inPublicSchema(() => expandProjects(bootstrap))
121132
state = await readProjectSchemaState()
122133
}
123134
if ((await classifyProjectBootstrap(state)) !== 'resume') assertProjectMembershipComplete(state)
124135
} else if (process.argv.includes('--bootstrap')) {
125136
if ((await classifyProjectBootstrap(state)) !== 'resume') throw new Error(REFUSAL)
126-
await runScriptMigrations(sql, [projectMembershipMigration])
137+
await inPublicSchema(() => runScriptMigrations(sql, [projectMembershipMigration]))
127138
assertProjectMembershipComplete(await readProjectSchemaState())
128139
} else {
129140
assertProjectMembershipComplete(state)

0 commit comments

Comments
 (0)