syntaxai/tdd.md · main · src / c13_database.ts

c13_database.ts 376 lines · 12153 bytes raw
import { Database } from "bun:sqlite";
import type { ProjectConfig, TestRunner, ProjectRow } from "./a31_project_config.ts";
import type { SxDocument, SxDocumentSummary } from "./a31_sxdoc.ts";
import { SX_DOC_VERSION } from "./a31_sxdoc.ts";

const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:";

let db: Database | null = null;

const getDb = (): Database => {
  if (db) return db;
  db = new Database(DB_PATH, { create: true });
  db.exec(`
    CREATE TABLE IF NOT EXISTS runs (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      owner TEXT NOT NULL,
      repo TEXT NOT NULL,
      head_sha TEXT NOT NULL,
      judged_at INTEGER NOT NULL,
      verdict_json TEXT NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_runs_owner_repo
      ON runs(owner, repo, judged_at DESC);

    CREATE TABLE IF NOT EXISTS projects (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      registered_by TEXT NOT NULL,
      repo_owner TEXT NOT NULL,
      repo_name TEXT NOT NULL,
      test_runner TEXT NOT NULL DEFAULT 'none',
      tracked_branches TEXT NOT NULL,
      display_name TEXT,
      team TEXT,
      registered_at INTEGER NOT NULL,
      status TEXT NOT NULL DEFAULT 'active',
      UNIQUE(repo_owner, repo_name)
    );
    CREATE INDEX IF NOT EXISTS idx_projects_registered_by
      ON projects(registered_by);

    CREATE TABLE IF NOT EXISTS sx_documents (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      slug TEXT NOT NULL,
      type TEXT NOT NULL,
      title TEXT NOT NULL,
      doc_json TEXT NOT NULL,
      doc_version INTEGER NOT NULL,
      hash TEXT NOT NULL,
      status TEXT NOT NULL DEFAULT 'published',
      primary_tag TEXT,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      UNIQUE(slug, type)
    );
    CREATE INDEX IF NOT EXISTS idx_sx_documents_type_updated
      ON sx_documents(type, updated_at DESC);
  `);

  // Note: a `proposals` table existed in earlier versions of this CMS
  // for queueing non-admin edit submissions and recording admin
  // direct-writes for audit. Both roles are now served by Forgejo:
  // admin edits become real commits via c14_forgejo.commitFile, and
  // non-admin proposals are out of scope until they become Forgejo
  // PRs. Legacy data on existing volumes is left untouched (drops
  // would throw on volumes from before the proposals tabel existed
  // anyway). New deployments simply never create the table.

  return db;
};

export type Mode = "strict" | "pragmatic" | "learning";

export interface StepVerdict {
  stepId: string;
  redSha: string | null;
  greenSha: string | null;
  redFailed: boolean | null;
  greenPassed: boolean | null;
  // Whether the kata's authoritative hidden tests pass against the agent's
  // implementation at the green commit. null when no hidden tests exist
  // for the step (unknown kata, or step not registered with the spec).
  hiddenPassed: boolean | null;
  status:
    | "verified"
    | "discipline-only"
    | "no-green"
    | "red-did-not-fail"
    | "green-did-not-pass"
    | "hidden-tests-failed"
    | "test-deleted"
    // Trace-only mode: tests not executed, only commit discipline checked.
    // Used when test_runner: "none" — language-agnostic, useful as a
    // CI gate on real projects where Bun can't run the test suite.
    | "trace-verified"
    | "trace-tests-shrunk";
  scoreDelta: number;
  // Coach-style explanation of the verdict — what happened, why the score
  // is what it is, and (when relevant) how to improve next time.
  explanation: string;
}

export interface RefactorVerdict {
  sha: string;
  stepId: string | null;
  testsPassed: boolean;
  scoreDelta: number;
  explanation: string;
}

export interface Verdict {
  headSha: string;
  mode: Mode;
  steps: StepVerdict[];
  refactors: RefactorVerdict[];
  totalScore: number;
  judgedAt: number;
}

export const saveRun = (owner: string, repo: string, verdict: Verdict): void => {
  getDb().run(
    `INSERT INTO runs (owner, repo, head_sha, judged_at, verdict_json) VALUES (?, ?, ?, ?, ?)`,
    [owner, repo, verdict.headSha, verdict.judgedAt, JSON.stringify(verdict)],
  );
};

export const latestRun = (owner: string, repo: string): Verdict | null => {
  const row = getDb()
    .query<{ verdict_json: string }, [string, string]>(
      `SELECT verdict_json FROM runs WHERE owner = ? AND repo = ? ORDER BY judged_at DESC LIMIT 1`,
    )
    .get(owner, repo);
  if (!row) return null;
  return JSON.parse(row.verdict_json) as Verdict;
};

// ProjectRow is now defined in Layer 0 (c31_project_config) alongside
// the rest of the project-config types, so c51 render code can
// reference it without importing from Layer 2.

interface ProjectDbRow {
  id: number;
  registered_by: string;
  repo_owner: string;
  repo_name: string;
  test_runner: string;
  tracked_branches: string;
  display_name: string | null;
  team: string | null;
  registered_at: number;
  status: string;
}

const rowToProject = (r: ProjectDbRow): ProjectRow => ({
  id: r.id,
  registeredBy: r.registered_by,
  repoOwner: r.repo_owner,
  repoName: r.repo_name,
  testRunner: (r.test_runner === "bun" ? "bun" : "none") as TestRunner,
  trackedBranches: JSON.parse(r.tracked_branches) as string[],
  displayName: r.display_name,
  team: r.team,
  registeredAt: r.registered_at,
  status: r.status === "paused" ? "paused" : "active",
});

// Inserts or updates a project. Re-registering the same repo refreshes
// its config (test_runner, tracked_branches, display_name, team) without
// duplicating the row. Returns the stored project.
export const upsertProject = (
  registeredBy: string,
  repoOwner: string,
  repoName: string,
  config: ProjectConfig,
): ProjectRow => {
  const now = Date.now();
  const branches = JSON.stringify(config.tracked_branches);
  const display = config.display_name ?? null;
  const team = config.team ?? null;
  getDb().run(
    `INSERT INTO projects (registered_by, repo_owner, repo_name, test_runner, tracked_branches, display_name, team, registered_at, status)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
     ON CONFLICT(repo_owner, repo_name) DO UPDATE SET
       test_runner = excluded.test_runner,
       tracked_branches = excluded.tracked_branches,
       display_name = excluded.display_name,
       team = excluded.team,
       status = 'active'`,
    [registeredBy, repoOwner, repoName, config.test_runner, branches, display, team, now],
  );
  const row = getDb()
    .query<ProjectDbRow, [string, string]>(
      `SELECT * FROM projects WHERE repo_owner = ? AND repo_name = ?`,
    )
    .get(repoOwner, repoName);
  if (!row) throw new Error("project upsert returned no row");
  return rowToProject(row);
};

export const getProject = (repoOwner: string, repoName: string): ProjectRow | null => {
  const row = getDb()
    .query<ProjectDbRow, [string, string]>(
      `SELECT * FROM projects WHERE repo_owner = ? AND repo_name = ?`,
    )
    .get(repoOwner, repoName);
  return row ? rowToProject(row) : null;
};

export const listActiveProjects = (): ProjectRow[] => {
  const rows = getDb()
    .query<ProjectDbRow, []>(
      `SELECT * FROM projects WHERE status = 'active' ORDER BY registered_at DESC`,
    )
    .all();
  return rows.map(rowToProject);
};

// ─── sx_documents ────────────────────────────────────────────────────────
// Canonical store for sxdoc-backed content (pages + posts). Sibling git
// commits in content/{slug}.{md,sxdoc.json} mirror this table for audit;
// the SQLite row is the source of truth (canon B, locked in plan.md).

export interface SxDocumentRow {
  id: number;
  slug: string;
  type: "page" | "post";
  title: string;
  doc: SxDocument;
  status: "published" | "draft";
  primaryTag: string | null;
  createdAt: number;
  updatedAt: number;
}

// SxDocumentSummary is the public summary shape; defined in Layer 0
// (c31_sxdoc) so render code can reference it without crossing the
// SAMA v2 import direction.

interface SxDocumentDbRow {
  id: number;
  slug: string;
  type: string;
  title: string;
  doc_json: string;
  doc_version: number;
  hash: string;
  status: string;
  primary_tag: string | null;
  created_at: number;
  updated_at: number;
}

interface SxDocumentSummaryDbRow {
  id: number;
  slug: string;
  type: string;
  title: string;
  status: string;
  primary_tag: string | null;
  updated_at: number;
}

const hashDoc = (json: string): string => {
  const h = new Bun.CryptoHasher("sha1");
  h.update(json);
  return h.digest("hex").slice(0, 16);
};

const rowToSxDocument = (r: SxDocumentDbRow): SxDocumentRow => ({
  id: r.id,
  slug: r.slug,
  type: r.type === "post" ? "post" : "page",
  title: r.title,
  doc: JSON.parse(r.doc_json) as SxDocument,
  status: r.status === "draft" ? "draft" : "published",
  primaryTag: r.primary_tag,
  createdAt: r.created_at,
  updatedAt: r.updated_at,
});

// Upsert a sxdoc keyed by (slug, type). created_at is preserved on
// updates so we can sort by first-publish elsewhere if needed.
export const saveDocument = (input: {
  slug: string;
  type: "page" | "post";
  title: string;
  doc: SxDocument;
  status?: "published" | "draft";
  primaryTag?: string | null;
}): void => {
  const now = Date.now();
  const json = JSON.stringify(input.doc);
  const hash = hashDoc(json);
  const status = input.status ?? "published";
  const primaryTag = input.primaryTag ?? null;
  getDb().run(
    `INSERT INTO sx_documents
       (slug, type, title, doc_json, doc_version, hash, status, primary_tag, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     ON CONFLICT(slug, type) DO UPDATE SET
       title = excluded.title,
       doc_json = excluded.doc_json,
       doc_version = excluded.doc_version,
       hash = excluded.hash,
       status = excluded.status,
       primary_tag = excluded.primary_tag,
       updated_at = excluded.updated_at`,
    [input.slug, input.type, input.title, json, SX_DOC_VERSION, hash, status, primaryTag, now, now],
  );
};

export const loadDocument = (slug: string, type: "page" | "post"): SxDocumentRow | null => {
  const row = getDb()
    .query<SxDocumentDbRow, [string, string]>(
      `SELECT * FROM sx_documents WHERE slug = ? AND type = ?`,
    )
    .get(slug, type);
  return row ? rowToSxDocument(row) : null;
};

export const deleteDocument = (slug: string, type: "page" | "post"): number => {
  const r = getDb().run(
    `DELETE FROM sx_documents WHERE slug = ? AND type = ?`,
    [slug, type],
  );
  return r.changes;
};

// Summary rows for the admin list / archive pages. Excludes doc_json so
// listing a thousand documents doesn't drag a megabyte of JSON through
// the query layer.
export const listDocuments = (filter: {
  type?: "page" | "post";
  status?: "published" | "draft";
} = {}): SxDocumentSummary[] => {
  const where: string[] = [];
  const params: string[] = [];
  if (filter.type) { where.push("type = ?"); params.push(filter.type); }
  if (filter.status) { where.push("status = ?"); params.push(filter.status); }
  const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
  const rows = getDb()
    .query<SxDocumentSummaryDbRow, string[]>(
      `SELECT id, slug, type, title, status, primary_tag, updated_at
       FROM sx_documents ${whereClause}
       ORDER BY updated_at DESC`,
    )
    .all(...params);
  return rows.map((r) => ({
    id: r.id,
    slug: r.slug,
    type: r.type === "post" ? "post" : "page",
    title: r.title,
    status: r.status === "draft" ? "draft" : "published",
    primaryTag: r.primary_tag,
    updatedAt: r.updated_at,
  }));
};

// Latest verdict per (owner, repo) across all agents — drives the
// leaderboard and the /agents index.
export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => {
  const rows = getDb()
    .query<{ owner: string; repo: string; verdict_json: string }, []>(
      `SELECT owner, repo, verdict_json FROM runs r1
       WHERE judged_at = (
         SELECT MAX(judged_at) FROM runs r2
         WHERE r2.owner = r1.owner AND r2.repo = r1.repo
       )`,
    )
    .all();
  return rows.map((r) => ({
    owner: r.owner,
    repo: r.repo,
    verdict: JSON.parse(r.verdict_json) as Verdict,
  }));
};