diff --git a/crates/manager-core/migrations/0057_group_routes.sql b/crates/manager-core/migrations/0057_group_routes.sql new file mode 100644 index 0000000..2874237 --- /dev/null +++ b/crates/manager-core/migrations/0057_group_routes.sql @@ -0,0 +1,48 @@ +-- §11 tail (v1.2 Hierarchies): group ROUTE templates. +-- +-- Until now every route was owned by exactly one app (`app_id NOT NULL`). +-- A group-owned route is a TEMPLATE: it is never served directly, but the +-- in-memory RouteTable rebuild EXPANDS it into every descendant app's slice +-- via the ancestor chain (live resolution, like vars/secrets/scripts, §11.6 +-- collections, and §11 trigger templates — no per-app materialized rows). It +-- binds a group-owned handler script and runs under each firing app's +-- `app_id`. +-- +-- Unlike triggers (a SQL-per-event dispatch), routes are served from an +-- in-memory cache, so the inheritance is resolved at table-rebuild time, not +-- per request. The reshape, however, is identical: a polymorphic owner column. +-- +-- Reshape mirrors 0056_group_triggers exactly (RESTRICT, not CASCADE — a route +-- template references a group script; a group can't be deleted out from under +-- it): +-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT. +-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group. +-- * the per-app unique binding index becomes per-owner partials. + +ALTER TABLE routes + ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT; + +ALTER TABLE routes + ALTER COLUMN app_id DROP NOT NULL; + +ALTER TABLE routes + ADD CONSTRAINT routes_owner_exactly_one + CHECK ((group_id IS NULL) <> (app_id IS NULL)); + +-- Per-owner binding uniqueness (partial so each owner column only constrains +-- its own rows). Existing app rows keep the exact binding-tuple uniqueness; a +-- group can't declare two identical templates. An app declaring a binding +-- identical to an inherited group template is a deliberate SHADOW, resolved +-- at rebuild (nearest-owner-wins) — not a DB conflict. +DROP INDEX routes_unique_binding_idx; +CREATE UNIQUE INDEX routes_unique_binding_idx + ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, '')) + WHERE app_id IS NOT NULL; +CREATE UNIQUE INDEX routes_group_binding_idx + ON routes (group_id, host_kind, host, path_kind, path, COALESCE(method, '')) + WHERE group_id IS NOT NULL; + +-- The expansion join matches routes by owner (`r.app_id = ac.owner_app OR +-- r.group_id = ac.owner_group`). The app side already has routes_app_id_idx; +-- add the parallel group lookup index. +CREATE INDEX routes_group_id_idx ON routes (group_id) WHERE group_id IS NOT NULL; diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 506efc0..b4299fd 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -338,9 +338,10 @@ table: routes path: text NOT NULL method: text NULL created_at: timestamp with time zone NOT NULL default=now() - app_id: uuid NOT NULL + app_id: uuid NULL dispatch_mode: text NOT NULL default='sync'::text enabled: boolean NOT NULL default=true + group_id: uuid NULL table: script_imports app_id: uuid NOT NULL @@ -578,10 +579,12 @@ indexes on queue_trigger_details: indexes on routes: routes_app_id_idx: public.routes USING btree (app_id) + routes_group_binding_idx: public.routes USING btree (group_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) WHERE (group_id IS NOT NULL) + routes_group_id_idx: public.routes USING btree (group_id) WHERE (group_id IS NOT NULL) routes_lookup_idx: public.routes USING btree (host_kind, host) routes_pkey: public.routes USING btree (id) routes_script_id_idx: public.routes USING btree (script_id) - routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) + routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) WHERE (app_id IS NOT NULL) indexes on script_imports: idx_script_imports_app: public.script_imports USING btree (app_id) @@ -802,8 +805,10 @@ constraints on queue_trigger_details: constraints on routes: [CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text]))) [CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text]))) + [CHECK] routes_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL))) [CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text]))) [FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE + [FOREIGN KEY] routes_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT [FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE [PRIMARY KEY] routes_pkey: PRIMARY KEY (id) @@ -907,3 +912,4 @@ constraints on vars: 0054: group docs 0055: group files 0056: group triggers + 0057: group routes