import "server-only"; import { mkdirSync } from "node:fs"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { DATA_ROOT } from "./paths"; const SCHEMA = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, bio TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, -- sha256 of the cookie token user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS ssh_keys ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, key_type TEXT NOT NULL, public_key TEXT NOT NULL, -- "ssh-ed25519 AAAA..." without comment fingerprint TEXT NOT NULL UNIQUE, -- SHA256:... created_at INTEGER NOT NULL, last_used_at INTEGER ); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, -- sha256 of the token hint TEXT NOT NULL, -- last 4 chars, shown in the UI permission TEXT NOT NULL CHECK (permission IN ('read', 'write')), all_repos INTEGER NOT NULL DEFAULT 1, expires_at INTEGER, created_at INTEGER NOT NULL, last_used_at INTEGER ); CREATE TABLE IF NOT EXISTS repos ( id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL COLLATE NOCASE, description TEXT NOT NULL DEFAULT '', visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), created_at INTEGER NOT NULL, pushed_at INTEGER, UNIQUE (owner_id, name) ); -- An uploaded repository image. Without a row, the owner's avatar is shown. CREATE TABLE IF NOT EXISTS repo_images ( repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE, content_type TEXT NOT NULL, data BLOB NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS api_token_repos ( token_id INTEGER NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, PRIMARY KEY (token_id, repo_id) ); CREATE TABLE IF NOT EXISTS collaborators ( repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, PRIMARY KEY (repo_id, user_id) ); CREATE TABLE IF NOT EXISTS issues ( id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, number INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE (repo_id, number) ); CREATE TABLE IF NOT EXISTS labels ( id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, name TEXT NOT NULL COLLATE NOCASE, color TEXT NOT NULL, -- #rrggbb UNIQUE (repo_id, name) ); CREATE TABLE IF NOT EXISTS issue_labels ( issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (issue_id, label_id) ); -- Property changes, shown in an issue's activity feed. Values are stored as -- they read at the time (status ids, priority numbers, usernames, label names). CREATE TABLE IF NOT EXISTS issue_events ( id INTEGER PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, field TEXT NOT NULL, old_value TEXT, new_value TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS issue_comments ( id INTEGER PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL, created_at INTEGER NOT NULL ); -- Container registry. Blob contents live under registry/blobs, shared by all repositories. CREATE TABLE IF NOT EXISTS registry_blobs ( digest TEXT PRIMARY KEY, -- sha256:… size INTEGER NOT NULL, created_at INTEGER NOT NULL ); -- The blobs a repository may serve: pushed to it or mounted from another repository. -- created_at is refreshed on use; unreferenced links are pruned once it's old. CREATE TABLE IF NOT EXISTS registry_repo_blobs ( repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, digest TEXT NOT NULL REFERENCES registry_blobs(digest) ON DELETE CASCADE, created_at INTEGER NOT NULL, PRIMARY KEY (repo_id, digest) ); CREATE TABLE IF NOT EXISTS registry_uploads ( id TEXT PRIMARY KEY, -- file name under registry/uploads repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, size INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); -- image is the name below owner/repo ('' for the repository's own image). CREATE TABLE IF NOT EXISTS registry_manifests ( repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, image TEXT NOT NULL, digest TEXT NOT NULL, media_type TEXT NOT NULL, content BLOB NOT NULL, size INTEGER NOT NULL, -- config and layers, compressed subject TEXT, -- digest of the manifest this one is attached to artifact_type TEXT, created_at INTEGER NOT NULL, PRIMARY KEY (repo_id, image, digest) ); -- What a manifest references: its config and layer blobs, or an index's manifests. CREATE TABLE IF NOT EXISTS registry_manifest_refs ( repo_id INTEGER NOT NULL, image TEXT NOT NULL, manifest_digest TEXT NOT NULL, digest TEXT NOT NULL, PRIMARY KEY (repo_id, image, manifest_digest, digest), FOREIGN KEY (repo_id, image, manifest_digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS registry_manifest_refs_digest ON registry_manifest_refs (repo_id, digest); CREATE TABLE IF NOT EXISTS registry_tags ( repo_id INTEGER NOT NULL, image TEXT NOT NULL, tag TEXT NOT NULL, digest TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (repo_id, image, tag), FOREIGN KEY (repo_id, image, digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE ); `; function open() { mkdirSync(DATA_ROOT, { recursive: true }); const db = new DatabaseSync(path.join(DATA_ROOT, "app.db")); db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); return db; } // Reuse one connection across hot reloads in development. The schema is // re-applied on every load (it's idempotent) so new tables reach a reused connection. const g = globalThis as unknown as { __db?: DatabaseSync }; export const db = (g.__db ??= open()); db.exec(SCHEMA); // Columns added to existing tables after their first release. CREATE TABLE IF NOT // EXISTS leaves old tables alone, so missing columns are added here. const ADDED_COLUMNS: { table: string; column: string; definition: string; backfill?: string }[] = [ { table: "issues", column: "status", definition: `TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('backlog', 'todo', 'in_progress', 'in_review', 'done', 'canceled'))`, // `state` stays in sync with `status`: done and canceled issues are closed. backfill: "UPDATE issues SET status = CASE state WHEN 'closed' THEN 'done' ELSE 'todo' END", }, // 0 = no priority, 1 = urgent … 4 = low { table: "issues", column: "priority", definition: "INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4)" }, { table: "issues", column: "assignee_id", definition: "INTEGER REFERENCES users(id) ON DELETE SET NULL" }, { table: "issues", column: "estimate", definition: "INTEGER" }, { table: "issues", column: "due_date", definition: "TEXT" }, // YYYY-MM-DD // Manual order on the board and in lists; new issues go to the top. { table: "issues", column: "sort_order", definition: "REAL NOT NULL DEFAULT 0", backfill: "UPDATE issues SET sort_order = -created_at" }, ]; for (const { table, column, definition, backfill } of ADDED_COLUMNS) { const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; if (columns.some((c) => c.name === column)) continue; db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); if (backfill) db.exec(backfill); } export function transaction(fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (e) { db.exec("ROLLBACK"); throw e; } }