Files
pages/internal/store/migrations/0001_init.sql
T
2026-08-15 07:13:00 +00:00

110 lines
5.5 KiB
SQL

-- Initial schema.
--
-- Timestamps are unix seconds (INTEGER), matching what the triggers' unixepoch()
-- produces. Digests are stored as raw 32-byte BLOBs, not hex text: half the
-- index size and no conversion on the read path. Hex exists only at the API
-- boundary.
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- ^[a-z0-9][a-z0-9._-]{0,62}$
display_name TEXT NOT NULL DEFAULT '',
index_file TEXT NOT NULL DEFAULT 'index.html',
not_found_file TEXT, -- NULL => bare 404
spa_fallback INTEGER NOT NULL DEFAULT 0 CHECK (spa_fallback IN (0,1)),
cache_control TEXT NOT NULL DEFAULT 'public, max-age=0, must-revalidate',
retention_count INTEGER NOT NULL DEFAULT 10,
retention_grace_s INTEGER NOT NULL DEFAULT 3600,
max_files INTEGER NOT NULL DEFAULT 50000,
max_file_bytes INTEGER NOT NULL DEFAULT 268435456, -- 256 MiB
max_total_bytes INTEGER NOT NULL DEFAULT 2147483648, -- 2 GiB
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- API keys. The id is the public lookup handle (safe to display); secret_hash is
-- sha256 of the secret half of the token. See internal/auth/token.go for why a
-- plain hash is the right choice for a 256-bit random secret.
CREATE TABLE api_keys (
id TEXT NOT NULL PRIMARY KEY, -- 16 chars of base32, no padding
secret_hash BLOB NOT NULL, -- sha256(secret), 32 raw bytes
scope TEXT NOT NULL CHECK (scope IN ('admin','project')),
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
expires_at INTEGER,
last_used_at INTEGER,
revoked_at INTEGER,
-- An admin key is not scoped to a project and a project key must be.
CHECK ((scope = 'admin' AND project_id IS NULL)
OR (scope = 'project' AND project_id IS NOT NULL))
) WITHOUT ROWID;
CREATE INDEX api_keys_project ON api_keys(project_id) WHERE project_id IS NOT NULL;
CREATE TABLE deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE, -- "dpl_" + 16 lowercase hex
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK (state IN
('pending','uploading','ready','failed','deleting')),
active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0,1)),
file_count INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
created_by_key TEXT REFERENCES api_keys(id) ON DELETE SET NULL,
meta TEXT NOT NULL DEFAULT '{}', -- JSON: git_sha/branch/ci_url/actor
error TEXT,
created_at INTEGER NOT NULL,
finalized_at INTEGER,
activated_at INTEGER,
deactivated_at INTEGER
);
-- "At most one active deployment per project" is an invariant, so the database
-- enforces it rather than the application. A projects.active_deployment_id
-- column would have needed a circular foreign key and deferred constraints to
-- say the same thing.
CREATE UNIQUE INDEX deployments_one_active ON deployments(project_id) WHERE active = 1;
CREATE INDEX deployments_proj_created ON deployments(project_id, created_at DESC);
CREATE INDEX deployments_state_created ON deployments(state, created_at);
CREATE INDEX deployments_gc ON deployments(deactivated_at)
WHERE active = 0 AND deactivated_at IS NOT NULL;
CREATE TABLE blobs (
digest BLOB NOT NULL PRIMARY KEY, -- sha256, 32 raw bytes
size INTEGER NOT NULL,
present INTEGER NOT NULL DEFAULT 0 CHECK (present IN (0,1)),
refcount INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_ref_at INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX blobs_gc ON blobs(last_ref_at) WHERE refcount = 0;
CREATE INDEX blobs_pending ON blobs(created_at) WHERE present = 0;
-- The file manifest of each deployment.
--
-- encoding is always '' in v1. It is part of the primary key so that a
-- pre-compressed sibling (.br/.gz) can be added later without a table rewrite.
CREATE TABLE deployment_files (
deployment_id INTEGER NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- slash-separated, fs.ValidPath
encoding TEXT NOT NULL DEFAULT '', -- '' | 'gzip' | 'br'
digest BLOB NOT NULL REFERENCES blobs(digest) ON DELETE RESTRICT,
size INTEGER NOT NULL,
PRIMARY KEY (deployment_id, path, encoding)
) WITHOUT ROWID;
CREATE INDEX deployment_files_digest ON deployment_files(digest);
-- Refcounts are maintained by the database so that no code path can forget.
--
-- Caution: ON DELETE CASCADE does not fire these triggers unless
-- recursive_triggers is ON (it is, see internal/store/db.go), and relying on
-- that alone is fragile — delete the manifest rows explicitly before deleting a
-- deployment and let the cascade be the backstop. fsck recomputes every refcount
-- from deployment_files and reports drift.
CREATE TRIGGER deployment_files_ai AFTER INSERT ON deployment_files BEGIN
UPDATE blobs SET refcount = refcount + 1, last_ref_at = unixepoch() WHERE digest = NEW.digest;
END;
CREATE TRIGGER deployment_files_ad AFTER DELETE ON deployment_files BEGIN
UPDATE blobs SET refcount = refcount - 1, last_ref_at = unixepoch() WHERE digest = OLD.digest;
END;