mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
refactor(schema): fan desired-state schema out to one file per table (#8131)
This commit is contained in:
31
schema/community_write_fence.sql
Normal file
31
schema/community_write_fence.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Attach the universal fence to every existing table carrying community_id,
|
||||
-- including deployment-private sidecars whose community_id is provenance.
|
||||
--
|
||||
-- Desired-state schema application does not replay migration history, so the
|
||||
-- explicit `SELECT attach_community_write_fence('<table>')` calls in
|
||||
-- `triggers/<table>.sql` stay as first-class catalog declarations. They also
|
||||
-- make the fence contract visible to migration linting instead of hiding it
|
||||
-- only in this dynamic bootstrap loop. This loop runs last, so it fences every
|
||||
-- desired-state table regardless: a fenced desired state does not prove the
|
||||
-- migration path attaches the fence. Keep the explicit calls.
|
||||
DO $$
|
||||
DECLARE
|
||||
target REGCLASS;
|
||||
BEGIN
|
||||
FOR target IN
|
||||
SELECT c.oid::REGCLASS
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p')
|
||||
AND NOT c.relispartition
|
||||
AND a.attname = 'community_id'
|
||||
AND NOT a.attisdropped
|
||||
AND NOT community_write_fence_excluded_table(c.relname)
|
||||
ORDER BY c.oid::REGCLASS::TEXT
|
||||
LOOP
|
||||
PERFORM attach_community_write_fence(target);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
78
schema/functions/assert_community_write_allowed.sql
Normal file
78
schema/functions/assert_community_write_allowed.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
CREATE FUNCTION assert_community_write_allowed(target UUID) RETURNS VOID
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
lifecycle TEXT;
|
||||
generation BIGINT;
|
||||
executor_community TEXT;
|
||||
executor_generation TEXT;
|
||||
serving_community TEXT;
|
||||
serving_lease_id TEXT;
|
||||
serving_owner TEXT;
|
||||
serving_generation TEXT;
|
||||
serving_fence_generation TEXT;
|
||||
serving_lease_valid BOOLEAN := false;
|
||||
BEGIN
|
||||
-- The fence proof requires a fresh statement snapshot after lock grant;
|
||||
-- pinned RR/Serializable snapshots can retain pre-fence authorization.
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION 'community writes require READ COMMITTED isolation'
|
||||
USING ERRCODE = 'invalid_transaction_state';
|
||||
END IF;
|
||||
|
||||
-- Nullable operator-attribution rows without a tenant are unrelated.
|
||||
IF target IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target));
|
||||
SELECT deletion_state, deletion_fence_generation
|
||||
INTO lifecycle, generation
|
||||
FROM communities
|
||||
WHERE id = target;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'community write rejected: community % is missing', target
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
|
||||
-- Authorization is evaluated independently for every community checked.
|
||||
executor_community := current_setting('buzz.deletion_executor_community', true);
|
||||
executor_generation := current_setting('buzz.deletion_fence_generation', true);
|
||||
IF executor_community = target::TEXT
|
||||
AND executor_generation ~ '^[0-9]+$'
|
||||
AND executor_generation::BIGINT = generation THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- A serving mutation admitted before quiescing may finish only while its
|
||||
-- exact durable lease remains current and bound to this fence generation.
|
||||
serving_community := current_setting('buzz.serving_write_community', true);
|
||||
serving_lease_id := current_setting('buzz.serving_write_lease_id', true);
|
||||
serving_owner := current_setting('buzz.serving_write_owner', true);
|
||||
serving_generation := current_setting('buzz.serving_write_generation', true);
|
||||
serving_fence_generation := current_setting('buzz.serving_write_fence_generation', true);
|
||||
IF lifecycle IN ('active', 'quiescing')
|
||||
AND serving_community = target::TEXT
|
||||
AND serving_lease_id ~ '^[0-9a-fA-F-]{36}$'
|
||||
AND serving_generation ~ '^[0-9]+$'
|
||||
AND serving_fence_generation ~ '^[0-9]+$'
|
||||
AND serving_fence_generation::BIGINT = generation THEN
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM community_serving_write_leases lease
|
||||
WHERE lease.id = serving_lease_id::UUID
|
||||
AND lease.community_id = target
|
||||
AND lease.owner = serving_owner
|
||||
AND lease.generation = serving_generation::BIGINT
|
||||
AND lease.fence_generation = serving_fence_generation::BIGINT
|
||||
AND lease.lease_until >= now()
|
||||
) INTO serving_lease_valid;
|
||||
IF serving_lease_valid THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF lifecycle <> 'active' THEN
|
||||
RAISE EXCEPTION 'community write fenced: community % generation %', target, generation
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
45
schema/functions/attach_community_write_fence.sql
Normal file
45
schema/functions/attach_community_write_fence.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Attach the universal fence to one community-scoped relation. Future
|
||||
-- migrations must invoke this helper explicitly after CREATE/ALTER introduces
|
||||
-- community_id; the migration lint enforces that contract.
|
||||
CREATE FUNCTION attach_community_write_fence(target REGCLASS) RETURNS VOID
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
relation_name NAME;
|
||||
BEGIN
|
||||
SELECT c.relname
|
||||
INTO relation_name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.oid = target
|
||||
AND n.nspname = current_schema()
|
||||
AND c.relkind IN ('r', 'p')
|
||||
AND NOT c.relispartition;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'community write fence target % is not a table in the current schema', target
|
||||
USING ERRCODE = 'wrong_object_type';
|
||||
END IF;
|
||||
IF community_write_fence_excluded_table(relation_name) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = target AND attname = 'community_id' AND NOT attisdropped
|
||||
) THEN
|
||||
RAISE EXCEPTION 'community write fence target % has no community_id', target
|
||||
USING ERRCODE = 'undefined_column';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger
|
||||
WHERE tgrelid = target
|
||||
AND tgname = 'community_write_fence_' || relation_name
|
||||
AND NOT tgisinternal
|
||||
) THEN
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON %s '
|
||||
'FOR EACH ROW EXECUTE FUNCTION enforce_community_write_fence()',
|
||||
'community_write_fence_' || relation_name,
|
||||
target
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
12
schema/functions/channels_community_id_immutable.sql
Normal file
12
schema/functions/channels_community_id_immutable.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 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;
|
||||
4
schema/functions/community_deletion_lock_key.sql
Normal file
4
schema/functions/community_deletion_lock_key.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
CREATE FUNCTION community_deletion_lock_key(target UUID) RETURNS BIGINT
|
||||
LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$
|
||||
SELECT hashtextextended('buzz-community-deletion:' || target::text, 0)
|
||||
$$;
|
||||
24
schema/functions/community_write_allowed.sql
Normal file
24
schema/functions/community_write_allowed.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Fleet-wide writers filter candidates through this VOLATILE predicate in
|
||||
-- the mutating statement so fenced tenants are skipped before row triggers run.
|
||||
CREATE FUNCTION community_write_allowed(target UUID) RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql VOLATILE AS $$
|
||||
DECLARE
|
||||
lifecycle TEXT;
|
||||
BEGIN
|
||||
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
||||
RAISE EXCEPTION 'community writes require READ COMMITTED isolation'
|
||||
USING ERRCODE = 'invalid_transaction_state';
|
||||
END IF;
|
||||
|
||||
IF target IS NULL THEN
|
||||
RETURN true;
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock_shared(community_deletion_lock_key(target));
|
||||
SELECT deletion_state
|
||||
INTO lifecycle
|
||||
FROM communities
|
||||
WHERE id = target;
|
||||
RETURN FOUND AND lifecycle = 'active';
|
||||
END
|
||||
$$;
|
||||
12
schema/functions/community_write_fence_excluded_table.sql
Normal file
12
schema/functions/community_write_fence_excluded_table.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Keep the deletion control plane writable while its target tenant is fenced.
|
||||
-- This predicate is the single SQL source of truth used by attachment and live
|
||||
-- catalog validation.
|
||||
CREATE FUNCTION community_write_fence_excluded_table(target NAME) RETURNS BOOLEAN
|
||||
LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE AS $$
|
||||
SELECT target::TEXT = ANY (ARRAY[
|
||||
'community_deletion_requests', 'community_deletion_approvals',
|
||||
'community_deletion_checkpoints', 'community_serving_write_leases',
|
||||
'community_deletion_executor_heartbeats', 'product_feedback',
|
||||
'rate_limit_violations', 'operator_listener_outbox'
|
||||
]::TEXT[])
|
||||
$$;
|
||||
28
schema/functions/enforce_community_tombstone.sql
Normal file
28
schema/functions/enforce_community_tombstone.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE FUNCTION enforce_community_tombstone() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
executor_community TEXT := current_setting('buzz.deletion_executor_community', true);
|
||||
executor_generation TEXT := current_setting('buzz.deletion_fence_generation', true);
|
||||
expected_generation BIGINT;
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF OLD.deletion_state <> 'active' OR OLD.deleted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'community tombstones are permanent'
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
expected_generation := CASE WHEN NEW.deletion_fence_generation > OLD.deletion_fence_generation
|
||||
THEN NEW.deletion_fence_generation ELSE OLD.deletion_fence_generation END;
|
||||
IF executor_community = OLD.id::text AND executor_generation ~ '^[0-9]+$'
|
||||
AND executor_generation::BIGINT = expected_generation THEN RETURN NEW; END IF;
|
||||
IF OLD.deletion_state <> 'active' OR NEW.deletion_state <> OLD.deletion_state
|
||||
OR NEW.deletion_fence_generation <> OLD.deletion_fence_generation
|
||||
OR NEW.deleted_at IS DISTINCT FROM OLD.deleted_at THEN
|
||||
RAISE EXCEPTION 'community tombstone mutation rejected: community % generation %',
|
||||
OLD.id, OLD.deletion_fence_generation
|
||||
USING ERRCODE = 'object_not_in_prerequisite_state';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
24
schema/functions/enforce_community_write_fence.sql
Normal file
24
schema/functions/enforce_community_write_fence.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE FUNCTION enforce_community_write_fence() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
ELSIF OLD.community_id IS NOT DISTINCT FROM NEW.community_id THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
ELSIF OLD.community_id IS NULL THEN
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
ELSIF NEW.community_id IS NULL THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
ELSIF OLD.community_id < NEW.community_id THEN
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
ELSE
|
||||
PERFORM assert_community_write_allowed(NEW.community_id);
|
||||
PERFORM assert_community_write_allowed(OLD.community_id);
|
||||
END IF;
|
||||
|
||||
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
||||
END
|
||||
$$;
|
||||
28
schema/functions/enqueue_push_match_job.sql
Normal file
28
schema/functions/enqueue_push_match_job.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- T1b push gate (keep in sync with migrations/0023). Enqueue only when the
|
||||
-- community has an active, endpoint-enabled, unexpired lease; the shared
|
||||
-- advisory lock pairs with the exclusive lock taken by lease activations
|
||||
-- (crates/buzz-db/src/push.rs) to close the lost-wake race.
|
||||
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 (9, 40002, 45001, 45003) 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
|
||||
$$;
|
||||
29
schema/functions/events_created_at_floor_guard.sql
Normal file
29
schema/functions/events_created_at_floor_guard.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Replica-fence floor guard (keep in sync with migrations/0021). A deferred
|
||||
-- constraint trigger re-checks, inside COMMIT processing, that channel-bearing
|
||||
-- event rows are no older than `buzz.created_at_floor` seconds before commit
|
||||
-- time (clock_timestamp(), NOT the transaction-frozen now()). This turns the
|
||||
-- relay's ingest-time created_at envelope into a commit-time storage
|
||||
-- invariant, which is what lets keyset-cursor pages below the replica fence
|
||||
-- be served by a read replica without holes. Enforcement is armed per session
|
||||
-- via the GUC (set by the relay's writer pool on connect); sessions without
|
||||
-- the GUC (pg_restore, manual backfills) bypass it and must hold the replica
|
||||
-- fence closed for their duration. The only structural exemption is
|
||||
-- channel_id IS NULL: those rows never appear in keyset-paged windows.
|
||||
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
|
||||
$$;
|
||||
72
schema/functions/guard_channel_roster_snapshot.sql
Normal file
72
schema/functions/guard_channel_roster_snapshot.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- Channel roster snapshot fence (keep in sync with migrations/0032).
|
||||
-- Prevent mixed-version relay pods from publishing a stale NIP-29 member
|
||||
-- snapshot after a newer canonical roster has been committed.
|
||||
--
|
||||
-- Old binaries already serialize kind 39002 replacement on the replacement
|
||||
-- advisory key. This trigger adds the channel-membership key at INSERT time,
|
||||
-- after that canonical key, and validates every p tag against the current
|
||||
-- active membership set and roles. New binaries take both keys in the same
|
||||
-- order before capture and replacement. Thus old and new writers remain
|
||||
-- compatible during a rolling deploy.
|
||||
CREATE OR REPLACE FUNCTION guard_channel_roster_snapshot()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
canonical_members TEXT[];
|
||||
snapshot_members TEXT[];
|
||||
BEGIN
|
||||
IF NEW.kind <> 39002 OR NEW.channel_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(
|
||||
'buzz_channel_membership:' || NEW.community_id::text || ':' || NEW.channel_id::text,
|
||||
0
|
||||
));
|
||||
|
||||
SELECT COALESCE(
|
||||
array_agg(encode(cm.pubkey, 'hex') || ':' || cm.role::text ORDER BY cm.pubkey),
|
||||
ARRAY[]::TEXT[]
|
||||
)
|
||||
INTO canonical_members
|
||||
FROM channel_members cm
|
||||
WHERE cm.community_id = NEW.community_id
|
||||
AND cm.channel_id = NEW.channel_id
|
||||
AND cm.removed_at IS NULL;
|
||||
|
||||
-- A roster is canonical only when every p tag uses the emitted four-field
|
||||
-- shape, contains a 32-byte hex pubkey and valid authoritative role, has no
|
||||
-- duplicate members, and exactly matches the active membership rows.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(NEW.tags) AS roster_tag(tag_json)
|
||||
WHERE roster_tag.tag_json->>0 = 'p'
|
||||
AND (
|
||||
jsonb_array_length(roster_tag.tag_json) <> 4
|
||||
OR COALESCE(roster_tag.tag_json->>1, '') !~ '^[0-9a-fA-F]{64}$'
|
||||
OR roster_tag.tag_json->>2 <> ''
|
||||
OR COALESCE(roster_tag.tag_json->>3, '') NOT IN ('owner', 'admin', 'bot', 'member', 'guest')
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'kind 39002 roster contains an invalid p tag'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(
|
||||
array_agg(
|
||||
lower((roster_tag.tag_json->>1)) || ':' || (roster_tag.tag_json->>3)
|
||||
ORDER BY decode((roster_tag.tag_json->>1), 'hex')
|
||||
),
|
||||
ARRAY[]::TEXT[]
|
||||
)
|
||||
INTO snapshot_members
|
||||
FROM jsonb_array_elements(NEW.tags) AS roster_tag(tag_json)
|
||||
WHERE roster_tag.tag_json->>0 = 'p';
|
||||
|
||||
IF snapshot_members IS DISTINCT FROM canonical_members THEN
|
||||
RAISE EXCEPTION 'kind 39002 roster does not match canonical channel membership'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE FUNCTION prevent_community_deletion_approval_removal()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'community deletion approval evidence is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,39 @@
|
||||
CREATE OR REPLACE FUNCTION prevent_community_deletion_request_retargeting()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.community_id IS DISTINCT FROM OLD.community_id
|
||||
OR NEW.community_host IS DISTINCT FROM OLD.community_host
|
||||
THEN
|
||||
RAISE EXCEPTION 'community deletion target identity is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF NEW.request_origin IS DISTINCT FROM OLD.request_origin
|
||||
OR NEW.owner_pubkey IS DISTINCT FROM OLD.owner_pubkey
|
||||
OR NEW.mediating_operator_pubkey IS DISTINCT FROM OLD.mediating_operator_pubkey
|
||||
OR NEW.acknowledgement_version IS DISTINCT FROM OLD.acknowledgement_version
|
||||
THEN
|
||||
RAISE EXCEPTION 'community deletion request provenance is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF OLD.inventory_frozen_at IS NOT NULL AND (
|
||||
NEW.schema_manifest IS DISTINCT FROM OLD.schema_manifest
|
||||
OR NEW.storage_manifest IS DISTINCT FROM OLD.storage_manifest
|
||||
OR NEW.inventory_manifest IS DISTINCT FROM OLD.inventory_manifest
|
||||
OR NEW.inventory_digest IS DISTINCT FROM OLD.inventory_digest
|
||||
OR NEW.inventory_frozen_at IS DISTINCT FROM OLD.inventory_frozen_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'frozen community deletion inventory is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF OLD.destructive_storage_frozen_at IS NOT NULL AND (
|
||||
NEW.destructive_storage_manifest IS DISTINCT FROM OLD.destructive_storage_manifest
|
||||
OR NEW.destructive_storage_frozen_at IS DISTINCT FROM OLD.destructive_storage_frozen_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'frozen destructive storage manifest is immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Chunk content is immutable once written; the only permitted update is the
|
||||
-- one-way deleted_at stamp. New chunks are permitted only while the request is
|
||||
-- fenced and its destructive manifest remains unfrozen. Removal is permitted
|
||||
-- only while the destructive manifest has not yet frozen (a retried partial
|
||||
-- freeze rewrites its chunks) or once the request has passed logical
|
||||
-- verification (terminal cleanup).
|
||||
CREATE FUNCTION protect_community_deletion_manifest_keys()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
frozen_at TIMESTAMPTZ;
|
||||
request_stage TEXT;
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.request_id IS DISTINCT FROM OLD.request_id
|
||||
OR NEW.chunk_no IS DISTINCT FROM OLD.chunk_no
|
||||
OR NEW.prefix IS DISTINCT FROM OLD.prefix
|
||||
OR NEW.keys IS DISTINCT FROM OLD.keys
|
||||
OR OLD.deleted_at IS NOT NULL
|
||||
THEN
|
||||
RAISE EXCEPTION 'community deletion manifest key chunks are immutable'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
SELECT destructive_storage_frozen_at, stage
|
||||
INTO frozen_at, request_stage
|
||||
FROM community_deletion_requests
|
||||
WHERE id = CASE WHEN TG_OP = 'INSERT' THEN NEW.request_id ELSE OLD.request_id END
|
||||
FOR UPDATE;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF FOUND AND frozen_at IS NULL AND request_stage = 'fenced' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'community deletion manifest key chunks require an unfrozen fenced request'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
IF NOT FOUND
|
||||
OR frozen_at IS NULL
|
||||
OR request_stage IN ('logically_verified', 'retention_pending')
|
||||
THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'community deletion manifest key chunks cannot be removed mid-execution'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END;
|
||||
$$;
|
||||
41
schema/functions/refresh_channel_ttl_after_event_insert.sql
Normal file
41
schema/functions/refresh_channel_ttl_after_event_insert.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Channel TTL refresh (keep in sync with migrations/0024). Runs deferred, in
|
||||
-- the transaction that makes a channel-scoped event durable, so a TTL
|
||||
-- transition committed while ingest was in flight is never missed. The
|
||||
-- per-channel advisory lock is SHARED here — permanent-channel commits admit
|
||||
-- each other — and taken EXCLUSIVE by TTL transitions (update_channel in
|
||||
-- crates/buzz-db/src/channel.rs), which forces the same total order the
|
||||
-- 0022 row lock provided without serializing the hot path.
|
||||
CREATE 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
|
||||
$$;
|
||||
12
schema/partitions/delivery_log.sql
Normal file
12
schema/partitions/delivery_log.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
16
schema/partitions/events.sql
Normal file
16
schema/partitions/events.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
2251
schema/schema.sql
2251
schema/schema.sql
File diff suppressed because it is too large
Load Diff
2
schema/seeds/_operator_global_tables.sql
Normal file
2
schema/seeds/_operator_global_tables.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('_operator_global_tables', 'the registry table itself');
|
||||
2
schema/seeds/communities.sql
Normal file
2
schema/seeds/communities.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('communities', 'the tenant registry itself; id IS the community key');
|
||||
2
schema/seeds/community_deletion_approvals.sql
Normal file
2
schema/seeds/community_deletion_approvals.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_deletion_approvals', 'deployment operator destructive approvals');
|
||||
2
schema/seeds/community_deletion_checkpoints.sql
Normal file
2
schema/seeds/community_deletion_checkpoints.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_deletion_checkpoints', 'deployment deletion executor checkpoints and failures');
|
||||
2
schema/seeds/community_deletion_executor_heartbeats.sql
Normal file
2
schema/seeds/community_deletion_executor_heartbeats.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_deletion_executor_heartbeats', 'deployment deletion worker liveness');
|
||||
2
schema/seeds/community_deletion_manifest_keys.sql
Normal file
2
schema/seeds/community_deletion_manifest_keys.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_deletion_manifest_keys', 'deployment deletion frozen destructive key chunks');
|
||||
2
schema/seeds/community_deletion_requests.sql
Normal file
2
schema/seeds/community_deletion_requests.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_deletion_requests', 'deployment deletion lifecycle and frozen inventory');
|
||||
2
schema/seeds/community_serving_write_leases.sql
Normal file
2
schema/seeds/community_serving_write_leases.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('community_serving_write_leases', 'deployment serving side-effect leases drained by deletion');
|
||||
2
schema/seeds/operator_listener_outbox.sql
Normal file
2
schema/seeds/operator_listener_outbox.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('operator_listener_outbox', 'deployment-global mention delivery queue; community_id is event provenance');
|
||||
2
schema/seeds/operator_listener_pubkeys.sql
Normal file
2
schema/seeds/operator_listener_pubkeys.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('operator_listener_pubkeys', 'deployment-global target registrations for operator listeners');
|
||||
2
schema/seeds/product_feedback.sql
Normal file
2
schema/seeds/product_feedback.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('product_feedback', 'deployment product inbox; community_id is provenance only');
|
||||
2
schema/seeds/push_gateway_challenges.sql
Normal file
2
schema/seeds/push_gateway_challenges.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_challenges', 'public gateway one-time challenges span relay communities');
|
||||
2
schema/seeds/push_gateway_delegations.sql
Normal file
2
schema/seeds/push_gateway_delegations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_delegations', 'public gateway relay delegations span relay communities');
|
||||
2
schema/seeds/push_gateway_delivery_auth_replays.sql
Normal file
2
schema/seeds/push_gateway_delivery_auth_replays.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities');
|
||||
2
schema/seeds/push_gateway_delivery_request_replays.sql
Normal file
2
schema/seeds/push_gateway_delivery_request_replays.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities');
|
||||
2
schema/seeds/push_gateway_endpoint_quotas.sql
Normal file
2
schema/seeds/push_gateway_endpoint_quotas.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_endpoint_quotas', 'public gateway endpoint abuse ceilings span relay communities');
|
||||
2
schema/seeds/push_gateway_installations.sql
Normal file
2
schema/seeds/push_gateway_installations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('push_gateway_installations', 'public gateway installation authority spans relay communities');
|
||||
2
schema/seeds/rate_limit_violations.sql
Normal file
2
schema/seeds/rate_limit_violations.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only');
|
||||
2
schema/seeds/relay_admin_actions.sql
Normal file
2
schema/seeds/relay_admin_actions.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_admin_actions', 'deployment-global enforcement state machine; community deletion purges rows by report_community_id');
|
||||
2
schema/seeds/relay_admin_outbox.sql
Normal file
2
schema/seeds/relay_admin_outbox.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_admin_outbox', 'deployment-global enforcement artifact delivery queue; community deletion purges rows with their action');
|
||||
2
schema/seeds/relay_operator_audit.sql
Normal file
2
schema/seeds/relay_operator_audit.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_operator_audit', 'deployment-global append-only roster mutation audit trail; no community_id intentionally');
|
||||
2
schema/seeds/relay_operators.sql
Normal file
2
schema/seeds/relay_operators.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('relay_operators', 'deployment-global operator/moderator roster; no community_id intentionally');
|
||||
3
schema/seeds/replica_heartbeat.sql
Normal file
3
schema/seeds/replica_heartbeat.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
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');
|
||||
2
schema/seeds/storage_accounting_snapshots.sql
Normal file
2
schema/seeds/storage_accounting_snapshots.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('storage_accounting_snapshots', 'deployment-global completed media accounting handoff');
|
||||
2
schema/seeds/storage_taxonomy_sweeps.sql
Normal file
2
schema/seeds/storage_taxonomy_sweeps.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason) VALUES
|
||||
('storage_taxonomy_sweeps', 'deployment object-store taxonomy sweep evidence');
|
||||
11
schema/tables/public/_operator_global_tables.sql
Normal file
11
schema/tables/public/_operator_global_tables.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- ── 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
|
||||
);
|
||||
24
schema/tables/public/api_tokens.sql
Normal file
24
schema/tables/public/api_tokens.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- ── 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);
|
||||
14
schema/tables/public/archived_identities.sql
Normal file
14
schema/tables/public/archived_identities.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- ── 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)
|
||||
);
|
||||
16
schema/tables/public/artifact_heads.sql
Normal file
16
schema/tables/public/artifact_heads.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- NIP-AR current heads and acceptance ledger are independent of event retention.
|
||||
CREATE TABLE artifact_heads (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
artifact_id UUID NOT NULL,
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
channel_id UUID NOT NULL,
|
||||
artifact_type TEXT NOT NULL,
|
||||
root BYTEA,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (community_id, artifact_id)
|
||||
);
|
||||
CREATE INDEX artifact_heads_event ON artifact_heads (community_id, event_id);
|
||||
|
||||
-- The relay does not expire events. Any future row retention or partition
|
||||
-- retirement must skip payloads referenced by `artifact_heads.event_id`
|
||||
-- (NIP-AR: expiring earlier revisions MUST NOT remove the current revision).
|
||||
8
schema/tables/public/artifact_revisions.sql
Normal file
8
schema/tables/public/artifact_revisions.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Every accepted revision ID, so replays stay idempotent after redaction or
|
||||
-- retention.
|
||||
CREATE TABLE artifact_revisions (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
artifact_id UUID NOT NULL,
|
||||
PRIMARY KEY (community_id, event_id)
|
||||
);
|
||||
20
schema/tables/public/audit_log.sql
Normal file
20
schema/tables/public/audit_log.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- ── 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_version SMALLINT NOT NULL DEFAULT 1 CHECK (hash_version IN (1, 2)),
|
||||
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);
|
||||
20
schema/tables/public/channel_members.sql
Normal file
20
schema/tables/public/channel_members.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- ── 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;
|
||||
54
schema/tables/public/channels.sql
Normal file
54
schema/tables/public/channels.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- ── 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;
|
||||
-- Tenant-independent channel-id → community lookups (Db::communities_of_channels,
|
||||
-- Db::community_of_channel) carry no community_id predicate, so no
|
||||
-- community_id-leading index can serve them. Covering + partial: index-only scan.
|
||||
-- Not UNIQUE — the same channel id may exist under more than one community.
|
||||
CREATE INDEX idx_channels_id_live ON channels (id) INCLUDE (community_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
30
schema/tables/public/communities.sql
Normal file
30
schema/tables/public/communities.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- ── 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,
|
||||
-- Per-community workspace icon (NIP-11 `icon`), set via kind:9033.
|
||||
-- Added by migration 0003; kept here so desired-state applies match.
|
||||
icon TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
archived_at TIMESTAMPTZ,
|
||||
deletion_state TEXT NOT NULL DEFAULT 'active' CHECK (deletion_state IN ('active', 'quiescing', 'fenced', 'tombstone')),
|
||||
deletion_fence_generation BIGINT NOT NULL DEFAULT 0 CHECK (deletion_fence_generation >= 0),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host));
|
||||
22
schema/tables/public/community_bans.sql
Normal file
22
schema/tables/public/community_bans.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- ── 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)
|
||||
);
|
||||
13
schema/tables/public/community_deletion_approvals.sql
Normal file
13
schema/tables/public/community_deletion_approvals.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE community_deletion_approvals (
|
||||
request_id UUID PRIMARY KEY,
|
||||
community_id UUID NOT NULL,
|
||||
inventory_digest BYTEA NOT NULL CHECK (length(inventory_digest) = 32),
|
||||
approved_by TEXT NOT NULL,
|
||||
approval_origin TEXT NOT NULL DEFAULT 'operator'
|
||||
CHECK (approval_origin IN ('operator', 'owner_automatic')),
|
||||
note TEXT,
|
||||
approved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (request_id, community_id, inventory_digest)
|
||||
REFERENCES community_deletion_requests(id, community_id, inventory_digest)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
17
schema/tables/public/community_deletion_checkpoints.sql
Normal file
17
schema/tables/public/community_deletion_checkpoints.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE community_deletion_checkpoints (
|
||||
request_id UUID NOT NULL REFERENCES community_deletion_requests(id) ON DELETE RESTRICT,
|
||||
sequence BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
stage TEXT NOT NULL,
|
||||
unit_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('started', 'completed', 'failed')),
|
||||
lease_generation BIGINT NOT NULL CHECK (lease_generation > 0),
|
||||
attempts INTEGER NOT NULL DEFAULT 1 CHECK (attempts > 0),
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (request_id, sequence),
|
||||
UNIQUE (request_id, stage, unit_key),
|
||||
CHECK ((status = 'completed') = (completed_at IS NOT NULL)),
|
||||
CHECK ((status = 'failed') = (error IS NOT NULL))
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE community_deletion_executor_heartbeats (
|
||||
executor_id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('run', 'drain', 'worker')),
|
||||
request_id UUID REFERENCES community_deletion_requests(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
draining BOOLEAN NOT NULL DEFAULT false,
|
||||
stopped_at TIMESTAMPTZ
|
||||
);
|
||||
14
schema/tables/public/community_deletion_manifest_keys.sql
Normal file
14
schema/tables/public/community_deletion_manifest_keys.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Frozen destructive key list, chunked out of the request row so a large
|
||||
-- tenant (100k-1M objects) never materializes as one multi-hundred-MB JSONB
|
||||
-- value. Rows are written once in the fenced stage, stamped `deleted_at` as
|
||||
-- the executor confirms each chunk removed, and dropped at logical
|
||||
-- verification. The request row keeps only per-prefix count/bytes/digest
|
||||
-- summaries; the chunk stream must hash to those frozen digests.
|
||||
CREATE TABLE community_deletion_manifest_keys (
|
||||
request_id UUID NOT NULL REFERENCES community_deletion_requests(id) ON DELETE CASCADE,
|
||||
chunk_no BIGINT NOT NULL CHECK (chunk_no >= 0),
|
||||
prefix TEXT NOT NULL,
|
||||
keys JSONB NOT NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (request_id, chunk_no)
|
||||
);
|
||||
89
schema/tables/public/community_deletion_requests.sql
Normal file
89
schema/tables/public/community_deletion_requests.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- ── Whole-community deletion control plane (migration 0029) ─────────────────
|
||||
CREATE TABLE community_deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
community_host TEXT NOT NULL,
|
||||
stage TEXT NOT NULL DEFAULT 'submitted' CHECK (stage IN (
|
||||
'submitted', 'inventoried', 'approved', 'fenced', 'drained',
|
||||
'bindings_removed', 'postgres_purged', 'cache_purged',
|
||||
'logically_verified', 'retention_pending', 'aborted'
|
||||
)),
|
||||
requested_by TEXT NOT NULL,
|
||||
request_origin TEXT NOT NULL DEFAULT 'operator'
|
||||
CHECK (request_origin IN ('operator', 'owner')),
|
||||
owner_pubkey TEXT,
|
||||
mediating_operator_pubkey TEXT,
|
||||
acknowledgement_version INTEGER,
|
||||
reason TEXT,
|
||||
schema_manifest JSONB,
|
||||
storage_manifest JSONB,
|
||||
destructive_storage_manifest JSONB,
|
||||
destructive_storage_frozen_at TIMESTAMPTZ,
|
||||
inventory_manifest JSONB,
|
||||
inventory_digest BYTEA CHECK (inventory_digest IS NULL OR length(inventory_digest) = 32),
|
||||
inventory_frozen_at TIMESTAMPTZ,
|
||||
fence_generation BIGINT CHECK (fence_generation IS NULL OR fence_generation > 0),
|
||||
lease_owner TEXT,
|
||||
lease_generation BIGINT NOT NULL DEFAULT 0 CHECK (lease_generation >= 0),
|
||||
lease_until TIMESTAMPTZ,
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
retry_stage TEXT CHECK (retry_stage IS NULL OR retry_stage IN (
|
||||
'submitted', 'approved', 'fenced', 'drained', 'bindings_removed',
|
||||
'postgres_purged', 'cache_purged', 'logically_verified'
|
||||
)),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
blocked_at TIMESTAMPTZ,
|
||||
blocked_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
pre_quiesce_archived_at TIMESTAMPTZ,
|
||||
quiescing_started_at TIMESTAMPTZ,
|
||||
aborted_by TEXT,
|
||||
abort_reason TEXT,
|
||||
aborted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
CHECK ((blocked_at IS NULL) = (blocked_reason IS NULL)),
|
||||
CHECK ((stage = 'aborted') = (aborted_at IS NOT NULL)),
|
||||
CHECK ((aborted_at IS NULL) = (aborted_by IS NULL)),
|
||||
CHECK ((aborted_at IS NULL) = (abort_reason IS NULL)),
|
||||
CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)),
|
||||
CONSTRAINT community_deletion_owner_provenance CHECK (
|
||||
(request_origin = 'operator'
|
||||
AND owner_pubkey IS NULL
|
||||
AND mediating_operator_pubkey IS NULL
|
||||
AND acknowledgement_version IS NULL)
|
||||
OR
|
||||
(request_origin = 'owner'
|
||||
AND NOT (owner_pubkey IS NULL)
|
||||
AND NOT (mediating_operator_pubkey IS NULL)
|
||||
AND NOT (acknowledgement_version IS NULL)
|
||||
AND owner_pubkey ~ '^[0-9a-f]{64}$'
|
||||
AND mediating_operator_pubkey ~ '^[0-9a-f]{64}$'
|
||||
AND acknowledgement_version BETWEEN 1 AND 32767
|
||||
AND requested_by = owner_pubkey)
|
||||
),
|
||||
UNIQUE (id, community_id, inventory_digest)
|
||||
);
|
||||
CREATE UNIQUE INDEX community_deletion_requests_active_community
|
||||
ON community_deletion_requests (community_id)
|
||||
WHERE stage <> 'aborted';
|
||||
CREATE INDEX community_deletion_requests_runnable
|
||||
ON community_deletion_requests (next_attempt_at, created_at)
|
||||
WHERE blocked_at IS NULL
|
||||
AND stage IN ('approved', 'fenced', 'drained', 'bindings_removed',
|
||||
'postgres_purged', 'cache_purged', 'logically_verified');
|
||||
CREATE INDEX community_deletion_requests_lease
|
||||
ON community_deletion_requests (lease_until) WHERE lease_owner IS NOT NULL;
|
||||
CREATE INDEX community_deletion_requests_owner_preparable
|
||||
ON community_deletion_requests (next_attempt_at, created_at)
|
||||
WHERE request_origin = 'owner'
|
||||
AND stage = 'submitted'
|
||||
AND blocked_at IS NULL;
|
||||
CREATE INDEX community_deletion_requests_owner_quota_reservations
|
||||
ON community_deletion_requests (owner_pubkey)
|
||||
INCLUDE (community_id, completed_at)
|
||||
WHERE request_origin = 'owner'
|
||||
AND stage <> 'aborted';
|
||||
14
schema/tables/public/community_serving_write_leases.sql
Normal file
14
schema/tables/public/community_serving_write_leases.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE community_serving_write_leases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
operation TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
generation BIGINT NOT NULL DEFAULT 1 CHECK (generation > 0),
|
||||
-- Community fence generation observed when this lease was acquired.
|
||||
fence_generation BIGINT NOT NULL CHECK (fence_generation >= 0),
|
||||
lease_until TIMESTAMPTZ NOT NULL,
|
||||
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX community_serving_write_leases_active
|
||||
ON community_serving_write_leases (community_id, lease_until);
|
||||
19
schema/tables/public/delivery_log.sql
Normal file
19
schema/tables/public/delivery_log.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- ── 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 INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id);
|
||||
22
schema/tables/public/event_mentions.sql
Normal file
22
schema/tables/public/event_mentions.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- ── 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);
|
||||
CREATE INDEX idx_event_mentions_community_event
|
||||
ON event_mentions (community_id, event_id);
|
||||
71
schema/tables/public/events.sql
Normal file
71
schema/tables/public/events.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- ── 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: encrypted/private routing wrappers and p-gated membership notices
|
||||
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
|
||||
-- never matches `@@`.
|
||||
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
|
||||
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) 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);
|
||||
|
||||
-- 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);
|
||||
|
||||
-- e-tag containment (`tags @> '[["e","<hex>"]]'`) for the aux closure and #e
|
||||
-- reads. Mirrors migrations/0004; jsonb_path_ops supports exactly @>.
|
||||
CREATE INDEX idx_events_tags_gin ON events USING GIN (tags jsonb_path_ops);
|
||||
10
schema/tables/public/git_repo_names.sql
Normal file
10
schema/tables/public/git_repo_names.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- ── Additive tenant tables represented in migrations 0002/0007/0017 ──────────
|
||||
-- Keep desired-state schema parity with the embedded SQLx migration path.
|
||||
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)
|
||||
);
|
||||
CREATE INDEX idx_git_repo_names_owner ON git_repo_names (community_id, owner_pubkey);
|
||||
14
schema/tables/public/join_policy_acceptances.sql
Normal file
14
schema/tables/public/join_policy_acceptances.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- ── Join policy acceptances ──────────────────────────────────────────────────
|
||||
-- Durable evidence of the policy version accepted when an invite claim grants
|
||||
-- relay membership. The composite foreign key keeps evidence bound to a live
|
||||
-- member in the same community and removes it 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
|
||||
);
|
||||
39
schema/tables/public/moderation_actions.sql
Normal file
39
schema/tables/public/moderation_actions.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- ── 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')),
|
||||
-- Deployment authority type for HTTP-initiated actions.
|
||||
actor_authority TEXT NOT NULL DEFAULT 'community'
|
||||
CHECK (actor_authority IN ('community', 'relay_operator', 'relay_moderator')),
|
||||
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;
|
||||
60
schema/tables/public/moderation_reports.sql
Normal file
60
schema/tables/public/moderation_reports.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- ── 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', 'processing', 'resolved', 'dismissed', 'escalated')),
|
||||
-- Non-null when status='processing': the relay_admin_actions row that claimed this report.
|
||||
active_action_id UUID,
|
||||
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),
|
||||
-- Same-community resolution provenance: a report can only be resolved by an
|
||||
-- action row in its own community.
|
||||
FOREIGN KEY (community_id, action_id) REFERENCES moderation_actions (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);
|
||||
25
schema/tables/public/operator_listener_outbox.sql
Normal file
25
schema/tables/public/operator_listener_outbox.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE operator_listener_outbox (
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32),
|
||||
target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
|
||||
community_id UUID NOT NULL,
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
event_kind INTEGER NOT NULL,
|
||||
event_created_at TIMESTAMPTZ NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending', 'sending')),
|
||||
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(),
|
||||
UNIQUE (listener_pubkey, target_pubkey, community_id, event_id)
|
||||
);
|
||||
CREATE INDEX operator_listener_outbox_due
|
||||
ON operator_listener_outbox (next_attempt_at, created_at, id)
|
||||
WHERE state = 'pending';
|
||||
CREATE INDEX operator_listener_outbox_recovery
|
||||
ON operator_listener_outbox (lease_until, created_at, id)
|
||||
WHERE state = 'sending';
|
||||
CREATE INDEX operator_listener_outbox_created_at
|
||||
ON operator_listener_outbox (created_at);
|
||||
14
schema/tables/public/operator_listener_pubkeys.sql
Normal file
14
schema/tables/public/operator_listener_pubkeys.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- ── Operator-listener mention delivery ──────────────────────────────────────
|
||||
-- Listener registrations are deployment-global. The outbox records community
|
||||
-- provenance for the event, but is intentionally not tenant-owned.
|
||||
|
||||
CREATE TABLE operator_listener_pubkeys (
|
||||
listener_pubkey BYTEA NOT NULL CHECK (length(listener_pubkey) = 32),
|
||||
target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (listener_pubkey, target_pubkey)
|
||||
);
|
||||
CREATE INDEX operator_listener_pubkeys_target
|
||||
ON operator_listener_pubkeys (target_pubkey, listener_pubkey);
|
||||
CREATE INDEX operator_listener_pubkeys_created_at
|
||||
ON operator_listener_pubkeys (created_at);
|
||||
9
schema/tables/public/parameterized_event_watermarks.sql
Normal file
9
schema/tables/public/parameterized_event_watermarks.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
);
|
||||
9
schema/tables/public/personal_read_accounts.sql
Normal file
9
schema/tables/public/personal_read_accounts.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Private accessory read progress. Never included in Nostr event queries.
|
||||
-- A frontier is the relay arrival time (events.received_at) of the message a
|
||||
-- context was read through; the unread cutoff alone is signed event time. An
|
||||
-- empty root_id denotes a channel frontier.
|
||||
CREATE TABLE personal_read_accounts (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
actor BYTEA NOT NULL CHECK (octet_length(actor) = 32),
|
||||
PRIMARY KEY (community_id, actor)
|
||||
);
|
||||
15
schema/tables/public/personal_read_frontiers.sql
Normal file
15
schema/tables/public/personal_read_frontiers.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE personal_read_frontiers (
|
||||
community_id UUID NOT NULL,
|
||||
actor BYTEA NOT NULL,
|
||||
channel_id UUID NOT NULL,
|
||||
root_id BYTEA NOT NULL DEFAULT ''::bytea CHECK (octet_length(root_id) IN (0, 32)),
|
||||
through_timestamp TIMESTAMPTZ NOT NULL,
|
||||
-- Whole-channel cut covering every thread; channel rows only.
|
||||
threads_through_timestamp TIMESTAMPTZ
|
||||
CHECK (threads_through_timestamp IS NULL OR root_id = ''::bytea),
|
||||
PRIMARY KEY (community_id, actor, channel_id, root_id),
|
||||
FOREIGN KEY (community_id, actor)
|
||||
REFERENCES personal_read_accounts (community_id, actor) ON DELETE CASCADE,
|
||||
FOREIGN KEY (community_id, channel_id)
|
||||
REFERENCES channels (community_id, id) ON DELETE CASCADE
|
||||
);
|
||||
18
schema/tables/public/product_feedback.sql
Normal file
18
schema/tables/public/product_feedback.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE product_feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id UUID REFERENCES communities(id) ON DELETE SET NULL,
|
||||
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(),
|
||||
-- Operator-managed lifecycle status.
|
||||
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'archived')),
|
||||
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);
|
||||
12
schema/tables/public/pubkey_allowlist.sql
Normal file
12
schema/tables/public/pubkey_allowlist.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- ── 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)
|
||||
);
|
||||
10
schema/tables/public/push_gateway_challenges.sql
Normal file
10
schema/tables/public/push_gateway_challenges.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- 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);
|
||||
14
schema/tables/public/push_gateway_delegations.sql
Normal file
14
schema/tables/public/push_gateway_delegations.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
7
schema/tables/public/push_gateway_endpoint_quotas.sql
Normal file
7
schema/tables/public/push_gateway_endpoint_quotas.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
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);
|
||||
19
schema/tables/public/push_gateway_installations.sql
Normal file
19
schema/tables/public/push_gateway_installations.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE push_gateway_installations (
|
||||
id UUID PRIMARY KEY,
|
||||
app_attest_key_id BYTEA NOT NULL 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 = 'buzz-ios-dogfood'),
|
||||
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()
|
||||
);
|
||||
CREATE UNIQUE INDEX push_gateway_installations_active_app_attest_key
|
||||
ON push_gateway_installations (app_attest_key_id) WHERE revoked_at IS NULL;
|
||||
CREATE UNIQUE INDEX push_gateway_installations_active_profile_token
|
||||
ON push_gateway_installations (app_profile, token_fingerprint) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX push_gateway_installations_expiry ON push_gateway_installations (expires_at) WHERE revoked_at IS NULL;
|
||||
27
schema/tables/public/push_leases.sql
Normal file
27
schema/tables/public/push_leases.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- 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,
|
||||
endpoint_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
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;
|
||||
19
schema/tables/public/push_match_queue.sql
Normal file
19
schema/tables/public/push_match_queue.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 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';
|
||||
25
schema/tables/public/push_wake_outbox.sql
Normal file
25
schema/tables/public/push_wake_outbox.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
15
schema/tables/public/rate_limit_violations.sql
Normal file
15
schema/tables/public/rate_limit_violations.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- ── 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)
|
||||
);
|
||||
20
schema/tables/public/reactions.sql
Normal file
20
schema/tables/public/reactions.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- ── 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(66) 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;
|
||||
79
schema/tables/public/relay_admin_actions.sql
Normal file
79
schema/tables/public/relay_admin_actions.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- ── Relay admin actions (HTTP enforcement state machine) ──────────────────────
|
||||
-- One row per HTTP report-resolution enforcement action. Tracks the durable
|
||||
-- state machine from claim → enforcing → succeeded|failed|cancelled.
|
||||
|
||||
CREATE TABLE relay_admin_actions (
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- NULL for a report-less direct action (migration 0055).
|
||||
report_id UUID,
|
||||
report_community_id UUID NOT NULL,
|
||||
-- Client-generated idempotency key (signed in NIP-98 request body).
|
||||
request_id UUID NOT NULL,
|
||||
-- Principal who claimed the report.
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
actor_role TEXT NOT NULL CHECK (actor_role IN ('operator', 'moderator')),
|
||||
-- The enforcement action requested.
|
||||
action TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
-- Timeout expiration for timeout actions; NULL otherwise.
|
||||
timeout_until TIMESTAMPTZ,
|
||||
-- Durable state machine: pending → enforcing → succeeded|failed|cancelled.
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending', 'enforcing', 'succeeded', 'failed', 'cancelled')),
|
||||
-- Step marker: the last durably committed mutation step (NULL = none yet).
|
||||
-- Values: 'mutation_committed' (core DB mutation done), 'artifacts_done' (tombstone/notice done).
|
||||
step_marker TEXT CHECK (step_marker IN ('mutation_committed', 'artifacts_done')),
|
||||
-- Principal who cancelled a pre-mutation failed action; NULL until cancelled.
|
||||
-- Attributes the cancel transition on the action row itself, mirroring
|
||||
-- moderation_reports.resolved_by for report resolution.
|
||||
cancelled_by BYTEA CHECK (cancelled_by IS NULL OR length(cancelled_by) = 32),
|
||||
-- Error from the last failure, if any.
|
||||
error_message TEXT,
|
||||
-- Per-action exclusive lease (migration 0037): fences concurrent same-request
|
||||
-- retries and lets the recovery worker claim/re-drive stranded actions.
|
||||
action_lease_token UUID,
|
||||
action_lease_expires_at TIMESTAMPTZ,
|
||||
-- Authoritative enforcement target (migration 0047): persisted at claim time
|
||||
-- so crash-recovery can fire live side effects without re-deriving from mutable
|
||||
-- sources. enforcement_target_pubkey is the resolved target pubkey bytes for
|
||||
-- kick/ban/timeout actions; NULL for event/blob targets. enforcement_channel_id
|
||||
-- is the channel targeted by kick actions; NULL for community-wide actions.
|
||||
enforcement_target_pubkey BYTEA
|
||||
CHECK (enforcement_target_pubkey IS NULL OR length(enforcement_target_pubkey) = 32),
|
||||
enforcement_channel_id UUID,
|
||||
-- Direct actions (migration 0055): deleted event, and requested timeout
|
||||
-- duration compared on idempotent retry.
|
||||
enforcement_target_event_id BYTEA
|
||||
CHECK (enforcement_target_event_id IS NULL OR length(enforcement_target_event_id) = 32),
|
||||
timeout_secs BIGINT CHECK (timeout_secs IS NULL OR timeout_secs > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Report-scoped idempotency: one action per (report, request_id).
|
||||
UNIQUE (report_community_id, report_id, request_id),
|
||||
CONSTRAINT relay_admin_actions_direct_shape CHECK (
|
||||
report_id IS NOT NULL
|
||||
OR (action = 'ban' AND enforcement_target_pubkey IS NOT NULL
|
||||
AND timeout_secs IS NULL AND timeout_until IS NULL)
|
||||
OR (action = 'timeout' AND enforcement_target_pubkey IS NOT NULL
|
||||
AND timeout_secs IS NOT NULL AND timeout_until IS NOT NULL)
|
||||
OR (action = 'delete' AND enforcement_target_event_id IS NOT NULL
|
||||
AND enforcement_target_pubkey IS NOT NULL
|
||||
AND timeout_secs IS NULL AND timeout_until IS NULL)
|
||||
),
|
||||
FOREIGN KEY (report_community_id, report_id)
|
||||
REFERENCES moderation_reports (community_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_admin_actions_report
|
||||
ON relay_admin_actions (report_community_id, report_id);
|
||||
-- Direct-action idempotency (migration 0055): one per (community, request_id).
|
||||
CREATE UNIQUE INDEX idx_relay_admin_actions_direct_request
|
||||
ON relay_admin_actions (report_community_id, request_id)
|
||||
WHERE report_id IS NULL;
|
||||
CREATE INDEX idx_relay_admin_actions_state
|
||||
ON relay_admin_actions (state)
|
||||
WHERE state IN ('pending', 'enforcing');
|
||||
-- Recovery worker (migration 0037): find stranded actions by lease expiry.
|
||||
CREATE INDEX idx_relay_admin_actions_lease
|
||||
ON relay_admin_actions (action_lease_expires_at)
|
||||
WHERE state IN ('pending', 'enforcing');
|
||||
36
schema/tables/public/relay_admin_outbox.sql
Normal file
36
schema/tables/public/relay_admin_outbox.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- ── Relay admin outbox (durable enforcement delivery) ────────────────────────
|
||||
-- Transactional outbox for durable artifact/notice delivery.
|
||||
|
||||
CREATE TABLE relay_admin_outbox (
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action_id UUID NOT NULL REFERENCES relay_admin_actions(id),
|
||||
-- Delivery task type: 'tombstone' | 'system_message' | 'reporter_notice'.
|
||||
task_type TEXT NOT NULL,
|
||||
-- Task payload (JSON).
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- Lease-based delivery: held_by identifies the worker pod.
|
||||
held_by TEXT,
|
||||
lease_expires_at TIMESTAMPTZ,
|
||||
-- Delivery state: pending → delivered | failed.
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending', 'delivered', 'failed')),
|
||||
-- Deduplication key: prevents re-creating an artifact after delivery.
|
||||
dedup_key TEXT UNIQUE,
|
||||
error_message TEXT,
|
||||
-- Retryable delivery with backoff (migration 0037): failures reschedule via
|
||||
-- retry_after rather than terminating immediately.
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
retry_after TIMESTAMPTZ,
|
||||
-- Per-claim ownership fence (migration 0038): completion/failure updates
|
||||
-- require the token written at claim time, so a stale worker cannot overwrite
|
||||
-- a newer worker's terminal update.
|
||||
outbox_claim_token UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_admin_outbox_action
|
||||
ON relay_admin_outbox (action_id);
|
||||
CREATE INDEX idx_relay_admin_outbox_pending
|
||||
ON relay_admin_outbox (retry_after, created_at)
|
||||
WHERE state = 'pending';
|
||||
21
schema/tables/public/relay_invites.sql
Normal file
21
schema/tables/public/relay_invites.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ── Relay invites (use-limited invite links) ──────────────────────────────────
|
||||
-- Conformance: durable invite records for atomic redemption, community-scoped.
|
||||
-- Stores only SHA-256(code) as 32-byte BYTEA; never the reusable bearer code.
|
||||
-- PK and UNIQUE both lead with community_id. max_uses NULL = unlimited.
|
||||
|
||||
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);
|
||||
15
schema/tables/public/relay_members.sql
Normal file
15
schema/tables/public/relay_members.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- ── 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);
|
||||
25
schema/tables/public/relay_operator_audit.sql
Normal file
25
schema/tables/public/relay_operator_audit.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- ── Relay operator audit (append-only roster mutation trail) ─────────────────
|
||||
-- One row per PUT/DELETE /operators/{pubkey} mutation. The roster is the
|
||||
-- deployment-wide root of trust and its mutations overwrite/remove in place;
|
||||
-- this append-only trail records who granted, elevated, or revoked whom, and
|
||||
-- when, so privilege changes are as auditable as the enforcement actions those
|
||||
-- principals perform. Written only inside the upsert/delete transactions; no
|
||||
-- UPDATE/DELETE path.
|
||||
|
||||
CREATE TABLE relay_operator_audit (
|
||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_pubkey BYTEA NOT NULL CHECK (length(actor_pubkey) = 32),
|
||||
target_pubkey BYTEA NOT NULL CHECK (length(target_pubkey) = 32),
|
||||
op TEXT NOT NULL CHECK (op IN ('grant', 'revoke')),
|
||||
prev_role TEXT CHECK (prev_role IN ('operator', 'moderator')),
|
||||
new_role TEXT CHECK (new_role IN ('operator', 'moderator')),
|
||||
-- created_at is wall-clock occurrence time (clock_timestamp()), informational
|
||||
-- only — not monotonic, so it never establishes order. `seq` is the sole
|
||||
-- chronology key: mutations write their audit row under the serializing lock,
|
||||
-- so identity order equals the true privilege chain. Reads use ORDER BY seq.
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
seq BIGINT GENERATED ALWAYS AS IDENTITY
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_operator_audit_target
|
||||
ON relay_operator_audit (target_pubkey, seq);
|
||||
11
schema/tables/public/relay_operators.sql
Normal file
11
schema/tables/public/relay_operators.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- ── Relay operator/moderator roster ──────────────────────────────────────────
|
||||
-- Deployment-level principals staffed via the admin API. Config-backed operators
|
||||
-- (RELAY_OPERATOR_PUBKEYS, RELAY_OWNER_PUBKEY owner-fallback) are NOT seeded here;
|
||||
-- they are authoritative in config and outrank any DB row.
|
||||
|
||||
CREATE TABLE relay_operators (
|
||||
pubkey BYTEA NOT NULL PRIMARY KEY CHECK (length(pubkey) = 32),
|
||||
role TEXT NOT NULL CHECK (role IN ('operator', 'moderator')),
|
||||
added_by BYTEA NOT NULL CHECK (length(added_by) = 32),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
16
schema/tables/public/replica_heartbeat.sql
Normal file
16
schema/tables/public/replica_heartbeat.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- ── Replica heartbeat (read-replica freshness fence) ─────────────────────────
|
||||
-- Portable read-side freshness observation for the replica fence (see
|
||||
-- crates/buzz-db/src/replica_fence.rs and migrations/0026). Exactly one row;
|
||||
-- the single-row token UPDATE is the serialization point that makes tokens
|
||||
-- globally commit-ordered across relay pods. `epoch` detects token resets
|
||||
-- (restore/re-seed) so a stale retained token can never masquerade as fresh
|
||||
-- coverage. Deployment-global by design: describes replication topology,
|
||||
-- never 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
|
||||
) WITH (
|
||||
vacuum_truncate = false
|
||||
);
|
||||
30
schema/tables/public/scheduled_workflow_fires.sql
Normal file
30
schema/tables/public/scheduled_workflow_fires.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- ── 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);
|
||||
11
schema/tables/public/storage_accounting_snapshots.sql
Normal file
11
schema/tables/public/storage_accounting_snapshots.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- ── Storage accounting snapshot ─────────────────────────────────────────────
|
||||
-- Deployment-global singleton produced by the isolated S3 accounting worker.
|
||||
|
||||
CREATE TABLE storage_accounting_snapshots (
|
||||
singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
|
||||
snapshot JSONB NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),
|
||||
duration_ms BIGINT NOT NULL CHECK (duration_ms >= 0),
|
||||
max_objects BIGINT NOT NULL CHECK (max_objects > 0),
|
||||
code_sha TEXT NOT NULL CHECK (octet_length(code_sha) BETWEEN 1 AND 128)
|
||||
);
|
||||
15
schema/tables/public/storage_taxonomy_sweeps.sql
Normal file
15
schema/tables/public/storage_taxonomy_sweeps.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Fleet-wide object-store taxonomy sweep evidence. This is an independent
|
||||
-- observability record: community deletion inventories only the target's owned
|
||||
-- prefixes and does not gate submission or execution on sweep state.
|
||||
CREATE TABLE storage_taxonomy_sweeps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
listed_objects BIGINT NOT NULL CHECK (listed_objects >= 0),
|
||||
unknown_object_count BIGINT NOT NULL CHECK (unknown_object_count >= 0),
|
||||
unknown_key_sample JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
object_cap BIGINT NOT NULL CHECK (object_cap > 0),
|
||||
CHECK (completed_at >= started_at)
|
||||
);
|
||||
CREATE INDEX storage_taxonomy_sweeps_latest
|
||||
ON storage_taxonomy_sweeps (completed_at DESC);
|
||||
23
schema/tables/public/subscriptions.sql
Normal file
23
schema/tables/public/subscriptions.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- ── 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)
|
||||
);
|
||||
28
schema/tables/public/thread_metadata.sql
Normal file
28
schema/tables/public/thread_metadata.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- ── 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_window
|
||||
ON thread_metadata (community_id, root_event_id, event_created_at DESC, event_id ASC);
|
||||
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);
|
||||
32
schema/tables/public/users.sql
Normal file
32
schema/tables/public/users.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- ── 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;
|
||||
29
schema/tables/public/workflow_approvals.sql
Normal file
29
schema/tables/public/workflow_approvals.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- ── 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);
|
||||
23
schema/tables/public/workflow_runs.sql
Normal file
23
schema/tables/public/workflow_runs.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- ── 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,
|
||||
error_code 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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user