Skip to content

Commit 32f49e3

Browse files
committed
merge: integrate Project authority switch into enforcement
2 parents d8a9627 + ceb7cf3 commit 32f49e3

13 files changed

Lines changed: 216 additions & 27 deletions

‎apps/sim/lib/projects/__integration__/backfill-repair.integration.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,6 @@ describeWithRedis('Operator archive repair against the full compatible schema',
112112
timeout: 120000,
113113
}
114114
)
115-
await client.unsafe(`CREATE TABLE IF NOT EXISTS project_membership_rollout (
116-
id text PRIMARY KEY CHECK (id = 'membership'),
117-
phase text NOT NULL DEFAULT 'connector' CONSTRAINT project_membership_rollout_phase CHECK (phase IN ('connector', 'column'))
118-
)`)
119-
await client`INSERT INTO project_membership_rollout (id, phase) VALUES ('membership', 'connector') ON CONFLICT DO NOTHING`
120115
await client`SELECT pg_advisory_lock(hashtextextended('sim:project-backfill-operator', 0))`
121116
try {
122117
await switchProjectMembershipAuthority(client)

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import type { Sql } from 'postgres'
66
export async function enforceProjectMembership(sql: Sql): Promise<void> {
77
const source = await readFile(new URL('./project-membership.sql', import.meta.url), 'utf8')
88
const connection = await sql.reserve()
9+
let previousSearchPath: string | undefined
910
try {
11+
const [settings] = await connection<{ search_path: string }[]>`SHOW search_path`
12+
previousSearchPath = settings.search_path
13+
/** The SQL spans committed phases, so pin this reserved session until all phases finish. */
14+
await connection`SELECT set_config('search_path', 'public, pg_temp', false)`
1015
for (const statement of source.split('--> statement-breakpoint')) {
1116
if (statement.trim()) await connection.unsafe(statement)
1217
}
@@ -19,6 +24,12 @@ export async function enforceProjectMembership(sql: Sql): Promise<void> {
1924
await connection
2025
.unsafe("SELECT pg_advisory_unlock(hashtextextended('sim:project-backfill-operator', 0))")
2126
.catch(() => undefined)
22-
connection.release()
27+
try {
28+
if (previousSearchPath !== undefined) {
29+
await connection`SELECT set_config('search_path', ${previousSearchPath}, false)`
30+
}
31+
} finally {
32+
connection.release()
33+
}
2334
}
2435
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ DO $$ BEGIN
1515
END IF;
1616
END $$;
1717
--> statement-breakpoint
18-
CREATE OR REPLACE FUNCTION pg_temp.validate_project_membership() RETURNS void LANGUAGE plpgsql AS $$
18+
CREATE OR REPLACE FUNCTION pg_temp.validate_project_membership() RETURNS void LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
1919
BEGIN
2020
IF EXISTS (
2121
WITH RECURSIVE reachable(id) AS (
@@ -91,7 +91,7 @@ DO $$ BEGIN
9191
END $$;
9292
--> statement-breakpoint
9393
CREATE OR REPLACE FUNCTION project_contract_assert_project(target_id text) RETURNS void
94-
LANGUAGE plpgsql AS $$
94+
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
9595
DECLARE
9696
record project%ROWTYPE;
9797
total bigint;
@@ -124,7 +124,7 @@ END;
124124
$$;
125125
--> statement-breakpoint
126126
CREATE OR REPLACE FUNCTION project_contract_assert_workspace(target_id text) RETURNS void
127-
LANGUAGE plpgsql AS $$
127+
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
128128
DECLARE
129129
parent_id text;
130130
member_project_id text;
@@ -150,7 +150,7 @@ END;
150150
$$;
151151
--> statement-breakpoint
152152
CREATE OR REPLACE FUNCTION project_contract_lock_projects(target_ids text[]) RETURNS void
153-
LANGUAGE plpgsql AS $$
153+
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
154154
DECLARE
155155
target_id text;
156156
BEGIN
@@ -164,7 +164,7 @@ END;
164164
$$;
165165
--> statement-breakpoint
166166
CREATE OR REPLACE FUNCTION project_contract_before_write() RETURNS trigger
167-
LANGUAGE plpgsql AS $$
167+
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
168168
DECLARE
169169
previous_id text;
170170
next_id text;
@@ -183,7 +183,7 @@ END;
183183
$$;
184184
--> statement-breakpoint
185185
CREATE OR REPLACE FUNCTION project_contract_after_write() RETURNS trigger
186-
LANGUAGE plpgsql AS $$
186+
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
187187
DECLARE
188188
previous_id text;
189189
next_id text;

‎packages/db/migrations/0405_workspace_project_column.sql‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,27 @@ SELECT set_config(CASE WHEN current_setting('transaction_timeout', true) IS NULL
66
SET LOCAL statement_timeout = '5s';
77
LOCK TABLE workspace IN ACCESS EXCLUSIVE MODE NOWAIT;
88
LOCK TABLE project IN SHARE ROW EXCLUSIVE MODE NOWAIT;
9-
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "project_id" text;
9+
CREATE TABLE IF NOT EXISTS "project_membership_rollout" (
10+
"id" text PRIMARY KEY,
11+
"phase" text DEFAULT 'connector' NOT NULL,
12+
CONSTRAINT "project_membership_rollout_singleton" CHECK ("id" = 'membership'),
13+
CONSTRAINT "project_membership_rollout_phase" CHECK ("phase" IN ('connector', 'column'))
14+
);
15+
DO $$
16+
DECLARE had_column boolean;
17+
BEGIN
18+
SELECT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = 'workspace'::regclass
19+
AND attname = 'project_id' AND NOT attisdropped) INTO had_column;
20+
ALTER TABLE workspace ADD COLUMN IF NOT EXISTS project_id text;
21+
IF NOT EXISTS (SELECT 1 FROM project_membership_rollout WHERE id = 'membership') THEN
22+
IF had_column THEN
23+
IF EXISTS (SELECT 1 FROM workspace WHERE project_id IS DISTINCT FROM NULL) THEN
24+
RAISE EXCEPTION 'Populated Project columns require explicit authority reconciliation before rollout initialization' USING ERRCODE = '55000';
25+
END IF;
26+
END IF;
27+
INSERT INTO project_membership_rollout (id, phase) VALUES ('membership', 'connector');
28+
END IF;
29+
END $$;
1030
DO $$ BEGIN
1131
IF NOT EXISTS (SELECT 1 FROM pg_constraint
1232
WHERE conrelid = 'workspace'::regclass AND conname = 'workspace_project_id_project_id_fk') THEN

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

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

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17488,6 +17488,41 @@
1748817488
},
1748917489
"isRLSEnabled": false
1749017490
},
17491+
"public.project_membership_rollout": {
17492+
"name": "project_membership_rollout",
17493+
"schema": "",
17494+
"columns": {
17495+
"id": {
17496+
"name": "id",
17497+
"type": "text",
17498+
"primaryKey": true,
17499+
"notNull": true
17500+
},
17501+
"phase": {
17502+
"name": "phase",
17503+
"type": "text",
17504+
"primaryKey": false,
17505+
"notNull": true,
17506+
"default": "'connector'"
17507+
}
17508+
},
17509+
"indexes": {},
17510+
"foreignKeys": {},
17511+
"compositePrimaryKeys": {},
17512+
"uniqueConstraints": {},
17513+
"policies": {},
17514+
"checkConstraints": {
17515+
"project_membership_rollout_singleton": {
17516+
"name": "project_membership_rollout_singleton",
17517+
"value": "\"project_membership_rollout\".\"id\" = 'membership'"
17518+
},
17519+
"project_membership_rollout_phase": {
17520+
"name": "project_membership_rollout_phase",
17521+
"value": "\"project_membership_rollout\".\"phase\" IN ('connector', 'column')"
17522+
}
17523+
},
17524+
"isRLSEnabled": false
17525+
},
1749117526
"public.public_share": {
1749217527
"name": "public_share",
1749317528
"schema": "",

‎packages/db/schema.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2114,6 +2114,24 @@ export const project = pgTable(
21142114
})
21152115
)
21162116

2117+
/** Durable authority selection; retained after connector retirement for expansion rollback. */
2118+
export const projectMembershipRollout = pgTable(
2119+
'project_membership_rollout',
2120+
{
2121+
id: text('id').primaryKey(),
2122+
phase: text('phase', { enum: ['connector', 'column'] })
2123+
.notNull()
2124+
.default('connector'),
2125+
},
2126+
(table) => ({
2127+
singleton: check('project_membership_rollout_singleton', sql`${table.id} = 'membership'`),
2128+
phase: check(
2129+
'project_membership_rollout_phase',
2130+
sql`${table.phase} IN ('connector', 'column')`
2131+
),
2132+
})
2133+
)
2134+
21172135
export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [
21182136
'workflow',
21192137
'oauth_credential',

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,6 @@ async function database(run: (sql: Sql, runner: Sql, url: string) => Promise<voi
3333
'utf8'
3434
)
3535
for (const statement of expansion.split('--> statement-breakpoint')) await sql.unsafe(statement)
36-
await sql.unsafe(`CREATE TABLE IF NOT EXISTS project_membership_rollout (
37-
id text PRIMARY KEY CHECK (id = 'membership'),
38-
phase text NOT NULL DEFAULT 'connector' CONSTRAINT project_membership_rollout_phase CHECK (phase IN ('connector', 'column'))
39-
)`)
40-
await sql`INSERT INTO project_membership_rollout (id, phase) VALUES ('membership', 'connector') ON CONFLICT DO NOTHING`
4136
await sql`INSERT INTO project (id,name,owner_id) VALUES ('retained','Retained','owner')`
4237
await sql`INSERT INTO workspace (id,name,owner_id) VALUES ('env','Environment','owner')`
4338
await sql`INSERT INTO project_workspace (project_id,workspace_id) VALUES ('retained','env')`

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

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
projectBackfillDatabaseId,
1212
verifyProjectBackfill,
1313
} from '@sim/db/maintenance/project-backfill'
14+
import { enforceProjectMembership } from '@sim/db/maintenance/project-enforcement'
1415
import journal from '@sim/db/migrations/meta/_journal.json'
1516
import { runScriptMigrations, scriptMigrations } from '@sim/db/script-migrations'
1617
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
@@ -55,12 +56,7 @@ async function database(run: (sql: Sql, url: string) => Promise<void>) {
5556
)
5657
await applyMigration(sql, expansion)
5758
/** These reconciliation fixtures model the already committed column-authority phase. */
58-
await sql.unsafe(`CREATE TABLE IF NOT EXISTS project_membership_rollout (
59-
id text PRIMARY KEY CHECK (id = 'membership'),
60-
phase text NOT NULL DEFAULT 'connector' CONSTRAINT project_membership_rollout_phase CHECK (phase IN ('connector', 'column'))
61-
)`)
62-
await sql`INSERT INTO project_membership_rollout (id, phase) VALUES ('membership', 'column')
63-
ON CONFLICT (id) DO UPDATE SET phase = 'column'`
59+
await sql`UPDATE project_membership_rollout SET phase = 'column' WHERE id = 'membership'`
6460
await run(sql, url.toString())
6561
} finally {
6662
await sql.end({ timeout: 2 })
@@ -519,6 +515,60 @@ describe('Project expand/backfill/contract against PostgreSQL', () => {
519515
}
520516
)
521517

518+
it('enforces public membership across a shadow search path and restores the caller path', async () => {
519+
await database(async (sql, url) => {
520+
await sql`INSERT INTO project (id,name,owner_id) VALUES ('retained','Retained','owner')`
521+
await sql`CREATE SCHEMA shadow`
522+
await sql`CREATE TABLE shadow.project (LIKE public.project INCLUDING ALL)`
523+
await sql`CREATE TABLE shadow.workspace (LIKE public.workspace INCLUDING ALL)`
524+
await sql`CREATE TABLE shadow.workflow (LIKE public.workflow INCLUDING ALL)`
525+
await sql`CREATE TABLE shadow.project_workspace (project_id text, workspace_id text)`
526+
await sql`INSERT INTO shadow.workspace (id,name,owner_id) VALUES ('shadow','Untouched','owner')`
527+
const scoped = postgres(url, {
528+
max: 1,
529+
connection: { search_path: 'shadow,public' },
530+
onnotice: () => {},
531+
})
532+
try {
533+
await expect(enforceProjectMembership(scoped)).rejects.toThrow('nonempty Projects')
534+
expect(await scoped`SHOW search_path`).toEqual([{ search_path: 'shadow,public' }])
535+
await sql`INSERT INTO workspace (id,name,owner_id,project_id) VALUES ('env','Environment','owner','retained')`
536+
await enforceProjectMembership(scoped)
537+
expect(await scoped`SHOW search_path`).toEqual([{ search_path: 'shadow,public' }])
538+
expect(await sql`SELECT to_regclass('public.project_workspace') AS connector`).toEqual([
539+
{ connector: null },
540+
])
541+
expect(
542+
await sql`SELECT attnotnull FROM pg_attribute WHERE attrelid = 'public.workspace'::regclass AND attname = 'project_id'`
543+
).toEqual([{ attnotnull: true }])
544+
expect(
545+
await sql`SELECT count(*)::int AS count FROM pg_trigger WHERE tgrelid IN ('public.workspace'::regclass,'public.project'::regclass) AND tgname IN ('project_contract_lock','project_contract_check')`
546+
).toEqual([{ count: 4 }])
547+
expect(await scoped`SELECT id,project_id FROM workspace`).toEqual([
548+
{ id: 'shadow', project_id: null },
549+
])
550+
expect(
551+
await sql`SELECT to_regclass('shadow.project_workspace') IS NOT NULL AS retained`
552+
).toEqual([{ retained: true }])
553+
expect(
554+
await sql`SELECT attnotnull FROM pg_attribute WHERE attrelid = 'shadow.workspace'::regclass AND attname = 'project_id'`
555+
).toEqual([{ attnotnull: false }])
556+
expect(
557+
await sql`SELECT count(*)::int AS count FROM pg_trigger WHERE tgrelid IN ('shadow.workspace'::regclass,'shadow.project'::regclass) AND NOT tgisinternal`
558+
).toEqual([{ count: 0 }])
559+
await scoped`UPDATE public.workspace SET name = 'Updated' WHERE id = 'env'`
560+
await expect(
561+
scoped`UPDATE public.workspace SET archived_at = now() WHERE id = 'env'`
562+
).rejects.toMatchObject({ code: '23514' })
563+
expect(await sql`SELECT name,archived_at FROM public.workspace`).toEqual([
564+
{ name: 'Updated', archived_at: null },
565+
])
566+
} finally {
567+
await scoped.end({ timeout: 2 })
568+
}
569+
})
570+
})
571+
522572
it('requires completed assignments before final enforcement and leaves missing assignments untouched', async () => {
523573
await database(async (sql) => {
524574
await sql`INSERT INTO workspace (id,name,owner_id) VALUES ('legacy','Legacy','owner')`

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ export const knowledgeBases = pgTable('knowledge_base', {
225225
}
226226
await schema(`export const rollout = pgTable('project_membership_rollout', {
227227
id: text('id').primaryKey(), phase: text('phase').notNull().default('connector'),
228-
}, (t) => [check('project_membership_rollout_id_check', sql\`\${t.id} = 'membership'\`), check('project_membership_rollout_phase_check', sql\`\${t.phase} IN ('connector', 'column')\`)])
228+
}, (t) => [check('project_membership_rollout_singleton', sql\`\${t.id} = 'membership'\`), check('project_membership_rollout_phase', sql\`\${t.phase} IN ('connector', 'column')\`)])
229229
export const projects = pgTable('project', { id: text('id').primaryKey() })
230230
export const workspaces = pgTable('workspace', { id: text('id').primaryKey(), projectId: text('project_id').notNull() })`)
231231
const result = runPush(['--force'])
@@ -261,7 +261,7 @@ export const workspaces = pgTable('workspace', { id: text('id').primaryKey(), pr
261261
}
262262
await schema(`export const rollout = pgTable('project_membership_rollout', {
263263
id: text('id').primaryKey(), phase: text('phase').notNull().default('connector'),
264-
}, (t) => [check('project_membership_rollout_id_check', sql\`\${t.id} = 'membership'\`), check('project_membership_rollout_phase_check', sql\`\${t.phase} IN ('connector', 'column')\`)])
264+
}, (t) => [check('project_membership_rollout_singleton', sql\`\${t.id} = 'membership'\`), check('project_membership_rollout_phase', sql\`\${t.phase} IN ('connector', 'column')\`)])
265265
export const projects = pgTable('project', {
266266
id: text('id').primaryKey(), name: text('name').notNull(), ownerId: text('owner_id').notNull(),
267267
organizationId: text('organization_id'), archivedAt: timestamp('archived_at'), updatedAt: timestamp('updated_at').notNull().defaultNow(),

0 commit comments

Comments
 (0)