mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(db): reconcile schema snapshot with mt migration
Relay E2E builds the database from schema/schema.sql via pgschema apply, while the rewrite migration had moved the first-class community_id schema forward. The snapshot was still mostly pre-MT, so it produced unscoped tables such as channels(id) instead of channels(community_id, id). Make the declarative snapshot match migrations/0001_initial_schema.sql exactly so the schema path and migration path create the same tenant-scoped shape. Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,25 @@
|
||||
-- Buzz Postgres schema reference.
|
||||
-- Buzz initial Postgres schema — multi-tenant.
|
||||
--
|
||||
-- Runtime migrations live under migrations/ and are applied by sqlx. Keep this
|
||||
-- file in sync as a human-readable snapshot of the desired schema.
|
||||
-- Source of truth for fresh database setup. This is a clean, from-scratch
|
||||
-- schema in which `community_id` is a first-class, server-resolved key on
|
||||
-- every tenant-scoped row. It is NOT additive over the single-community
|
||||
-- schema; the rewrite replaces it. Existing single-community deployments
|
||||
-- migrate via the documented backfill migration (0002), which assigns all
|
||||
-- pre-existing rows to one default community.
|
||||
--
|
||||
-- The governing contract is docs/multi-tenant-conformance.md. Every table
|
||||
-- below cites the conformance surface it implements. The invariant behind the
|
||||
-- whole schema (conformance "row zero"): a request's community is resolved
|
||||
-- from the connection host by the server, never supplied by the client, and
|
||||
-- every scoped row carries that immutable `community_id`.
|
||||
--
|
||||
-- Migration-lint obligations enforced by the Lane 0 lint harness:
|
||||
-- 1. Every tenant-scoped table has `community_id NOT NULL`.
|
||||
-- 2. No UNIQUE / PRIMARY KEY / FK on a scoped table is observable across
|
||||
-- communities: each leads with `community_id` (or, for child rows whose
|
||||
-- parent already pins the community, joins carry the community tuple).
|
||||
-- 3. `channels.community_id` is immutable (trigger below; no UPDATE path).
|
||||
-- 4. Operator-global tables are named in the explicit allowlist, not implied.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
@@ -19,9 +37,18 @@ CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
|
||||
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
|
||||
|
||||
-- ── Communities ───────────────────────────────────────────────────────────────
|
||||
-- Host normalization: `host` is stored already-normalized — ASCII-lowercased,
|
||||
-- trailing dot stripped, default port omitted. The UNIQUE is on `lower(host)` so
|
||||
-- resolution and storage agree even if a writer forgets to normalize first.
|
||||
-- Conformance: row zero (host binding). The host map. `resolve_host(host)`
|
||||
-- reads exactly one row here to mint the request's TenantContext. This table
|
||||
-- is OPERATOR-GLOBAL: it is the registry of tenants, not itself tenant-scoped,
|
||||
-- so it carries no `community_id` of its own (its `id` IS the community key).
|
||||
-- Listed in the lint allowlist as operator-global.
|
||||
--
|
||||
-- Host normalization (Lane 0 contract): `host` is stored already-normalized —
|
||||
-- ASCII-lowercased, trailing dot stripped, default port omitted. The UNIQUE is
|
||||
-- on `lower(host)` belt-and-suspenders so `Relay.Example` and `relay.example`
|
||||
-- can never become two tenants even if a writer forgets to normalize.
|
||||
-- `resolve_host()` (buzz-core) applies the identical normalization before
|
||||
-- lookup, so resolution and storage agree by construction.
|
||||
|
||||
CREATE TABLE communities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -34,9 +61,17 @@ CREATE TABLE communities (
|
||||
CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host));
|
||||
|
||||
-- ── Channels ──────────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Channels and channel membership". `community_id` immutable.
|
||||
-- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique:
|
||||
-- the PK is `(community_id, id)`, so the same UUID may legitimately exist in two
|
||||
-- communities (conformance lists "same channel UUID collision in two
|
||||
-- communities" as a required isolation test). Handlers always carry `ctx`, so
|
||||
-- `(ctx.community, h)` names exactly one channel; a client-supplied `h` can
|
||||
-- never reach another community's channel.
|
||||
|
||||
CREATE TABLE channels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
channel_type channel_type NOT NULL DEFAULT 'stream',
|
||||
visibility channel_visibility NOT NULL DEFAULT 'open',
|
||||
@@ -47,7 +82,7 @@ CREATE TABLE channels (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
archived_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
nip29_group_id VARCHAR(255) UNIQUE,
|
||||
nip29_group_id VARCHAR(255),
|
||||
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
max_members INT,
|
||||
topic TEXT,
|
||||
@@ -59,20 +94,44 @@ CREATE TABLE channels (
|
||||
participant_hash BYTEA,
|
||||
ttl_seconds INT,
|
||||
ttl_deadline TIMESTAMPTZ,
|
||||
PRIMARY KEY (community_id, id),
|
||||
CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channels_type ON channels (channel_type);
|
||||
CREATE INDEX idx_channels_visibility ON channels (visibility);
|
||||
CREATE INDEX idx_channels_created_by ON channels (created_by);
|
||||
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash);
|
||||
-- nip29 group id and DM participant hash are unique WITHIN a community, not globally.
|
||||
CREATE UNIQUE INDEX idx_channels_nip29_group ON channels (community_id, nip29_group_id)
|
||||
WHERE nip29_group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (community_id, participant_hash)
|
||||
WHERE participant_hash IS NOT NULL;
|
||||
CREATE INDEX idx_channels_community_type ON channels (community_id, channel_type);
|
||||
CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibility);
|
||||
CREATE INDEX idx_channels_created_by ON channels (community_id, created_by);
|
||||
CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline)
|
||||
WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL;
|
||||
|
||||
-- channels.community_id is immutable: a channel can never be re-tenanted.
|
||||
-- (Conformance: "Migration lint forbids channel re-tenanting except through an
|
||||
-- explicitly modeled admission path." We have no such path, so: hard block.)
|
||||
CREATE FUNCTION channels_community_id_immutable() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.community_id IS DISTINCT FROM OLD.community_id THEN
|
||||
RAISE EXCEPTION 'channels.community_id is immutable (channel % cannot be re-tenanted)', OLD.id
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_channels_community_id_immutable
|
||||
BEFORE UPDATE ON channels
|
||||
FOR EACH ROW EXECUTE FUNCTION channels_community_id_immutable();
|
||||
|
||||
-- ── Channel members ───────────────────────────────────────────────────────────
|
||||
-- Conformance: "Channels and channel membership". PK leads with community_id.
|
||||
|
||||
CREATE TABLE channel_members (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
channel_id UUID NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
role member_role NOT NULL DEFAULT 'member',
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
@@ -80,41 +139,71 @@ CREATE TABLE channel_members (
|
||||
removed_at TIMESTAMPTZ,
|
||||
removed_by BYTEA,
|
||||
hidden_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (channel_id, pubkey)
|
||||
PRIMARY KEY (community_id, channel_id, pubkey),
|
||||
FOREIGN KEY (community_id, channel_id)
|
||||
REFERENCES channels (community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_members_pubkey ON channel_members (pubkey)
|
||||
CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey)
|
||||
WHERE removed_at IS NULL;
|
||||
|
||||
-- ── Users ─────────────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Users, profiles, NIP-05, and user search". One profile per
|
||||
-- (community, pubkey): the same key reposts kind:0 in each community it joins.
|
||||
|
||||
CREATE TABLE users (
|
||||
pubkey BYTEA PRIMARY KEY,
|
||||
nip05_handle VARCHAR(255) UNIQUE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey BYTEA NOT NULL,
|
||||
nip05_handle VARCHAR(255),
|
||||
display_name VARCHAR(255),
|
||||
avatar_url TEXT,
|
||||
about TEXT,
|
||||
agent_type VARCHAR(255),
|
||||
capabilities JSONB,
|
||||
okta_user_id VARCHAR(255) UNIQUE,
|
||||
okta_user_id VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deactivated_at TIMESTAMPTZ,
|
||||
metadata_event_id BYTEA,
|
||||
agent_owner_pubkey BYTEA REFERENCES users(pubkey) ON DELETE SET NULL,
|
||||
agent_owner_pubkey BYTEA,
|
||||
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
|
||||
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32)
|
||||
PRIMARY KEY (community_id, pubkey),
|
||||
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32),
|
||||
-- agent owner is a user in the SAME community.
|
||||
FOREIGN KEY (community_id, agent_owner_pubkey)
|
||||
REFERENCES users (community_id, pubkey) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- NIP-05 handle and Okta id unique within a community, not globally.
|
||||
CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle))
|
||||
WHERE nip05_handle IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id)
|
||||
WHERE okta_user_id IS NOT NULL;
|
||||
|
||||
-- ── Events (partitioned by month on created_at) ──────────────────────────────
|
||||
-- Conformance: "Channel-less global events and DMs". `community_id` leads the
|
||||
-- PK and every hot-path index. Partition stays BY RANGE (created_at) — the
|
||||
-- monthly partition manager is unchanged (Max's call, plan §5/Lane0 contract).
|
||||
-- Cross-community dedup: same signed event may exist in two communities;
|
||||
-- (community_id, created_at, id) dedupes within one, allows across.
|
||||
|
||||
CREATE TABLE events (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id BYTEA NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
kind INT NOT NULL,
|
||||
tags JSONB NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
-- Full-text search vector (Typesense → Postgres FTS). Generated/STORED so
|
||||
-- it is a single source of truth — no sidecar indexer to keep coherent
|
||||
-- (Quinn option A, Lane-0 call). 'simple' config = no stemming/stopwords,
|
||||
-- matching the existing substring-ish search semantics; the search lane can
|
||||
-- revisit the config behind evidence. Tenant scoping is by the
|
||||
-- community-leading btree filters BitmapAnd-ed with the GIN probe, so the
|
||||
-- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat:
|
||||
-- avoid btree_gin unless EXPLAIN proves it buys something).
|
||||
search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
|
||||
sig BYTEA NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
channel_id UUID,
|
||||
@@ -122,7 +211,7 @@ CREATE TABLE events (
|
||||
d_tag TEXT,
|
||||
not_before BIGINT,
|
||||
delivered_at BIGINT,
|
||||
PRIMARY KEY (created_at, id)
|
||||
PRIMARY KEY (community_id, created_at, id)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE events_p_past PARTITION OF events
|
||||
@@ -142,35 +231,60 @@ CREATE TABLE events_p2026_06 PARTITION OF events
|
||||
CREATE TABLE events_p_future PARTITION OF events
|
||||
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
||||
|
||||
CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at);
|
||||
CREATE INDEX idx_events_channel_created ON events (channel_id, created_at);
|
||||
CREATE INDEX idx_events_kind_created ON events (kind, created_at);
|
||||
CREATE INDEX idx_events_id ON events (id);
|
||||
CREATE INDEX idx_events_deleted ON events (deleted_at);
|
||||
CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at);
|
||||
CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL;
|
||||
CREATE INDEX idx_events_not_before ON events (not_before)
|
||||
-- Direct id lookup: the PK can't serve `WHERE id=$1` because created_at sits
|
||||
-- between community_id and id. This index makes the scoped form
|
||||
-- `WHERE community_id=$ AND id=$` index-served, not a partition scan.
|
||||
CREATE INDEX idx_events_community_id ON events (community_id, id, created_at DESC);
|
||||
-- Hot-path indexes, all community-leading.
|
||||
CREATE INDEX idx_events_community_channel_created
|
||||
ON events (community_id, channel_id, created_at DESC, id);
|
||||
CREATE INDEX idx_events_community_pubkey_kind_created
|
||||
ON events (community_id, pubkey, kind, created_at DESC, id);
|
||||
CREATE INDEX idx_events_community_kind_created
|
||||
ON events (community_id, kind, created_at DESC, id);
|
||||
CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at);
|
||||
-- Addressable (replaceable) and NIP-33 parameterized lookups.
|
||||
CREATE INDEX idx_events_addressable
|
||||
ON events (community_id, kind, pubkey, channel_id, deleted_at);
|
||||
CREATE INDEX idx_events_parameterized
|
||||
ON events (community_id, kind, pubkey, d_tag, created_at DESC, id)
|
||||
WHERE d_tag IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX idx_events_not_before ON events (community_id, not_before)
|
||||
WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;
|
||||
-- Full-text search. Minimal GIN over the generated tsvector; community scoping
|
||||
-- is supplied by the community-leading btree filters above (BitmapAnd), so this
|
||||
-- stays a single-column GIN. The search lane confirms the final spelling with
|
||||
-- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat).
|
||||
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
||||
|
||||
-- ── Event mentions ────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to
|
||||
-- events MUST carry the community tuple (e.community_id = m.community_id AND
|
||||
-- e.id = m.event_id) — bare e.id = m.event_id would leak cross-community
|
||||
-- mentions (Max, verified at event.rs:222).
|
||||
|
||||
CREATE TABLE event_mentions (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey_hex VARCHAR(64) NOT NULL,
|
||||
event_id BYTEA NOT NULL,
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
channel_id UUID,
|
||||
event_kind INT,
|
||||
PRIMARY KEY (pubkey_hex, event_id)
|
||||
PRIMARY KEY (community_id, pubkey_hex, event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_mentions_pubkey_created ON event_mentions (pubkey_hex, event_created_at DESC);
|
||||
CREATE INDEX idx_event_mentions_pubkey_kind_created ON event_mentions (pubkey_hex, event_kind, event_created_at DESC);
|
||||
CREATE INDEX idx_event_mentions_pubkey_created
|
||||
ON event_mentions (community_id, pubkey_hex, event_created_at DESC);
|
||||
CREATE INDEX idx_event_mentions_pubkey_kind_created
|
||||
ON event_mentions (community_id, pubkey_hex, event_kind, event_created_at DESC);
|
||||
|
||||
-- ── Subscriptions ─────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Mesh, agents, ACP/MCP, and CLI" (persisted subscriptions).
|
||||
|
||||
CREATE TABLE subscriptions (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id VARCHAR(255) NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL,
|
||||
filter_kinds JSONB,
|
||||
filter_authors JSONB,
|
||||
filter_channel_ids JSONB,
|
||||
@@ -183,12 +297,17 @@ CREATE TABLE subscriptions (
|
||||
delivered_count BIGINT NOT NULL DEFAULT 0,
|
||||
error_count BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey)
|
||||
);
|
||||
|
||||
-- ── Delivery log (partitioned by month on delivered_at) ──────────────────────
|
||||
-- Conformance: subscription delivery audit. community_id carried for tenant
|
||||
-- attribution; child of subscriptions.
|
||||
|
||||
CREATE TABLE delivery_log (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
subscription_id VARCHAR(255),
|
||||
event_id BYTEA,
|
||||
@@ -214,28 +333,40 @@ CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
|
||||
CREATE TABLE delivery_log_p_future PARTITION OF delivery_log
|
||||
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
||||
|
||||
CREATE INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id);
|
||||
|
||||
-- ── Workflows ─────────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Workflows, runs, approvals, webhooks, schedules". Definition's
|
||||
-- community fixed at create from req.community; runs/approvals inherit it.
|
||||
|
||||
CREATE TABLE workflows (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
|
||||
channel_id UUID REFERENCES channels(id),
|
||||
owner_pubkey BYTEA NOT NULL,
|
||||
channel_id UUID,
|
||||
definition JSONB NOT NULL,
|
||||
definition_hash BYTEA NOT NULL,
|
||||
status workflow_status NOT NULL DEFAULT 'active',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey),
|
||||
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled);
|
||||
CREATE INDEX idx_workflows_channel_active ON workflows (community_id, channel_id, status, enabled);
|
||||
-- Scheduler scans enabled schedule workflows; community_id returned per row so
|
||||
-- side effects run under the owning tenant's context (Lane0 contract §4a.5).
|
||||
CREATE INDEX idx_workflows_enabled ON workflows (enabled, status) WHERE enabled;
|
||||
|
||||
-- ── Workflow runs ─────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE workflow_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
workflow_id UUID NOT NULL,
|
||||
status run_status NOT NULL DEFAULT 'pending',
|
||||
trigger_event_id BYTEA,
|
||||
current_step INT NOT NULL DEFAULT 0,
|
||||
@@ -244,18 +375,24 @@ CREATE TABLE workflow_runs (
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, workflow_id)
|
||||
REFERENCES workflows (community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id);
|
||||
CREATE INDEX idx_workflow_runs_status ON workflow_runs (status);
|
||||
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (community_id, workflow_id);
|
||||
CREATE INDEX idx_workflow_runs_status ON workflow_runs (community_id, status);
|
||||
|
||||
-- ── Workflow approvals ────────────────────────────────────────────────────────
|
||||
-- token-hash lookup scoped: approval token grants cannot act on another
|
||||
-- community's same hash (conformance).
|
||||
|
||||
CREATE TABLE workflow_approvals (
|
||||
token BYTEA PRIMARY KEY,
|
||||
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
token BYTEA NOT NULL,
|
||||
workflow_id UUID NOT NULL,
|
||||
run_id UUID NOT NULL,
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
step_index INT NOT NULL,
|
||||
approver_spec TEXT NOT NULL,
|
||||
@@ -265,19 +402,47 @@ CREATE TABLE workflow_approvals (
|
||||
granted_at TIMESTAMPTZ,
|
||||
denied_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, token),
|
||||
FOREIGN KEY (community_id, workflow_id)
|
||||
REFERENCES workflows (community_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (community_id, run_id)
|
||||
REFERENCES workflow_runs (community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id);
|
||||
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (run_id);
|
||||
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status);
|
||||
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (community_id, workflow_id);
|
||||
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (community_id, run_id);
|
||||
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (community_id, status);
|
||||
|
||||
-- ── Scheduled workflow fires (cron claim) ─────────────────────────────────────
|
||||
-- Plan §5: the at-most-once cron fire claim. UNIQUE (community_id, workflow_id,
|
||||
-- scheduled_for) — only the pod that wins the claim insert creates the run.
|
||||
-- Restart-safe (DB-durable). community resolved server-side from workflow_id,
|
||||
-- never a caller-supplied claim parameter (S1 tenant binding).
|
||||
|
||||
CREATE TABLE scheduled_workflow_fires (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
workflow_id UUID NOT NULL,
|
||||
scheduled_for TIMESTAMPTZ NOT NULL,
|
||||
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, workflow_id, scheduled_for),
|
||||
FOREIGN KEY (community_id, workflow_id)
|
||||
REFERENCES workflows (community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- The interval anchor reads MAX(scheduled_for) per workflow; the janitor prunes
|
||||
-- by claimed_at globally (operator concern). See plan §5 retention coupling.
|
||||
CREATE INDEX idx_scheduled_fires_claimed_at ON scheduled_workflow_fires (claimed_at);
|
||||
|
||||
-- ── API tokens ────────────────────────────────────────────────────────────────
|
||||
-- Conformance: "API tokens and NIP-98 replay". token_hash uniqueness scoped to
|
||||
-- (community_id, token_hash); channel claims reference channels in same community.
|
||||
|
||||
CREATE TABLE api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash BYTEA NOT NULL UNIQUE,
|
||||
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
token_hash BYTEA NOT NULL,
|
||||
owner_pubkey BYTEA NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
scopes JSONB NOT NULL,
|
||||
channel_ids JSONB,
|
||||
@@ -287,13 +452,21 @@ CREATE TABLE api_tokens (
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by BYTEA,
|
||||
created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey),
|
||||
CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_api_tokens_hash ON api_tokens (community_id, token_hash);
|
||||
|
||||
-- ── Rate limit violations ─────────────────────────────────────────────────────
|
||||
-- OPERATOR-GLOBAL: a deployment-health / abuse table, never tenant-observable.
|
||||
-- Listed in the lint allowlist. Carries community_id as an attribution label
|
||||
-- only (nullable, no uniqueness over it).
|
||||
|
||||
CREATE TABLE rate_limit_violations (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
community_id UUID,
|
||||
pubkey BYTEA,
|
||||
violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
limit_type VARCHAR(64),
|
||||
@@ -303,11 +476,13 @@ CREATE TABLE rate_limit_violations (
|
||||
);
|
||||
|
||||
-- ── Thread metadata ───────────────────────────────────────────────────────────
|
||||
-- Conformance: thread lookups filter by community before event matching.
|
||||
|
||||
CREATE TABLE thread_metadata (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
event_id BYTEA NOT NULL,
|
||||
channel_id UUID NOT NULL REFERENCES channels(id),
|
||||
channel_id UUID NOT NULL,
|
||||
parent_event_id BYTEA,
|
||||
parent_event_created_at TIMESTAMPTZ,
|
||||
root_event_id BYTEA,
|
||||
@@ -317,17 +492,21 @@ CREATE TABLE thread_metadata (
|
||||
descendant_count INT NOT NULL DEFAULT 0,
|
||||
last_reply_at TIMESTAMPTZ,
|
||||
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (event_created_at, event_id)
|
||||
PRIMARY KEY (community_id, event_created_at, event_id),
|
||||
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (parent_event_id);
|
||||
CREATE INDEX idx_thread_metadata_root ON thread_metadata (root_event_id);
|
||||
CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (channel_id, depth, event_created_at);
|
||||
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (event_id);
|
||||
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (community_id, parent_event_id);
|
||||
CREATE INDEX idx_thread_metadata_root ON thread_metadata (community_id, root_event_id);
|
||||
CREATE INDEX idx_thread_metadata_channel_depth
|
||||
ON thread_metadata (community_id, channel_id, depth, event_created_at);
|
||||
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, event_id);
|
||||
|
||||
-- ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
-- Conformance: reactions filter by community before event/pubkey matching.
|
||||
|
||||
CREATE TABLE reactions (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
event_id BYTEA NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
@@ -335,37 +514,48 @@ CREATE TABLE reactions (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
removed_at TIMESTAMPTZ,
|
||||
reaction_event_id BYTEA,
|
||||
PRIMARY KEY (event_created_at, event_id, pubkey, emoji)
|
||||
PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at);
|
||||
CREATE INDEX idx_reactions_pubkey ON reactions (pubkey);
|
||||
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id);
|
||||
CREATE INDEX idx_reactions_event ON reactions (community_id, event_id, event_created_at);
|
||||
CREATE INDEX idx_reactions_pubkey ON reactions (community_id, pubkey);
|
||||
-- A reaction's source event id is unique within a community.
|
||||
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (community_id, reaction_event_id)
|
||||
WHERE reaction_event_id IS NOT NULL;
|
||||
|
||||
-- ── Pubkey allowlist ──────────────────────────────────────────────────────────
|
||||
-- Conformance: "Relay membership, pubkey allowlist, archived identities".
|
||||
-- PK becomes (community_id, pubkey).
|
||||
|
||||
CREATE TABLE pubkey_allowlist (
|
||||
pubkey BYTEA PRIMARY KEY,
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey BYTEA NOT NULL,
|
||||
added_by BYTEA,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT
|
||||
note TEXT,
|
||||
PRIMARY KEY (community_id, pubkey)
|
||||
);
|
||||
|
||||
-- ── Relay members (NIP-43) ────────────────────────────────────────────────────
|
||||
-- Conformance: membership gate, community-scoped. pubkey stored as hex TEXT
|
||||
-- (unchanged wire form). PK (community_id, pubkey).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_members (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
CREATE TABLE relay_members (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
added_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, pubkey)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role);
|
||||
CREATE INDEX idx_relay_members_role ON relay_members (community_id, role);
|
||||
|
||||
-- ── Archived identities (NIP-IA) ──────────────────────────────────────────────
|
||||
-- Conformance: archive cannot hide a key in another community. PK scoped.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archived_identities (
|
||||
CREATE TABLE archived_identities (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey TEXT NOT NULL,
|
||||
consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')),
|
||||
@@ -376,3 +566,40 @@ CREATE TABLE IF NOT EXISTS archived_identities (
|
||||
archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, pubkey)
|
||||
);
|
||||
|
||||
-- ── Audit log ─────────────────────────────────────────────────────────────────
|
||||
-- Conformance: "Audit log and observability". Per-community hash chain:
|
||||
-- uniqueness (community_id, seq) and (community_id, hash). One chain per tenant.
|
||||
-- (Lane Audit/Dawn builds the chain logic; Lane 0 fixes the scoped schema.)
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
seq BIGINT NOT NULL,
|
||||
hash BYTEA NOT NULL,
|
||||
prev_hash BYTEA,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_pubkey BYTEA,
|
||||
object_id TEXT,
|
||||
detail JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, seq)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash);
|
||||
|
||||
-- ── Lint allowlist registry ───────────────────────────────────────────────────
|
||||
-- The explicit registry of tables that are deliberately operator-global (NOT
|
||||
-- tenant-scoped). The migration-lint harness reads this: any table NOT listed
|
||||
-- here MUST carry a NOT NULL community_id and lead its uniques with it. Making
|
||||
-- the allowlist a DB table (not a hard-coded list in the linter) keeps the
|
||||
-- registry next to the schema it governs and reviewable in one migration diff.
|
||||
|
||||
CREATE TABLE _operator_global_tables (
|
||||
table_name TEXT PRIMARY KEY,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('communities', 'the tenant registry itself; id IS the community key'),
|
||||
('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'),
|
||||
('_operator_global_tables', 'the registry table itself');
|
||||
|
||||
Reference in New Issue
Block a user