init.sql
Rama main · réplica de lectura del repositorio original.
-- 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;