M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).
Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
ext point on the importer's chain first; on a hit it resolves against
`App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
query returns Some only when the NEAREST declaration of the name is an ext
point (a peer/nearer concrete module shadows it). A group origin can't reach
app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).
Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
(name-based, default change = Update), reconcile (upsert after scripts so the
default resolves) + prune, `validate_bundle` (module-name shape; default must
be a local module), `check_imports_resolve` (a declared/on-chain ext point
satisfies an import), and the state_token fold. Writes gate on the
script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.
CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.
Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
50 lines
2.7 KiB
SQL
50 lines
2.7 KiB
SQL
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
|
|
--
|
|
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
|
|
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
|
|
-- against the importer's own defining node — a leaf app cannot shadow it
|
|
-- (the trust boundary). When a node declares `x` an extension point, an
|
|
-- importer whose defining node is on that node's chain instead resolves `x`
|
|
-- against the INHERITING app's effective view, so each descendant app may
|
|
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
|
|
-- descendant cannot make one of its parent's sealed imports dynamic, because
|
|
-- the declaration must live on the importer's own ancestry.
|
|
--
|
|
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
|
|
-- Optional `default_script_id` is the fallback module body used when the
|
|
-- inheriting app provides no module of `name`.
|
|
|
|
CREATE TABLE extension_points (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
-- Polymorphic owner: exactly one FK set (real FKs so ON DELETE CASCADE
|
|
-- works per owner kind and a dangling owner is impossible).
|
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
|
CONSTRAINT extension_points_owner_exactly_one
|
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
|
name TEXT NOT NULL,
|
|
-- Optional fallback module (a module owned by the declaring node). SET
|
|
-- NULL on delete so deleting the module can never block — the fallback is
|
|
-- then absent and a non-providing app errors at import time (the next plan
|
|
-- shows the now-null default as an Update). A valid manifest apply can't
|
|
-- reach this: validation rejects a kept ext point whose default names a
|
|
-- module not in the bundle.
|
|
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- One declaration per (owner, name), case-insensitive to match the module
|
|
-- name indexes (0050). Partial because the owner is split across two columns.
|
|
CREATE UNIQUE INDEX extension_points_group_uidx
|
|
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
|
CREATE UNIQUE INDEX extension_points_app_uidx
|
|
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
|
|
|
CREATE INDEX extension_points_group_id_idx
|
|
ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
|
CREATE INDEX extension_points_app_id_idx
|
|
ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
|
CREATE INDEX extension_points_default_script_idx
|
|
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;
|