Skip to content

Commit 36b0074

Browse files
committed
refactor(projects): replace integrity triggers with native foreign keys
1 parent 69df502 commit 36b0074

8 files changed

Lines changed: 362 additions & 298 deletions

File tree

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,25 @@ function setProjectsEnabled(enabled: boolean) {
105105

106106
beforeEach(async () => {
107107
setProjectsEnabled(true)
108-
const rows = await db.execute(sql`SELECT tgrelid::regclass::text AS table_name FROM pg_trigger
109-
WHERE tgname = 'project_contract_check'
110-
AND tgrelid IN ('project'::regclass, 'workspace'::regclass)
111-
ORDER BY table_name`)
112-
expect(rows).toEqual([{ table_name: 'project' }, { table_name: 'workspace' }])
108+
const rows =
109+
await db.execute(sql`SELECT conname, condeferrable, condeferred, convalidated FROM pg_constraint
110+
WHERE conrelid = 'workspace'::regclass
111+
AND conname IN ('workspace_project_organization_fk', 'workspace_fork_project_fk')
112+
ORDER BY conname`)
113+
expect(rows).toEqual([
114+
{
115+
conname: 'workspace_fork_project_fk',
116+
condeferrable: true,
117+
condeferred: true,
118+
convalidated: true,
119+
},
120+
{
121+
conname: 'workspace_project_organization_fk',
122+
condeferrable: true,
123+
condeferred: true,
124+
convalidated: true,
125+
},
126+
])
113127
})
114128

115129
const request = { requestId: 'project-foundation-integration', headers: new Headers() }
@@ -301,6 +315,7 @@ describe('Project foundation at the database and application boundary', () => {
301315
request,
302316
})
303317
expect(resolved.project.id).toBe(created.project.id)
318+
expect(resolved.project).not.toHaveProperty('organizationScopeKey')
304319
const fork = await createFork({
305320
source,
306321
policy: await getWorkspaceCreationPolicy({ userId: f.ownerId }),

‎apps/sim/lib/projects/application/use-cases.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ const READ_SNAPSHOT = { isolationLevel: 'repeatable read', accessMode: 'read onl
2323

2424
function presentProject(context: AuthorizedProject) {
2525
return {
26-
...context.record,
26+
id: context.record.id,
27+
name: context.record.name,
28+
organizationId: context.record.organizationId,
29+
ownerId: context.record.ownerId,
30+
archivedAt: context.record.archivedAt,
31+
createdAt: context.record.createdAt,
32+
updatedAt: context.record.updatedAt,
2733
environments: context.environments,
2834
capabilities: { administer: context.canAdminister, issues: context.canUseIssues },
2935
}

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

Lines changed: 65 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -90,132 +90,78 @@ DO $$ BEGIN
9090
END IF;
9191
END $$;
9292
--> statement-breakpoint
93-
CREATE OR REPLACE FUNCTION project_contract_assert_project(target_id text) RETURNS void
94-
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
95-
DECLARE
96-
record project%ROWTYPE;
97-
total bigint;
98-
active bigint;
99-
BEGIN
100-
SELECT * INTO record FROM project WHERE id = target_id;
101-
IF NOT FOUND THEN RETURN; END IF;
102-
SELECT count(*), count(*) FILTER (WHERE w.archived_at IS NULL)
103-
INTO total, active FROM workspace w WHERE w.project_id = target_id;
104-
IF total = 0 OR (record.archived_at IS NULL AND active = 0)
105-
OR (record.archived_at IS NOT NULL AND active > 0) THEN
106-
RAISE EXCEPTION 'Project must retain environments with consistent archive state'
107-
USING ERRCODE = '23514', CONSTRAINT = 'project_environment_lifecycle';
93+
-- The generated expression always returns non-NULL, including personal scope; writers cannot override it.
94+
-- Stored generated columns rewrite existing rows on PG16/17. Bound the rewrite and lock wait;
95+
-- a timeout rolls this phase back and leaves the connector intact for a later retry.
96+
BEGIN;
97+
SET LOCAL lock_timeout = '1s';
98+
SET LOCAL statement_timeout = '5s';
99+
LOCK TABLE project, workspace IN ACCESS EXCLUSIVE MODE NOWAIT;
100+
DO $$ BEGIN
101+
IF to_regclass('project_workspace') IS NOT NULL THEN
102+
LOCK TABLE project_workspace IN ACCESS EXCLUSIVE MODE NOWAIT;
108103
END IF;
109-
IF EXISTS (
110-
SELECT 1 FROM workspace w
111-
WHERE w.project_id = target_id AND w.organization_id IS DISTINCT FROM record.organization_id
112-
) THEN
113-
RAISE EXCEPTION 'Project and environment organization must agree'
114-
USING ERRCODE = '23514', CONSTRAINT = 'project_environment_organization';
104+
END $$;
105+
ALTER TABLE project ADD COLUMN IF NOT EXISTS organization_scope_key text
106+
GENERATED ALWAYS AS (CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END) STORED;
107+
ALTER TABLE workspace ADD COLUMN IF NOT EXISTS organization_scope_key text
108+
GENERATED ALWAYS AS (CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END) STORED;
109+
COMMIT;
110+
--> statement-breakpoint
111+
-- Only invalid, unreferenced indexes from interrupted builds are removed. Valid indexes survive
112+
-- replay, including when the final foreign keys already depend on them.
113+
DO $$ DECLARE target_name text; BEGIN
114+
FOR target_name IN SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
115+
WHERE c.relnamespace = 'public'::regnamespace AND NOT i.indisvalid
116+
AND c.relname IN ('project_id_organization_scope_unique', 'workspace_id_project_unique')
117+
LOOP
118+
EXECUTE format('DROP INDEX %I', target_name);
119+
END LOOP;
120+
END $$;
121+
--> statement-breakpoint
122+
SET lock_timeout = 0;
123+
SET statement_timeout = '60s';
124+
--> statement-breakpoint
125+
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS project_id_organization_scope_unique ON project(id, organization_scope_key);
126+
--> statement-breakpoint
127+
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS workspace_id_project_unique ON workspace(id, project_id);
128+
--> statement-breakpoint
129+
SET lock_timeout = '1s';
130+
--> statement-breakpoint
131+
DO $$ BEGIN
132+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'project'::regclass
133+
AND conname = 'project_id_organization_scope_unique') THEN
134+
ALTER TABLE project ADD CONSTRAINT project_id_organization_scope_unique
135+
UNIQUE USING INDEX project_id_organization_scope_unique;
115136
END IF;
116-
IF record.archived_at IS NOT NULL AND EXISTS (
117-
SELECT 1 FROM workspace w JOIN workflow f ON f.workspace_id = w.id
118-
WHERE w.project_id = target_id AND f.archived_at IS NULL
119-
) THEN
120-
RAISE EXCEPTION 'Archived Project cannot contain active workflows'
121-
USING ERRCODE = '23514', CONSTRAINT = 'project_workflow_lifecycle';
137+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'workspace'::regclass
138+
AND conname = 'workspace_id_project_unique') THEN
139+
ALTER TABLE workspace ADD CONSTRAINT workspace_id_project_unique
140+
UNIQUE USING INDEX workspace_id_project_unique;
122141
END IF;
123-
END;
124-
$$;
125-
--> statement-breakpoint
126-
CREATE OR REPLACE FUNCTION project_contract_assert_workspace(target_id text) RETURNS void
127-
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
128-
DECLARE
129-
parent_id text;
130-
member_project_id text;
131-
parent_project_id text;
132-
BEGIN
133-
SELECT forked_from_workspace_id, project_id INTO parent_id, member_project_id FROM workspace WHERE id = target_id;
134-
IF NOT FOUND THEN RETURN; END IF;
135-
IF parent_id IS NOT NULL THEN
136-
SELECT project_id INTO parent_project_id FROM workspace WHERE id = parent_id;
137-
IF parent_project_id IS DISTINCT FROM member_project_id THEN
138-
RAISE EXCEPTION 'Connected fork environments must belong to the same Project'
139-
USING ERRCODE = '23514', CONSTRAINT = 'workspace_fork_project';
140-
END IF;
142+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'workspace'::regclass
143+
AND conname = 'workspace_project_organization_fk') THEN
144+
ALTER TABLE workspace ADD CONSTRAINT workspace_project_organization_fk
145+
FOREIGN KEY (project_id, organization_scope_key) REFERENCES project(id, organization_scope_key)
146+
DEFERRABLE INITIALLY DEFERRED NOT VALID;
141147
END IF;
142-
IF EXISTS (
143-
SELECT 1 FROM workspace child
144-
WHERE child.forked_from_workspace_id = target_id AND child.project_id IS DISTINCT FROM member_project_id
145-
) THEN
146-
RAISE EXCEPTION 'Connected fork environments must belong to the same Project'
147-
USING ERRCODE = '23514', CONSTRAINT = 'workspace_fork_project';
148+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'workspace'::regclass
149+
AND conname = 'workspace_fork_project_fk') THEN
150+
ALTER TABLE workspace ADD CONSTRAINT workspace_fork_project_fk
151+
FOREIGN KEY (forked_from_workspace_id, project_id) REFERENCES workspace(id, project_id)
152+
DEFERRABLE INITIALLY DEFERRED NOT VALID;
148153
END IF;
149-
END;
150-
$$;
154+
END $$;
151155
--> statement-breakpoint
152-
CREATE OR REPLACE FUNCTION project_contract_lock_projects(target_ids text[]) RETURNS void
153-
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
154-
DECLARE
155-
target_id text;
156-
BEGIN
157-
FOR target_id IN SELECT DISTINCT id FROM unnest(target_ids) AS ids(id) WHERE id IS NOT NULL ORDER BY id LOOP
158-
-- BEFORE row triggers already hold row locks; waiting here could invert the application lock order.
159-
IF NOT pg_try_advisory_xact_lock(hashtextextended('project:' || target_id, 0)) THEN
160-
RAISE EXCEPTION 'Project is changing; retry the operation' USING ERRCODE = '55P03';
161-
END IF;
162-
END LOOP;
163-
END;
164-
$$;
156+
-- Drizzle represents the FK columns/actions; the shared finalizer owns deferred timing on push.
157+
ALTER TABLE workspace ALTER CONSTRAINT workspace_project_organization_fk DEFERRABLE INITIALLY DEFERRED;
158+
ALTER TABLE workspace ALTER CONSTRAINT workspace_fork_project_fk DEFERRABLE INITIALLY DEFERRED;
165159
--> statement-breakpoint
166-
CREATE OR REPLACE FUNCTION project_contract_before_write() RETURNS trigger
167-
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
168-
DECLARE
169-
previous_id text;
170-
next_id text;
171-
BEGIN
172-
IF TG_TABLE_NAME = 'project' THEN
173-
IF TG_OP <> 'INSERT' THEN previous_id := OLD.id; END IF;
174-
IF TG_OP <> 'DELETE' THEN next_id := NEW.id; END IF;
175-
ELSE
176-
IF TG_OP <> 'INSERT' THEN previous_id := OLD.project_id; END IF;
177-
IF TG_OP <> 'DELETE' THEN next_id := NEW.project_id; END IF;
178-
END IF;
179-
PERFORM project_contract_lock_projects(ARRAY[previous_id, next_id]);
180-
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
181-
RETURN NEW;
182-
END;
183-
$$;
160+
SET statement_timeout = '60s';
184161
--> statement-breakpoint
185-
CREATE OR REPLACE FUNCTION project_contract_after_write() RETURNS trigger
186-
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
187-
DECLARE
188-
previous_id text;
189-
next_id text;
190-
target_ids text[];
191-
workspace_ids text[];
192-
target_id text;
193-
BEGIN
194-
IF TG_TABLE_NAME = 'project' THEN
195-
-- Only the final row version needs a full scan; earlier deferred events describe superseded states.
196-
IF TG_OP <> 'DELETE' AND EXISTS (SELECT 1 FROM project WHERE id = NEW.id AND ctid = NEW.ctid) THEN
197-
PERFORM project_contract_assert_project(NEW.id);
198-
END IF;
199-
RETURN NULL;
200-
END IF;
201-
IF TG_OP <> 'INSERT' THEN previous_id := OLD.project_id; END IF;
202-
IF TG_OP <> 'DELETE' THEN next_id := NEW.project_id; END IF;
203-
target_ids := ARRAY[previous_id, next_id];
204-
previous_id := NULL;
205-
next_id := NULL;
206-
IF TG_OP <> 'INSERT' THEN previous_id := OLD.id; END IF;
207-
IF TG_OP <> 'DELETE' THEN next_id := NEW.id; END IF;
208-
workspace_ids := ARRAY[previous_id, next_id];
209-
FOR target_id IN SELECT DISTINCT id FROM unnest(target_ids) AS ids(id) WHERE id IS NOT NULL ORDER BY id LOOP
210-
-- Touch after the mutation: immediate constraints must see it too, and repeatable-read must detect stale snapshots.
211-
UPDATE project SET updated_at = updated_at WHERE id = target_id;
212-
END LOOP;
213-
FOR target_id IN SELECT DISTINCT id FROM unnest(workspace_ids) AS ids(id) WHERE id IS NOT NULL ORDER BY id LOOP
214-
PERFORM project_contract_assert_workspace(target_id);
215-
END LOOP;
216-
RETURN NULL;
217-
END;
218-
$$;
162+
ALTER TABLE workspace VALIDATE CONSTRAINT workspace_project_organization_fk;
163+
--> statement-breakpoint
164+
ALTER TABLE workspace VALIDATE CONSTRAINT workspace_fork_project_fk;
219165
--> statement-breakpoint
220166
BEGIN;
221167
--> statement-breakpoint
@@ -241,29 +187,12 @@ ALTER TABLE workspace ALTER COLUMN project_id SET NOT NULL;
241187
-- migration-safe: the required column now enforces the validated helper check's invariant.
242188
ALTER TABLE workspace DROP CONSTRAINT IF EXISTS workspace_project_id_present;
243189
--> statement-breakpoint
244-
DROP TRIGGER IF EXISTS project_contract_lock ON project;
245-
CREATE TRIGGER project_contract_lock BEFORE INSERT OR UPDATE OR DELETE ON project
246-
FOR EACH ROW EXECUTE FUNCTION project_contract_before_write();
247-
DROP TRIGGER IF EXISTS project_contract_check ON project;
248-
CREATE CONSTRAINT TRIGGER project_contract_check AFTER INSERT OR UPDATE OR DELETE ON project
249-
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION project_contract_after_write();
250-
--> statement-breakpoint
251-
DROP TRIGGER IF EXISTS project_contract_lock ON workspace;
252-
CREATE TRIGGER project_contract_lock BEFORE INSERT OR UPDATE OF id, project_id, archived_at, organization_id, forked_from_workspace_id OR DELETE ON workspace
253-
FOR EACH ROW EXECUTE FUNCTION project_contract_before_write();
254-
DROP TRIGGER IF EXISTS project_contract_check ON workspace;
255-
CREATE CONSTRAINT TRIGGER project_contract_check AFTER INSERT OR UPDATE OF id, project_id, archived_at, organization_id, forked_from_workspace_id OR DELETE ON workspace
256-
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION project_contract_after_write();
257-
--> statement-breakpoint
258-
DROP TRIGGER IF EXISTS workspace_sync_project_membership ON workspace;
259190
-- 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.
260191
DROP TABLE IF EXISTS project_workspace;
261-
DROP FUNCTION IF EXISTS workspace_sync_project_membership_fn();
262-
DROP FUNCTION IF EXISTS project_workspace_sync_column_fn();
263192
--> statement-breakpoint
264193
COMMIT;
265194
--> statement-breakpoint
266-
-- Membership triggers and compatible workspace-guarded writers protect new writes during validation.
195+
-- Composite foreign keys protect structural writes; compatible application transactions own lifecycle.
267196
SELECT pg_temp.validate_project_membership();
268197
--> statement-breakpoint
269198
DROP FUNCTION pg_temp.validate_project_membership();

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

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17417,6 +17417,16 @@
1741717417
"primaryKey": false,
1741817418
"notNull": true,
1741917419
"default": "now()"
17420+
},
17421+
"organization_scope_key": {
17422+
"name": "organization_scope_key",
17423+
"type": "text",
17424+
"primaryKey": false,
17425+
"notNull": false,
17426+
"generated": {
17427+
"as": "CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END",
17428+
"type": "stored"
17429+
}
1742017430
}
1742117431
},
1742217432
"indexes": {
@@ -17496,7 +17506,13 @@
1749617506
}
1749717507
},
1749817508
"compositePrimaryKeys": {},
17499-
"uniqueConstraints": {},
17509+
"uniqueConstraints": {
17510+
"project_id_organization_scope_unique": {
17511+
"name": "project_id_organization_scope_unique",
17512+
"nullsNotDistinct": false,
17513+
"columns": ["id", "organization_scope_key"]
17514+
}
17515+
},
1750017516
"policies": {},
1750117517
"checkConstraints": {
1750217518
"project_name_length": {
@@ -27330,6 +27346,16 @@
2733027346
"primaryKey": false,
2733127347
"notNull": true,
2733227348
"default": "now()"
27349+
},
27350+
"organization_scope_key": {
27351+
"name": "organization_scope_key",
27352+
"type": "text",
27353+
"primaryKey": false,
27354+
"notNull": false,
27355+
"generated": {
27356+
"as": "CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END",
27357+
"type": "stored"
27358+
}
2733327359
}
2733427360
},
2733527361
"indexes": {
@@ -27476,10 +27502,34 @@
2747627502
"columnsTo": ["id"],
2747727503
"onDelete": "set null",
2747827504
"onUpdate": "no action"
27505+
},
27506+
"workspace_project_organization_fk": {
27507+
"name": "workspace_project_organization_fk",
27508+
"tableFrom": "workspace",
27509+
"tableTo": "project",
27510+
"columnsFrom": ["project_id", "organization_scope_key"],
27511+
"columnsTo": ["id", "organization_scope_key"],
27512+
"onDelete": "no action",
27513+
"onUpdate": "no action"
27514+
},
27515+
"workspace_fork_project_fk": {
27516+
"name": "workspace_fork_project_fk",
27517+
"tableFrom": "workspace",
27518+
"tableTo": "workspace",
27519+
"columnsFrom": ["forked_from_workspace_id", "project_id"],
27520+
"columnsTo": ["id", "project_id"],
27521+
"onDelete": "no action",
27522+
"onUpdate": "no action"
2747927523
}
2748027524
},
2748127525
"compositePrimaryKeys": {},
27482-
"uniqueConstraints": {},
27526+
"uniqueConstraints": {
27527+
"workspace_id_project_unique": {
27528+
"name": "workspace_id_project_unique",
27529+
"nullsNotDistinct": false,
27530+
"columns": ["id", "project_id"]
27531+
}
27532+
},
2748327533
"policies": {},
2748427534
"checkConstraints": {
2748527535
"workspace_storage_used_bytes_non_negative": {

0 commit comments

Comments
 (0)