feat(modules): group-collection registry + group-KV storage schema (§11.6 C1)
Data layer for shared cross-app group collections (KV-only MVP), nothing wired yet. Two migrations + two repos, modeled on the extension_points (0051) marker and the kv_entries (0007) store: - 0052_group_collections.sql: owner-polymorphic marker table declaring a collection name group-shared, with a `kind` discriminator (CHECK 'kv' for now; generalizes to docs/files/topics/queue later) and per-owner partial-unique (owner, LOWER(name), kind) indexes. CASCADE — a marker is config, not code. - 0053_group_kv_entries.sql: the shared store, keyed by (group_id, collection, key) — NO app_id (a shared row belongs to the group). CASCADE on group delete (data dies with its group, like vars/secrets config; an app delete leaves it). - group_collection_repo: list_for_owner, insert/delete_collection_tx, and the load-bearing resolve_owning_group — walks the reading app's chain (CHAIN_LEVELS_CTE) for the nearest ancestor group declaring the name (nearest-wins via ORDER BY depth LIMIT 1). That walk IS the isolation boundary; the join is on group_owner only. - group_kv_repo: a near-clone of kv_repo keyed by group_id. Schema snapshot re-blessed (53 migrations). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- §11.6 (v1.2 Hierarchies): group-collection registry — shared cross-app data.
|
||||
--
|
||||
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
||||
-- owning group's subtree may read it (and, with an authenticated editor+,
|
||||
-- write it) via the explicit `kv::shared("name")` SDK handle. Resolution is
|
||||
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
||||
-- walk is the isolation boundary (a foreign app's chain never contains the
|
||||
-- owning group, so the name simply does not resolve). See docs §11.6.
|
||||
--
|
||||
-- This table holds only the MARKER (owner, name, kind) — the data itself lives
|
||||
-- in a per-kind storage table (`group_kv_entries`, 0053, for kind='kv'). It is
|
||||
-- pure declaration, structurally identical to an `extension_points` marker
|
||||
-- (0051). A marker is config, not code → ON DELETE CASCADE.
|
||||
--
|
||||
-- Ownership is polymorphic (mirrors vars/secrets/scripts/extension_points):
|
||||
-- exactly one of (app_id, group_id) is set. MVP authoring is restricted to
|
||||
-- GROUP owners at the manifest layer (an app-declared shared collection is
|
||||
-- degenerate — only that app would read it); the polymorphic shape keeps the
|
||||
-- owner-generic reconcile path uniform with the other inheritable kinds.
|
||||
--
|
||||
-- `kind` is the storage discriminator. Only 'kv' ships in the MVP; the column
|
||||
-- + CHECK exist now so docs/files/topics/queue slot in later without a
|
||||
-- migration, and so a future `docs` collection of the same name is a distinct
|
||||
-- row (the unique index covers `kind`).
|
||||
|
||||
CREATE TABLE group_collections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT group_collections_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'kv' CHECK (kind IN ('kv')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One marker per (owner, name, kind), case-insensitive. Partial because the
|
||||
-- owner is split across two nullable columns.
|
||||
CREATE UNIQUE INDEX group_collections_group_uidx
|
||||
ON group_collections (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX group_collections_app_uidx
|
||||
ON group_collections (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL;
|
||||
|
||||
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||
CREATE INDEX group_collections_group_id_idx ON group_collections (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX group_collections_app_id_idx ON group_collections (app_id) WHERE app_id IS NOT NULL;
|
||||
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- §11.6 (v1.2 Hierarchies): group-KV storage — the shared read/write data for
|
||||
-- a collection declared group-shared in `group_collections` (0052, kind='kv').
|
||||
--
|
||||
-- Identity tuple `(group_id, collection, key)`. Unlike per-app `kv_entries`
|
||||
-- (0007) this is keyed by the OWNING GROUP, not by app — the whole point is
|
||||
-- that many apps in the group's subtree share one store. There is no `app_id`
|
||||
-- column: a shared row belongs to the group, not to whichever app wrote it.
|
||||
--
|
||||
-- `value` is JSONB, mirroring `kv_entries`. The reading/writing app is resolved
|
||||
-- to its owning group at the service layer (walking the app's ancestor chain);
|
||||
-- this table only ever sees a concrete `group_id`.
|
||||
--
|
||||
-- ON DELETE CASCADE on group_id: the shared store is DATA, so it dies with its
|
||||
-- owning group — like `vars`/`secrets` config CASCADE, deliberately UNLIKE the
|
||||
-- `scripts` (0050) RESTRICT (code is not data). Deleting an app does NOT touch
|
||||
-- this table (the data is the group's, and survives the app's departure).
|
||||
|
||||
CREATE TABLE group_kv_entries (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, key)
|
||||
);
|
||||
|
||||
-- Supports list-by-collection (keyset pagination); the PK already covers
|
||||
-- (group_id, collection) as a prefix but the explicit index makes intent clear.
|
||||
CREATE INDEX idx_group_kv_entries_group_collection ON group_kv_entries (group_id, collection);
|
||||
Reference in New Issue
Block a user