Skip to content

Commit 467d269

Browse files
committed
fix(projects): recognize completed rollout without its marker
1 parent 1988c88 commit 467d269

6 files changed

Lines changed: 86 additions & 5 deletions

File tree

‎apps/sim/lib/billing/organizations/lock-order.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('workspace payer-change transaction lock ordering', () => {
235235
return chain
236236
}
237237
const tx = {
238-
execute: async () => [],
238+
execute: async () => [{ marker: true, complete: false }],
239239
select,
240240
selectDistinct: select,
241241
insert: () => ({

‎apps/sim/lib/projects/__integration__/foundation.integration.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,60 @@ afterAll(async () => {
314314
})
315315

316316
describe('Project foundation at the database and application boundary', () => {
317+
check(
318+
'completed contraction needs no rollout marker, but incomplete schemas fail closed',
319+
async () => {
320+
const f = await fixture(false, 1)
321+
const rollback = new Error('Restore expansion schema after contraction proof')
322+
await expect(
323+
db.transaction(async (tx) => {
324+
await tx.execute(sql`LOCK TABLE workspace IN ACCESS EXCLUSIVE MODE NOWAIT`)
325+
await tx.execute(sql`DROP TABLE project_membership_rollout`)
326+
await expect(getProjectMembershipPhase(tx)).rejects.toThrow('authority is missing')
327+
await tx.execute(sql`DROP TABLE project_workspace`)
328+
await expect(getProjectMembershipPhase(tx)).rejects.toThrow('authority is missing')
329+
await tx.execute(sql`ALTER TABLE workspace ALTER COLUMN project_id SET NOT NULL`)
330+
await expect(getProjectMembershipPhase(tx)).rejects.toThrow('authority is missing')
331+
await tx.execute(sql`ALTER TABLE project ADD COLUMN organization_scope_key text
332+
GENERATED ALWAYS AS (CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END) STORED`)
333+
await tx.execute(sql`ALTER TABLE workspace ADD COLUMN organization_scope_key text
334+
GENERATED ALWAYS AS (CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END) STORED`)
335+
await tx.execute(
336+
sql`ALTER TABLE project ADD CONSTRAINT project_id_organization_scope_unique UNIQUE (id, organization_scope_key)`
337+
)
338+
await tx.execute(
339+
sql`ALTER TABLE workspace ADD CONSTRAINT workspace_id_project_unique UNIQUE (id, project_id)`
340+
)
341+
await tx.execute(sql`ALTER TABLE workspace ADD CONSTRAINT workspace_project_organization_fk
342+
FOREIGN KEY (project_id, organization_scope_key) REFERENCES project(id, organization_scope_key) DEFERRABLE INITIALLY DEFERRED`)
343+
await tx.execute(sql`ALTER TABLE workspace ADD CONSTRAINT workspace_fork_project_fk
344+
FOREIGN KEY (forked_from_workspace_id, project_id) REFERENCES workspace(id, project_id) DEFERRABLE INITIALLY DEFERRED`)
345+
await tx.execute(
346+
sql`ALTER TABLE workspace VALIDATE CONSTRAINT workspace_project_id_project_id_fk`
347+
)
348+
expect(await getProjectMembershipPhase(tx)).toBe('column')
349+
const created = await createWorkspaceInTransaction(tx, {
350+
userId: f.ownerId,
351+
name: 'After contraction',
352+
organizationId: null,
353+
observedOrganizationId: null,
354+
governingPermissionGroupOrganizationId: null,
355+
workspaceMode: 'personal',
356+
billedAccountUserId: f.ownerId,
357+
skipDefaultWorkflow: true,
358+
})
359+
const environments = await getProjectEnvironmentSource(tx)
360+
const [row] = await tx
361+
.select({ projectId: environments.projectId })
362+
.from(environments)
363+
.where(eq(environments.id, created.id))
364+
expect(row.projectId).toEqual(expect.any(String))
365+
throw rollback
366+
})
367+
).rejects.toBe(rollback)
368+
}
369+
)
370+
317371
for (const phase of ['connector', 'column'] as const) {
318372
check(`workspace creation and fork/disconnect use only ${phase} authority`, async () => {
319373
setProjectsEnabled(false)

‎apps/sim/lib/projects/environment-source.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ export async function getProjectMembershipPhase(
3939
tx: DbTransaction
4040
): Promise<'connector' | 'column'> {
4141
await lockProjectMembershipBarrier(tx)
42+
const [schema] = await tx.execute<{ marker: boolean; complete: boolean }>(sql`
43+
SELECT to_regclass('public.project_membership_rollout') IS NOT NULL AS marker,
44+
to_regclass('public.project_workspace') IS NULL
45+
AND EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'public.workspace'::regclass
46+
AND attname = 'project_id' AND attnotnull AND NOT attisdropped)
47+
AND (SELECT count(*) = 3 FROM pg_constraint
48+
WHERE conrelid = 'public.workspace'::regclass AND contype = 'f' AND convalidated
49+
AND conname IN ('workspace_project_id_project_id_fk',
50+
'workspace_project_organization_fk', 'workspace_fork_project_fk')) AS complete
51+
`)
52+
if (!schema?.marker) {
53+
if (schema?.complete) return 'column'
54+
throw new Error('Project membership authority is missing or invalid')
55+
}
4256
const [state] = await tx
4357
.select({ phase: projectMembershipRollout.phase })
4458
.from(projectMembershipRollout)

‎packages/db/PROJECT_MEMBERSHIP.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,16 @@ deferred timing after fresh schema push because Drizzle does not represent that
4242
only for unassigned legacy rows. It performs no bulk membership backfill.
4343
2. After verifying incompatible membership-sensitive requests/workers have drained,
4444
#8590 switches authority, performs bounded backfill/reconciliation, validates native
45-
constraints, requires membership, and retires the connector. Its migration runs
45+
constraints, requires membership, and retires the connector and authority marker atomically. Its migration runs
4646
while #8830 is serving, before the new app is promoted.
4747

4848
The all-at-once traffic switch stops fresh requests to retired app servers. Remaining
4949
in-flight operations and independently scheduled workers must be identified explicitly.
5050
No new infrastructure maintenance mechanism, synchronization triggers, dual writes, or
5151
extra compatibility release is required. After authority switches, #8830 is the oldest
52-
supported application rollback; retain the column-phase marker and database changes.
52+
supported application rollback; retain the database changes. #8830 recognizes the completed schema (no connector or
53+
marker, required membership, and validated foreign keys) as column authority; a missing
54+
marker before completion remains an error. This supersedes retaining the marker for rollback.
5355

5456
On PostgreSQL 16/17, adding stored generated scope columns rewrites their tables. The
5557
finalizer bounds the rewrite and refuses busy tables without waiting. A timeout leaves

‎packages/db/schema.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2122,7 +2122,7 @@ export const project = pgTable(
21222122
})
21232123
)
21242124

2125-
/** Durable authority selection; retained after connector retirement for expansion rollback. */
2125+
/** contract-pending(after #8830 is fully deployed): #8590 drops this with the connector; readers recognize the completed schema. */
21262126
export const projectMembershipRollout = pgTable(
21272127
'project_membership_rollout',
21282128
{

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,18 @@ const update = chainSpy()
244244
const set = chainSpy()
245245
const del = chainSpy()
246246
const returning = vi.fn(() => Promise.resolve([] as unknown[]))
247-
const execute = 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+
})
248259
const query = vi.fn(() => Promise.resolve([] as unknown[]))
249260
const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise<void>)
250261
const onConflictDoNothing = vi.fn(() => ({ returning }) as unknown as Promise<void>)

0 commit comments

Comments
 (0)