§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).
- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
plan); tree Phase B expands each chain template into a concrete route per
descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
is no-churn, --prune reaps expansions). Collision with a hand-declared route,
or between two templates, is a hard error. Each RESOLVED expansion is
validated like a hand-declared route (reserved-path, path/host parse,
host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
prune-reap + collision-rejected); placeholder/validation unit tests; schema
golden reblessed.
Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
50 lines
2.7 KiB
SQL
50 lines
2.7 KiB
SQL
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
|
||
--
|
||
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
|
||
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
|
||
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
|
||
-- fans it out into one concrete per-`app_id` row for every descendant app. This
|
||
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
|
||
-- isolation boundary is unchanged); the template is just a stamp.
|
||
--
|
||
-- Placeholders in template fields are a small, inert, documented set resolved
|
||
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
|
||
-- effective var). Provenance: each expanded `routes` row carries `from_template`
|
||
-- (the template id it was stamped from), so re-apply diffs idempotently and
|
||
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
|
||
-- same identity.
|
||
--
|
||
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
|
||
-- `triggers.from_template`.)
|
||
|
||
-- Route templates — one per (group, name). Mirrors the `routes` column shape
|
||
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
|
||
-- nearest inherited endpoint at expansion, like declarative route bindings).
|
||
CREATE TABLE route_templates (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||
-- Explicit identity/upsert key, unique per group (case-insensitive).
|
||
name TEXT NOT NULL,
|
||
script_name TEXT NOT NULL,
|
||
method TEXT,
|
||
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
|
||
host TEXT NOT NULL DEFAULT '',
|
||
host_param_name TEXT,
|
||
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
|
||
path TEXT NOT NULL,
|
||
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
|
||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
CREATE UNIQUE INDEX route_templates_group_name_idx
|
||
ON route_templates (group_id, LOWER(name));
|
||
|
||
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
|
||
-- manifest); non-NULL = stamped from this template, managed by template
|
||
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
|
||
-- deletes orphaned expansions explicitly), and a dangling id after a raw
|
||
-- template delete is harmless (treated as "template gone → prune the row").
|
||
ALTER TABLE routes ADD COLUMN from_template UUID;
|
||
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);
|