feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
-- Buzz initial Postgres schema — multi-tenant.
|
||||
--
|
||||
-- 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;
|
||||
|
||||
-- ── Custom types ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
|
||||
CREATE TYPE channel_visibility AS ENUM ('open', 'private');
|
||||
CREATE TYPE member_role AS ENUM ('owner', 'admin', 'member', 'guest', 'bot');
|
||||
CREATE TYPE workflow_status AS ENUM ('active', 'disabled', 'archived');
|
||||
CREATE TYPE run_status AS ENUM ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled');
|
||||
CREATE TYPE approval_status AS ENUM ('pending', 'granted', 'denied', 'expired');
|
||||
CREATE TYPE delivery_method AS ENUM ('webhook', 'websocket');
|
||||
CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted');
|
||||
CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
|
||||
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
|
||||
|
||||
-- ── Communities ───────────────────────────────────────────────────────────────
|
||||
-- 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(),
|
||||
host VARCHAR(255) NOT NULL,
|
||||
signing_key BYTEA,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
|
||||
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 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',
|
||||
description TEXT,
|
||||
canvas TEXT,
|
||||
created_by BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
archived_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
nip29_group_id VARCHAR(255),
|
||||
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
max_members INT,
|
||||
topic TEXT,
|
||||
topic_set_by BYTEA,
|
||||
topic_set_at TIMESTAMPTZ,
|
||||
purpose TEXT,
|
||||
purpose_set_by BYTEA,
|
||||
purpose_set_at TIMESTAMPTZ,
|
||||
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)
|
||||
);
|
||||
|
||||
-- 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 (
|
||||
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(),
|
||||
invited_by BYTEA,
|
||||
removed_at TIMESTAMPTZ,
|
||||
removed_by BYTEA,
|
||||
hidden_at TIMESTAMPTZ,
|
||||
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 (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 (
|
||||
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),
|
||||
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,
|
||||
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
|
||||
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).
|
||||
--
|
||||
-- Privacy kind exclusions (parity with the pre-rewrite Typesense feed —
|
||||
-- old relay's `handlers/event.rs:287-290` skip set):
|
||||
-- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext)
|
||||
-- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
|
||||
-- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state)
|
||||
-- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice)
|
||||
-- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice)
|
||||
-- NULL tsvector never matches `@@`, so excluded rows are storage-level
|
||||
-- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP,
|
||||
-- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY,
|
||||
-- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION); inlined
|
||||
-- here because a sqlx
|
||||
-- migration is frozen SQL and cannot import the Rust constant. If a new
|
||||
-- privacy-sensitive kind is added there, update this list and add a
|
||||
-- regression test in `buzz-search/tests/fts_integration.rs`.
|
||||
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector
|
||||
ELSE to_tsvector('simple', content)
|
||||
END
|
||||
) STORED,
|
||||
sig BYTEA NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
channel_id UUID,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
d_tag TEXT,
|
||||
not_before BIGINT,
|
||||
delivered_at BIGINT,
|
||||
PRIMARY KEY (community_id, created_at, id)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE events_p_past PARTITION OF events
|
||||
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
|
||||
CREATE TABLE events_p2026_01 PARTITION OF events
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
CREATE TABLE events_p2026_02 PARTITION OF events
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE events_p2026_03 PARTITION OF events
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE events_p2026_04 PARTITION OF events
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE events_p2026_05 PARTITION OF events
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE events_p2026_06 PARTITION OF events
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE events_p_future PARTITION OF events
|
||||
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
||||
|
||||
-- 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 (community_id, pubkey_hex, event_id)
|
||||
);
|
||||
|
||||
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 (
|
||||
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,
|
||||
filter_since TIMESTAMPTZ,
|
||||
filter_until TIMESTAMPTZ,
|
||||
delivery_method delivery_method NOT NULL DEFAULT 'webhook',
|
||||
delivery_url TEXT,
|
||||
status subscription_status NOT NULL DEFAULT 'active',
|
||||
pause_reason pause_reason,
|
||||
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(),
|
||||
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,
|
||||
method delivery_method,
|
||||
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
success BOOLEAN,
|
||||
http_status INT,
|
||||
error_message TEXT,
|
||||
attempt_number INT DEFAULT 1,
|
||||
PRIMARY KEY (delivered_at, id)
|
||||
) PARTITION BY RANGE (delivered_at);
|
||||
|
||||
CREATE TABLE delivery_log_p_past PARTITION OF delivery_log
|
||||
FOR VALUES FROM (MINVALUE) TO ('2026-03-01');
|
||||
CREATE TABLE delivery_log_p2026_03 PARTITION OF delivery_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE delivery_log_p2026_04 PARTITION OF delivery_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE delivery_log_p2026_05 PARTITION OF delivery_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
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 (
|
||||
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,
|
||||
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(),
|
||||
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 (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 (
|
||||
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,
|
||||
execution_trace JSONB NOT NULL DEFAULT '[]',
|
||||
trigger_context JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
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 (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 (
|
||||
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,
|
||||
status approval_status NOT NULL DEFAULT 'pending',
|
||||
approver_pubkey BYTEA,
|
||||
note TEXT,
|
||||
granted_at TIMESTAMPTZ,
|
||||
denied_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
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 (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 is server provenance: the scheduler passes
|
||||
-- workflow.community_id from list_all_enabled_workflows(), never a client input.
|
||||
-- workflow_id is NOT globally unique under the (community_id, id) workflow key, so
|
||||
-- the claim binds both community and id explicitly rather than resolving from id.
|
||||
-- workflow_run_id links the won claim to the run it created (audit; NULL until the
|
||||
-- post-insert attach, and stays NULL if run creation failed after a won claim).
|
||||
-- The FK to workflow_runs uses NO ACTION (not SET NULL): community_id is shared
|
||||
-- with the claim PK and is NOT NULL, so SET NULL is unimplementable here; a future
|
||||
-- delete of a still-linked run is blocked rather than orphaning the at-most-once
|
||||
-- claim row. workflow_runs are not pruned today, so this is a guardrail, not a path.
|
||||
|
||||
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(),
|
||||
workflow_run_id UUID,
|
||||
PRIMARY KEY (community_id, workflow_id, scheduled_for),
|
||||
FOREIGN KEY (community_id, workflow_id)
|
||||
REFERENCES workflows (community_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (community_id, workflow_run_id)
|
||||
REFERENCES workflow_runs (community_id, id) ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
-- 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 (
|
||||
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,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
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),
|
||||
limit_value INT,
|
||||
actual_value INT,
|
||||
action_taken VARCHAR(64)
|
||||
);
|
||||
|
||||
-- ── 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,
|
||||
parent_event_id BYTEA,
|
||||
parent_event_created_at TIMESTAMPTZ,
|
||||
root_event_id BYTEA,
|
||||
root_event_created_at TIMESTAMPTZ,
|
||||
depth INT NOT NULL DEFAULT 0,
|
||||
reply_count INT NOT NULL DEFAULT 0,
|
||||
descendant_count INT NOT NULL DEFAULT 0,
|
||||
last_reply_at TIMESTAMPTZ,
|
||||
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
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 (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,
|
||||
emoji VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
removed_at TIMESTAMPTZ,
|
||||
reaction_event_id BYTEA,
|
||||
PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji)
|
||||
);
|
||||
|
||||
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 (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey BYTEA NOT NULL,
|
||||
added_by BYTEA,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
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 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(),
|
||||
PRIMARY KEY (community_id, pubkey)
|
||||
);
|
||||
|
||||
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 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')),
|
||||
actor TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
replaced_by TEXT,
|
||||
request_event_id TEXT NOT NULL,
|
||||
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');
|
||||
@@ -0,0 +1,29 @@
|
||||
-- ── Git repo name registry (NIP-34 kind:30617) ───────────────────────────────
|
||||
-- The relay holds no persistent per-repo filesystem state: git reads/writes
|
||||
-- hydrate an ephemeral bare repo from object storage per request, and writer
|
||||
-- serialization is the object-store pointer CAS (docs/git-on-object-storage.md,
|
||||
-- Inv_NoFork). This table is the one remaining shared-state need — repo-name
|
||||
-- uniqueness — moved off local disk so the relay is stateless and can run
|
||||
-- multiple replicas without a ReadWriteMany volume.
|
||||
--
|
||||
-- Additive migration (not folded into 0001): brownfield databases that already
|
||||
-- applied the pre-PR 0001 must not see its checksum change, or sqlx aborts
|
||||
-- startup with a VersionMismatch. New table + index only; no edits to existing
|
||||
-- objects.
|
||||
--
|
||||
-- Per-community, not global: a repo name is unique within a community, matching
|
||||
-- the multi-tenant invariant (community_id leads the PK). The PK enforces
|
||||
-- uniqueness atomically (INSERT … ON CONFLICT), replacing the old atomic
|
||||
-- `create_dir`. `owner_pubkey` distinguishes idempotent re-announce (same owner)
|
||||
-- from collision (different owner), and backs the per-pubkey quota via COUNT.
|
||||
|
||||
CREATE TABLE git_repo_names (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
repo_id TEXT NOT NULL,
|
||||
owner_pubkey TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, repo_id)
|
||||
);
|
||||
|
||||
-- Backs the per-pubkey repo quota: COUNT(*) WHERE community_id = $1 AND owner_pubkey = $2.
|
||||
CREATE INDEX idx_git_repo_names_owner ON git_repo_names (community_id, owner_pubkey);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ── Per-community workspace icon (NIP-11 `icon`) ─────────────────────────────
|
||||
-- Set by relay admins/owners via the kind:9033 command; served to clients in
|
||||
-- the standard NIP-11 relay information document `icon` field, bound to the
|
||||
-- community resolved from the request Host (same row-zero seam as WS/NIP-05).
|
||||
--
|
||||
-- Additive migration: previously applied files must not change checksum.
|
||||
-- `communities` is an operator-global registry table (no community_id column
|
||||
-- by design); this adds a per-row presentation attribute, not tenant data in
|
||||
-- a shared table. TEXT holds either an http(s) URL or a small data:image/*
|
||||
-- URL — validated and size-capped at the 9033 write path, not here.
|
||||
|
||||
ALTER TABLE communities ADD COLUMN icon TEXT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ── GIN index for e-tag containment lookups ──────────────────────────────────
|
||||
-- The channel-window aux closure (bridge.rs handle_channel_window_filter) and
|
||||
-- every other #e fan-out resolve "events targeting these rows" with JSONB
|
||||
-- containment: tags @> '[["e","<hex>"]]', OR-ed once per retained row. With no
|
||||
-- index over `tags`, each hop bitmap-scans every events partition on the
|
||||
-- (community_id, kind, ...) btree and filters ~100k rows to find a handful —
|
||||
-- measured ~900ms per hop on staging, two sequential hops per scroll-back
|
||||
-- page (~1.7s of the ~2.1s per-page total; RESEARCH/PERF_STAGING_SCROLLBACK).
|
||||
--
|
||||
-- jsonb_path_ops: smaller and faster than the default jsonb_ops, supports
|
||||
-- exactly the @> operator — the only operator the query path uses
|
||||
-- (event.rs e-tag pushdown).
|
||||
--
|
||||
-- Partitioned parent: CREATE INDEX recurses to all partitions and future
|
||||
-- partitions inherit it. Built without CONCURRENTLY (not supported on
|
||||
-- partitioned parents); on brownfield databases this takes a share lock per
|
||||
-- partition while each partition's index builds — run during a deploy window.
|
||||
--
|
||||
-- Additive migration: previously applied files must not change checksum.
|
||||
|
||||
CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- ── Exclude kind 44200 (NIP-AM Agent Turn Metrics) from full-text search ──────
|
||||
-- NIP-AM events carry NIP-44 ciphertext in `content`. Indexing that ciphertext
|
||||
-- would waste storage and violate the spec's "NOT index the event in any
|
||||
-- full-text search" requirement.
|
||||
--
|
||||
-- Additive migration: previously applied files must not change checksum.
|
||||
-- We must DROP the generated column and re-ADD it with the extended exclusion
|
||||
-- list; ALTER COLUMN cannot change a GENERATED expression in Postgres.
|
||||
--
|
||||
-- Final kind exclusion list after this migration:
|
||||
-- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext)
|
||||
-- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
|
||||
-- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state)
|
||||
-- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice)
|
||||
-- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice)
|
||||
-- 44200 = KIND_AGENT_TURN_METRIC (NIP-AM: p-gated encrypted turn metrics)
|
||||
-- Constants kept in `buzz_core::kind`; inlined here because a sqlx migration
|
||||
-- is frozen SQL and cannot import the Rust constant. If a new privacy-sensitive
|
||||
-- kind is added there, add a new additive migration following this pattern and
|
||||
-- add a regression test in `buzz-search/tests/fts_integration.rs`.
|
||||
--
|
||||
-- NULL tsvector never matches `@@`, so excluded rows are storage-level
|
||||
-- unsearchable.
|
||||
|
||||
ALTER TABLE events DROP COLUMN search_tsv;
|
||||
ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector
|
||||
ELSE to_tsvector('simple', content)
|
||||
END
|
||||
) STORED;
|
||||
|
||||
-- Recreate the GIN index dropped with the column.
|
||||
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Community moderation (Phase 1): reports, bans/timeouts, audit actions.
|
||||
--
|
||||
-- Design: PLANS/COMMUNITY_MODERATION_PLAN.md §0 (decisions locked by Tyler,
|
||||
-- 2026-07-07). All three tables are tenant-scoped: community_id NOT NULL and
|
||||
-- community-id-leading keys, per the tenant-isolation lints in
|
||||
-- crates/buzz-db/src/migration.rs. Report/ban targets are only ever resolved
|
||||
-- under the requesting TenantContext — no global lookups (MOD invariants,
|
||||
-- docs/spec/MultiTenantRelay.tla).
|
||||
|
||||
-- ── NIP-56 reports (kind:1984 ingest) ─────────────────────────────────────────
|
||||
-- One row per accepted report event. Reports are signals, never triggers:
|
||||
-- nothing auto-actions on them (NIP-56). Reporter identity is visible to
|
||||
-- moderators in the queue but never revealed to the reported author.
|
||||
|
||||
CREATE TABLE moderation_reports (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
-- The signed kind:1984 event id (stored for audit/idempotency).
|
||||
report_event_id BYTEA NOT NULL CHECK (length(report_event_id) = 32),
|
||||
reporter_pubkey BYTEA NOT NULL CHECK (length(reporter_pubkey) = 32),
|
||||
-- What was reported. Exactly one target class per row (CHECK-enforced below).
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('event', 'pubkey', 'blob')),
|
||||
target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32),
|
||||
target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32),
|
||||
target_blob_sha256 BYTEA CHECK (target_blob_sha256 IS NULL OR length(target_blob_sha256) = 32),
|
||||
-- Channel inferred from an in-tenant target event row, when resolvable.
|
||||
channel_id UUID,
|
||||
-- NIP-56 report type: illegal|nudity|malware|spam|impersonation|profanity|other.
|
||||
report_type TEXT NOT NULL,
|
||||
-- Reporter's optional free-text context (mod-queue-only; never public).
|
||||
note TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'resolved', 'dismissed', 'escalated')),
|
||||
resolved_by BYTEA,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
-- moderation_actions row that resolved this report, if any.
|
||||
action_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
-- Exactly one target class per row: target_kind is authoritative and the
|
||||
-- matching column (only) is populated. Queue/action code never guesses.
|
||||
CHECK (
|
||||
(target_kind = 'event' AND target_event_id IS NOT NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NULL) OR
|
||||
(target_kind = 'pubkey' AND target_event_id IS NULL AND target_pubkey IS NOT NULL AND target_blob_sha256 IS NULL) OR
|
||||
(target_kind = 'blob' AND target_event_id IS NULL AND target_pubkey IS NULL AND target_blob_sha256 IS NOT NULL)
|
||||
),
|
||||
-- Same-community channel provenance (channels are soft-deleted, never
|
||||
-- hard-deleted, so this FK cannot dangle).
|
||||
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
||||
);
|
||||
|
||||
-- Queue reads: open reports, newest first, per community.
|
||||
CREATE INDEX idx_moderation_reports_status
|
||||
ON moderation_reports (community_id, status, created_at DESC);
|
||||
-- Group-by-target for triage aggregation.
|
||||
CREATE INDEX idx_moderation_reports_target_event
|
||||
ON moderation_reports (community_id, target_event_id)
|
||||
WHERE target_event_id IS NOT NULL;
|
||||
CREATE INDEX idx_moderation_reports_target_pubkey
|
||||
ON moderation_reports (community_id, target_pubkey)
|
||||
WHERE target_pubkey IS NOT NULL;
|
||||
-- Idempotency: one row per report event per community.
|
||||
CREATE UNIQUE INDEX idx_moderation_reports_event
|
||||
ON moderation_reports (community_id, report_event_id);
|
||||
|
||||
-- ── Bans + timeouts (one restriction row per member) ──────────────────────────
|
||||
-- Ban = connection block, enforced at the NIP-42 auth seam
|
||||
-- ("blocked: you are banned from this community") + join/ingest surfaces.
|
||||
-- Timeout = write-block only ("restricted: you are timed out until <ts>").
|
||||
-- A row may be ban-only, timeout-only, or both over its lifetime.
|
||||
|
||||
CREATE TABLE community_bans (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
pubkey BYTEA NOT NULL CHECK (length(pubkey) = 32),
|
||||
banned BOOLEAN NOT NULL DEFAULT false,
|
||||
-- NULL + banned=true ⇒ permanent.
|
||||
ban_expires_at TIMESTAMPTZ,
|
||||
ban_reason TEXT,
|
||||
-- Write-block until this timestamp; NULL or past ⇒ not timed out.
|
||||
muted_until TIMESTAMPTZ,
|
||||
mute_reason TEXT,
|
||||
-- Moderator who last modified this row.
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, pubkey)
|
||||
);
|
||||
|
||||
-- ── Moderation audit ──────────────────────────────────────────────────────────
|
||||
-- One row per accepted moderation action. Full detail (reporter identities,
|
||||
-- private reasons, matched NIP-OA principal) stays mod/audit-only; the public
|
||||
-- tombstone carries only action_id + reason_code + sanitized public_reason.
|
||||
|
||||
CREATE TABLE moderation_actions (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
action TEXT NOT NULL CHECK (action IN (
|
||||
'delete_message', 'kick', 'ban', 'unban',
|
||||
'timeout', 'untimeout', 'dismiss_report', 'escalate',
|
||||
'resolve:delete', 'resolve:kick', 'resolve:ban',
|
||||
'resolve:timeout')),
|
||||
target_pubkey BYTEA CHECK (target_pubkey IS NULL OR length(target_pubkey) = 32),
|
||||
target_event_id BYTEA CHECK (target_event_id IS NULL OR length(target_event_id) = 32),
|
||||
channel_id UUID,
|
||||
-- Machine-readable rule/reason code (e.g. "spam", "community_rule_3").
|
||||
reason_code TEXT,
|
||||
-- Sanitized, safe for the public tombstone.
|
||||
public_reason TEXT,
|
||||
-- Mod-only context; never leaves the audit surface.
|
||||
private_reason TEXT,
|
||||
-- NIP-OA: which principal matched a ban ('self' | 'owner'); audit-only,
|
||||
-- the client never learns which.
|
||||
matched_principal TEXT CHECK (matched_principal IS NULL OR matched_principal IN ('self', 'owner')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_moderation_actions_created
|
||||
ON moderation_actions (community_id, created_at DESC);
|
||||
CREATE INDEX idx_moderation_actions_target_pubkey
|
||||
ON moderation_actions (community_id, target_pubkey)
|
||||
WHERE target_pubkey IS NOT NULL;
|
||||
|
||||
-- Same-community resolution provenance: a report can only be resolved by an
|
||||
-- action row in its own community. Added after moderation_actions exists.
|
||||
ALTER TABLE moderation_reports
|
||||
ADD FOREIGN KEY (community_id, action_id)
|
||||
REFERENCES moderation_actions (community_id, id);
|
||||
@@ -0,0 +1,140 @@
|
||||
-- Bound NIP-RS storage while preserving NIP-33 replay ordering.
|
||||
--
|
||||
-- The payload table previously retained every superseded kind:30078 event as a
|
||||
-- soft-deleted row. Besides keeping the encrypted blob, search_tsv tokenized it
|
||||
-- and the GIN index amplified it further. A compact ordering watermark retains
|
||||
-- the only historical fact replacement needs without retaining user payloads.
|
||||
-- The relay may still have old instances writing during a rolling deploy. Hold a
|
||||
-- table-level writer lock for this transaction so the seed is a complete
|
||||
-- high-water mark: without it, an old instance could insert between the seed
|
||||
-- and purge, then a later NIP-09 deletion could reopen a replay window. Reads
|
||||
-- remain available; inserts, updates, and deletes wait for migration commit.
|
||||
LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
CREATE TABLE parameterized_event_watermarks (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
kind INT NOT NULL,
|
||||
pubkey BYTEA NOT NULL,
|
||||
d_tag TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
event_id BYTEA NOT NULL,
|
||||
PRIMARY KEY (community_id, kind, pubkey, d_tag)
|
||||
);
|
||||
|
||||
-- Superseded read-state events normally have no p-tags, but malformed/legacy
|
||||
-- rows can. Serve defensive mention cleanup without a per-replacement seq scan.
|
||||
CREATE INDEX idx_event_mentions_community_event
|
||||
ON event_mentions (community_id, event_id);
|
||||
|
||||
-- Fail closed on legacy anomalies that would make a deleted tuple outrank a
|
||||
-- live head. Seeding that tuple would freeze legitimate writes; ignoring it
|
||||
-- would weaken replay protection. Operators must inspect and repair such a
|
||||
-- coordinate before retrying the migration.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM events dead
|
||||
JOIN LATERAL (
|
||||
SELECT live.created_at, live.id
|
||||
FROM events live
|
||||
WHERE live.community_id = dead.community_id
|
||||
AND live.kind = dead.kind
|
||||
AND live.pubkey = dead.pubkey
|
||||
AND live.d_tag = dead.d_tag
|
||||
AND live.deleted_at IS NULL
|
||||
ORDER BY live.created_at DESC, live.id ASC
|
||||
LIMIT 1
|
||||
) live ON TRUE
|
||||
WHERE dead.kind = 30078
|
||||
AND dead.deleted_at IS NOT NULL
|
||||
AND dead.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(dead.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
)
|
||||
AND (dead.created_at > live.created_at
|
||||
OR (dead.created_at = live.created_at AND dead.id < live.id))
|
||||
) THEN
|
||||
RAISE EXCEPTION 'NIP-RS retention blocked: deleted event outranks live head';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Seed the greatest accepted tuple (newest created_at; lowest id wins ties)
|
||||
-- from live and historical NIP-RS rows before removing payload history.
|
||||
INSERT INTO parameterized_event_watermarks
|
||||
(community_id, kind, pubkey, d_tag, created_at, event_id)
|
||||
SELECT DISTINCT ON (community_id, kind, pubkey, d_tag)
|
||||
community_id, kind, pubkey, d_tag, created_at, id
|
||||
FROM events e
|
||||
WHERE kind = 30078
|
||||
AND d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(e.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
)
|
||||
ORDER BY community_id, kind, pubkey, d_tag, created_at DESC, id ASC;
|
||||
|
||||
-- Mentions are denormalized and do not have a foreign key to the partitioned
|
||||
-- events table. Delete any defensive/legacy rows for the exact purge set first.
|
||||
DELETE FROM event_mentions mention
|
||||
USING events old
|
||||
WHERE mention.community_id = old.community_id
|
||||
AND mention.event_id = old.id
|
||||
AND mention.event_created_at = old.created_at
|
||||
AND old.kind = 30078
|
||||
AND old.deleted_at IS NOT NULL
|
||||
AND old.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(old.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM events live
|
||||
WHERE live.community_id = old.community_id
|
||||
AND live.kind = old.kind
|
||||
AND live.pubkey = old.pubkey
|
||||
AND live.d_tag = old.d_tag
|
||||
AND live.deleted_at IS NULL
|
||||
AND (live.created_at > old.created_at
|
||||
OR (live.created_at = old.created_at AND live.id < old.id))
|
||||
);
|
||||
|
||||
-- Purge only replacement history with a strictly dominating live head. Rows
|
||||
-- deleted explicitly through NIP-09 have no live head and remain untouched.
|
||||
DELETE FROM events old
|
||||
WHERE old.kind = 30078
|
||||
AND old.deleted_at IS NOT NULL
|
||||
AND old.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(old.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM events live
|
||||
WHERE live.community_id = old.community_id
|
||||
AND live.kind = old.kind
|
||||
AND live.pubkey = old.pubkey
|
||||
AND live.d_tag = old.d_tag
|
||||
AND live.deleted_at IS NULL
|
||||
AND (live.created_at > old.created_at
|
||||
OR (live.created_at = old.created_at AND live.id < old.id))
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Give new, empty installations the positive FTS allowlist without rewriting
|
||||
-- populated databases during relay startup. Existing installations keep their
|
||||
-- current search_tsv expression until an operator runs the sized out-of-band
|
||||
-- maintenance script in scripts/maintenance/nip_rs_search_allowlist.sql.
|
||||
--
|
||||
-- Serialize the emptiness check with event writers. Reads remain available on
|
||||
-- populated databases; an actually empty table upgrades briefly to ACCESS
|
||||
-- EXCLUSIVE for the generated-column replacement and index build.
|
||||
LOCK TABLE events IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM events LIMIT 1) THEN
|
||||
ALTER TABLE events DROP COLUMN search_tsv;
|
||||
ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (0, 9, 40002, 45001, 45003)
|
||||
THEN to_tsvector('simple', content)
|
||||
ELSE NULL::tsvector
|
||||
END
|
||||
) STORED;
|
||||
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Enforce NIP-RS retention across mixed relay versions.
|
||||
--
|
||||
-- Migration 0007 is already published and checksum-frozen. These database
|
||||
-- triggers are additive so databases that applied 0007/0008 can upgrade safely,
|
||||
-- while pre-PR relay binaries cannot bypass watermark or payload-retention rules.
|
||||
|
||||
-- Keep the invariant in PostgreSQL so it also covers pre-migration relay
|
||||
-- binaries during a rolling deployment. Every conforming NIP-RS insert must
|
||||
-- advance the watermark; an insert older than the greatest accepted tuple is
|
||||
-- rejected even when no live row remains.
|
||||
CREATE FUNCTION guard_nip_rs_watermark() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
advanced BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.kind = 30078
|
||||
AND NEW.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(NEW.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
) THEN
|
||||
INSERT INTO parameterized_event_watermarks
|
||||
(community_id, kind, pubkey, d_tag, created_at, event_id)
|
||||
VALUES
|
||||
(NEW.community_id, NEW.kind, NEW.pubkey, NEW.d_tag, NEW.created_at, NEW.id)
|
||||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET
|
||||
created_at = EXCLUDED.created_at,
|
||||
event_id = EXCLUDED.event_id
|
||||
WHERE EXCLUDED.created_at > parameterized_event_watermarks.created_at
|
||||
OR (EXCLUDED.created_at = parameterized_event_watermarks.created_at
|
||||
AND EXCLUDED.event_id < parameterized_event_watermarks.event_id)
|
||||
RETURNING TRUE INTO advanced;
|
||||
|
||||
IF NOT COALESCE(advanced, FALSE) THEN
|
||||
-- Let an exact duplicate reach the events uniqueness constraint so
|
||||
-- legacy `ON CONFLICT DO NOTHING` keeps its existing idempotence.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM parameterized_event_watermarks watermark
|
||||
JOIN events live
|
||||
ON live.community_id = watermark.community_id
|
||||
AND live.kind = watermark.kind
|
||||
AND live.pubkey = watermark.pubkey
|
||||
AND live.d_tag = watermark.d_tag
|
||||
AND live.created_at = watermark.created_at
|
||||
AND live.id = watermark.event_id
|
||||
AND live.deleted_at IS NULL
|
||||
WHERE watermark.community_id = NEW.community_id
|
||||
AND watermark.kind = NEW.kind
|
||||
AND watermark.pubkey = NEW.pubkey
|
||||
AND watermark.d_tag = NEW.d_tag
|
||||
AND watermark.created_at = NEW.created_at
|
||||
AND watermark.event_id = NEW.id
|
||||
) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'stale NIP-RS event rejected by durable watermark'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_events_nip_rs_watermark
|
||||
BEFORE INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_nip_rs_watermark();
|
||||
|
||||
-- NIP-RS payloads have no historical product value. Enforce physical removal
|
||||
-- in the database when old relay binaries use their legacy soft-delete path,
|
||||
-- including NIP-09 coordinate deletion during a mixed-version rollout.
|
||||
CREATE FUNCTION purge_soft_deleted_nip_rs() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.deleted_at IS NULL
|
||||
AND NEW.deleted_at IS NOT NULL
|
||||
AND NEW.kind = 30078
|
||||
AND NEW.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(NEW.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
) THEN
|
||||
DELETE FROM events
|
||||
WHERE community_id = NEW.community_id
|
||||
AND created_at = NEW.created_at
|
||||
AND id = NEW.id;
|
||||
|
||||
DELETE FROM event_mentions
|
||||
WHERE community_id = NEW.community_id AND event_id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_events_purge_soft_deleted_nip_rs
|
||||
AFTER UPDATE OF deleted_at ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION purge_soft_deleted_nip_rs();
|
||||
|
||||
-- Mention indexing runs after the event transaction commits. Lock the live event
|
||||
-- row while a mention is inserted so a concurrent hard delete cannot leave an
|
||||
-- orphan behind; if deletion already won, silently skip the stale index row.
|
||||
CREATE FUNCTION guard_event_mention_live() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.event_kind IS DISTINCT FROM 30078 THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM events
|
||||
WHERE community_id = NEW.community_id
|
||||
AND id = NEW.event_id
|
||||
AND created_at = NEW.event_created_at
|
||||
AND deleted_at IS NULL
|
||||
FOR KEY SHARE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_event_mentions_require_live_event
|
||||
BEFORE INSERT ON event_mentions
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_event_mention_live();
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Replace the published 0009 watermark guard without changing its checksum.
|
||||
-- Exact replay is a durable coordinate-level no-op, independent of whether the
|
||||
-- physically retained payload still exists.
|
||||
CREATE OR REPLACE FUNCTION guard_nip_rs_watermark() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
advanced BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.kind = 30078
|
||||
AND NEW.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(NEW.tags) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) = 2
|
||||
AND tag->>0 = 't'
|
||||
AND tag->>1 = 'read-state'
|
||||
) THEN
|
||||
INSERT INTO parameterized_event_watermarks
|
||||
(community_id, kind, pubkey, d_tag, created_at, event_id)
|
||||
VALUES
|
||||
(NEW.community_id, NEW.kind, NEW.pubkey, NEW.d_tag, NEW.created_at, NEW.id)
|
||||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET
|
||||
created_at = EXCLUDED.created_at,
|
||||
event_id = EXCLUDED.event_id
|
||||
WHERE EXCLUDED.created_at > parameterized_event_watermarks.created_at
|
||||
OR (EXCLUDED.created_at = parameterized_event_watermarks.created_at
|
||||
AND EXCLUDED.event_id < parameterized_event_watermarks.event_id)
|
||||
RETURNING TRUE INTO advanced;
|
||||
|
||||
IF NOT COALESCE(advanced, FALSE) THEN
|
||||
-- Exact equality is idempotent at the durable coordinate level,
|
||||
-- whether or not its payload is still live. Skip it in the trigger
|
||||
-- so concurrent physical deletion cannot create a resurrection
|
||||
-- window between an existence check and uniqueness enforcement.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM parameterized_event_watermarks
|
||||
WHERE community_id = NEW.community_id
|
||||
AND kind = NEW.kind
|
||||
AND pubkey = NEW.pubkey
|
||||
AND d_tag = NEW.d_tag
|
||||
AND created_at = NEW.created_at
|
||||
AND event_id = NEW.id
|
||||
) THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'stale NIP-RS event rejected by durable watermark'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Match NIP-RS's exact tag cardinality in mixed-version database guards.
|
||||
-- Published migrations 0007-0010 remain checksum-frozen.
|
||||
-- Remove polluted watermarks only when their exact source payload still exists
|
||||
-- and proves the event was nonconforming. Missing source payloads are left
|
||||
-- untouched: they may be legitimate NIP-09-deleted read state and have no
|
||||
-- remaining provenance that permits safe automatic classification.
|
||||
DELETE FROM parameterized_event_watermarks watermark
|
||||
USING events source
|
||||
WHERE source.community_id = watermark.community_id
|
||||
AND source.kind = watermark.kind
|
||||
AND source.pubkey = watermark.pubkey
|
||||
AND source.d_tag = watermark.d_tag
|
||||
AND source.created_at = watermark.created_at
|
||||
AND source.id = watermark.event_id
|
||||
AND source.kind = 30078
|
||||
AND NOT (
|
||||
source.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(source.tags) = 'array' THEN source.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND tag->0 = '"d"'::jsonb
|
||||
) = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(source.tags) = 'array' THEN source.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) >= 2
|
||||
AND jsonb_typeof(tag->1) = 'string'
|
||||
AND tag->>0 = 'd'
|
||||
AND tag->>1 = source.d_tag
|
||||
)
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(source.tags) = 'array' THEN source.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE tag = '["t", "read-state"]'::jsonb
|
||||
) = 1
|
||||
);
|
||||
|
||||
-- A relay binary from before this migration can classify an incoming event by
|
||||
-- broad EXISTS predicates and hard-delete the current coordinate before its
|
||||
-- corrected INSERT guard runs. Fail the whole old-writer transaction rather
|
||||
-- than silently skipping the DELETE (which would permit two live rows and
|
||||
-- strip the retained row's mentions). Corrected paths opt in transaction-locally.
|
||||
CREATE FUNCTION guard_nip_rs_hard_delete() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_setting('buzz.nip_rs_hard_delete', true) IS DISTINCT FROM 'on' THEN
|
||||
RAISE EXCEPTION 'NIP-RS hard delete requires corrected writer opt-in'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_events_guard_nip_rs_hard_delete
|
||||
BEFORE DELETE ON events
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.kind = 30078 AND OLD.d_tag ~ '^read-state:[0-9a-f]{32}$')
|
||||
EXECUTE FUNCTION guard_nip_rs_hard_delete();
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_nip_rs_watermark() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
advanced BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.kind = 30078
|
||||
AND NEW.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(NEW.tags) = 'array' THEN NEW.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND tag->0 = '"d"'::jsonb
|
||||
) = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(NEW.tags) = 'array' THEN NEW.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) >= 2
|
||||
AND jsonb_typeof(tag->1) = 'string'
|
||||
AND tag->>0 = 'd'
|
||||
AND tag->>1 = NEW.d_tag
|
||||
)
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(NEW.tags) = 'array' THEN NEW.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE tag = '["t", "read-state"]'::jsonb
|
||||
) = 1 THEN
|
||||
INSERT INTO parameterized_event_watermarks
|
||||
(community_id, kind, pubkey, d_tag, created_at, event_id)
|
||||
VALUES
|
||||
(NEW.community_id, NEW.kind, NEW.pubkey, NEW.d_tag, NEW.created_at, NEW.id)
|
||||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET
|
||||
created_at = EXCLUDED.created_at,
|
||||
event_id = EXCLUDED.event_id
|
||||
WHERE EXCLUDED.created_at > parameterized_event_watermarks.created_at
|
||||
OR (EXCLUDED.created_at = parameterized_event_watermarks.created_at
|
||||
AND EXCLUDED.event_id < parameterized_event_watermarks.event_id)
|
||||
RETURNING TRUE INTO advanced;
|
||||
|
||||
IF NOT COALESCE(advanced, FALSE) THEN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM parameterized_event_watermarks
|
||||
WHERE community_id = NEW.community_id
|
||||
AND kind = NEW.kind
|
||||
AND pubkey = NEW.pubkey
|
||||
AND d_tag = NEW.d_tag
|
||||
AND created_at = NEW.created_at
|
||||
AND event_id = NEW.id
|
||||
) THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'stale NIP-RS event rejected by durable watermark'
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION purge_soft_deleted_nip_rs() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.deleted_at IS NULL
|
||||
AND NEW.deleted_at IS NOT NULL
|
||||
AND NEW.kind = 30078
|
||||
AND NEW.d_tag ~ '^read-state:[0-9a-f]{32}$'
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(NEW.tags) = 'array' THEN NEW.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND tag->0 = '"d"'::jsonb
|
||||
) = 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(NEW.tags) = 'array' THEN NEW.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE jsonb_typeof(tag) = 'array'
|
||||
AND jsonb_array_length(tag) >= 2
|
||||
AND jsonb_typeof(tag->1) = 'string'
|
||||
AND tag->>0 = 'd'
|
||||
AND tag->>1 = NEW.d_tag
|
||||
)
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(NEW.tags) = 'array' THEN NEW.tags ELSE '[]'::jsonb END) tag
|
||||
WHERE tag = '["t", "read-state"]'::jsonb
|
||||
) = 1 THEN
|
||||
PERFORM set_config('buzz.nip_rs_hard_delete', 'on', true);
|
||||
|
||||
DELETE FROM events
|
||||
WHERE community_id = NEW.community_id
|
||||
AND created_at = NEW.created_at
|
||||
AND id = NEW.id;
|
||||
|
||||
DELETE FROM event_mentions
|
||||
WHERE community_id = NEW.community_id AND event_id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- NIP-PL effective lease state and durable wake outbox. Every key is led by
|
||||
-- community_id: client-provided origin is confirmation only, never routing.
|
||||
CREATE TABLE push_leases (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
author BYTEA NOT NULL CHECK (length(author) = 32),
|
||||
installation_id TEXT NOT NULL CHECK (octet_length(installation_id) BETWEEN 1 AND 64),
|
||||
source_event_id BYTEA NOT NULL CHECK (length(source_event_id) = 32),
|
||||
source_created_at BIGINT NOT NULL,
|
||||
generation BIGINT NOT NULL CHECK (generation > 0),
|
||||
active BOOLEAN NOT NULL,
|
||||
app_profile TEXT,
|
||||
endpoint_hash BYTEA CHECK (endpoint_hash IS NULL OR length(endpoint_hash) = 32),
|
||||
endpoint_grant TEXT,
|
||||
max_class TEXT CHECK (max_class IS NULL OR max_class IN ('silent','default','time_sensitive','urgent')),
|
||||
subscriptions JSONB,
|
||||
expires_at BIGINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, author, installation_id),
|
||||
UNIQUE (community_id, source_event_id),
|
||||
CHECK ((active AND app_profile IS NOT NULL AND endpoint_hash IS NOT NULL AND endpoint_grant IS NOT NULL AND max_class IS NOT NULL AND subscriptions IS NOT NULL)
|
||||
OR (NOT active AND app_profile IS NULL AND endpoint_hash IS NULL AND endpoint_grant IS NULL AND max_class IS NULL AND subscriptions IS NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX push_leases_endpoint_unique
|
||||
ON push_leases (community_id, author, app_profile, endpoint_hash)
|
||||
WHERE active;
|
||||
CREATE INDEX push_leases_expiry ON push_leases (community_id, expires_at) WHERE active;
|
||||
|
||||
CREATE TABLE push_wake_outbox (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
author BYTEA NOT NULL CHECK (length(author) = 32),
|
||||
installation_id TEXT NOT NULL,
|
||||
lease_generation BIGINT NOT NULL CHECK (lease_generation > 0),
|
||||
endpoint_hash BYTEA NOT NULL CHECK (length(endpoint_hash) = 32),
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
class TEXT NOT NULL CHECK (class IN ('silent','default','time_sensitive','urgent')),
|
||||
expires_at BIGINT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','sending','delivered','failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
lease_until TIMESTAMPTZ,
|
||||
claim_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, author, installation_id)
|
||||
REFERENCES push_leases (community_id, author, installation_id),
|
||||
UNIQUE (community_id, endpoint_hash, event_id)
|
||||
);
|
||||
CREATE INDEX push_wake_outbox_due
|
||||
ON push_wake_outbox (community_id, next_attempt_at) WHERE state = 'pending';
|
||||
CREATE INDEX push_wake_outbox_recovery
|
||||
ON push_wake_outbox (community_id, lease_until) WHERE state = 'sending';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Transport invalidation is generation-scoped and does not rewrite the signed
|
||||
-- lease's active/tombstone state. A higher-generation replacement re-enables it.
|
||||
ALTER TABLE push_leases
|
||||
ADD COLUMN endpoint_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- NIP-PL kind:30350 contains endpoint-bearing NIP-44 ciphertext and is
|
||||
-- author-only. Exclude it from full-text search without changing the search
|
||||
-- policy of existing installations. In particular, migration 0008 deliberately
|
||||
-- gives only empty/fresh databases the positive allowlist; populated databases
|
||||
-- retain their prior expression until an operator runs the out-of-band rewrite.
|
||||
--
|
||||
-- PostgreSQL cannot alter a generated expression in place. Capture the current
|
||||
-- expression before replacing the column, then wrap it with the new exclusion.
|
||||
-- This preserves both the fresh-install allowlist and any brownfield/operator-
|
||||
-- managed expression for every kind other than 30350.
|
||||
DO $$
|
||||
DECLARE
|
||||
existing_expression TEXT;
|
||||
BEGIN
|
||||
SELECT pg_get_expr(d.adbin, d.adrelid)
|
||||
INTO existing_expression
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = d.adrelid
|
||||
AND a.attnum = d.adnum
|
||||
WHERE d.adrelid = 'events'::regclass
|
||||
AND a.attname = 'search_tsv';
|
||||
|
||||
IF existing_expression IS NULL THEN
|
||||
RAISE EXCEPTION 'events.search_tsv generated expression not found';
|
||||
END IF;
|
||||
|
||||
ALTER TABLE events DROP COLUMN search_tsv;
|
||||
EXECUTE format(
|
||||
'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30350 THEN NULL::tsvector ELSE (%s) END) STORED',
|
||||
existing_expression
|
||||
);
|
||||
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
||||
END $$;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Durable, deployment-global authority for the public NIP-PL push gateway.
|
||||
-- This state is intentionally outside relay community tenancy: installations
|
||||
-- delegate to relay signing keys and may authorize multiple relay deployments.
|
||||
CREATE TABLE push_gateway_challenges (
|
||||
id UUID PRIMARY KEY,
|
||||
challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX push_gateway_challenges_expiry ON push_gateway_challenges (expires_at);
|
||||
|
||||
CREATE TABLE push_gateway_installations (
|
||||
id UUID PRIMARY KEY,
|
||||
app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128),
|
||||
app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256),
|
||||
assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295),
|
||||
app_profile TEXT NOT NULL CHECK (app_profile IN ('buzz-ios-production','buzz-ios-sandbox')),
|
||||
token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048),
|
||||
token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32),
|
||||
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (app_profile, token_fingerprint)
|
||||
);
|
||||
CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE push_gateway_delegations (
|
||||
id UUID PRIMARY KEY,
|
||||
installation_id UUID NOT NULL REFERENCES push_gateway_installations(id),
|
||||
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
||||
endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0),
|
||||
generation BIGINT NOT NULL CHECK (generation > 0),
|
||||
not_before TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (installation_id, relay_pubkey),
|
||||
CHECK (not_before < expires_at)
|
||||
);
|
||||
CREATE INDEX push_gateway_delegations_expiry ON push_gateway_delegations (expires_at) WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE push_gateway_endpoint_quotas (
|
||||
token_fingerprint BYTEA PRIMARY KEY CHECK (length(token_fingerprint) = 32),
|
||||
window_started_at TIMESTAMPTZ NOT NULL,
|
||||
admitted BIGINT NOT NULL CHECK (admitted >= 0),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX push_gateway_endpoint_quotas_updated ON push_gateway_endpoint_quotas (updated_at);
|
||||
|
||||
CREATE TABLE push_gateway_delivery_auth_replays (
|
||||
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
||||
auth_event_id BYTEA NOT NULL CHECK (length(auth_event_id) = 32),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (relay_pubkey, auth_event_id)
|
||||
);
|
||||
CREATE INDEX push_gateway_delivery_auth_replays_expiry ON push_gateway_delivery_auth_replays (expires_at);
|
||||
|
||||
CREATE TABLE push_gateway_delivery_request_replays (
|
||||
relay_pubkey BYTEA NOT NULL CHECK (length(relay_pubkey) = 32),
|
||||
request_id UUID NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (relay_pubkey, request_id)
|
||||
);
|
||||
CREATE INDEX push_gateway_delivery_request_replays_expiry ON push_gateway_delivery_request_replays (expires_at);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_challenges', 'public gateway one-time challenges span relay communities'),
|
||||
('push_gateway_installations', 'public gateway installation authority spans relay communities'),
|
||||
('push_gateway_delegations', 'public gateway relay delegations span relay communities'),
|
||||
('push_gateway_endpoint_quotas', 'public gateway endpoint abuse ceilings span relay communities'),
|
||||
('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities'),
|
||||
('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities');
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Durable community archival state. Archived hosts remain reserved by the existing
|
||||
-- full unique index and continue to count toward owner quotas.
|
||||
ALTER TABLE communities ADD COLUMN archived_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Buzz product feedback is accepted through a dedicated signed event kind and
|
||||
-- sidecarred here instead of entering the ordinary events table. Rows remain
|
||||
-- attributable to their source community, while deployment operators may
|
||||
-- review the table across communities through internal tooling.
|
||||
CREATE TABLE product_feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
submitter_pubkey BYTEA NOT NULL CHECK (length(submitter_pubkey) = 32),
|
||||
category TEXT CHECK (category IN ('bug', 'praise', 'needs-work')),
|
||||
body TEXT NOT NULL CHECK (length(btrim(body)) > 0),
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(tags) = 'array'),
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_product_feedback_received
|
||||
ON product_feedback (received_at DESC, id);
|
||||
CREATE INDEX idx_product_feedback_community_received
|
||||
ON product_feedback (community_id, received_at DESC, id);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('product_feedback', 'deployment product inbox; community_id is provenance only');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Durable event-to-push matching follower. The trigger runs in the event insert
|
||||
-- transaction, so every accepted persistent event has a crash-safe match job and
|
||||
-- rejected/rolled-back events never do. Processing is idempotent through the
|
||||
-- push_wake_outbox endpoint/event unique key.
|
||||
CREATE TABLE push_match_queue (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
lease_until TIMESTAMPTZ,
|
||||
claim_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, event_id)
|
||||
);
|
||||
CREATE INDEX push_match_queue_due
|
||||
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
|
||||
CREATE INDEX push_match_queue_recovery
|
||||
ON push_match_queue (lease_until) WHERE state = 'matching';
|
||||
|
||||
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
|
||||
-- Centralizing it on the events table covers every durable producer,
|
||||
-- including internal paths that bypass live dispatch.
|
||||
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
|
||||
INSERT INTO push_match_queue (community_id, event_id)
|
||||
VALUES (NEW.community_id, NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER events_enqueue_push_match
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Mesh status is a heartbeat carried in a reserved kind:30003 coordinate.
|
||||
-- Only the live head has product value; retaining every superseded 45-second
|
||||
-- payload creates unbounded physical history. Clean up existing history and
|
||||
-- cover soft-delete writes from older relay binaries during rolling deploys.
|
||||
|
||||
DELETE FROM event_mentions mention
|
||||
USING events status
|
||||
WHERE mention.community_id = status.community_id
|
||||
AND mention.event_id = status.id
|
||||
AND status.kind = 30003
|
||||
AND status.d_tag LIKE 'buzz-mesh-member-status:%'
|
||||
AND status.deleted_at IS NOT NULL
|
||||
AND status.tags @> '[["k", "buzz-mesh-status"]]'::jsonb;
|
||||
|
||||
DELETE FROM events
|
||||
WHERE kind = 30003
|
||||
AND d_tag LIKE 'buzz-mesh-member-status:%'
|
||||
AND deleted_at IS NOT NULL
|
||||
AND tags @> '[["k", "buzz-mesh-status"]]'::jsonb;
|
||||
|
||||
CREATE FUNCTION purge_soft_deleted_buzz_mesh_status() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.deleted_at IS NULL
|
||||
AND NEW.deleted_at IS NOT NULL
|
||||
AND NEW.kind = 30003
|
||||
AND NEW.d_tag LIKE 'buzz-mesh-member-status:%'
|
||||
AND NEW.tags @> '[["k", "buzz-mesh-status"]]'::jsonb THEN
|
||||
DELETE FROM events
|
||||
WHERE community_id = NEW.community_id
|
||||
AND created_at = NEW.created_at
|
||||
AND id = NEW.id;
|
||||
|
||||
DELETE FROM event_mentions
|
||||
WHERE community_id = NEW.community_id AND event_id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_events_purge_soft_deleted_buzz_mesh_status
|
||||
AFTER UPDATE OF deleted_at ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION purge_soft_deleted_buzz_mesh_status();
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Durable evidence of the policy version accepted when an invite claim grants
|
||||
-- relay membership. Rows are scoped to the same community and member identity
|
||||
-- as relay_members and are deleted with that membership.
|
||||
CREATE TABLE join_policy_acceptances (
|
||||
community_id UUID NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
policy_version TEXT NOT NULL CHECK (length(policy_version) = 64),
|
||||
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, pubkey, policy_version),
|
||||
FOREIGN KEY (community_id, pubkey)
|
||||
REFERENCES relay_members (community_id, pubkey) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Replica-fence floor: make the ingest-time created_at envelope a commit-time
|
||||
-- storage invariant for channel-window rows.
|
||||
--
|
||||
-- Why: cursor (keyset) pages may be served by a read replica behind a
|
||||
-- "fence" timestamp. The fence proof requires that once a replica has
|
||||
-- replayed past a sampled writer LSN, no transaction can later commit an
|
||||
-- events row whose created_at is older than (commit time - floor). The
|
||||
-- ingest handler checks |created_at - now| <= 900s at acceptance time, but
|
||||
-- acceptance and commit are separated by unbounded async work, and several
|
||||
-- writers (workflow sink, side effects, replace_*) bypass ingest entirely.
|
||||
--
|
||||
-- Mechanism: a DEFERRABLE INITIALLY DEFERRED constraint trigger runs inside
|
||||
-- COMMIT processing, re-evaluating clock_timestamp() (NOT now(), which is
|
||||
-- frozen at transaction start) so the bound is measured at commit, not at
|
||||
-- INSERT. A transaction that holds an old-created_at insert open past the
|
||||
-- floor budget is aborted at COMMIT and can never introduce a below-fence
|
||||
-- row. Verified on PostgreSQL 16 against a partitioned table.
|
||||
--
|
||||
-- Scope: rows with channel_id IS NOT NULL — exactly the rows that channel
|
||||
-- windows and thread pagination serve. channel_id-NULL rows (push leases,
|
||||
-- profile/discovery snapshots) legitimately carry client-signed historical
|
||||
-- timestamps and never appear in keyset-paged windows.
|
||||
--
|
||||
-- Enforcement is opt-in per session via the buzz.created_at_floor GUC
|
||||
-- (seconds). The relay's writer pool sets it on every connection
|
||||
-- (after_connect); when the GUC is unset or blank the guard is a no-op so
|
||||
-- pg_restore/backfills and test fixtures that legitimately write historical
|
||||
-- rows keep working. There is deliberately NO in-band bypass for
|
||||
-- channel-bearing rows (the only structural exemption is channel_id IS
|
||||
-- NULL, which never appears in keyset-paged windows): any operational
|
||||
-- backfill of channel rows must run on a connection without the GUC — i.e.
|
||||
-- outside the relay's writer pool — and the operator must hold the replica
|
||||
-- breaker closed from before the backfill transaction begins until its WAL
|
||||
-- is replayed on the replica (see Db::read routing docs). Disabling
|
||||
-- triggers via session_replication_role = replica (pg_restore) is likewise
|
||||
-- a breaker-closed operation.
|
||||
--
|
||||
-- Partition coverage: a constraint trigger created on the partitioned
|
||||
-- parent is cloned onto every existing partition and onto partitions
|
||||
-- created later (`CREATE TABLE .. PARTITION OF`), so partition rotation
|
||||
-- keeps the guard. Row-level triggers also fire for COPY. Coverage across
|
||||
-- the partition topology is asserted by a buzz-db test.
|
||||
|
||||
CREATE FUNCTION events_created_at_floor_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
floor_secs numeric := nullif(current_setting('buzz.created_at_floor', true), '')::numeric;
|
||||
BEGIN
|
||||
IF floor_secs IS NOT NULL
|
||||
AND floor_secs > 0
|
||||
AND NEW.channel_id IS NOT NULL
|
||||
AND NEW.created_at < clock_timestamp() - make_interval(secs => floor_secs)
|
||||
THEN
|
||||
RAISE EXCEPTION
|
||||
'events.created_at % is more than % s before commit time %; below the replica-fence floor',
|
||||
NEW.created_at, floor_secs, clock_timestamp()
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- INSERT OR UPDATE OF: an UPDATE can move a previously exempt row into the
|
||||
-- guarded set (channel_id NULL -> NOT NULL) or move a channel row's
|
||||
-- created_at below the fence, so both mutation paths re-run the guard on the
|
||||
-- NEW row. Partition-key note: a created_at rewrite that crosses partition
|
||||
-- bounds is executed as DELETE + INSERT, which fires the cloned AFTER INSERT
|
||||
-- guard on the destination partition; an in-partition rewrite fires the
|
||||
-- UPDATE OF arm. Either way the NEW row is checked.
|
||||
CREATE CONSTRAINT TRIGGER events_created_at_floor
|
||||
AFTER INSERT OR UPDATE OF created_at, channel_id ON events
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION events_created_at_floor_guard();
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Refresh ephemeral-channel expiry in the transaction that makes a
|
||||
-- channel-scoped event durable. Deferring to COMMIT closes the stale-prefetch
|
||||
-- race: the UPDATE sees a TTL transition committed while ingest was in flight,
|
||||
-- or waits on its row lock and rechecks after it commits, without restoring a
|
||||
-- separate hot-path transaction.
|
||||
CREATE FUNCTION refresh_channel_ttl_after_event_insert() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Kind 9007 creates the channel and initializes its deadline itself.
|
||||
IF NEW.channel_id IS NOT NULL AND NEW.kind <> 9007 THEN
|
||||
BEGIN
|
||||
-- Lock by identity before testing ttl_seconds. If a concurrent TTL
|
||||
-- transition is uncommitted, this waits and follows its updated row
|
||||
-- version instead of treating the old permanent version as final.
|
||||
PERFORM 1 FROM channels
|
||||
WHERE community_id = NEW.community_id AND id = NEW.channel_id
|
||||
FOR UPDATE;
|
||||
|
||||
UPDATE channels
|
||||
SET ttl_deadline = clock_timestamp() + make_interval(secs => ttl_seconds)
|
||||
WHERE community_id = NEW.community_id
|
||||
AND id = NEW.channel_id
|
||||
AND ttl_seconds IS NOT NULL
|
||||
AND archived_at IS NULL
|
||||
AND deleted_at IS NULL;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Preserve the existing best-effort contract: a TTL refresh failure
|
||||
-- must not reject an otherwise valid durable event.
|
||||
RAISE WARNING 'channel TTL refresh failed for community %, channel %: %',
|
||||
NEW.community_id, NEW.channel_id, SQLERRM;
|
||||
END;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE CONSTRAINT TRIGGER events_refresh_channel_ttl
|
||||
AFTER INSERT ON events
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION refresh_channel_ttl_after_event_insert();
|
||||
@@ -0,0 +1,43 @@
|
||||
-- T1b push gate: skip push_match_queue enqueue entirely for communities with
|
||||
-- no active, endpoint-enabled, unexpired push lease. In lease-less communities
|
||||
-- (most of them) every durable message currently pays the full matcher cost
|
||||
-- (enqueue + claim + lease scan + delete) to conclude "notify no one".
|
||||
--
|
||||
-- Correctness protocol (write-amp plan rev 3, [R2/R3]):
|
||||
-- * The gate lives HERE, in the events trigger, so every durable producer is
|
||||
-- covered — including internal paths that bypass live dispatch.
|
||||
-- * Lost-wake race: a naive EXISTS check could read "no lease" while a lease
|
||||
-- activation commits concurrently, silently dropping that user's wake with
|
||||
-- no retry. Closed with a per-community advisory lock held to transaction
|
||||
-- end: event inserts take the lock SHARED (concurrent with each other),
|
||||
-- lease transitions that can make eligibility true take it EXCLUSIVE
|
||||
-- (crates/buzz-db/src/push.rs: accept_lease_event and replace_lease).
|
||||
-- The conflict forces a total order: either the event's check sees the
|
||||
-- committed lease, or the activation strictly follows the event's commit —
|
||||
-- in which case no lease existed when the event was accepted and no wake
|
||||
-- was owed. The lease-activation backfill is product recovery coverage
|
||||
-- only and is not part of this proof.
|
||||
-- * Lock key domain 'buzz_push_gate:' is distinct from the audit lock
|
||||
-- ('buzz_audit:') and both lease-address lock families.
|
||||
CREATE OR REPLACE FUNCTION enqueue_push_match_job() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
|
||||
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
|
||||
PERFORM pg_advisory_xact_lock_shared(
|
||||
hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM push_leases
|
||||
WHERE community_id = NEW.community_id
|
||||
AND active
|
||||
AND endpoint_enabled
|
||||
AND expires_at > EXTRACT(EPOCH FROM now())::bigint
|
||||
) THEN
|
||||
INSERT INTO push_match_queue (community_id, event_id)
|
||||
VALUES (NEW.community_id, NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,58 @@
|
||||
-- T1a repair: the 0022 trigger takes FOR UPDATE on the channel row before
|
||||
-- testing ttl_seconds, so every durable message in a PERMANENT channel
|
||||
-- serializes on that tuple at commit time (deferred trigger) — one hot
|
||||
-- channel means fully serialized commits, each holding the lock across its
|
||||
-- WAL flush. Observed live at 200 QPS: commit latency 0.07ms -> ~15ms,
|
||||
-- non-CPU DB load ~9/vCPU with CPU under 45%.
|
||||
--
|
||||
-- Repair keeps 0022's stale-prefetch proof but moves the synchronization to
|
||||
-- a per-channel advisory lock, shared on the hot path:
|
||||
-- * Event insert: shared channel-key lock -> read ttl_seconds. NULL returns
|
||||
-- with no tuple lock and no update; shared locks admit each other, so
|
||||
-- permanent-channel commits proceed concurrently.
|
||||
-- * Permanent->ephemeral (or TTL-change) transition (update_channel in
|
||||
-- crates/buzz-db/src/channel.rs) takes the same key EXCLUSIVE before its
|
||||
-- UPDATE. Either the transition commits first and the event's read sees
|
||||
-- the TTL (and refreshes), or the event commits first and the
|
||||
-- transition's own deadline reset is later than anything the event would
|
||||
-- have written. No stale-NULL hole in either order.
|
||||
-- * Ephemeral channels still run the conditional UPDATE; their row updates
|
||||
-- serialize per channel, but only ephemeral channels pay that.
|
||||
-- Lock key domain 'buzz_channel_ttl:' is distinct from 'buzz_push_gate:'
|
||||
-- (migration 0023) and the audit/lease lock families. Lock order note: the
|
||||
-- deferred trigger acquires this key at COMMIT, after any push-gate shared
|
||||
-- lock taken during insert; no path acquires both domains exclusively.
|
||||
CREATE OR REPLACE FUNCTION refresh_channel_ttl_after_event_insert() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
channel_ttl INTEGER;
|
||||
BEGIN
|
||||
-- Kind 9007 creates the channel and initializes its deadline itself.
|
||||
IF NEW.channel_id IS NOT NULL AND NEW.kind <> 9007 THEN
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock_shared(hashtextextended(
|
||||
'buzz_channel_ttl:' || NEW.community_id::text || ':' || NEW.channel_id::text, 0));
|
||||
|
||||
SELECT ttl_seconds INTO channel_ttl
|
||||
FROM channels
|
||||
WHERE community_id = NEW.community_id AND id = NEW.channel_id;
|
||||
|
||||
IF channel_ttl IS NOT NULL THEN
|
||||
UPDATE channels
|
||||
SET ttl_deadline = clock_timestamp() + make_interval(secs => ttl_seconds)
|
||||
WHERE community_id = NEW.community_id
|
||||
AND id = NEW.channel_id
|
||||
AND ttl_seconds IS NOT NULL
|
||||
AND archived_at IS NULL
|
||||
AND deleted_at IS NULL;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Preserve the existing best-effort contract: a TTL refresh failure
|
||||
-- must not reject an otherwise valid durable event.
|
||||
RAISE WARNING 'channel TTL refresh failed for community %, channel %: %',
|
||||
NEW.community_id, NEW.channel_id, SQLERRM;
|
||||
END;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Use-limited invite links: durable invite records for atomic redemption.
|
||||
--
|
||||
-- Stateless HMAC bearer tokens (v1) cannot enforce use limits: their signed
|
||||
-- payload is immutable and no invite row exists to record consumption. This
|
||||
-- migration introduces a durable `relay_invites` table that stores only the
|
||||
-- SHA-256 hash of an opaque v2 code, never the reusable bearer secret itself.
|
||||
--
|
||||
-- Every lookup binds both (community_id, token_hash) so a code presented on
|
||||
-- the wrong tenant host returns Invalid — there is no cross-tenant lookup by
|
||||
-- hash alone. `FOR UPDATE` during claim serializes concurrent claims for one
|
||||
-- invite across relay processes; membership insertion, join-policy evidence,
|
||||
-- and use_count increment share a single commit so exactly one claimant can
|
||||
-- win the final slot.
|
||||
--
|
||||
-- max_uses is optional: NULL means unlimited (preserving current behavior).
|
||||
-- use_count is always incremented for new members, even when unlimited, for
|
||||
-- observability. role is pinned to 'member' — invite links never grant admin.
|
||||
CREATE TABLE relay_invites (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
token_hash BYTEA NOT NULL CHECK (length(token_hash) = 32),
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role = 'member'),
|
||||
max_uses INTEGER CHECK (max_uses BETWEEN 1 AND 10000),
|
||||
use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
UNIQUE (community_id, token_hash),
|
||||
CHECK (max_uses IS NULL OR use_count <= max_uses)
|
||||
);
|
||||
|
||||
CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Replica heartbeat: a portable read-side freshness observation for the
|
||||
-- replica fence (see crates/buzz-db/src/replica_fence.rs).
|
||||
--
|
||||
-- Why: the fence's ordered writer-side proof previously ended in a WAL-LSN
|
||||
-- comparison (`pg_last_wal_replay_lsn() >= L`), which Aurora's reader
|
||||
-- endpoints do not expose — the fence therefore never opened on Aurora, by
|
||||
-- design (fail closed). This table replaces only that read-side observation:
|
||||
-- the probe commits a monotonically increasing `token` AFTER the ordered
|
||||
-- writer scan (clock sample -> oldest-xact guard), and a reader session that
|
||||
-- observes token >= M has, by WAL/storage replay order, also replayed every
|
||||
-- commit that preceded M. The commit-time floor guard (migration 0021) and
|
||||
-- the writer-side scan remain load-bearing and unchanged.
|
||||
--
|
||||
-- Shape: exactly one row, enforced by the CHECK'd primary key. Every relay
|
||||
-- pod's probe increments the same row; the single-row UPDATE is the
|
||||
-- serialization point that makes tokens globally commit-ordered, which is
|
||||
-- what lets a pod prove coverage from the greatest token it retained that is
|
||||
-- <= the token a reader session observes (multi-pod safety).
|
||||
--
|
||||
-- `epoch` detects resets: a restore/re-seed that rolls `token` backwards
|
||||
-- must never let a stale retained token masquerade as fresh coverage.
|
||||
-- Readers validate the observed epoch against the epoch retained with each
|
||||
-- token; a mismatch fails closed (route to writer).
|
||||
--
|
||||
-- Not an events row: exempt from the created_at floor guard by construction,
|
||||
-- and deliberately deployment-global (no community_id) — it describes the
|
||||
-- replication topology, not tenant data.
|
||||
|
||||
CREATE TABLE replica_heartbeat (
|
||||
id smallint PRIMARY KEY CHECK (id = 1),
|
||||
epoch uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
token bigint NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO replica_heartbeat (id) VALUES (1);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data');
|
||||
@@ -0,0 +1,58 @@
|
||||
-- ── Covering index for channel-id → community lookups ───────────────────────
|
||||
-- `channels` is keyed PRIMARY KEY (community_id, id), and every secondary index
|
||||
-- leads with community_id:
|
||||
--
|
||||
-- idx_channels_nip29_group (community_id, nip29_group_id)
|
||||
-- idx_channels_dm_hash (community_id, participant_hash)
|
||||
-- idx_channels_community_type (community_id, channel_type)
|
||||
-- idx_channels_community_visibility (community_id, visibility)
|
||||
-- idx_channels_created_by (community_id, created_by)
|
||||
--
|
||||
-- The tenant-independent lookups in buzz-db resolve a channel's owning
|
||||
-- community *without* a community_id predicate — that independence is the
|
||||
-- point (buzz-db/src/lib.rs: the read-seam projects a row's true label
|
||||
-- regardless of the fetch query's WHERE clause, which is what makes
|
||||
-- Inv_NonInterference non-vacuous):
|
||||
--
|
||||
-- Db::communities_of_channels SELECT id, community_id FROM channels
|
||||
-- WHERE id = ANY($1) AND deleted_at IS NULL
|
||||
-- Db::community_of_channel SELECT community_id FROM channels
|
||||
-- WHERE id = $1 AND deleted_at IS NULL
|
||||
--
|
||||
-- A composite btree is only usable when its leading column is constrained, so
|
||||
-- neither query can use the primary key and no other index leads with `id`.
|
||||
-- Both therefore sequentially scan `channels` on every call. Observed as the
|
||||
-- top "Load by waits (AAS)" on the staging writer (db.r8g.8xlarge, ~53% CPU).
|
||||
--
|
||||
-- INCLUDE (community_id): both queries select only (id, community_id), so the
|
||||
-- index is covering and the planner can serve them index-only, with no heap
|
||||
-- fetch for visible rows.
|
||||
--
|
||||
-- Partial on deleted_at IS NULL: matches both predicates exactly, keeps the
|
||||
-- index off soft-deleted history, and lets Postgres skip re-checking the
|
||||
-- predicate.
|
||||
--
|
||||
-- NOT UNIQUE, deliberately. `id` alone is not unique in this table —
|
||||
-- handlers/command_executor.rs documents that community_of_channel(channel_id)
|
||||
-- is ambiguous because the same channel id can appear under more than one
|
||||
-- community. A unique index would encode a false constraint and would fail to
|
||||
-- build on any database that already holds such a pair.
|
||||
--
|
||||
-- Lock note: built without CONCURRENTLY, matching migration 0004's precedent —
|
||||
-- sqlx runs each migration inside a transaction and CREATE INDEX CONCURRENTLY
|
||||
-- cannot run in one. This takes a SHARE lock on `channels` (blocking writes,
|
||||
-- not reads) for the duration of the build. `channels` is a small table
|
||||
-- relative to `events`, so this is expected to be brief, but on a large
|
||||
-- brownfield database an operator may prefer to pre-build it by hand:
|
||||
--
|
||||
-- CREATE INDEX CONCURRENTLY idx_channels_id_live
|
||||
-- ON channels (id) INCLUDE (community_id)
|
||||
-- WHERE deleted_at IS NULL;
|
||||
--
|
||||
-- IF NOT EXISTS then makes this migration a no-op on that database.
|
||||
--
|
||||
-- Additive migration: previously applied files must not change checksum.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_id_live
|
||||
ON channels (id) INCLUDE (community_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- A valid 64-character custom emoji shortcode is wrapped as `:shortcode:` in
|
||||
-- NIP-25 reaction content. Preserve the wrapper in the reaction projection.
|
||||
ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66);
|
||||
Reference in New Issue
Block a user