-- 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;