Skip to the content

Capsid menu

app/lib/cv/save.server.ts

207 lines and 21 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of save.server.ts
FunctionLinesComplexityCoveredCRAPMutation
constructor33 to 361No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
registeredCvFile40 to 502No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:46>46 to 461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
compile53 to 551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
judge61 to 714No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:65>65 to 651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:65>65 to 651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readCvFile74 to 853No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
searchRecords88 to 932No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
refreshCvSearch99 to 1021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
writeCvRow104 to 1141No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
deleteCvRow117 to 1191No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rowIsCurrent122 to 1251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
decide128 to 1322No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
saveCvFile143 to 2289No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rowIsCurrent170 to 1701No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
commit171 to 1771No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
write201 to 2051No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
recordDivergence208 to 2081No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:222>222 to 2221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:223>223 to 2232No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/db/cv.ts, app/db/publications.ts, app/lib/cache-purge.server.ts, app/lib/carrel/errors.server.ts, app/lib/content-pages.mjs, app/lib/content/load-pipeline.server.ts, app/lib/cv/compile.mjs, app/lib/cv/parse.mjs, app/lib/cv/pdf.server.ts, app/lib/editor/converge.mjs, app/lib/editor/github.server.ts, app/lib/editor/publish-policy.mjs, app/lib/editor/write-path.server.ts, app/lib/search/records.mjs, app/lib/search/replace.server.ts. Imported by app/lib/carrel/cv-handler.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts, app/lib/publications/save.server.ts.

Source

// The one CV save, called by the site-api adapter for Carrel (app/lib/carrel/cv-handler.server.ts), and the reads
// the operator's list_cv and get_cv tools answer. The CV is edited through Carrel and nothing else: there is no
// save tool and no admin editor. A save judges the file the way CI does (compile.mjs), joins it to the other
// files D1 holds and to the publications, compiles the page its search records come from, refuses it with every
// message if any of that fails, commits it to the repository (unless it is byte-identical, commitUnlessUnchanged),
// then writes its D1 row and the CV's search records, so the page, its twin and its charts change on the next
// request with no build or deploy. The commit is never reverted when the D1 write fails; the error
// says the commit landed and how to repair the row. A save cannot create a file: the set of files is structure
// and lives in CV_FILES.

import matter from "gray-matter";

import { listCvRows, readCv } from "~/db/cv";
import { listPublishedPublications } from "~/db/publications";
import { ContentInvalid } from "~/lib/carrel/errors.server";
import { purgeCv, type PurgeOutcome } from "~/lib/cache-purge.server";
import { loadPipeline } from "~/lib/content/load-pipeline.server";
import { contentPageSearchUid } from "~/lib/content-pages.mjs";
import { compileCv, compileCvFile, compileCvPage } from "~/lib/cv/compile.mjs";
import { CV_FILES, cvFileFor, cvSourcePath } from "~/lib/cv/parse.mjs";
import { regenerateCvPdfAfterSave, type CvPdfOutcome } from "~/lib/cv/pdf.server";
import { convergeWithRetry } from "~/lib/editor/converge.mjs";
import { blobGuard, commitFiles, readFile } from "~/lib/editor/github.server";
import { commitUnlessUnchanged, UNCHANGED_NOTE } from "~/lib/editor/write-path.server";
import { PolicyError, WRITE_CAPABILITIES, type Actor } from "~/lib/editor/publish-policy.mjs";
import { recordsForPages } from "~/lib/search/records.mjs";
import { replaceSearchRecords } from "~/lib/search/replace.server";

type CvEnv = Env & { GITHUB_TOKEN?: string };

/** A file the validator refused: 422, with every message, so the caller can fix its own edit. */
export class CvInvalid extends ContentInvalid {
  constructor(slug: string, errors: string[]) {
    super(`The CV file "${slug}" was not saved: it fails ${errors.length} check(s). Nothing was committed.`, errors);
    this.name = "CvInvalid";
  }
}

/** The file a slug names. A slug outside CV_FILES is refused before any read or write: a save cannot create a file. */
function registeredCvFile(slug: string) {
  const clean = slug.trim();
  const file = cvFileFor(clean);
  if (!file) {
    throw new CvInvalid(clean, [
      `"${clean}" is not a CV file. A save cannot create a new one: the set of files is structure, so a new ` +
        `section is added to CV_FILES (app/lib/cv/parse.mjs) in code first. The files are ${CV_FILES.map((f) => f.slug).join(", ")}.`,
    ]);
  }
  return { slug: clean, file, path: cvSourcePath(clean) };
}

/** The one compile door for a file: the save and sync_cv both read a file through it (judged alone, against D1's papers). */
export async function compile(env: CvEnv, slug: string, raw: string) {
  return compileCvFile({ slug, raw, publications: await listPublishedPublications(env) });
}

/**
 * Compile, then the rules that need the whole CV, then the page the search records come from: every message
 * the file has now, which is what CI and a save both refuse. The whole is D1's other files with this one in place.
 */
async function judge(env: CvEnv, slug: string, raw: string) {
  const publications = await listPublishedPublications(env);
  const compiled = await compileCvFile({ slug, raw, publications });
  if (!compiled.ok) return compiled;
  const others = (await listCvRows(env)).filter((row) => row.slug !== slug).map((row) => row.record);
  const whole = compileCv({ records: [...others, compiled.record], publications });
  if (!whole.ok) return whole;
  const { renderBody, findWideDashes } = await loadPipeline();
  const page = await compileCvPage({ cv: whole.cv, pipeline: { renderBody, findWideDashes } });
  return page.ok ? compiled : page;
}

/** The file, and its front matter as parsed, with what the validator says of it now. */
export async function readCvFile(env: CvEnv, slug: string) {
  const { path } = registeredCvFile(slug);
  const existing = await readFile(env, path);
  if (!existing) return null;
  const judged = await judge(env, slug, existing.content);
  return {
    slug,
    raw: existing.content,
    record: matter(existing.content).data,
    errors: judged.ok ? [] : judged.errors,
  };
}

/** The CV's search records, compiled from D1's rows as they stand. */
async function searchRecords(env: CvEnv) {
  const { renderBody, findWideDashes } = await loadPipeline();
  const page = await compileCvPage({ cv: await readCv(env), pipeline: { renderBody, findWideDashes } });
  if (!page.ok) throw new Error(`The CV page does not compile: ${page.errors.join("; ")}`);
  return recordsForPages([page.searchInput]);
}

/**
 * Rewrites the CV's search records from D1's rows. They depend on every file and on the papers the CV cites,
 * so the file save, sync_cv and a publication save all call it after they write.
 */
export async function refreshCvSearch(env: CvEnv) {
  const records = await searchRecords(env);
  await env.DB.batch(replaceSearchRecords(env.DB, contentPageSearchUid("/cv"), records));
}

export async function writeCvRow(env: CvEnv, compiled: Extract<Awaited<ReturnType<typeof compile>>, { ok: true }>) {
  await env.DB.prepare(
    `INSERT INTO cv (slug, type, record, source_path, source_blob_sha, synced_at)
     VALUES (?1, ?2, ?3, ?4, ?5, unixepoch())
     ON CONFLICT(slug) DO UPDATE SET type = excluded.type, record = excluded.record,
       source_path = excluded.source_path, source_blob_sha = excluded.source_blob_sha,
       synced_at = excluded.synced_at`,
  )
    .bind(compiled.record.slug, compiled.record.type, JSON.stringify(compiled.record), compiled.sourcePath, compiled.sourceBlobSha)
    .run();
}

/** The row of a file that is gone: the one removal sync_cv makes. */
export async function deleteCvRow(env: CvEnv, row: { slug: string }) {
  await env.DB.prepare(`DELETE FROM cv WHERE slug = ?1`).bind(row.slug).run();
}

/** True when D1's row for the file was compiled from exactly this file (the git blob sha matches). */
async function rowIsCurrent(env: CvEnv, slug: string, blobSha: string) {
  const row = await env.DB.prepare("SELECT source_blob_sha FROM cv WHERE slug = ?1").bind(slug).first<{ source_blob_sha: string | null }>();
  return row?.source_blob_sha === blobSha;
}

/** A credential that may not write is refused; the CV has no draft state, so there is no first-publication rule. */
function decide(actor: Actor) {
  if (!WRITE_CAPABILITIES[actor.kind].write) {
    throw new PolicyError("Refused: this credential is READ ONLY and may not save a CV file.", "smoke-is-read-only");
  }
}

/**
 * What the save says of the PDF it starts. "scheduled": handed to `background` (ctx.waitUntil), so the save's
 * answer does not wait for Browser Run and a failure is an alert plus the cv-pdf-drift check, never this answer.
 * Without a `background` the render is awaited here and the outcome (or the failure) is in the result. Either way
 * the commit and the D1 row stand.
 */
export type CvPdfReport = "scheduled" | "not-needed" | { rendered: CvPdfOutcome } | { failed: string };

/** The CV save. */
export async function saveCvFile(
  env: CvEnv,
  options: {
    slug: string;
    raw: string;
    expectedHeadSha?: string | null;
    /** The blob sha of this file as Carrel loaded it; undefined means no check. */
    expectedBlobSha?: string | null;
    actor: Actor;
    /** ctx.waitUntil, from the caller that has one: the PDF render runs after the response. */
    background?: (work: Promise<unknown>) => void;
  },
) {
  const { raw, actor } = options;
  const { slug, path } = registeredCvFile(options.slug);
  const existing = await readFile(env, path);
  if (!existing) throw new CvInvalid(slug, [`${path} is missing from the repository, so the CV file cannot be saved; restore it first`]);

  const compiled = await judge(env, slug, raw);
  if (!compiled.ok) throw new CvInvalid(slug, compiled.errors);
  decide(actor);

  const tag = actor.kind === "operator" ? ` [operator:${actor.id}]` : actor.kind === "carrel" ? ` [carrel:${actor.changeId}]` : "";
  // After the gate, so a file that fails validation is still refused even when it is unchanged.
  const written = await commitUnlessUnchanged(env, {
    existing,
    raw,
    rowIsCurrent: () => rowIsCurrent(env, slug, compiled.sourceBlobSha),
    commit: () =>
      commitFiles(env, {
        expectedHeadSha: options.expectedHeadSha,
        expectedBlobs: blobGuard(path, options.expectedBlobSha),
        message: `Update CV: ${slug}${tag}`,
        changes: [{ path, content: raw }],
      }),
  });
  if (written.action === "noop") {
    return {
      slug,
      commitSha: written.commitSha,
      sourceBlobSha: compiled.sourceBlobSha,
      unchanged: true,
      note: UNCHANGED_NOTE,
      purged: null as PurgeOutcome,
      pdf: "not-needed" as CvPdfReport,
    };
  }
  const { commitSha, blobShas } = written;
  if (blobShas[path] && blobShas[path] !== compiled.sourceBlobSha) {
    throw new Error(
      `The CV file "${slug}" WAS committed as ${commitSha}, but the committed bytes (${blobShas[path]}) are not ` +
        `the bytes compiled (${compiled.sourceBlobSha}), so its row was not updated. Run the content sync to ` +
        `rebuild the row from the repository.`,
    );
  }

  let purged: PurgeOutcome = false;
  await convergeWithRetry({
    write: async () => {
      await writeCvRow(env, compiled);
      await refreshCvSearch(env);
      purged = await purgeCv(`save CV ${slug}`);
    },
    // Nothing kept in KV for the CV: the thrown error names the commit and the repair (the content sync), and
    // the next ship's sync converges the row from the repository anyway.
    recordDivergence: async () => undefined,
    slug,
    commitSha,
  });

  // After the row, so the render reads the data the save just wrote. Never inside convergeWithRetry: a render
  // failure must not retry or fail the D1 write, and the commit is not undone by it.
  let pdf: CvPdfReport;
  const render = regenerateCvPdfAfterSave(env, `save CV ${slug}`);
  if (options.background) {
    options.background(render);
    pdf = "scheduled";
  } else {
    pdf = await render.then(
      (rendered): CvPdfReport => ({ rendered }),
      (error): CvPdfReport => ({ failed: error instanceof Error ? error.message : String(error) }),
    );
  }

  return { slug, commitSha, sourceBlobSha: compiled.sourceBlobSha, unchanged: written.action !== "commit", purged, pdf };
}