-- v1.1.1: Trigger framework — Layout E (design notes §2 + §7). -- -- A parent `triggers` table holds the common columns (script_id, retry -- config, dispatch_mode, registered-by principal); per-kind detail -- tables hold the kind-specific filter columns. v1.1.1 ships two -- kinds: KV (collection_glob + ops) and dead_letter (source / trigger -- / script filters). Future kinds (cron, pubsub, queue, email) extend -- the parent and add their own detail table. -- -- `registered_by_principal` captures the admin user that registered -- the trigger. The dispatcher resolves this back to a `Principal` at -- execution time so the trigger runs as the user that set it up -- (design notes §4: "a trigger execution runs as the principal that -- registered the trigger"). -- -- HTTP routes stay in their own `routes` table for now (Phase 3 -- production schema with its own trie-index columns); the dispatcher -- discriminates HTTP outbox rows by `source_kind = 'http'` and -- `trigger_id` referencing `routes.id`. Folding routes into triggers -- is a v1.2 cleanup, not a v1.1.1 requirement. CREATE TABLE triggers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('kv', 'dead_letter')), enabled BOOLEAN NOT NULL DEFAULT TRUE, -- Async by default — sync would mean the trigger fires inline with -- the originating mutation, which v1.1.1 doesn't support. dispatch_mode TEXT NOT NULL DEFAULT 'async' CHECK (dispatch_mode IN ('sync', 'async')), -- Defaults applied at write time so the row is auditable on its -- own. Per-trigger overrides set on create; the env-defined -- defaults provide the fallback values. retry_max_attempts INT NOT NULL, retry_backoff TEXT NOT NULL CHECK (retry_backoff IN ('exponential', 'linear', 'constant')), retry_base_ms INT NOT NULL, registered_by_principal UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- The dispatcher's hot lookup: "all enabled triggers for app X of -- kind Y". Indexed only when enabled = TRUE so disabled rows don't -- pollute the index. CREATE INDEX idx_triggers_app_kind_enabled ON triggers (app_id, kind) WHERE enabled = TRUE; -- One row per KV trigger. `collection_glob` accepts: -- "*" — any collection in the app -- "widgets" — exact match -- "users:*" — prefix wildcard (matched in Rust, not SQL) -- `ops` is the subset of {insert, update, delete} this trigger -- subscribes to. Empty array means "any op" (the trigger fires on -- every mutation; admin endpoint validates this). CREATE TABLE kv_trigger_details ( trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE, collection_glob TEXT NOT NULL, ops TEXT[] NOT NULL ); -- One row per dead-letter trigger. All three filter columns are -- nullable — NULL means "no filter on this dimension". A trigger -- with all three nullable filters fires on every dead-letter row. CREATE TABLE dead_letter_trigger_details ( trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE, source_filter TEXT, trigger_id_filter UUID, script_id_filter UUID );