feat(routes): polymorphic owner on routes for group templates (§11 tail R1)
A group-owned route is a TEMPLATE, expanded into every descendant app's in-memory RouteTable slice at rebuild (live, no materialization). The schema reshape mirrors 0056_group_triggers exactly: a nullable group_id FK→groups ON DELETE RESTRICT (a route template is a binding, not data), app_id made nullable, an exactly-one owner CHECK, and the per-app unique binding index split into per-owner partials. A group can't declare two identical templates; an app declaring an identical binding is a deliberate shadow resolved at rebuild (nearest-owner-wins), not a DB conflict. Adds routes_group_id_idx for the expansion join. Schema blessed at 57 migrations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
crates/manager-core/migrations/0057_group_routes.sql
Normal file
48
crates/manager-core/migrations/0057_group_routes.sql
Normal file
@@ -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;
|
||||||
@@ -338,9 +338,10 @@ table: routes
|
|||||||
path: text NOT NULL
|
path: text NOT NULL
|
||||||
method: text NULL
|
method: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
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
|
dispatch_mode: text NOT NULL default='sync'::text
|
||||||
enabled: boolean NOT NULL default=true
|
enabled: boolean NOT NULL default=true
|
||||||
|
group_id: uuid NULL
|
||||||
|
|
||||||
table: script_imports
|
table: script_imports
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
@@ -578,10 +579,12 @@ indexes on queue_trigger_details:
|
|||||||
|
|
||||||
indexes on routes:
|
indexes on routes:
|
||||||
routes_app_id_idx: public.routes USING btree (app_id)
|
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_lookup_idx: public.routes USING btree (host_kind, host)
|
||||||
routes_pkey: public.routes USING btree (id)
|
routes_pkey: public.routes USING btree (id)
|
||||||
routes_script_id_idx: public.routes USING btree (script_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:
|
indexes on script_imports:
|
||||||
idx_script_imports_app: public.script_imports USING btree (app_id)
|
idx_script_imports_app: public.script_imports USING btree (app_id)
|
||||||
@@ -802,8 +805,10 @@ constraints on queue_trigger_details:
|
|||||||
constraints on routes:
|
constraints on routes:
|
||||||
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
[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_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])))
|
[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_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
|
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
@@ -907,3 +912,4 @@ constraints on vars:
|
|||||||
0054: group docs
|
0054: group docs
|
||||||
0055: group files
|
0055: group files
|
||||||
0056: group triggers
|
0056: group triggers
|
||||||
|
0057: group routes
|
||||||
|
|||||||
Reference in New Issue
Block a user