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