-- schema_extras.sql -- Idempotent PostgreSQL DDL for constraints that SeaORM entity-first `sync` -- cannot express via entity attributes. Applies after every `sync` when -- auto_migrate is enabled (historical convention: lib/db/schema.rs; the db -- crate is currently removed and must apply this file when restored). -- Safe to re-run on every startup; every statement is guarded with -- IF NOT EXISTS / DO-block checks. -- --------------------------------------------------------------------------- -- Partial unique indexes (polymorphic targets, WHERE-clause filtered). -- Not expressible as #[sea_orm(unique_key)] — overlapping columns and partial -- predicates have no entity attribute. The plain unique indexes declared in -- the models (issue_watch.issue_user / project_user, issue_reaction.*) cover -- non-NULL rows; these partial variants additionally guard the NULL-tolerant -- lookup patterns the sync layer would otherwise drop. -- --------------------------------------------------------------------------- CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_issue_user ON issue_watch (issue_id, user_id) WHERE issue_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_watch_project_user ON issue_watch (project_id, user_id) WHERE project_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_issue ON issue_reaction (issue_id, user_id, emoji) WHERE issue_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_comment ON issue_reaction (comment_id, user_id, emoji) WHERE comment_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_issue_reaction_project ON issue_reaction (project_id, user_id, emoji) WHERE project_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_branch_protection_whitelist_user ON repo_branch_protection_whitelist (policy_id, kind, user_id) WHERE user_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_branch_protection_whitelist_group ON repo_branch_protection_whitelist (policy_id, kind, group_id) WHERE group_id IS NOT NULL; -- --------------------------------------------------------------------------- -- Composite non-unique indexes (entity attributes only express single-column -- indexed/unique; lookups below span two columns). -- --------------------------------------------------------------------------- CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment (parent_id); CREATE INDEX IF NOT EXISTS idx_git_commit_status_sha ON git_commit_status (repo_id, sha); -- Single-column indexes for reserved project_id columns (parity with the -- historical project module; columns are kept until project is restored). CREATE INDEX IF NOT EXISTS idx_comment_project ON comment (project_id); CREATE INDEX IF NOT EXISTS idx_issue_reaction_uniq_proj ON issue_reaction (project_id); CREATE INDEX IF NOT EXISTS idx_issue_watch_project ON issue_watch (project_id); CREATE INDEX IF NOT EXISTS idx_issue_event_project ON issue_event_line (project_id); CREATE INDEX IF NOT EXISTS idx_issue_pin_project ON issue_pin (project_id); -- --------------------------------------------------------------------------- -- CHECK constraints (polymorphic XOR / exactly-one / range guards). -- ALTER TABLE ... ADD CONSTRAINT has no IF NOT EXISTS, so guard each with a -- DO block against pg_constraint. -- --------------------------------------------------------------------------- DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comment_target_xor') THEN ALTER TABLE comment ADD CONSTRAINT comment_target_xor CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL)); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_reaction_target_exactly_one') THEN ALTER TABLE issue_reaction ADD CONSTRAINT issue_reaction_target_exactly_one CHECK (((issue_id IS NOT NULL)::int + (comment_id IS NOT NULL)::int + (project_id IS NOT NULL)::int) = 1); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_watch_target_xor') THEN ALTER TABLE issue_watch ADD CONSTRAINT issue_watch_target_xor CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL)); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_event_target_xor') THEN ALTER TABLE issue_event_line ADD CONSTRAINT issue_event_target_xor CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL)); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_pin_target_xor') THEN ALTER TABLE issue_pin ADD CONSTRAINT issue_pin_target_xor CHECK ((issue_id IS NOT NULL) <> (project_id IS NOT NULL)); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'branch_protection_whitelist_principal_xor') THEN ALTER TABLE repo_branch_protection_whitelist ADD CONSTRAINT branch_protection_whitelist_principal_xor CHECK ((user_id IS NOT NULL) <> (group_id IS NOT NULL)); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'product_review_rating_range') THEN ALTER TABLE product_review ADD CONSTRAINT product_review_rating_range CHECK (rating BETWEEN 1 AND 5); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'products_price_non_negative') THEN ALTER TABLE products ADD CONSTRAINT products_price_non_negative CHECK (price IS NULL OR price >= 0); END IF; END $$; -- --------------------------------------------------------------------------- -- Chat domain (lib/chat): message ordering, idempotency, FTS, and composite -- indexes that entity attributes cannot express. -- --------------------------------------------------------------------------- CREATE INDEX IF NOT EXISTS idx_chat_message_thread_seq ON chat_message (thread_id, seq) WHERE thread_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_chat_message_author_created ON chat_message (author_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_message_mentions ON chat_message USING gin (mentions); CREATE INDEX IF NOT EXISTS idx_chat_message_pinned ON chat_message (room_id, seq DESC) WHERE pinned; CREATE INDEX IF NOT EXISTS idx_chat_message_edit_message ON chat_message_edit (message_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_thread_room ON chat_thread (room_id, archived, last_reply_at DESC NULLS LAST); CREATE INDEX IF NOT EXISTS idx_chat_thread_member_user ON chat_thread_member (user_id); CREATE INDEX IF NOT EXISTS idx_chat_read_state_user ON chat_read_state (user_id); CREATE INDEX IF NOT EXISTS idx_chat_room_setting_user ON chat_room_user_setting (user_id); CREATE INDEX IF NOT EXISTS idx_chat_reaction_message ON chat_reaction (message_id); CREATE INDEX IF NOT EXISTS idx_chat_attachment_user_created ON chat_attachment (user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_bookmark_room ON chat_room_bookmark (room_id, position); CREATE INDEX IF NOT EXISTS idx_chat_reminder_due ON chat_message_reminder (status, remind_at); CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_draft_unique ON chat_user_draft (user_id, room_id, thread_id) NULLS NOT DISTINCT; CREATE UNIQUE INDEX IF NOT EXISTS idx_space_join_request_pending ON space_join_request (space_id, user_id) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS idx_chat_webhook_token ON chat_room_webhook (token_hash); CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_dm_pair ON rooms (lower_name) WHERE kind = 15 AND space_id IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_gdm_key ON rooms (lower_name) WHERE kind = 1 AND space_id IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_message_room_nonce ON chat_message (room_id, author_id, nonce) WHERE nonce IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_chat_outbound_webhook_room ON chat_outbound_webhook (room_id); CREATE INDEX IF NOT EXISTS idx_chat_webhook_delivery_due ON chat_webhook_delivery (status, next_retry_at); -- phase-2 data backfills (idempotent) UPDATE repo_watch SET mode = 0 WHERE mode = 1; UPDATE issue_watch SET mode = 0 WHERE mode = 1; UPDATE room_members rm SET role = 3 FROM rooms r WHERE rm.room_id = r.id AND rm.user_id = r.owner_id AND rm.left_at IS NULL; -- user_access_key is owned by KenOAuth now; GitData only normalises legacy rows -- left in a database that still has the table. DO $$ BEGIN IF to_regclass('user_access_key') IS NOT NULL THEN UPDATE user_access_key SET scope = 'write' WHERE scope IN ('repo', 'all'); UPDATE user_access_key SET scope = 'read' WHERE scope NOT IN ('read', 'write'); END IF; END $$; -- --------------------------------------------------------------------------- -- Pull Request domain (lib/pulls): list/filter lookups and inline-comment -- shape guards that entity attributes cannot express. -- --------------------------------------------------------------------------- CREATE INDEX IF NOT EXISTS idx_pull_request_repo_state ON pull_request (repo_id, has_merged, closed_at); CREATE INDEX IF NOT EXISTS idx_pull_request_repo_author ON pull_request (repo_id, author_id); CREATE INDEX IF NOT EXISTS idx_pull_request_head ON pull_request (head_repo_id, head_branch); CREATE INDEX IF NOT EXISTS idx_pull_request_author_created ON pull_request (author_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pull_request_issue ON pull_request (issue_id); CREATE INDEX IF NOT EXISTS idx_pull_request_review_pr ON pull_request_review (pull_request_id, created_at); CREATE INDEX IF NOT EXISTS idx_pull_request_review_user ON pull_request_review (user_id, pull_request_id); CREATE INDEX IF NOT EXISTS idx_pull_request_comment_pr ON pull_request_comment (pull_request_id, created_at); CREATE INDEX IF NOT EXISTS idx_pull_request_comment_file ON pull_request_comment (pull_request_id, path, line); CREATE INDEX IF NOT EXISTS idx_pull_request_comment_parent ON pull_request_comment (parent_comment_id); CREATE INDEX IF NOT EXISTS idx_pull_request_reviewer_user ON pull_request_reviewer (user_id, state); CREATE INDEX IF NOT EXISTS idx_pull_request_reviewer_pr ON pull_request_reviewer (pull_request_id, state); CREATE INDEX IF NOT EXISTS idx_pull_request_assignee_user ON pull_request_assignee (user_id); CREATE INDEX IF NOT EXISTS idx_pull_request_event_pr ON pull_request_event_line (pull_request_id, created_at); CREATE INDEX IF NOT EXISTS idx_pull_request_label_pr ON pull_request_label (pull_request_id); CREATE INDEX IF NOT EXISTS idx_issue_relation_pull_pr ON issue_relation_pull (pull_request_id); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pull_request_comment_inline_shape') THEN ALTER TABLE pull_request_comment ADD CONSTRAINT pull_request_comment_inline_shape CHECK ((path IS NULL) = (commit_sha IS NULL)); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pull_request_comment_side') THEN ALTER TABLE pull_request_comment ADD CONSTRAINT pull_request_comment_side CHECK (side IS NULL OR side IN ('left', 'right')); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pull_request_merged_consistent') THEN ALTER TABLE pull_request ADD CONSTRAINT pull_request_merged_consistent CHECK ((has_merged = false) OR merged_at IS NOT NULL); END IF; END $$; -- --------------------------------------------------------------------------- -- event_outbox: relay claims un-published rows; the partial indexes keep the -- claim query and per-ordering-key head lookup cheap as the table grows. -- --------------------------------------------------------------------------- CREATE INDEX IF NOT EXISTS idx_event_outbox_pending ON event_outbox (available_at, occurred_at, id) WHERE published_at IS NULL; CREATE INDEX IF NOT EXISTS idx_event_outbox_ordering ON event_outbox (ordering_key, occurred_at, id) WHERE published_at IS NULL; CREATE INDEX IF NOT EXISTS idx_event_outbox_published ON event_outbox (published_at) WHERE published_at IS NOT NULL; -- --------------------------------------------------------------------------- -- 2026-10-30: KenOAuth becomes the only authority for organizations, -- membership and permissions. The old local mirrors are retired: -- * presentation-only fields move to space_extras -- * invited_at rows move to space_member_extras -- * `spaces` and `space_members` are dropped, together with every FK that -- pointed at `spaces` (local rows keep space_id as a plain UUID). -- Idempotent: the block is skipped once `spaces` no longer exists. -- --------------------------------------------------------------------------- DO $$ DECLARE fk record; BEGIN IF to_regclass('spaces') IS NOT NULL THEN IF to_regclass('space_extras') IS NOT NULL THEN INSERT INTO space_extras ( space_id, avatar_url, invite_sort_url_hash, banner_url, banner_width, banner_height, invite_banner_url, invite_banner_width, invite_banner_height, embed_splash_url, embed_splash_width, embed_splash_height, welcome_object, created_at, updated_at ) SELECT id, avatar_url, invite_sort_url_hash, banner_url, banner_width, banner_height, invite_banner_url, invite_banner_width, invite_banner_height, embed_splash_url, embed_splash_width, embed_splash_height, welcome_object, created_at, updated_at FROM spaces ON CONFLICT (space_id) DO NOTHING; END IF; IF to_regclass('space_member_extras') IS NOT NULL AND to_regclass('space_members') IS NOT NULL THEN INSERT INTO space_member_extras (space_id, user_id, invited_at, created_at, updated_at) SELECT space_id, user_id, invited_at, created_at, COALESCE(updated_at, created_at) FROM space_members ON CONFLICT (space_id, user_id) DO NOTHING; END IF; FOR fk IN SELECT conrelid::regclass AS table_name, conname FROM pg_constraint WHERE contype = 'f' AND confrelid = 'spaces'::regclass LOOP EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', fk.table_name, fk.conname); END LOOP; DROP TABLE IF EXISTS space_members CASCADE; DROP TABLE IF EXISTS spaces CASCADE; END IF; END $$; -- --------------------------------------------------------------------------- -- 2026-10-31: retire the local identity projection. -- KenOAuth owns username / display name / email / account state / password / -- TOTP. Only local settings and a local avatar asset remain in `users`. -- --------------------------------------------------------------------------- DO $$ BEGIN IF to_regclass('users') IS NOT NULL THEN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'users' AND column_name = 'avatar_url' ) THEN UPDATE users SET local_avatar_url = avatar_url WHERE local_avatar_url = '' AND avatar_url LIKE 'storage://%'; END IF; ALTER TABLE users DROP COLUMN IF EXISTS username, DROP COLUMN IF EXISTS lower_name, DROP COLUMN IF EXISTS display_name, DROP COLUMN IF EXISTS avatar_url, DROP COLUMN IF EXISTS is_active, DROP COLUMN IF EXISTS prohibit_login, DROP COLUMN IF EXISTS bot, DROP COLUMN IF EXISTS system, DROP COLUMN IF EXISTS passwd_hash, DROP COLUMN IF EXISTS password_last_changed_at, DROP COLUMN IF EXISTS passwd_version, DROP COLUMN IF EXISTS totp_encrypted_secret, DROP COLUMN IF EXISTS totp_enabled, DROP COLUMN IF EXISTS totp_last_used_at, DROP COLUMN IF EXISTS totp_last_counter, DROP COLUMN IF EXISTS created_at; END IF; DROP TABLE IF EXISTS user_emails CASCADE; END $$; -- dir.kind: legacy `Object` rows were created by the repo/issue-board domains. -- Split them into concrete kinds so callers read the type from the dir row. UPDATE dir SET kind = 3 WHERE kind = 1 AND id IN (SELECT dir_id FROM repos); UPDATE dir SET kind = 2 WHERE kind = 1 AND id IN (SELECT dir_id FROM issues); -- Channel dirs: space rooms created before the unified dir tree have no `dir` -- row. Reuse the room id as the dir id so the mapping stays deterministic and -- the insert can be retried safely. INSERT INTO dir ( id, space_id, name, kind, parent_dir_id, color, position, icon, topic, purpose, is_archived, archived_at, archived_by, created_at, updated_at, created_by_uid ) SELECT r.id, r.space_id, r.name, CASE WHEN r.kind IN (11, 12) THEN 5 ELSE 4 END, r.parent_id, NULL, r.position, NULL, r.topic, NULL, NOT r.is_active, NULL, NULL, r.created_at, r.updated_at, r.owner_id FROM rooms r WHERE r.space_id IS NOT NULL AND r.dir_id IS NULL AND NOT EXISTS (SELECT 1 FROM dir d WHERE d.id = r.id); UPDATE rooms SET dir_id = id WHERE dir_id IS NULL AND space_id IS NOT NULL; -- Private channels move their access list onto dir overwrites: any row on a -- dir makes the default "visible to every space member" an allow-list, so the -- channel members and the owner need an explicit allow row. INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at) SELECT r.dir_id, m.user_id, 1, 0, NULL FROM rooms r JOIN room_members m ON m.room_id = r.id WHERE r.dir_id IS NOT NULL AND r.is_private AND m.left_at IS NULL ON CONFLICT (dir_id, user_id) DO NOTHING; INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at) SELECT r.dir_id, r.owner_id, 1, 0, NULL FROM rooms r WHERE r.dir_id IS NOT NULL AND r.is_private ON CONFLICT (dir_id, user_id) DO NOTHING;