Files
buzz/scripts/cutover/1321_backfill_default_community.sql
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d 666cfe4157 migrations: move 1321 cutover script out of the embedded sqlx dir
0002_backfill_default_community.sql is a psql operator script (\set, \ir,
:'var' interpolation) but lived in migrations/, which sqlx::migrate! embeds
wholesale. sqlx cannot run psql meta-commands, so every fresh deploy broke:
BUZZ_AUTO_MIGRATE=true exited the relay with 'syntax error at or near "\"',
buzz-admin migrate (the just setup path) failed identically with exit 5, and
both left a wedged half-migrated DB. This also reddened the buzz-db unit test
embedded_migrator_contains_consolidated_initial_schema (asserts exactly the
consolidated 0001), which CI never ran.

The module doc and that test already declare the intended invariant: the
embedded migrator is the consolidated 0001 only; cutover/backfill is a separate
operator script, not startup migration state. Restore it by relocating the
script to scripts/cutover/1321_backfill_default_community.sql (dropping the
0002_ prefix so it never reads as sqlx state again), switching its schema
include from \i to \ir so it resolves relative to the script rather than the
operator's cwd, and adding scripts/cutover/README.md as the operator runbook.

Wire the pure (infra-free) buzz-db tests into the unit path so this class of
regression can't hide again. CI installs nextest, so just test-unit takes the
nextest branch and bypasses run-tests.sh entirely — patch BOTH paths.

Verified locally on a pristine Postgres: buzz-admin migrate -> exit 0, one
migration row (v1); relay with BUZZ_AUTO_MIGRATE=true -> migrations complete,
readiness 200, 36 tables, no wedge. buzz-db --lib goes from RED (left:2 right:1)
to green. The relocated script's \ir resolves 0001 from a non-repo cwd.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
2026-06-28 22:20:39 -04:00

291 lines
14 KiB
PL/PgSQL

-- scripts/cutover/1321_backfill_default_community.sql
--
-- One-off cutover: single-community (pre-1321) Postgres -> multi-tenant (1321)
-- schema. Assigns EVERY existing row to one default community derived from the
-- deployment host.
--
-- ─────────────────────────────────────────────────────────────────────────────
-- STRATEGY: rename pre-1321 objects aside into a `legacy` schema, rebuild the
-- correct 1321 schema in `public` from 0001 VERBATIM, copy data
-- forward, drop `legacy`.
--
-- Why not an in-place ALTER ("just add community_id and re-key")? The 1321
-- schema differs from pre-1321 by far MORE than a prepended community_id, and
-- hand-transcribing the difference is how silent isolation defects get in (local
-- validation caught an in-place draft that left 5 tables single-tenant-keyed and
-- dropped ~13 FKs). The real delta:
-- * channels gains ttl_seconds, ttl_deadline
-- * events gains not_before, delivered_at, search_tsv (GENERATED) + GIN
-- * EVERY scoped table is re-keyed to a community-leading PK/UNIQUE
-- * EVERY hot-path index is replaced with a community-leading one
-- * channels gains a community_id immutability trigger
-- * 6 brand-new tables (communities, scheduled_workflow_fires, relay_members,
-- archived_identities, audit_log, _operator_global_tables)
--
-- So we do NOT describe the target by hand. We let 1321's own
-- migrations/0001_initial_schema.sql define it, verbatim, into a clean `public`.
-- The result is structurally identical to a fresh 1321 DB BY CONSTRUCTION.
--
-- The one obstacle to running the from-scratch 0001 against an existing DB is
-- name collisions: 0001 issues bare `CREATE TYPE <enum>` (Postgres has no
-- CREATE TYPE IF NOT EXISTS) and `CREATE TABLE <name>`. We clear `public` of
-- those names first by renaming the pre-1321 tables AND their enum types into a
-- `legacy` schema. `CREATE EXTENSION IF NOT EXISTS pgcrypto` in 0001 is a no-op
-- (idempotent), and pgcrypto stays in public untouched.
--
-- Phases (all in ONE transaction; all-or-nothing):
-- A CREATE SCHEMA legacy; move every pre-1321 table + enum type into it
-- B \i the repo's 0001 -> full correct 1321 schema in clean public
-- C create the one default community from :host
-- D INSERT ... SELECT default_community, <explicit cols> from each legacy.*
-- (enum cols cast ::text::public.<enum>; search_tsv regenerates; new
-- columns default NULL)
-- E DROP SCHEMA legacy CASCADE
-- ─────────────────────────────────────────────────────────────────────────────
--
-- PRECONDITIONS
-- * DB is the pre-1321 single-community schema (no community_id columns, no
-- `communities` table). Guard below refuses to run otherwise.
-- * Run psql with this script via -f (not piped on stdin): the Phase B
-- include below uses \ir, which resolves relative to THIS file, so the
-- repo's migrations/0001 is found regardless of the operator's cwd.
-- * TAKE A pg_dump / PVC SNAPSHOT FIRST. No down-path; the snapshot IS the
-- rollback.
--
-- USAGE (run from anywhere; \ir below resolves the schema include relative to
-- this file, so cwd does not matter)
-- psql "$DATABASE_URL" \
-- -v host="'sprout-oss.stage.blox.sqprod.co'" \
-- -v ON_ERROR_STOP=1 \
-- -f scripts/cutover/1321_backfill_default_community.sql
--
-- After commit: boot the 1321 relay with BUZZ_AUTO_MIGRATE=false. The schema is
-- already correct; the relay's idempotent boot-time
-- ensure_configured_community / allowlist->relay_members backfill finds this
-- community and no-ops. (If you instead want the relay's sqlx migrator to
-- consider 0001 "applied", seed its _sqlx_migrations row — see the runbook;
-- not required for correct operation.)
\set ON_ERROR_STOP on
BEGIN;
-- ── Guard: refuse to run on anything but pristine pre-1321 ───────────────────
DO $guard$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema='public' AND table_name='communities') THEN
RAISE EXCEPTION
'communities table already exists -- DB is not pristine pre-1321 '
'(already migrated). Refusing to run.';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND column_name='community_id') THEN
RAISE EXCEPTION
'a community_id column already exists -- DB is not pristine pre-1321. '
'Refusing to run.';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.schemata
WHERE schema_name='legacy') THEN
RAISE EXCEPTION
'schema legacy already exists -- leftover from a failed run. '
'Drop it (DROP SCHEMA legacy CASCADE) and retry.';
END IF;
END
$guard$;
-- ── Phase A: move pre-1321 tables + enum types into `legacy` ─────────────────
CREATE SCHEMA legacy;
-- Tables (partitioned parents move with their partitions via SET SCHEMA on the
-- parent? -- no: SET SCHEMA does NOT move partition children. So we move the
-- parent AND each partition. Simpler and safe: move every base/partitioned/
-- partition relation owned in public that is one of our pre-1321 tables.)
DO $move_tables$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind IN ('r','p') -- ordinary + partitioned parents
ORDER BY c.relkind DESC -- move partition children before parents? not needed
LOOP
EXECUTE format('ALTER TABLE public.%I SET SCHEMA legacy', r.relname);
END LOOP;
END
$move_tables$;
-- Enum types (must leave public so 0001 can CREATE TYPE with the same names).
DO $move_types$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT t.typname
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public' AND t.typtype = 'e'
LOOP
EXECUTE format('ALTER TYPE public.%I SET SCHEMA legacy', r.typname);
END LOOP;
END
$move_types$;
-- ── Phase B: the authoritative 1321 schema, verbatim, into clean public ──────
-- \ir = include relative to THIS script's directory (scripts/cutover/), so the
-- repo's migrations/0001 resolves regardless of the operator's cwd.
\ir ../../migrations/0001_initial_schema.sql
-- ── Phase C: the one default community, normalized like the relay does ───────
INSERT INTO public.communities (host)
VALUES (rtrim(lower(:host), '.'));
SELECT id AS default_community
FROM public.communities
WHERE lower(host) = rtrim(lower(:host), '.') \gset
-- ── Phase D: copy data forward ───────────────────────────────────────────────
-- Source legacy.*, target public.*. Enum columns cross a type boundary
-- (legacy.<enum> -> public.<enum>); same labels, so ::text::public.<enum> is
-- lossless. search_tsv omitted (GENERATED); new-in-1321 columns omitted (NULL).
INSERT INTO public.channels
(community_id, id, name, channel_type, visibility, description, canvas,
created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id,
topic_required, max_members, topic, topic_set_by, topic_set_at, purpose,
purpose_set_by, purpose_set_at, participant_hash)
SELECT :'default_community', id, name,
channel_type::text::public.channel_type,
visibility::text::public.channel_visibility,
description, canvas, created_by, created_at, updated_at, archived_at,
deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by,
topic_set_at, purpose, purpose_set_by, purpose_set_at, participant_hash
FROM legacy.channels;
INSERT INTO public.channel_members
(community_id, channel_id, pubkey, role, joined_at, invited_by, removed_at,
removed_by, hidden_at)
SELECT :'default_community', channel_id, pubkey,
role::text::public.member_role,
joined_at, invited_by, removed_at, removed_by, hidden_at
FROM legacy.channel_members;
INSERT INTO public.users
(community_id, pubkey, nip05_handle, display_name, avatar_url, about,
agent_type, capabilities, okta_user_id, created_at, updated_at,
deactivated_at, metadata_event_id, agent_owner_pubkey, channel_add_policy)
SELECT :'default_community', pubkey, nip05_handle, display_name, avatar_url, about,
agent_type, capabilities, okta_user_id, created_at, updated_at,
deactivated_at, metadata_event_id, agent_owner_pubkey,
channel_add_policy::text::public.channel_add_policy
FROM legacy.users;
INSERT INTO public.events
(community_id, id, pubkey, created_at, kind, tags, content, sig,
received_at, channel_id, deleted_at, d_tag)
SELECT :'default_community', id, pubkey, created_at, kind, tags, content, sig,
received_at, channel_id, deleted_at, d_tag
FROM legacy.events;
INSERT INTO public.event_mentions
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind)
SELECT :'default_community', pubkey_hex, event_id, event_created_at, channel_id, event_kind
FROM legacy.event_mentions;
INSERT INTO public.subscriptions
(community_id, id, owner_pubkey, filter_kinds, filter_authors,
filter_channel_ids, filter_since, filter_until, delivery_method,
delivery_url, status, pause_reason, delivered_count, error_count,
created_at, updated_at)
SELECT :'default_community', id, owner_pubkey, filter_kinds, filter_authors,
filter_channel_ids, filter_since, filter_until,
delivery_method::text::public.delivery_method, delivery_url,
status::text::public.subscription_status,
pause_reason::text::public.pause_reason,
delivered_count, error_count, created_at, updated_at
FROM legacy.subscriptions;
-- delivery_log.id is GENERATED ALWAYS AS IDENTITY; preserve original ids.
INSERT INTO public.delivery_log
(community_id, id, subscription_id, event_id, method, delivered_at, success,
http_status, error_message, attempt_number)
OVERRIDING SYSTEM VALUE
SELECT :'default_community', id, subscription_id, event_id,
method::text::public.delivery_method, delivered_at, success,
http_status, error_message, attempt_number
FROM legacy.delivery_log;
INSERT INTO public.workflows
(community_id, id, name, owner_pubkey, channel_id, definition,
definition_hash, status, enabled, created_at, updated_at)
SELECT :'default_community', id, name, owner_pubkey, channel_id, definition,
definition_hash, status::text::public.workflow_status, enabled,
created_at, updated_at
FROM legacy.workflows;
INSERT INTO public.workflow_runs
(community_id, id, workflow_id, status, trigger_event_id, current_step,
execution_trace, trigger_context, started_at, completed_at, error_message,
created_at)
SELECT :'default_community', id, workflow_id,
status::text::public.run_status, trigger_event_id, current_step,
execution_trace, trigger_context, started_at, completed_at, error_message,
created_at
FROM legacy.workflow_runs;
INSERT INTO public.workflow_approvals
(community_id, token, workflow_id, run_id, step_id, step_index, approver_spec,
status, approver_pubkey, note, granted_at, denied_at, expires_at, created_at)
SELECT :'default_community', token, workflow_id, run_id, step_id, step_index,
approver_spec, status::text::public.approval_status, approver_pubkey,
note, granted_at, denied_at, expires_at, created_at
FROM legacy.workflow_approvals;
INSERT INTO public.api_tokens
(community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids,
created_at, expires_at, last_used_at, revoked_at, revoked_by,
created_by_self_mint)
SELECT :'default_community', id, token_hash, owner_pubkey, name, scopes, channel_ids,
created_at, expires_at, last_used_at, revoked_at, revoked_by,
created_by_self_mint
FROM legacy.api_tokens;
-- rate_limit_violations: OPERATOR-GLOBAL; community_id nullable attribution.
-- id is IDENTITY; preserve.
INSERT INTO public.rate_limit_violations
(id, community_id, pubkey, violation_at, limit_type, limit_value,
actual_value, action_taken)
OVERRIDING SYSTEM VALUE
SELECT id, :'default_community', pubkey, violation_at, limit_type, limit_value,
actual_value, action_taken
FROM legacy.rate_limit_violations;
INSERT INTO public.thread_metadata
(community_id, event_created_at, event_id, channel_id, parent_event_id,
parent_event_created_at, root_event_id, root_event_created_at, depth,
reply_count, descendant_count, last_reply_at, broadcast)
SELECT :'default_community', event_created_at, event_id, channel_id, parent_event_id,
parent_event_created_at, root_event_id, root_event_created_at, depth,
reply_count, descendant_count, last_reply_at, broadcast
FROM legacy.thread_metadata;
INSERT INTO public.reactions
(community_id, event_created_at, event_id, pubkey, emoji, created_at,
removed_at, reaction_event_id)
SELECT :'default_community', event_created_at, event_id, pubkey, emoji, created_at,
removed_at, reaction_event_id
FROM legacy.reactions;
INSERT INTO public.pubkey_allowlist
(community_id, pubkey, added_by, added_at, note)
SELECT :'default_community', pubkey, added_by, added_at, note
FROM legacy.pubkey_allowlist;
-- ── Phase E: retire the pre-1321 objects ─────────────────────────────────────
DROP SCHEMA legacy CASCADE;
COMMIT;
-- ── Post-commit sanity (run manually; not part of the txn) ───────────────────
-- SELECT count(*) FROM communities; -- expect 1
-- SELECT count(*) FROM events WHERE community_id IS NULL; -- expect 0
-- SELECT to_regnamespace('legacy'); -- expect NULL (gone)