-- Cloud Codex - Database Schema -- -- All Rights Reserved to Cloud City Computing, LLC 2026 -- https://cloudcitycomputing.com -- SQL script to initialize the database schema. -- This script creates the schema for workspaces, squads, users, archives, logs, versions, and permissions. -- It defines the necessary tables and their relationships. SET FOREIGN_KEY_CHECKS = 0; DROP TABLE IF EXISTS webhook_deliveries, webhook_events, webhook_sequence_lock, webhook_subscriptions, doc_images; DROP TABLE IF EXISTS notifications; DROP TABLE IF EXISTS watches; DROP TABLE IF EXISTS activity_log; DROP TABLE IF EXISTS github_links; DROP TABLE IF EXISTS favorites; DROP TABLE IF EXISTS comment_replies; DROP TABLE IF EXISTS comments; DROP TABLE IF EXISTS squad_invitations; DROP TABLE IF EXISTS squad_members; DROP TABLE IF EXISTS versions; DROP TABLE IF EXISTS logs; DROP TABLE IF EXISTS github_pr_sessions; DROP TABLE IF EXISTS github_embed_refs; DROP TABLE IF EXISTS archive_repos; DROP TABLE IF EXISTS archives; DROP TABLE IF EXISTS squad_permissions; DROP TABLE IF EXISTS permissions; DROP TABLE IF EXISTS squads; DROP TABLE IF EXISTS two_factor_codes; DROP TABLE IF EXISTS password_reset_tokens; DROP TABLE IF EXISTS user_invitations; DROP TABLE IF EXISTS oauth_accounts; DROP TABLE IF EXISTS sessions; DROP TABLE IF EXISTS user_identities; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS workspaces; SET FOREIGN_KEY_CHECKS = 1; CREATE TABLE workspaces ( id INT AUTO_INCREMENT PRIMARY KEY, name TEXT NOT NULL, owner_id INT DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, password_hash TEXT, avatar_url VARCHAR(512) DEFAULT NULL, two_factor_method ENUM('none', 'email', 'totp') DEFAULT 'none', totp_secret VARCHAR(64) DEFAULT NULL, is_admin BOOLEAN DEFAULT FALSE, notification_prefs JSON DEFAULT NULL, onboarded_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deactivated_at TIMESTAMP NULL DEFAULT NULL ) ENGINE=InnoDB; -- workspaces is declared before users, so its owner FK is added here rather -- than inline, where it would reference a table that does not exist yet. -- ON DELETE SET NULL: deleting a user must not delete their workspace. It -- leaves the workspace ownerless, which only stops clause 4 of the access -- fragments from matching; squad membership and the per-squad and -- workspace-wide grants still reach everything in it. ALTER TABLE workspaces ADD CONSTRAINT fk_workspaces_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; CREATE TABLE oauth_accounts ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, provider ENUM('google', 'github') NOT NULL, provider_user_id VARCHAR(255) NOT NULL, provider_email VARCHAR(255) NOT NULL, provider_username VARCHAR(255) DEFAULT NULL, provider_avatar_url VARCHAR(500) DEFAULT NULL, encrypted_token TEXT DEFAULT NULL, token_status ENUM('active','revoked','unknown') DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -- A provider account belongs to at most one user, and a user holds at most -- one account per provider (migrations/2026-09-25-oauth-one-link-per-provider.sql). UNIQUE KEY uq_provider_user (provider, provider_user_id), UNIQUE KEY uq_oauth_user_provider (user_id, provider), INDEX (user_id) ) ENGINE=InnoDB; -- One identity per (issuer, sub), linking a local user to an OIDC issuer's -- subject (W6-CDX-5; migrations/2026-10-11-user-identities.sql). utf8mb4_bin -- because a sub is case-sensitive. UNIQUE (user_id, issuer) makes "one identity -- per issuer per user" a database fact. CREATE TABLE user_identities ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, issuer VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, subject VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, email_at_link VARCHAR(255) NOT NULL, email_at_last_signin VARCHAR(255) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_user_identities_issuer_subject (issuer, subject), UNIQUE KEY uq_user_identities_user_issuer (user_id, issuer) ) ENGINE=InnoDB; CREATE TABLE sessions ( -- The SHA-256 digest of the session token, lowercase hex, never the token -- itself (hashSessionToken in cloudcodex/services/session-token.js): a dump -- of this table yields nothing a browser can present. One row per sign-in. id CHAR(64) PRIMARY KEY, user_id INT NOT NULL, -- Which flow minted this session. NOT NULL with no DEFAULT, so a flow that -- forgets to name itself fails at insert (error 1364); VARCHAR + CHECK, not -- ENUM, for password_reset_tokens.purpose's reason below. Keep the CHECK in -- sync with the newest migration that sets it -- (migrations/2026-10-11-user-identities.sql). auth_provider VARCHAR(16) NOT NULL, -- The identity an OIDC sign-in used, and the issuer's sid for back-channel -- logout (migrations/2026-10-11-user-identities.sql). Both NULL for local and -- Google sessions. chk_sessions_identity_pairing keeps 'oidc' and identity_id -- together. identity_id INT NULL, provider_sid VARCHAR(255) NULL, -- The sealed ID token a sign-out sends as id_token_hint (migrations/2026-10-13-session-id-token.sql, -- services/id-token-seal.js). NULL for local and Google sessions, and for OIDC sessions signed in -- before it. Never the token itself. oidc_id_token TEXT NULL, ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- When the person signed in. A rotation keeps it (migrations/2026-10-14-session-rotation.sql), so the -- back-channel bound measures the sign-in, not the replacement's created_at. signed_in_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, -- The digest of the session this one replaced; a logout presenting the old token ends it too. rotated_from CHAR(64) NULL, last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_sessions_rotated_from (rotated_from), CONSTRAINT chk_sessions_auth_provider CHECK (auth_provider IN ('local', 'google', 'oidc')), CONSTRAINT chk_sessions_identity_pairing CHECK ((auth_provider = 'oidc') = (identity_id IS NOT NULL)), CONSTRAINT chk_sessions_id_token_oidc_only CHECK (oidc_id_token IS NULL OR auth_provider = 'oidc'), CONSTRAINT fk_sessions_identity FOREIGN KEY (identity_id) REFERENCES user_identities(id) ON DELETE CASCADE, INDEX (user_id), INDEX (expires_at), INDEX idx_sessions_provider_sid (provider_sid) ) ENGINE=InnoDB; CREATE TABLE password_reset_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token CHAR(64) NOT NULL UNIQUE, -- Which of the five flows minted this row. NOT NULL with no DEFAULT so a -- sixth flow that forgets to name its purpose fails at insert (error 1364) -- instead of silently minting a password reset token. -- -- Deliberately VARCHAR + CHECK and not ENUM: MySQL gives a NOT NULL ENUM -- with no DEFAULT an implicit default of the FIRST enumerated value even -- under STRICT_TRANS_TABLES, so an omitted purpose would silently become -- 'password_reset', which is the exact defect this column exists to close. -- -- Keep in sync with TOKEN_PURPOSE in cloudcodex/routes/helpers/shared.js -- and with the newest migration that sets the CHECK -- (migrations/2026-09-25-token-purpose-email-change.sql). purpose VARCHAR(32) NOT NULL, -- The address an email_change row confirms, and set on no other purpose -- (chk_password_reset_tokens_new_email), so a confirmation applies exactly -- the address its code was sent for. new_email VARCHAR(255) NULL, expires_at TIMESTAMP NOT NULL, used BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT chk_password_reset_tokens_purpose CHECK (purpose IN ('password_reset','two_factor_login','totp_setup','two_factor_disable','email_change')), CONSTRAINT chk_password_reset_tokens_new_email CHECK ((purpose = 'email_change') = (new_email IS NOT NULL)), INDEX (token), INDEX (expires_at) ) ENGINE=InnoDB; CREATE TABLE two_factor_codes ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, code CHAR(6) NOT NULL, expires_at TIMESTAMP NOT NULL, used BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX (user_id), INDEX (expires_at) ) ENGINE=InnoDB; CREATE TABLE user_invitations ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, token CHAR(64) NOT NULL UNIQUE, invited_by INT NOT NULL, squad_id INT NULL, role ENUM('member', 'admin', 'owner') DEFAULT 'member', can_read BOOLEAN DEFAULT TRUE, can_write BOOLEAN DEFAULT FALSE, can_create_log BOOLEAN DEFAULT FALSE, can_create_archive BOOLEAN DEFAULT FALSE, can_manage_members BOOLEAN DEFAULT FALSE, can_delete_version BOOLEAN DEFAULT FALSE, can_publish BOOLEAN DEFAULT FALSE, expires_at TIMESTAMP NOT NULL, accepted BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE CASCADE, INDEX (token), INDEX (email), INDEX (expires_at) ) ENGINE=InnoDB; CREATE TABLE squads ( id INT AUTO_INCREMENT PRIMARY KEY, workspace_id INT, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INT, github_org VARCHAR(255) DEFAULT NULL, github_team_slug VARCHAR(255) DEFAULT NULL, team_sync_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_squad_team (github_org, github_team_slug) ) ENGINE=InnoDB; -- user_invitations is declared before squads, so its squad FK is added here -- rather than inline, where it would reference a table that does not exist yet. ALTER TABLE user_invitations ADD CONSTRAINT fk_user_invitations_squad FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE SET NULL; CREATE TABLE permissions ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, create_squad BOOLEAN DEFAULT FALSE, create_archive BOOLEAN DEFAULT FALSE, create_log BOOLEAN DEFAULT TRUE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB; CREATE TABLE squad_members ( id INT AUTO_INCREMENT PRIMARY KEY, squad_id INT NOT NULL, user_id INT NOT NULL, role ENUM('member', 'admin', 'owner') DEFAULT 'member', can_read BOOLEAN DEFAULT TRUE, can_write BOOLEAN DEFAULT FALSE, can_create_log BOOLEAN DEFAULT FALSE, can_create_archive BOOLEAN DEFAULT FALSE, can_manage_members BOOLEAN DEFAULT FALSE, can_delete_version BOOLEAN DEFAULT FALSE, can_publish BOOLEAN DEFAULT FALSE, joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_squad_user (squad_id, user_id) ) ENGINE=InnoDB; CREATE TABLE squad_invitations ( id INT AUTO_INCREMENT PRIMARY KEY, squad_id INT NOT NULL, invited_by INT NOT NULL, invited_user_id INT NOT NULL, role ENUM('member', 'admin', 'owner') DEFAULT 'member', can_read BOOLEAN DEFAULT TRUE, can_write BOOLEAN DEFAULT FALSE, can_create_log BOOLEAN DEFAULT FALSE, can_create_archive BOOLEAN DEFAULT FALSE, can_manage_members BOOLEAN DEFAULT FALSE, can_delete_version BOOLEAN DEFAULT FALSE, can_publish BOOLEAN DEFAULT FALSE, status ENUM('pending', 'accepted', 'declined') DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, responded_at TIMESTAMP NULL, FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE CASCADE, FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (invited_user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_invitation (squad_id, invited_user_id, status) ) ENGINE=InnoDB; CREATE TABLE archives ( id INT AUTO_INCREMENT PRIMARY KEY, squad_id INT, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INT, read_access JSON DEFAULT (JSON_ARRAY()), write_access JSON DEFAULT (JSON_ARRAY()), read_access_squads JSON DEFAULT (JSON_ARRAY()), write_access_squads JSON DEFAULT (JSON_ARRAY()), read_access_workspace BOOLEAN DEFAULT FALSE, write_access_workspace BOOLEAN DEFAULT FALSE, `system` BOOLEAN DEFAULT FALSE, FOREIGN KEY (squad_id) REFERENCES squads(id) ON DELETE SET NULL, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_archives_system (`system`) ) ENGINE=InnoDB; CREATE TABLE archive_repos ( id INT AUTO_INCREMENT PRIMARY KEY, archive_id INT NOT NULL, repo_full_name VARCHAR(255) NOT NULL, repo_owner VARCHAR(255) NOT NULL, repo_name VARCHAR(255) NOT NULL, default_branch VARCHAR(255) DEFAULT 'main', docs_path VARCHAR(500) DEFAULT 'docs', auto_link_imports BOOLEAN DEFAULT TRUE, linked_by INT NOT NULL, linked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (archive_id) REFERENCES archives(id) ON DELETE CASCADE, FOREIGN KEY (linked_by) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_archive_repo (archive_id, repo_full_name), INDEX (archive_id) ) ENGINE=InnoDB; CREATE TABLE logs ( id INT AUTO_INCREMENT PRIMARY KEY, archive_id INT, title TEXT NOT NULL, html_content MEDIUMTEXT, markdown_content MEDIUMTEXT DEFAULT NULL, ydoc_state LONGBLOB DEFAULT NULL, plain_content MEDIUMTEXT GENERATED ALWAYS AS (REGEXP_REPLACE(html_content, '<[^>]+>', '')) STORED, parent_id INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_by INT, version INT DEFAULT 0, read_access JSON DEFAULT (JSON_ARRAY()), write_access JSON DEFAULT (JSON_ARRAY()), FULLTEXT INDEX ft_logs_search (title, plain_content), FOREIGN KEY (archive_id) REFERENCES archives(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES logs(id) ON DELETE SET NULL, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; CREATE TABLE github_embed_refs ( id INT AUTO_INCREMENT PRIMARY KEY, log_id INT NOT NULL, embed_type ENUM('code','issue','pr','file') NOT NULL, repo_owner VARCHAR(255) NOT NULL, repo_name VARCHAR(255) NOT NULL, ref_value VARCHAR(500) NOT NULL, pinned_sha VARCHAR(64) DEFAULT NULL, branch VARCHAR(255) DEFAULT NULL, last_seen_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, INDEX idx_embed_log (log_id), INDEX idx_embed_repo (repo_owner, repo_name, embed_type) ) ENGINE=InnoDB; CREATE TABLE github_pr_sessions ( id INT AUTO_INCREMENT PRIMARY KEY, log_id INT NOT NULL, repo_owner VARCHAR(255) NOT NULL, repo_name VARCHAR(255) NOT NULL, pr_number INT NOT NULL, opened_by INT NOT NULL, opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, FOREIGN KEY (opened_by) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_pr_session (repo_owner, repo_name, pr_number), INDEX idx_pr_session_log (log_id) ) ENGINE=InnoDB; CREATE TABLE github_links ( id INT AUTO_INCREMENT PRIMARY KEY, log_id INT NOT NULL, repo_owner VARCHAR(255) NOT NULL, repo_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, branch VARCHAR(255) NOT NULL, file_sha VARCHAR(64) DEFAULT NULL, base_sha VARCHAR(64) DEFAULT NULL, last_pulled_at TIMESTAMP NULL DEFAULT NULL, last_pushed_at TIMESTAMP NULL DEFAULT NULL, sync_status ENUM('clean','remote_ahead','local_ahead','diverged','conflict') DEFAULT 'clean', linked_by INT NOT NULL, linked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, FOREIGN KEY (linked_by) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_log_link (log_id), INDEX idx_links_status (sync_status) ) ENGINE=InnoDB; CREATE TABLE versions ( id INT AUTO_INCREMENT PRIMARY KEY, log_id INT, version INT NOT NULL, title VARCHAR(255) DEFAULT NULL, notes TEXT DEFAULT NULL, html_content MEDIUMTEXT, github_release_id BIGINT DEFAULT NULL, github_tag_name VARCHAR(255) DEFAULT NULL, github_target_repo VARCHAR(500) DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by INT, read_access JSON DEFAULT (JSON_ARRAY()), FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; -- Who may see a document image: one row per (image, document holding it). -- hash is the file name without .webp. uploaded_by is set only when the -- writer supplied the bytes. See migrations/2026-09-27-who-may-see-doc-images.sql. CREATE TABLE doc_images ( hash CHAR(16) NOT NULL, log_id INT NOT NULL, uploaded_by INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (hash, log_id), INDEX idx_doc_images_log (log_id), FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; CREATE TABLE comments ( id INT AUTO_INCREMENT PRIMARY KEY, log_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, tag ENUM('comment', 'suggestion', 'question', 'issue', 'note', 'pr_review') DEFAULT 'comment', status ENUM('open', 'resolved', 'dismissed') DEFAULT 'open', selection_start INT DEFAULT NULL, selection_end INT DEFAULT NULL, selected_text TEXT DEFAULT NULL, external_kind ENUM('pr_file_line','pr_general','issue_thread') DEFAULT NULL, external_ref VARCHAR(500) DEFAULT NULL, external_id VARCHAR(64) DEFAULT NULL, resolved_by INT DEFAULT NULL, resolved_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_comments_log (log_id), INDEX idx_comments_status (log_id, status), INDEX idx_comments_external (external_kind, external_ref) ) ENGINE=InnoDB; CREATE TABLE comment_replies ( id INT AUTO_INCREMENT PRIMARY KEY, comment_id INT NOT NULL, user_id INT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_replies_comment (comment_id) ) ENGINE=InnoDB; CREATE TABLE favorites ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, log_id INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_favorites_user_log (user_id, log_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (log_id) REFERENCES logs(id) ON DELETE CASCADE, INDEX idx_favorites_user (user_id) ) ENGINE=InnoDB; CREATE TABLE activity_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, workspace_id INT NOT NULL, squad_id INT DEFAULT NULL, user_id INT NOT NULL, action VARCHAR(50) NOT NULL, resource_type VARCHAR(30) NOT NULL, resource_id INT NOT NULL, metadata JSON DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_activity_workspace_time (workspace_id, created_at), INDEX idx_activity_squad_time (squad_id, created_at), INDEX idx_activity_resource (resource_type, resource_id, created_at), INDEX idx_activity_user_time (user_id, created_at) ) ENGINE=InnoDB; CREATE TABLE watches ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, resource_type VARCHAR(30) NOT NULL, resource_id INT NOT NULL, source VARCHAR(20) NOT NULL DEFAULT 'manual', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_watches_user_resource (user_id, resource_type, resource_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_watches_resource (resource_type, resource_id) ) ENGINE=InnoDB; CREATE TABLE notifications ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, type VARCHAR(50) NOT NULL, actor_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, body TEXT DEFAULT NULL, link_url VARCHAR(512) DEFAULT NULL, resource_type VARCHAR(30) DEFAULT NULL, resource_id INT DEFAULT NULL, metadata JSON DEFAULT NULL, read_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_notifications_user_created (user_id, created_at), INDEX idx_notifications_user_unread (user_id, read_at, created_at), INDEX idx_notifications_resource (resource_type, resource_id) ) ENGINE=InnoDB; -- Outbound webhooks (migrations/2026-09-28-webhooks.sql). An env subscription -- (source 'env') stores no secret; it is read from WEBHOOK_SECRET at send time. -- workspace_id on a subscription has no foreign key on purpose, and -- webhook_events.body holds the exact bytes every delivery sends. CREATE TABLE webhook_subscriptions ( id INT AUTO_INCREMENT PRIMARY KEY, url VARCHAR(2048) NOT NULL, secret VARCHAR(255) NULL, source VARCHAR(8) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, event_types JSON NULL, workspace_id INT NULL, disabled_reason VARCHAR(255) NULL, consecutive_failures INT NOT NULL DEFAULT 0, paused_until DATETIME(3) NULL, created_by INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_webhook_subscriptions_source CHECK (source IN ('env', 'admin')), CONSTRAINT chk_webhook_subscriptions_secret CHECK ((source = 'env') = (secret IS NULL)), FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; CREATE TABLE webhook_events ( id BIGINT AUTO_INCREMENT PRIMARY KEY, event_uuid CHAR(36) NOT NULL, type VARCHAR(32) NOT NULL, workspace_id INT NOT NULL, occurred_at DATETIME(3) NOT NULL, body MEDIUMBLOB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_webhook_events_uuid (event_uuid), INDEX idx_webhook_events_created (created_at) ) ENGINE=InnoDB; CREATE TABLE webhook_deliveries ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subscription_id INT NOT NULL, event_id BIGINT NOT NULL, status VARCHAR(12) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, leased_by CHAR(36) NULL, lease_expires_at DATETIME(3) NULL, last_status SMALLINT NULL, last_error VARCHAR(255) NULL, delivered_at DATETIME(3) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_webhook_deliveries_status CHECK (status IN ('pending', 'delivered', 'dead')), UNIQUE KEY uq_webhook_deliveries_sub_event (subscription_id, event_id), INDEX idx_webhook_deliveries_head (subscription_id, status, event_id), FOREIGN KEY (subscription_id) REFERENCES webhook_subscriptions(id) ON DELETE CASCADE, FOREIGN KEY (event_id) REFERENCES webhook_events(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- The emit order lock (migrations/2026-10-10-webhook-sequence-lock.sql): an -- emit creates its one row if absent and takes it FOR UPDATE, so their ids are -- allocated in commit order. No row is seeded: restore.sh treats a database -- with no rows as a new install, and a seeded row would make it look used. CREATE TABLE webhook_sequence_lock ( id TINYINT PRIMARY KEY, CONSTRAINT chk_webhook_sequence_lock_row CHECK (id = 1) ) ENGINE=InnoDB;