| 1 | -- Dogfood initial schema (spec §5). | |
| 2 | -- | |
| 3 | -- Target: PostgreSQL 17 (the provided instance; the spec said 16, the extra | |
| 4 | -- version is harmless — nothing here depends on 17-only behaviour). | |
| 5 | -- | |
| 6 | -- UUIDv7 primary keys are generated in the application, not by the database, so | |
| 7 | -- that IDs are known before insert and a row can reference itself in the same | |
| 8 | -- transaction. Time-ordered, index-friendly, and they do not leak counts. | |
| 9 | ||
| 10 | CREATE EXTENSION IF NOT EXISTS citext; | |
| 11 | CREATE EXTENSION IF NOT EXISTS pg_trgm; | |
| 12 | ||
| 13 | -- ─── identity ──────────────────────────────────────────────────────────────── | |
| 14 | ||
| 15 | CREATE TABLE users ( | |
| 16 | id uuid PRIMARY KEY, | |
| 17 | subject text NOT NULL UNIQUE, -- OIDC sub claim (Kratos identity id) | |
| 18 | handle citext NOT NULL UNIQUE, | |
| 19 | display_name text, | |
| 20 | email citext, | |
| 21 | avatar_url text, | |
| 22 | is_admin boolean NOT NULL DEFAULT false, | |
| 23 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 24 | -- NOTE the ::text cast. `handle` is citext, and citext makes the `~` | |
| 25 | -- operator case-INSENSITIVE, so without the cast this constraint accepts | |
| 26 | -- 'Bob' despite the lowercase-only character class. Verified. | |
| 27 | CONSTRAINT handle_format CHECK (handle::text ~ '^[a-z0-9][a-z0-9-]{0,38}$') | |
| 28 | ); | |
| 29 | ||
| 30 | CREATE TABLE orgs ( | |
| 31 | id uuid PRIMARY KEY, | |
| 32 | handle citext NOT NULL UNIQUE, | |
| 33 | display_name text, | |
| 34 | description text, | |
| 35 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 36 | -- NOTE the ::text cast. `handle` is citext, and citext makes the `~` | |
| 37 | -- operator case-INSENSITIVE, so without the cast this constraint accepts | |
| 38 | -- 'Bob' despite the lowercase-only character class. Verified. | |
| 39 | CONSTRAINT handle_format CHECK (handle::text ~ '^[a-z0-9][a-z0-9-]{0,38}$') | |
| 40 | ); | |
| 41 | ||
| 42 | -- Users and orgs share a handle namespace so that /{handle} is unambiguous. | |
| 43 | -- Enforced with triggers on both tables rather than a CHECK, which cannot see | |
| 44 | -- another table. | |
| 45 | CREATE FUNCTION assert_handle_unused() RETURNS trigger AS $$ | |
| 46 | BEGIN | |
| 47 | IF TG_TABLE_NAME = 'users' THEN | |
| 48 | IF EXISTS (SELECT 1 FROM orgs WHERE handle = NEW.handle) THEN | |
| 49 | RAISE EXCEPTION 'handle % is already taken by an organization', NEW.handle | |
| 50 | USING ERRCODE = 'unique_violation'; | |
| 51 | END IF; | |
| 52 | ELSE | |
| 53 | IF EXISTS (SELECT 1 FROM users WHERE handle = NEW.handle) THEN | |
| 54 | RAISE EXCEPTION 'handle % is already taken by a user', NEW.handle | |
| 55 | USING ERRCODE = 'unique_violation'; | |
| 56 | END IF; | |
| 57 | END IF; | |
| 58 | RETURN NEW; | |
| 59 | END; | |
| 60 | $$ LANGUAGE plpgsql; | |
| 61 | ||
| 62 | CREATE TRIGGER users_handle_namespace | |
| 63 | BEFORE INSERT OR UPDATE OF handle ON users | |
| 64 | FOR EACH ROW EXECUTE FUNCTION assert_handle_unused(); | |
| 65 | ||
| 66 | CREATE TRIGGER orgs_handle_namespace | |
| 67 | BEFORE INSERT OR UPDATE OF handle ON orgs | |
| 68 | FOR EACH ROW EXECUTE FUNCTION assert_handle_unused(); | |
| 69 | ||
| 70 | -- Reserved handles: these are top-level routes, so a user or org holding one | |
| 71 | -- would shadow them (spec §7 puts /settings, /new, /search at the root). | |
| 72 | CREATE TABLE reserved_handles (handle citext PRIMARY KEY); | |
| 73 | INSERT INTO reserved_handles (handle) VALUES | |
| 74 | ('new'), ('settings'), ('search'), ('login'), ('logout'), ('auth'), | |
| 75 | ('setup'), ('admin'), ('api'), ('static'), ('assets'), ('healthz'), | |
| 76 | ('readyz'), ('metrics'), ('about'), ('help'), ('docs'), ('status'), | |
| 77 | ('explore'), ('notifications'), ('dashboard'), ('repos'), ('orgs'), | |
| 78 | ('users'), ('git'), ('jj'), ('www'), ('mail'), ('root'); | |
| 79 | ||
| 80 | CREATE TYPE org_role AS ENUM ('member', 'admin'); | |
| 81 | ||
| 82 | CREATE TABLE org_members ( | |
| 83 | org_id uuid NOT NULL REFERENCES orgs ON DELETE CASCADE, | |
| 84 | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 85 | role org_role NOT NULL DEFAULT 'member', | |
| 86 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 87 | PRIMARY KEY (org_id, user_id) | |
| 88 | ); | |
| 89 | CREATE INDEX org_members_user_idx ON org_members (user_id); | |
| 90 | ||
| 91 | CREATE TABLE ssh_keys ( | |
| 92 | id uuid PRIMARY KEY, | |
| 93 | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 94 | name text NOT NULL, | |
| 95 | key_type text NOT NULL, | |
| 96 | fingerprint text NOT NULL UNIQUE, -- SHA256:… — the auth lookup key | |
| 97 | public_key text NOT NULL, | |
| 98 | last_used_at timestamptz, | |
| 99 | created_at timestamptz NOT NULL DEFAULT now() | |
| 100 | ); | |
| 101 | CREATE INDEX ssh_keys_fingerprint_idx ON ssh_keys (fingerprint); | |
| 102 | CREATE INDEX ssh_keys_user_idx ON ssh_keys (user_id); | |
| 103 | ||
| 104 | CREATE TABLE access_tokens ( | |
| 105 | id uuid PRIMARY KEY, | |
| 106 | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 107 | name text NOT NULL, | |
| 108 | token_hash text NOT NULL, -- argon2id PHC string; plaintext shown once | |
| 109 | prefix text NOT NULL, -- first 8 chars, for identification in UI | |
| 110 | scopes text[] NOT NULL DEFAULT '{}', | |
| 111 | expires_at timestamptz, | |
| 112 | last_used_at timestamptz, | |
| 113 | created_at timestamptz NOT NULL DEFAULT now() | |
| 114 | ); | |
| 115 | CREATE INDEX access_tokens_user_idx ON access_tokens (user_id); | |
| 116 | -- Token auth looks up by prefix, then verifies the argon2 hash. Hashing is | |
| 117 | -- deliberately slow, so we must not hash against every row in the table. | |
| 118 | CREATE INDEX access_tokens_prefix_idx ON access_tokens (prefix); | |
| 119 | ||
| 120 | CREATE TABLE sessions ( | |
| 121 | id uuid PRIMARY KEY, | |
| 122 | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 123 | expires_at timestamptz NOT NULL, | |
| 124 | user_agent text, | |
| 125 | ip inet, | |
| 126 | created_at timestamptz NOT NULL DEFAULT now() | |
| 127 | ); | |
| 128 | CREATE INDEX sessions_user_idx ON sessions (user_id); | |
| 129 | CREATE INDEX sessions_expiry_idx ON sessions (expires_at); | |
| 130 | ||
| 131 | -- In-flight OIDC authorization code flows. PKCE verifier and nonce are held | |
| 132 | -- server-side and keyed by the opaque state parameter; nothing sensitive is | |
| 133 | -- round-tripped through the browser. | |
| 134 | CREATE TABLE auth_flows ( | |
| 135 | state text PRIMARY KEY, | |
| 136 | pkce_verifier text NOT NULL, | |
| 137 | nonce text NOT NULL, | |
| 138 | redirect_after text, | |
| 139 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 140 | expires_at timestamptz NOT NULL | |
| 141 | ); | |
| 142 | CREATE INDEX auth_flows_expiry_idx ON auth_flows (expires_at); | |
| 143 | ||
| 144 | -- ─── access control (decided: invite/allowlist only) ───────────────────────── | |
| 145 | ||
| 146 | -- A successful OIDC login is not sufficient to get a Dogfood account. The | |
| 147 | -- identity must additionally match an allowlist entry or an open invitation. | |
| 148 | CREATE TABLE invitations ( | |
| 149 | id uuid PRIMARY KEY, | |
| 150 | email citext NOT NULL UNIQUE, | |
| 151 | invited_by uuid REFERENCES users ON DELETE SET NULL, | |
| 152 | accepted_at timestamptz, | |
| 153 | accepted_by uuid REFERENCES users ON DELETE SET NULL, | |
| 154 | created_at timestamptz NOT NULL DEFAULT now() | |
| 155 | ); | |
| 156 | ||
| 157 | -- One-time site-admin bootstrap (decided). The web process mints a token on | |
| 158 | -- first boot when no admin exists, logs it once, and stores only its hash. | |
| 159 | CREATE TABLE setup_tokens ( | |
| 160 | id uuid PRIMARY KEY, | |
| 161 | token_hash text NOT NULL, | |
| 162 | consumed_at timestamptz, | |
| 163 | consumed_by uuid REFERENCES users ON DELETE SET NULL, | |
| 164 | created_at timestamptz NOT NULL DEFAULT now() | |
| 165 | ); | |
| 166 | ||
| 167 | -- ─── repositories ──────────────────────────────────────────────────────────── | |
| 168 | ||
| 169 | CREATE TYPE owner_kind AS ENUM ('user', 'org'); | |
| 170 | CREATE TYPE visibility AS ENUM ('public', 'private'); | |
| 171 | ||
| 172 | CREATE TABLE repos ( | |
| 173 | id uuid PRIMARY KEY, | |
| 174 | owner_kind owner_kind NOT NULL, | |
| 175 | owner_user_id uuid REFERENCES users ON DELETE CASCADE, | |
| 176 | owner_org_id uuid REFERENCES orgs ON DELETE CASCADE, | |
| 177 | name citext NOT NULL, | |
| 178 | description text, | |
| 179 | visibility visibility NOT NULL DEFAULT 'private', | |
| 180 | default_bookmark text NOT NULL DEFAULT 'main', | |
| 181 | -- Forks use separate storage (decided): this records provenance only, and | |
| 182 | -- never causes two repos to share an object database. | |
| 183 | fork_of_repo_id uuid REFERENCES repos ON DELETE SET NULL, | |
| 184 | size_bytes bigint NOT NULL DEFAULT 0, | |
| 185 | pushed_at timestamptz, | |
| 186 | archived boolean NOT NULL DEFAULT false, | |
| 187 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 188 | CONSTRAINT one_owner CHECK (num_nonnulls(owner_user_id, owner_org_id) = 1), | |
| 189 | CONSTRAINT owner_kind_matches CHECK ( | |
| 190 | (owner_kind = 'user' AND owner_user_id IS NOT NULL) OR | |
| 191 | (owner_kind = 'org' AND owner_org_id IS NOT NULL) | |
| 192 | ), | |
| 193 | CONSTRAINT name_format CHECK (name ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$' AND name !~ '\.\.') | |
| 194 | ); | |
| 195 | CREATE UNIQUE INDEX repos_owner_name_idx ON repos (COALESCE(owner_user_id, owner_org_id), name); | |
| 196 | CREATE INDEX repos_visibility_idx ON repos (visibility) WHERE archived = false; | |
| 197 | ||
| 198 | CREATE TYPE repo_role AS ENUM ('read', 'write', 'maintain', 'admin'); | |
| 199 | ||
| 200 | CREATE TABLE repo_collaborators ( | |
| 201 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 202 | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 203 | role repo_role NOT NULL, | |
| 204 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 205 | PRIMARY KEY (repo_id, user_id) | |
| 206 | ); | |
| 207 | CREATE INDEX repo_collaborators_user_idx ON repo_collaborators (user_id); | |
| 208 | ||
| 209 | CREATE TABLE bookmarks ( | |
| 210 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 211 | name text NOT NULL, | |
| 212 | target text NOT NULL, -- RevId | |
| 213 | protected boolean NOT NULL DEFAULT false, | |
| 214 | updated_at timestamptz NOT NULL DEFAULT now(), | |
| 215 | PRIMARY KEY (repo_id, name) | |
| 216 | ); | |
| 217 | ||
| 218 | -- ─── changes ───────────────────────────────────────────────────────────────── | |
| 219 | ||
| 220 | CREATE TYPE change_state AS ENUM ('draft', 'open', 'merged', 'abandoned'); | |
| 221 | ||
| 222 | CREATE TABLE changes ( | |
| 223 | id uuid PRIMARY KEY, | |
| 224 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 225 | change_id text NOT NULL, -- jj change id, canonical letter encoding | |
| 226 | synthetic boolean NOT NULL DEFAULT false, | |
| 227 | number bigint NOT NULL, -- per-repo display number | |
| 228 | title text NOT NULL, | |
| 229 | description text NOT NULL DEFAULT '', | |
| 230 | -- 'draft' is set by the author in the UI and is never inferred from pushed | |
| 231 | -- metadata (decided). The indexer may move a change to merged/abandoned but | |
| 232 | -- must not overwrite draft. | |
| 233 | state change_state NOT NULL DEFAULT 'open', | |
| 234 | conflicted boolean NOT NULL DEFAULT false, | |
| 235 | author_user_id uuid REFERENCES users ON DELETE SET NULL, | |
| 236 | target_bookmark text NOT NULL, | |
| 237 | head_revision_id uuid, -- FK added after revisions | |
| 238 | merged_at timestamptz, | |
| 239 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 240 | updated_at timestamptz NOT NULL DEFAULT now(), | |
| 241 | UNIQUE (repo_id, change_id), | |
| 242 | UNIQUE (repo_id, number) | |
| 243 | ); | |
| 244 | CREATE INDEX changes_list_idx ON changes (repo_id, state, updated_at DESC); | |
| 245 | ||
| 246 | -- Prefix lookup: users type a short prefix, exactly as in the CLI. | |
| 247 | CREATE INDEX changes_prefix_idx ON changes (repo_id, change_id text_pattern_ops); | |
| 248 | ||
| 249 | CREATE TABLE revisions ( | |
| 250 | id uuid PRIMARY KEY, | |
| 251 | change_id_fk uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 252 | rev text NOT NULL, -- RevId | |
| 253 | seq int NOT NULL, -- 1-based, per change | |
| 254 | parents text[] NOT NULL DEFAULT '{}', | |
| 255 | author_name text NOT NULL, | |
| 256 | author_email text NOT NULL, | |
| 257 | authored_at timestamptz NOT NULL, | |
| 258 | message text NOT NULL, | |
| 259 | conflicted boolean NOT NULL DEFAULT false, | |
| 260 | conflict_data jsonb, -- sides, base, paths | |
| 261 | pushed_by uuid REFERENCES users ON DELETE SET NULL, | |
| 262 | pushed_at timestamptz NOT NULL DEFAULT now(), | |
| 263 | UNIQUE (change_id_fk, rev), | |
| 264 | UNIQUE (change_id_fk, seq) | |
| 265 | ); | |
| 266 | CREATE INDEX revisions_rev_idx ON revisions (rev); | |
| 267 | ||
| 268 | ALTER TABLE changes ADD CONSTRAINT changes_head_fk | |
| 269 | FOREIGN KEY (head_revision_id) REFERENCES revisions ON DELETE SET NULL; | |
| 270 | ||
| 271 | -- Stack edges: parent_change is immediately below child_change in a stack. | |
| 272 | -- Computed at index time; never recomputed on page render (spec §4). | |
| 273 | CREATE TABLE change_edges ( | |
| 274 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 275 | parent_change uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 276 | child_change uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 277 | PRIMARY KEY (parent_change, child_change), | |
| 278 | CONSTRAINT no_self_edge CHECK (parent_change <> child_change) | |
| 279 | ); | |
| 280 | CREATE INDEX change_edges_child_idx ON change_edges (repo_id, child_change); | |
| 281 | ||
| 282 | -- ─── discussion ────────────────────────────────────────────────────────────── | |
| 283 | ||
| 284 | CREATE TYPE anchor_state AS ENUM ('current', 'outdated', 'orphaned'); | |
| 285 | ||
| 286 | CREATE TABLE comments ( | |
| 287 | id uuid PRIMARY KEY, | |
| 288 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 289 | change_id_fk uuid REFERENCES changes ON DELETE CASCADE, | |
| 290 | issue_id uuid, -- FK below | |
| 291 | parent_id uuid REFERENCES comments ON DELETE CASCADE, | |
| 292 | author_user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 293 | body text NOT NULL, | |
| 294 | ||
| 295 | -- inline anchor, null for top-level comments | |
| 296 | anchor_revision uuid REFERENCES revisions ON DELETE SET NULL, | |
| 297 | anchor_path text, | |
| 298 | anchor_line int, | |
| 299 | anchor_side text CHECK (anchor_side IN ('old','new')), | |
| 300 | anchor_state anchor_state NOT NULL DEFAULT 'current', | |
| 301 | anchor_context text, -- the line's content when written | |
| 302 | ||
| 303 | resolved_at timestamptz, | |
| 304 | resolved_by uuid REFERENCES users ON DELETE SET NULL, | |
| 305 | edited_at timestamptz, | |
| 306 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 307 | CONSTRAINT one_target CHECK (num_nonnulls(change_id_fk, issue_id) = 1) | |
| 308 | ); | |
| 309 | CREATE INDEX comments_change_idx ON comments (change_id_fk, created_at); | |
| 310 | CREATE INDEX comments_issue_idx ON comments (issue_id, created_at); | |
| 311 | -- Anchor rebasing loads every inline comment on a change by its anchored file. | |
| 312 | CREATE INDEX comments_anchor_idx ON comments (change_id_fk, anchor_path) | |
| 313 | WHERE anchor_path IS NOT NULL; | |
| 314 | ||
| 315 | CREATE TYPE review_verdict AS ENUM ('approve', 'request_changes', 'comment'); | |
| 316 | ||
| 317 | CREATE TABLE reviews ( | |
| 318 | id uuid PRIMARY KEY, | |
| 319 | change_id_fk uuid NOT NULL REFERENCES changes ON DELETE CASCADE, | |
| 320 | revision_id uuid NOT NULL REFERENCES revisions ON DELETE CASCADE, | |
| 321 | reviewer_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 322 | verdict review_verdict NOT NULL, | |
| 323 | body text, | |
| 324 | created_at timestamptz NOT NULL DEFAULT now() | |
| 325 | ); | |
| 326 | CREATE INDEX reviews_change_idx ON reviews (change_id_fk, created_at DESC); | |
| 327 | ||
| 328 | -- ─── issues ────────────────────────────────────────────────────────────────── | |
| 329 | ||
| 330 | CREATE TYPE issue_state AS ENUM ('open', 'closed'); | |
| 331 | ||
| 332 | CREATE TABLE issues ( | |
| 333 | id uuid PRIMARY KEY, | |
| 334 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 335 | number bigint NOT NULL, | |
| 336 | title text NOT NULL, | |
| 337 | body text NOT NULL DEFAULT '', | |
| 338 | state issue_state NOT NULL DEFAULT 'open', | |
| 339 | author_user_id uuid REFERENCES users ON DELETE SET NULL, | |
| 340 | closed_at timestamptz, | |
| 341 | created_at timestamptz NOT NULL DEFAULT now(), | |
| 342 | updated_at timestamptz NOT NULL DEFAULT now(), | |
| 343 | UNIQUE (repo_id, number) | |
| 344 | ); | |
| 345 | CREATE INDEX issues_list_idx ON issues (repo_id, state, updated_at DESC); | |
| 346 | ||
| 347 | ALTER TABLE comments ADD CONSTRAINT comments_issue_fk | |
| 348 | FOREIGN KEY (issue_id) REFERENCES issues ON DELETE CASCADE; | |
| 349 | ||
| 350 | CREATE TABLE labels ( | |
| 351 | id uuid PRIMARY KEY, | |
| 352 | repo_id uuid NOT NULL REFERENCES repos ON DELETE CASCADE, | |
| 353 | name text NOT NULL, | |
| 354 | color text NOT NULL, | |
| 355 | UNIQUE (repo_id, name) | |
| 356 | ); | |
| 357 | ||
| 358 | CREATE TABLE issue_labels ( | |
| 359 | issue_id uuid NOT NULL REFERENCES issues ON DELETE CASCADE, | |
| 360 | label_id uuid NOT NULL REFERENCES labels ON DELETE CASCADE, | |
| 361 | PRIMARY KEY (issue_id, label_id) | |
| 362 | ); | |
| 363 | ||
| 364 | CREATE TABLE issue_assignees ( | |
| 365 | issue_id uuid NOT NULL REFERENCES issues ON DELETE CASCADE, | |
| 366 | user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE, | |
| 367 | PRIMARY KEY (issue_id, user_id) | |
| 368 | ); | |
| 369 | ||
| 370 | -- Per-repo issue and change numbering. A sequence per repo would be cleaner but | |
| 371 | -- cannot be created dynamically without DDL on every repo creation; this row is | |
| 372 | -- locked with SELECT … FOR UPDATE when allocating. | |
| 373 | CREATE TABLE repo_counters ( | |
| 374 | repo_id uuid PRIMARY KEY REFERENCES repos ON DELETE CASCADE, | |
| 375 | next_change bigint NOT NULL DEFAULT 1, | |
| 376 | next_issue bigint NOT NULL DEFAULT 1 | |
| 377 | ); | |
| 378 | ||
| 379 | -- ─── activity ──────────────────────────────────────────────────────────────── | |
| 380 | ||
| 381 | -- Timeline events. Also the substrate a future activity feed would read from — | |
| 382 | -- written correctly now even though nothing renders a feed in v1. | |
| 383 | CREATE TABLE events ( | |
| 384 | id uuid PRIMARY KEY, | |
| 385 | repo_id uuid REFERENCES repos ON DELETE CASCADE, | |
| 386 | actor_id uuid REFERENCES users ON DELETE SET NULL, | |
| 387 | kind text NOT NULL, -- pushed, rebased, conflicted, resolved, | |
| 388 | -- merged, abandoned, reviewed, commented, … | |
| 389 | subject_type text NOT NULL, -- change | issue | repo | bookmark | |
| 390 | subject_id uuid, | |
| 391 | payload jsonb NOT NULL DEFAULT '{}', | |
| 392 | created_at timestamptz NOT NULL DEFAULT now() | |
| 393 | ); | |
| 394 | CREATE INDEX events_repo_idx ON events (repo_id, created_at DESC); | |
| 395 | CREATE INDEX events_subject_idx ON events (subject_type, subject_id, created_at); | |
| 396 | ||
| 397 | CREATE TABLE audit_log ( | |
| 398 | id uuid PRIMARY KEY, | |
| 399 | actor_id uuid REFERENCES users ON DELETE SET NULL, | |
| 400 | action text NOT NULL, | |
| 401 | target text NOT NULL, | |
| 402 | ip inet, | |
| 403 | metadata jsonb NOT NULL DEFAULT '{}', | |
| 404 | created_at timestamptz NOT NULL DEFAULT now() | |
| 405 | ); | |
| 406 | CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC); | |
| 407 | CREATE INDEX audit_log_created_idx ON audit_log (created_at DESC); | |
| 408 | ||
| 409 | -- ─── rendering cache ───────────────────────────────────────────────────────── | |
| 410 | ||
| 411 | -- Syntax highlighting is expensive and its input is content-addressed, so the | |
| 412 | -- blob OID is a perfect cache key (spec §8). Highlighting on every request is | |
| 413 | -- the easiest performance mistake to make here. | |
| 414 | CREATE TABLE highlight_cache ( | |
| 415 | blob_oid text PRIMARY KEY, | |
| 416 | language text, | |
| 417 | html text NOT NULL, | |
| 418 | bytes int NOT NULL, | |
| 419 | created_at timestamptz NOT NULL DEFAULT now() | |
| 420 | ); | |
| 421 | ||
| 422 | -- ─── jobs ──────────────────────────────────────────────────────────────────── | |
| 423 | ||
| 424 | CREATE TABLE jobs ( | |
| 425 | id uuid PRIMARY KEY, | |
| 426 | kind text NOT NULL, | |
| 427 | payload jsonb NOT NULL, | |
| 428 | run_at timestamptz NOT NULL DEFAULT now(), | |
| 429 | attempts int NOT NULL DEFAULT 0, | |
| 430 | max_attempts int NOT NULL DEFAULT 5, | |
| 431 | locked_at timestamptz, | |
| 432 | locked_by text, | |
| 433 | last_error text, | |
| 434 | created_at timestamptz NOT NULL DEFAULT now() | |
| 435 | ); | |
| 436 | CREATE INDEX jobs_ready_idx ON jobs (run_at) WHERE locked_at IS NULL; | |
| 437 | ||
| 438 | -- Wake the worker on insert so it does not poll on the hot path. The worker | |
| 439 | -- also polls every 5s as a fallback for missed notifications (spec §5). | |
| 440 | CREATE FUNCTION notify_job() RETURNS trigger AS $$ | |
| 441 | BEGIN | |
| 442 | PERFORM pg_notify('dogfood_jobs', NEW.kind); | |
| 443 | RETURN NEW; | |
| 444 | END; | |
| 445 | $$ LANGUAGE plpgsql; | |
| 446 | ||
| 447 | CREATE TRIGGER jobs_notify AFTER INSERT ON jobs | |
| 448 | FOR EACH ROW EXECUTE FUNCTION notify_job(); |
448 lines · SQL