-- 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 --file=migrations/schema.sql (remote) -- wrangler d1 execute --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 `/` 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 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);