feat(modules): polymorphic owner on triggers for group templates (§11 tail T1)
Reshape the triggers table to allow a GROUP owner, mirroring 0050_group_scripts exactly. A group-owned trigger is a TEMPLATE: never dispatched directly, but unioned into every descendant app's match queries via the ancestor-chain CTE (live, no per-app materialization). - 0056_group_triggers.sql: add nullable group_id (FK→groups ON DELETE RESTRICT), make app_id nullable, add the exactly-one owner CHECK, and split the name-unique + dispatch-hot indexes into per-owner partials (idx_triggers_group_kind_enabled serves the chain-union lookups). - Detail tables unchanged (they hang off trigger_id). Schema snapshot blessed (56 migrations); existing trigger tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
crates/manager-core/migrations/0056_group_triggers.sql
Normal file
49
crates/manager-core/migrations/0056_group_triggers.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): group TRIGGER templates.
|
||||||
|
--
|
||||||
|
-- Until now every trigger was owned by exactly one app (`app_id NOT NULL`).
|
||||||
|
-- A group-owned trigger is a TEMPLATE: it is never dispatched directly, but
|
||||||
|
-- the dispatcher's hot-path match queries UNION it in for every descendant app
|
||||||
|
-- via the ancestor-chain CTE (live resolution, like vars/secrets/scripts and
|
||||||
|
-- §11.6 collections — no per-app materialized rows). It binds a group-owned
|
||||||
|
-- handler script and fires under each firing app's `app_id`.
|
||||||
|
--
|
||||||
|
-- Scope: EVENT kinds only (kv/docs/files/pubsub) — those are stateless at
|
||||||
|
-- dispatch. cron/queue/email carry per-instance state (last_fired_at, the
|
||||||
|
-- one-consumer advisory lock, a sealed inbound secret) and would need
|
||||||
|
-- materialization; they are rejected on a [group] at the manifest layer and
|
||||||
|
-- deferred. The CHECK here is deliberately NOT narrowed to event kinds: the
|
||||||
|
-- column allows any kind for forward-compat, the authoring gate is upstream.
|
||||||
|
--
|
||||||
|
-- Reshape mirrors 0050_group_scripts exactly (RESTRICT, not CASCADE — a
|
||||||
|
-- trigger 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.
|
||||||
|
-- * per-app unique name index + dispatch index become per-owner partials.
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD CONSTRAINT triggers_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner name uniqueness (partial so each owner column only constrains its
|
||||||
|
-- own rows). Existing app rows keep the exact (app_id, name) uniqueness.
|
||||||
|
DROP INDEX triggers_app_name_uniq;
|
||||||
|
CREATE UNIQUE INDEX triggers_app_name_uniq
|
||||||
|
ON triggers (app_id, name) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX triggers_group_name_uniq
|
||||||
|
ON triggers (group_id, name) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Per-owner dispatch hot-path index ("all enabled triggers of kind Y for
|
||||||
|
-- owner X"). The app index keeps its name + shape (now owner-partial); a
|
||||||
|
-- parallel group index serves the chain-union's group-template lookups.
|
||||||
|
DROP INDEX idx_triggers_app_kind_enabled;
|
||||||
|
CREATE INDEX idx_triggers_app_kind_enabled
|
||||||
|
ON triggers (app_id, kind) WHERE enabled = TRUE AND app_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_triggers_group_kind_enabled
|
||||||
|
ON triggers (group_id, kind) WHERE enabled = TRUE AND group_id IS NOT NULL;
|
||||||
@@ -385,7 +385,7 @@ table: topics
|
|||||||
|
|
||||||
table: triggers
|
table: triggers
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NULL
|
||||||
script_id: uuid NOT NULL
|
script_id: uuid NOT NULL
|
||||||
kind: text NOT NULL
|
kind: text NOT NULL
|
||||||
enabled: boolean NOT NULL default=true
|
enabled: boolean NOT NULL default=true
|
||||||
@@ -397,6 +397,7 @@ table: triggers
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
name: text NOT NULL default=(gen_random_uuid())::text
|
name: text NOT NULL default=(gen_random_uuid())::text
|
||||||
|
group_id: uuid NULL
|
||||||
|
|
||||||
table: vars
|
table: vars
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
@@ -605,10 +606,12 @@ indexes on topics:
|
|||||||
topics_pkey: public.topics USING btree (app_id, name)
|
topics_pkey: public.topics USING btree (app_id, name)
|
||||||
|
|
||||||
indexes on triggers:
|
indexes on triggers:
|
||||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (app_id IS NOT NULL))
|
||||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||||
|
idx_triggers_group_kind_enabled: public.triggers USING btree (group_id, kind) WHERE ((enabled = true) AND (group_id IS NOT NULL))
|
||||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
triggers_app_name_uniq: public.triggers USING btree (app_id, name) WHERE (app_id IS NOT NULL)
|
||||||
|
triggers_group_name_uniq: public.triggers USING btree (group_id, name) WHERE (group_id IS NOT NULL)
|
||||||
triggers_pkey: public.triggers USING btree (id)
|
triggers_pkey: public.triggers USING btree (id)
|
||||||
|
|
||||||
indexes on vars:
|
indexes on vars:
|
||||||
@@ -833,8 +836,10 @@ constraints on topics:
|
|||||||
constraints on triggers:
|
constraints on triggers:
|
||||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||||
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
|
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
|
||||||
|
[CHECK] triggers_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
[CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text])))
|
[CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text])))
|
||||||
[FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] triggers_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||||
[FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE
|
[FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||||
@@ -901,3 +906,4 @@ constraints on vars:
|
|||||||
0053: group kv entries
|
0053: group kv entries
|
||||||
0054: group docs
|
0054: group docs
|
||||||
0055: group files
|
0055: group files
|
||||||
|
0056: group triggers
|
||||||
|
|||||||
Reference in New Issue
Block a user