@@ -90,132 +90,78 @@ DO $$ BEGIN
9090 END IF;
9191END $$;
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
220166BEGIN ;
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.
242188ALTER 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.
260191DROP 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
264193COMMIT ;
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 .
267196SELECT pg_temp .validate_project_membership ();
268197-- > statement-breakpoint
269198DROP FUNCTION pg_temp .validate_project_membership ();
0 commit comments