Skip to content

Commit d2f0893

Browse files
committed
fix(projects): retire rollout marker with legacy membership
1 parent d4e3da6 commit d2f0893

10 files changed

Lines changed: 46 additions & 115 deletions

‎packages/db/maintenance/project-membership.sql‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ SET statement_timeout = '60s';
55
--> statement-breakpoint
66
DO $$ BEGIN
77
IF to_regclass('public.project_membership_rollout') IS NULL THEN
8-
RAISE EXCEPTION 'Project membership rollout marker is missing' USING ERRCODE = '55000';
9-
END IF;
10-
IF NOT EXISTS (SELECT 1 FROM public.project_membership_rollout WHERE id = 'membership' AND phase = 'column') THEN
8+
IF to_regclass('public.project_workspace') IS NOT NULL
9+
OR NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.workspace'::regclass
10+
AND attname = 'project_id' AND attnotnull AND NOT attisdropped)
11+
OR (SELECT count(*) FROM pg_constraint
12+
WHERE conrelid = 'public.workspace'::regclass AND contype = 'f' AND convalidated
13+
AND conname IN ('workspace_project_id_project_id_fk',
14+
'workspace_project_organization_fk', 'workspace_fork_project_fk')) <> 3 THEN
15+
RAISE EXCEPTION 'Project membership rollout marker is missing before completed contraction' USING ERRCODE = '55000';
16+
END IF;
17+
ELSIF NOT EXISTS (SELECT 1 FROM public.project_membership_rollout WHERE id = 'membership' AND phase = 'column') THEN
1118
RAISE EXCEPTION 'Project membership authority must switch before enforcement' USING ERRCODE = '55000';
1219
END IF;
1320
IF NOT pg_try_advisory_lock(hashtextextended('sim:project-backfill-operator', 0)) THEN
@@ -189,6 +196,8 @@ ALTER TABLE workspace DROP CONSTRAINT IF EXISTS workspace_project_id_present;
189196
--> statement-breakpoint
190197
-- migration-safe: contract of #8830, gated on its authority-aware release being fully deployed, incompatible app/worker versions having drained, and the column-authority switch being committed; no supported application reader or writer then needs this connector.
191198
DROP TABLE IF EXISTS project_workspace;
199+
-- migration-safe: contract of #8830, whose deployed readers recognize the required column and validated foreign keys after both temporary tables disappear atomically under the workspace barrier.
200+
DROP TABLE IF EXISTS project_membership_rollout;
192201
--> statement-breakpoint
193202
COMMIT;
194203
--> statement-breakpoint

‎packages/db/maintenance/project-rollout.ts‎

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,19 @@ type ProjectMembershipPhase = 'connector' | 'column'
99
export async function readProjectMembershipPhase(
1010
sql: Sql | TransactionSql
1111
): Promise<ProjectMembershipPhase> {
12-
const [table] =
13-
await sql`SELECT to_regclass('public.project_membership_rollout') IS NOT NULL AS present`
14-
if (!table?.present) throw new Error('Project membership rollout marker is missing')
12+
const [table] = await sql`SELECT
13+
to_regclass('public.project_membership_rollout') IS NOT NULL AS present,
14+
to_regclass('public.project_workspace') IS NULL
15+
AND EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass('public.workspace')
16+
AND attname = 'project_id' AND attnotnull AND NOT attisdropped)
17+
AND (SELECT count(*) = 3 FROM pg_constraint
18+
WHERE conrelid = to_regclass('public.workspace') AND contype = 'f' AND convalidated
19+
AND conname IN ('workspace_project_id_project_id_fk',
20+
'workspace_project_organization_fk', 'workspace_fork_project_fk')) AS complete`
21+
if (!table?.present) {
22+
if (table?.complete) return 'column'
23+
throw new Error('Project membership rollout marker is missing before completed contraction')
24+
}
1525
const rows = await sql<
1626
{ id: string; phase: string }[]
1727
>`SELECT id, phase FROM public.project_membership_rollout`
@@ -64,26 +74,3 @@ export async function switchProjectMembershipAuthority(sql: Sql): Promise<void>
6474
}
6575
}
6676
}
67-
68-
/** Only an empty final schema push may initialize a missing singleton directly in column mode. */
69-
export async function bootstrapProjectColumnAuthority(sql: Sql): Promise<void> {
70-
await sql.begin(async (tx) => {
71-
await tx`SET LOCAL statement_timeout = '3s'`
72-
await tx`LOCK TABLE public.workspace IN ACCESS EXCLUSIVE MODE NOWAIT`
73-
const [state] = await tx`SELECT
74-
to_regclass('public.project_workspace') IS NULL AS retired,
75-
EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.workspace'::regclass
76-
AND attname = 'project_id' AND attnotnull AND NOT attisdropped) AS required,
77-
NOT EXISTS (SELECT 1 FROM public.workspace) AND NOT EXISTS (SELECT 1 FROM public.project) AS empty`
78-
if (!state?.retired || !state.required)
79-
throw new Error('Project authority bootstrap requires the fresh final schema')
80-
const [table] =
81-
await tx`SELECT to_regclass('public.project_membership_rollout') IS NOT NULL AS present`
82-
if (!table?.present)
83-
throw new Error('Project membership rollout table is missing from the final schema')
84-
const rows = await tx`SELECT id, phase FROM public.project_membership_rollout`
85-
if (!rows.length && state.empty)
86-
await tx`INSERT INTO public.project_membership_rollout (id, phase) VALUES ('membership', 'column')`
87-
await assertProjectColumnAuthority(tx)
88-
})
89-
}

‎packages/db/migrations/meta/0407_snapshot.json‎

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30610,41 +30610,6 @@
3061030610
"policies": {},
3061130611
"checkConstraints": {},
3061230612
"isRLSEnabled": false
30613-
},
30614-
"public.project_membership_rollout": {
30615-
"name": "project_membership_rollout",
30616-
"schema": "",
30617-
"columns": {
30618-
"id": {
30619-
"name": "id",
30620-
"type": "text",
30621-
"primaryKey": true,
30622-
"notNull": true
30623-
},
30624-
"phase": {
30625-
"name": "phase",
30626-
"type": "text",
30627-
"primaryKey": false,
30628-
"notNull": true,
30629-
"default": "'connector'"
30630-
}
30631-
},
30632-
"indexes": {},
30633-
"foreignKeys": {},
30634-
"compositePrimaryKeys": {},
30635-
"uniqueConstraints": {},
30636-
"policies": {},
30637-
"checkConstraints": {
30638-
"project_membership_rollout_singleton": {
30639-
"name": "project_membership_rollout_singleton",
30640-
"value": "\"project_membership_rollout\".\"id\" = 'membership'"
30641-
},
30642-
"project_membership_rollout_phase": {
30643-
"name": "project_membership_rollout_phase",
30644-
"value": "\"project_membership_rollout\".\"phase\" IN ('connector', 'column')"
30645-
}
30646-
},
30647-
"isRLSEnabled": false
3064830613
}
3064930614
},
3065030615
"enums": {

‎packages/db/schema.ts‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,24 +2148,6 @@ export const project = pgTable(
21482148
})
21492149
)
21502150

2151-
/** contract-pending(after #8830 is fully deployed): #8590 drops this with the connector; readers recognize the completed schema. */
2152-
export const projectMembershipRollout = pgTable(
2153-
'project_membership_rollout',
2154-
{
2155-
id: text('id').primaryKey(),
2156-
phase: text('phase', { enum: ['connector', 'column'] })
2157-
.notNull()
2158-
.default('connector'),
2159-
},
2160-
(table) => ({
2161-
singleton: check('project_membership_rollout_singleton', sql`${table.id} = 'membership'`),
2162-
phase: check(
2163-
'project_membership_rollout_phase',
2164-
sql`${table.phase} IN ('connector', 'column')`
2165-
),
2166-
})
2167-
)
2168-
21692151
export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [
21702152
'workflow',
21712153
'oauth_credential',

‎packages/db/scripts/project-authority.integration.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFile } from 'node:child_process'
22
import { readFile } from 'node:fs/promises'
33
import { promisify } from 'node:util'
4+
import { readProjectMembershipPhase } from '@sim/db/maintenance/project-rollout'
45
import { runScriptMigrations, scriptMigrations } from '@sim/db/script-migrations'
56
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
67
import { generateId } from '@sim/utils/id'
@@ -52,7 +53,7 @@ function migrate(sql: Sql) {
5253
}
5354

5455
async function phase(sql: Sql) {
55-
return sql`SELECT phase FROM project_membership_rollout WHERE id = 'membership'`
56+
return [{ phase: await readProjectMembershipPhase(sql) }]
5657
}
5758

5859
describe('Project membership authority cutover', () => {
@@ -66,6 +67,9 @@ describe('Project membership authority cutover', () => {
6667
})
6768
await migrate(runner)
6869
expect(await phase(sql)).toEqual([{ phase: 'column' }])
70+
expect(await sql`SELECT to_regclass('public.project_membership_rollout') AS marker`).toEqual([
71+
{ marker: null },
72+
])
6973
expect(await sql`SELECT name,project_id FROM workspace`).toEqual([
7074
{ name: 'Legacy write', project_id: 'retained' },
7175
])

‎packages/db/scripts/project-contract.integration.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,9 @@ describe('Project expand/backfill/contract against PostgreSQL', () => {
296296
expect(await sql`SELECT to_regclass('project_workspace')::text AS connector`).toEqual([
297297
{ connector: null },
298298
])
299+
expect(await sql`SELECT to_regclass('public.project_membership_rollout') AS marker`).toEqual([
300+
{ marker: null },
301+
])
299302
const assignments = await sql`SELECT id,project_id FROM workspace ORDER BY id`
300303
await runRegisteredProjectMigration(url)
301304
expect(await sql`SELECT id,project_id FROM workspace ORDER BY id`).toEqual(assignments)
@@ -1215,6 +1218,9 @@ describe('Project expand/backfill/contract against PostgreSQL', () => {
12151218
expect(await sql`SELECT to_regclass('project_workspace')::text AS connector`).toEqual([
12161219
{ connector: null },
12171220
])
1221+
expect(
1222+
await sql`SELECT to_regclass('public.project_membership_rollout') AS marker`
1223+
).toEqual([{ marker: null }])
12181224
expect(await sql`SELECT * FROM script_migrations`).toHaveLength(0)
12191225
await expect(
12201226
sql`INSERT INTO workspace (id,name,owner_id) VALUES ('bad','Bad','owner')`

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

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,7 @@ export const knowledgeBases = pgTable('knowledge_base', {
223223
await sql`ALTER TABLE workspace ADD COLUMN project_id text`
224224
await sql`UPDATE workspace SET project_id = 'retained'`
225225
}
226-
await schema(`export const rollout = pgTable('project_membership_rollout', {
227-
id: text('id').primaryKey(), phase: text('phase').notNull().default('connector'),
228-
}, (t) => [check('project_membership_rollout_singleton', sql\`\${t.id} = 'membership'\`), check('project_membership_rollout_phase', sql\`\${t.phase} IN ('connector', 'column')\`)])
229-
export const projects = pgTable('project', { id: text('id').primaryKey() })
226+
await schema(`export const projects = pgTable('project', { id: text('id').primaryKey() })
230227
export const workspaces = pgTable('workspace', { id: text('id').primaryKey(), projectId: text('project_id').notNull() })`)
231228
const result = runPush(['--force'])
232229
expect(result.error).toBeUndefined()
@@ -259,10 +256,7 @@ export const workspaces = pgTable('workspace', { id: text('id').primaryKey(), pr
259256
expect(result.error).toBeUndefined()
260257
expect(result.status, result.stdout + result.stderr).toBe(0)
261258
}
262-
await schema(`export const rollout = pgTable('project_membership_rollout', {
263-
id: text('id').primaryKey(), phase: text('phase').notNull().default('connector'),
264-
}, (t) => [check('project_membership_rollout_singleton', sql\`\${t.id} = 'membership'\`), check('project_membership_rollout_phase', sql\`\${t.phase} IN ('connector', 'column')\`)])
265-
export const projects = pgTable('project', {
259+
await schema(`export const projects = pgTable('project', {
266260
id: text('id').primaryKey(), name: text('name').notNull(), ownerId: text('owner_id').notNull(),
267261
organizationScopeKey: text('organization_scope_key').generatedAlwaysAs(sql\`CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END\`),
268262
organizationId: text('organization_id'), archivedAt: timestamp('archived_at'), updatedAt: timestamp('updated_at').notNull().defaultNow(),
@@ -284,8 +278,8 @@ export const workflows = pgTable('workflow', {
284278
expect(first.error, first.stderr).toBeUndefined()
285279
expect(first.status, first.stdout + first.stderr).toBe(0)
286280
reconcileProjects()
287-
expect(await sql`SELECT id, phase FROM project_membership_rollout`).toEqual([
288-
{ id: 'membership', phase: 'column' },
281+
expect(await sql`SELECT to_regclass('public.project_membership_rollout') AS marker`).toEqual([
282+
{ marker: null },
289283
])
290284
await sql`INSERT INTO project (id,name,owner_id) VALUES ('empty','Empty','owner')`
291285
await sql`DELETE FROM project WHERE id = 'empty'`
@@ -297,7 +291,9 @@ export const workflows = pgTable('workflow', {
297291
expect(repeated.error, repeated.stderr).toBeUndefined()
298292
expect(repeated.status, repeated.stdout + repeated.stderr).toBe(0)
299293
reconcileProjects()
300-
expect(await sql`SELECT phase FROM project_membership_rollout`).toEqual([{ phase: 'column' }])
294+
expect(await sql`SELECT to_regclass('public.project_membership_rollout') AS marker`).toEqual([
295+
{ marker: null },
296+
])
301297
expect(await sql`SELECT id,project_id FROM workspace ORDER BY id`).toEqual([
302298
{ id: 'fork', project_id: 'family' },
303299
{ id: 'root', project_id: 'family' },

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { enforceProjectMembership } from '@sim/db/maintenance/project-enforcement'
2-
import { bootstrapProjectColumnAuthority } from '@sim/db/maintenance/project-rollout'
32
import { createLogger } from '@sim/logger'
43
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
54
import postgres from 'postgres'
@@ -24,10 +23,9 @@ try {
2423
)
2524
}
2625
if (!process.argv.includes('--prepare') && state.workspace && state.project) {
27-
// Drizzle cannot express lifecycle triggers; fresh push uses the same enforcement as migrations.
28-
await bootstrapProjectColumnAuthority(sql)
26+
/** Drizzle cannot express deferred FK timing; fresh push shares the migration finalizer. */
2927
await enforceProjectMembership(sql)
30-
logger.info('Project membership validation and lifecycle enforcement completed')
28+
logger.info('Project membership validation and native constraints completed')
3129
}
3230
} catch (error) {
3331
logger.error('Project schema push stopped; resolve the error and retry', {

‎packages/testing/src/mocks/database.mock.ts‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,6 @@ function dequeueChainRows(tables: unknown[]): unknown[] | null {
151151
const queue = tableRowQueues.get(table)
152152
if (queue && queue.length > 0) return queue.shift() ?? null
153153
}
154-
/** Default unit fixtures represent a migrated database after authority cutover. */
155-
if (tables.includes(schemaMock.projectMembershipRollout)) {
156-
return [{ id: 'membership', phase: 'column' }]
157-
}
158154
return null
159155
}
160156

@@ -244,18 +240,7 @@ const update = chainSpy()
244240
const set = chainSpy()
245241
const del = chainSpy()
246242
const returning = vi.fn(() => Promise.resolve([] as unknown[]))
247-
const execute = vi.fn((statement?: unknown) => {
248-
if (
249-
statement &&
250-
typeof statement === 'object' &&
251-
'toSQL' in statement &&
252-
typeof statement.toSQL === 'function' &&
253-
statement.toSQL().sql.includes("to_regclass('public.project_membership_rollout')")
254-
) {
255-
return Promise.resolve([{ marker: true, complete: false }] as unknown[])
256-
}
257-
return Promise.resolve([] as unknown[])
258-
})
243+
const execute = vi.fn(() => Promise.resolve([] as unknown[]))
259244
const query = vi.fn(() => Promise.resolve([] as unknown[]))
260245
const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise<void>)
261246
const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise<void>)

‎packages/testing/src/mocks/schema-tables.generated.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,6 @@ export const GENERATED_SCHEMA_TABLES = {
517517
'createdAt',
518518
'updatedAt',
519519
],
520-
projectMembershipRollout: ['id', 'phase'],
521520
workspaceForkResourceMap: [
522521
'id',
523522
'childWorkspaceId',

0 commit comments

Comments
 (0)