Migrations 0008-0011 lay down the triggers framework's storage: - `triggers` + `kv_trigger_details` + `dead_letter_trigger_details` (Layout E, design notes §2). Parent table carries common columns including `registered_by_principal` — the dispatcher uses this to run the trigger as the user that registered it (design notes §4). - `outbox`: universal async dispatch substrate. KV/cron/pubsub/queue/ email/dead-letter all write rows in the same shape; the dispatcher claims due rows via FOR UPDATE SKIP LOCKED. `reply_to` is the NATS-style inbox id for sync HTTP (commit 6) — its presence flags "don't retry" per the design. - `dead_letters`: exact schema from design notes §4 with the four- value `resolution` CHECK constraint (`replayed | ignored | handled_by_script | handler_failed`) and partial index on unresolved rows for the dashboard badge. - `abandoned_executions`: forensic table for the dispatcher's "tried to resolve a dropped inbox" edge case (design notes §3 #9). Repo surfaces with Postgres impls behind traits so unit tests can swap in-memory backings: - `TriggerRepo` — CRUD + the `list_matching_kv` / `list_matching_dead_letter` hot paths the dispatcher uses. Includes a `collection_matches` helper that handles `*`, `prefix:*`, and exact-name globs. - `OutboxRepo` — insert + claim-due + delete + reschedule. - `DeadLetterRepo` — insert + get + list + unresolved-count + resolve + GC. - `AbandonedRepo` — insert + GC. `TriggerConfig::from_env` (new module) follows the existing `SandboxCeiling` env-loading pattern for `PICLOUD_MAX_TRIGGER_DEPTH`, `PICLOUD_TRIGGER_RETRY_*`, `PICLOUD_DEAD_LETTER_RETENTION_DAYS`, and `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`. `Capability::AppManageTriggers(AppId)` and `AppDeadLetterManage(AppId)` join the enum. Both map onto the existing `Scope::AppAdmin` per the seven-scope commitment; `role_satisfies` grants them at the `AppAdmin` per-app role. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 lines
3.5 KiB
SQL
73 lines
3.5 KiB
SQL
-- 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
|
|
);
|