You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

106 lines
5.7 KiB
SQL

-- pages.elijah.run — D1 schema
--
-- Mirrors docs/plans/2026-07-12-pages-design.md §4.2 exactly. All statements
-- use `IF NOT EXISTS` so this file can be re-applied safely (e.g. re-running
-- `wrangler d1 execute` against a database that already has these objects).
--
-- Apply with:
-- wrangler d1 execute <DB_NAME> --file=migrations/schema.sql (remote)
-- wrangler d1 execute <DB_NAME> --local --file=migrations/schema.sql (local dev)
-- Registered users, upserted on every successful GitHub OAuth login
-- (src/db.rs::Db::upsert_user_on_login). `github_id` is the stable GitHub
-- account id (not the login, which can change) and is the natural
-- conflict key for the upsert. `can_upload` and `is_admin` are booleans
-- stored as INTEGER 0/1 (SQLite/D1 has no native boolean type) — see the
-- `UserRow` -> `User` conversion in src/db.rs for how these are turned
-- into real `bool`s on read. Both default to 0: new OAuth users can log
-- in but cannot publish until an admin grants `can_upload` (design §2.4).
-- `ADMIN_GITHUB_LOGIN` logins are auto-promoted (both flags forced to 1)
-- on every login by the upsert, independent of this default. `trusted` is a
-- separate, admin-only-granted flag (pages-shqc): it is never touched by
-- admin auto-promotion or by any login upsert — see the "Trust escape
-- hatch" addendum in docs/plans/2026-07-12-pages-design.md §2.1. A user's
-- `trusted` flag contributes to a page's *effective* trust (alongside the
-- page's own `trusted` flag below) via `p.trusted OR u.trusted`.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
github_id INTEGER NOT NULL UNIQUE,
login TEXT NOT NULL,
avatar_url TEXT,
can_upload INTEGER NOT NULL DEFAULT 0,
is_admin INTEGER NOT NULL DEFAULT 0,
trusted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Server-side session records. The cookie sent to the browser holds the raw
-- session token; only its SHA-256 hex hash is ever persisted here, so a D1
-- read leak does not yield a usable session token (design §2.4).
-- `ON DELETE CASCADE` means deleting a user row (none currently does this in
-- v0, but future admin tooling might) also removes their sessions.
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the cookie token
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One row per published page. `slug` is the primary key, so a duplicate
-- upload name surfaces as a UNIQUE/PK constraint violation, which src/db.rs
-- maps to a typed `DbError::NameTaken` (HTTP 409 at the routes layer,
-- design §2.3). `file_count`/`total_bytes` are denormalized summaries of the
-- R2 objects stored under the `<slug>/` prefix (design §4.3), populated at
-- upload time so the page list and per-user quota check (design §2.5) don't
-- need to touch R2. `trusted` is an admin-only-granted flag (pages-shqc,
-- design §2.1 addendum): a page's *effective* trust is
-- `pages.trusted OR <owner's> users.trusted`, computed at serve time —
-- see src/db.rs's `PageRow::effective_trust`.
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
file_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
trusted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Per-user page quota (design §2.5) is enforced by counting
-- `pages` rows WHERE owner_id = ? before an upload; without this index that
-- scan is a full table scan on every upload.
CREATE INDEX IF NOT EXISTS idx_pages_owner_id ON pages(owner_id);
-- get_user_by_session filters `WHERE expires_at > datetime('now')` on every
-- authenticated request; this index keeps that lookup (and any future
-- expired-session cleanup job) from scanning the whole table.
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
-- Not required by any current query pattern (session lookups go through
-- `token_hash`, the primary key) but cheap to maintain and speeds up the
-- `ON DELETE CASCADE` scan when a user row is deleted, plus any future
-- "list my sessions" / "log out everywhere" feature keyed on user_id.
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
-- Page aliases (pages-9lsk): extra, long-lived names a page can be reached
-- at, in the *same* URL namespace as `pages.slug`. `alias` is the primary key
-- so a duplicate alias surfaces as a UNIQUE violation (mapped to
-- `DbError::NameTaken` -> 409, exactly like `pages.slug`). Namespace
-- uniqueness across BOTH tables (an alias may not equal any existing slug,
-- and vice versa) is enforced in the route handlers (src/routes/api.rs), not
-- by a DB constraint — SQLite can't express a cross-table uniqueness rule.
-- `slug` is the canonical page the alias resolves to; content lives only once,
-- under the canonical `<slug>/` R2 prefix, and an alias hit serves it directly
-- (no redirect) — see src/routes/serve.rs. Aliases are removed explicitly when
-- their page is deleted (Db::delete_aliases_for_page in the delete_page
-- handler); the `REFERENCES pages(slug)` FK documents the relationship but the
-- cleanup does not rely on cascade being enabled. `idx_aliases_slug` backs the
-- per-page alias lookups (list/count/delete-for-page) so they don't scan.
CREATE TABLE IF NOT EXISTS aliases (
alias TEXT PRIMARY KEY,
slug TEXT NOT NULL REFERENCES pages(slug),
owner_id INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_aliases_slug ON aliases(slug);