Skip to the content

Capsid menu

app/lib/phages/save.server.ts

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

Functions

The functions of save.server.ts
FunctionLinesComplexityCoveredCRAPMutation
constructor36 to 391No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
githubHost47 to 491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
paper48 to 481No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
compile52 to 551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
registered60 to 652No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readPhage68 to 744No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
otherPhages77 to 791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:78>78 to 781No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
refreshPage87 to 1004No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
writePhageRow103 to 1141No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
writeRow120 to 1241No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
deletePhageRow127 to 1301No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rowIsCurrent133 to 1381No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
savePhage152 to 21413No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isDraft170 to 1701No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rowIsCurrent177 to 1771No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
commit178 to 1842No 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

Imports

It imports app/db/phages.ts, app/lib/cache-purge.server.ts, app/lib/carrel/errors.server.ts, app/lib/content/load-pipeline.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/pages/compile.mjs, app/lib/pages/save.server.ts, app/lib/phages/compile.mjs, app/lib/publications/parse.mjs. Imported by app/lib/carrel/phage-handler.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts.

Source

// The one phage save, called by the site-api adapter for Carrel (app/lib/carrel/phage-handler.server.ts), and
// the read the operator's get_phage tool answers. The phage table is edited through Carrel and nothing else:
// there is no save tool and no admin editor. A save compiles the file the way CI does (compile.mjs), refuses
// it with every message if it fails, commits it to the repository (unless it is byte-identical,
// commitUnlessUnchanged), then writes its D1 row and re-derives the page that is drawn from the rows, so the
// table changes on the next request with no build or deploy. The commit is never reverted when a derived write
// fails: the error says the commit landed and how to repair it.
//
// /research/phages is compiled from these rows (its table, its sections, its twin, its search records), and
// those live in the page's own row. So a write refreshes that page row FIRST, through the page compile door,
// and the phage's own row LAST: the phage row's blob sha is what the drift check reads as "current", so a
// failure between the two leaves the phage reading stale and the repair re-runs both. The purge is the pages
// tag, because the page's HTML and its twin both embed the table.

import { listPhages } from "~/db/phages";
import { ContentInvalid } from "~/lib/carrel/errors.server";
import { purgePhages, type PurgeOutcome } from "~/lib/cache-purge.server";
import { loadPipeline } from "~/lib/content/load-pipeline.server";
import { convergeWithRetry } from "~/lib/editor/converge.mjs";
import { blobGuard, commitFiles, readFile } from "~/lib/editor/github.server";
import { decideFileWrite, type Actor } from "~/lib/editor/publish-policy.mjs";
import { commitUnlessUnchanged, UNCHANGED_NOTE } from "~/lib/editor/write-path.server";
import { pageSlug, pageSourcePath } from "~/lib/pages/compile.mjs";
import { PUBLICATIONS_DIR } from "~/lib/publications/parse.mjs";
import { compile as compilePageFile, writeRow as writePageRow } from "~/lib/pages/save.server";

import { PHAGES_PAGE_PATH, PHAGE_FACT_PAGES, compilePhage, phagePath, phageSetErrors, phageSlug, sortPhages, type Phage, type PhageHost } from "./compile.mjs";

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

/** A phage's file key is its name in lower case, which is also what keeps an id from naming any other path. */
export const PHAGE_SLUG = /^[a-z][a-z0-9]*$/;

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

/**
 * The papers the site holds, asked of the repository, which is what CI asks too: a genome announcement is a paper
 * this site has a file, and so a page, for. Asking the repository rather than D1 keeps a phage save and a
 * sync_phages independent of whether the publications have been synced yet.
 */
function githubHost(env: PhageEnv): PhageHost {
  return { paper: async (slug) => (await readFile(env, `${PUBLICATIONS_DIR}/${slug}.md`)) !== null };
}

/** The one compile door: the save, get_phage and sync_phages all read a file through it. */
export async function compile(env: PhageEnv, slug: string, raw: string) {
  const { findWideDashes } = await loadPipeline();
  return compilePhage({ slug, raw, host: githubHost(env), pipeline: { findWideDashes } });
}

type Compiled = Extract<Awaited<ReturnType<typeof compile>>, { ok: true }>;

/** The slug of an id, or a refusal: nothing but a lower-case name names a phage file. */
function registered(slug: string) {
  if (!PHAGE_SLUG.test(slug)) {
    throw new PhageInvalid(slug, [`"${slug}" is not a phage file key; a phage's file is named for its name in lower case, such as acorn15`]);
  }
  return { slug, file: phagePath(slug) };
}

/** The phage the committed file holds, judged by the same validator, for get_phage. */
export async function readPhage(env: PhageEnv, slug: string) {
  const { file } = registered(slug);
  const existing = await readFile(env, file);
  if (!existing) return null;
  const compiled = await compile(env, slug, existing.content);
  return { slug, path: file, raw: existing.content, phage: compiled.ok ? compiled.phage : null, errors: compiled.ok ? [] : compiled.errors };
}

/** Every other phage's row, from D1: CI re-checks the whole set on the commit a save makes. */
async function otherPhages(env: PhageEnv, slug: string) {
  return (await listPhages(env)).filter((phage) => phageSlug(phage.name) !== slug);
}

/**
 * Re-derives the page the table is drawn into: its HTML, twin and search records, compiled from the page's file
 * with `phages` in place of whatever D1 holds. A page with no file has nothing derived to refresh, and that is an
 * error here: the table has nowhere to be drawn. A page whose file no longer compiles is an error too, never
 * skipped: the phage would otherwise read as converged while the page is stale.
 */
export async function refreshPage(env: PhageEnv, phages: Phage[]) {
  // The table's page, then every page that states a phage fact by token ({{phages.count}}): each is derived from
  // the same rows, so a phage write leaves none of them stale.
  for (const path of [PHAGES_PAGE_PATH, ...PHAGE_FACT_PAGES]) {
    const slug = pageSlug(path);
    const file = await readFile(env, pageSourcePath(slug));
    if (!file) throw new Error(`the page ${path} has no file (${pageSourcePath(slug)}), so what it draws from the phages has nowhere to be drawn`);
    const compiled = await compilePageFile(env, slug, file.content, undefined, sortPhages(phages));
    if (!compiled.ok) {
      throw new Error(`the page ${path} does not compile with these phages, so its table, twin and search record were not refreshed: ${compiled.errors.join("; ")}`);
    }
    await writePageRow(env, compiled);
  }
}

/** The phage's D1 row alone: the derived store the drift check reads. One row, in one statement. */
export async function writePhageRow(env: PhageEnv, compiled: Compiled) {
  const phage: Phage = compiled.phage;
  await env.DB.prepare(
    `INSERT INTO phages (slug, name, year, record, source_path, source_blob_sha, synced_at)
     VALUES (?1, ?2, ?3, ?4, ?5, ?6, unixepoch())
     ON CONFLICT(slug) DO UPDATE SET name = excluded.name, year = excluded.year, record = excluded.record,
       source_path = excluded.source_path, source_blob_sha = excluded.source_blob_sha,
       synced_at = excluded.synced_at`,
  )
    .bind(phageSlug(phage.name), phage.name, phage.year, compiled.record, compiled.sourcePath, compiled.sourceBlobSha)
    .run();
}

/**
 * Every derived store a phage feeds: the page's row first, then its own (see the header). Shared by the save,
 * so a repair that re-runs it writes what a save writes.
 */
async function writeRow(env: PhageEnv, compiled: Compiled) {
  const slug = phageSlug(compiled.phage.name);
  await refreshPage(env, [...(await otherPhages(env, slug)), compiled.phage]);
  await writePhageRow(env, compiled);
}

/** A phage whose file is gone: the page loses it first, then its row goes (the row is the drift marker). */
export async function deletePhageRow(env: PhageEnv, row: { slug: string }) {
  await refreshPage(env, await otherPhages(env, row.slug));
  await env.DB.prepare(`DELETE FROM phages WHERE slug = ?1`).bind(row.slug).run();
}

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

export type SavedPhage = {
  slug: string;
  commitSha: string;
  /** The git blob sha of the committed file: Carrel's version of the item. */
  sourceBlobSha: string;
  unchanged: boolean;
  created: boolean;
  purged: PurgeOutcome;
  note?: string;
};

/** validate -> policy -> commitUnlessUnchanged -> blob verify -> derived writes (convergeWithRetry) -> purge by tag. */
export async function savePhage(
  env: PhageEnv,
  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; isNew: boolean; actor: Actor },
): Promise<SavedPhage> {
  const { raw, actor } = options;
  const { slug, file } = registered(options.slug);
  const existing = await readFile(env, file);
  if (options.isNew && existing) throw new PhageInvalid(slug, [`the phage ${slug} already has a file (${file}); pass isNew false to edit it`]);
  if (!options.isNew && !existing) throw new PhageInvalid(slug, [`the phage ${slug} has no file yet; pass isNew to create it`]);

  const compiled = await compile(env, slug, raw);
  if (!compiled.ok) throw new PhageInvalid(slug, compiled.errors);
  // The set as it will stand: this phage against every other, so a record two phages claim is refused here.
  const setErrors = phageSetErrors([...(await otherPhages(env, slug)), compiled.phage]);
  if (setErrors.length > 0) throw new PhageInvalid(slug, setErrors);
  // A phage has no draft state: it is public when it is saved, so the policy reads it as published.
  decideFileWrite({ actor, noun: "phage", incomingDraft: false, priorRaw: existing?.content ?? null, isDraft: () => false });

  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(file, options.expectedBlobSha),
        message: `${options.isNew ? "Add" : "Update"} phage: ${compiled.phage.name}${tag}`,
        changes: [{ path: file, content: raw }],
      }),
  });
  if (written.action === "noop") {
    return { slug, commitSha: written.commitSha, sourceBlobSha: compiled.sourceBlobSha, unchanged: true, created: false, purged: null, note: UNCHANGED_NOTE };
  }
  const { commitSha } = written;
  const blobSha = written.blobShas[file];
  if (blobSha && blobSha !== compiled.sourceBlobSha) {
    throw new Error(
      `The phage "${slug}" WAS committed as ${commitSha}, but the committed bytes (${blobSha}) are not ` +
        `the bytes compiled (${compiled.sourceBlobSha}), so its page was not updated. Run the content sync to ` +
        `rebuild the row from the repository.`,
    );
  }

  let purged: PurgeOutcome = false;
  await convergeWithRetry({
    write: async () => {
      await writeRow(env, compiled);
      // The pages tag: the page's HTML embeds the table and its twin embeds the rows, and both carry it.
      purged = await purgePhages(`save phage ${slug}`);
    },
    // Nothing kept in KV for a phage: the thrown error names the commit and the repair (the content sync),
    // and the next ship's sync converges the rows from the repository anyway.
    recordDivergence: async () => undefined,
    slug,
    commitSha,
  });

  return { slug, commitSha, sourceBlobSha: compiled.sourceBlobSha, unchanged: written.action === "repair", created: options.isNew, purged };
}