Skip to the content

Capsid menu

app/lib/carrel/phage-handler.server.ts

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

Functions

The functions of phage-handler.server.ts
FunctionLinesComplexityCoveredCRAPMutation
phageHandler22 to 1161No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
write24 to 383No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
owns41 to 411No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
list46 to 621No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:48>48 to 581No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:60>60 to 602No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:61>61 to 613No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
get64 to 825No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
saveDraft85 to 916No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
publish93 to 994No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
unpublish101 to 1061No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
revisions108 to 1102No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
revisionSource112 to 1142No 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/carrel/content-kinds.server.ts, app/lib/carrel/errors.server.ts, app/lib/editor/github.server.ts, app/lib/phages/compile.mjs, app/lib/phages/save.server.ts. Imported by app/lib/carrel/site-adapter.server.ts.

Source

// The phage kind of Carrel's /content group (docs/PHAGES.md): ids are `phage.<slug>` (the registry encodes
// them), the slug being the phage's name in lower case (content/phages/acorn15.md). Every write is savePhage,
// the one path a phage reaches the repository by, so a phage saved through Carrel is validated by the code CI
// runs, committed, written to D1, drawn into /research/phages and live on the next request with no deploy.
// There is no editor in the site's admin for this kind and no operator save tool. A phage has no draft state:
// it is public when it is saved, so saving and publishing are the same write and unpublishing is refused.

import { RefusedError, VersionConflictError } from "@dustinedwards/site-api";
import type { ContentSummary, WriteResult } from "@dustinedwards/site-api";

import { listPhageRows } from "~/db/phages";
import type { ContentKindHandler } from "~/lib/carrel/content-kinds.server";
import { asSiteApiError } from "~/lib/carrel/errors.server";
import { fileRevisions, fileRevisionSource, readFile } from "~/lib/editor/github.server";
import { PHAGES_PAGE_PATH, phagePath } from "~/lib/phages/compile.mjs";
import { PHAGE_SLUG, savePhage } from "~/lib/phages/save.server";

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

/** A revision's version is a commit sha; an item's own version is the blob sha of its file. */

export function phageHandler(env: PhageEnv): ContentKindHandler {
  /** One write through savePhage as the Carrel actor, answered in the package's terms. */
  async function write(slug: string, changeId: string, raw: string, expectedVersion: string | null): Promise<WriteResult> {
    try {
      const saved = await savePhage(env, {
        slug,
        raw,
        expectedBlobSha: expectedVersion ?? undefined,
        isNew: expectedVersion === null,
        actor: { kind: "carrel", changeId },
      });
      return { id: slug, version: saved.sourceBlobSha, status: "published", changeId };
    } catch (error) {
      // A PhageInvalid carries every message the validator gave; asSiteApiError passes them on.
      return asSiteApiError(env, error, phagePath(slug));
    }
  }

  /** A slug that is not a lower-case name owns no file, so a read of it is a missing id and never a path to read. */
  const owns = (slug: string) => PHAGE_SLUG.test(slug);

  return {
    kind: "phage",

    async list(query) {
      const needle = query.q?.toLowerCase();
      const summaries: ContentSummary[] = (await listPhageRows(env)).map((row) => ({
        id: row.slug,
        kind: "phage",
        title: row.name,
        status: "published",
        // A phage has no page of its own: it is a row of the table, and a section, on the phages page.
        path: `${PHAGES_PAGE_PATH}#${row.slug}`,
        publishAt: null,
        publishedAt: null,
        updatedAt: row.syncedAt.toISOString(),
      }));
      return summaries
        .filter((s) => !query.status || s.status === query.status)
        .filter((s) => !needle || s.title.toLowerCase().includes(needle) || s.id.includes(needle));
    },

    async get(slug) {
      if (!owns(slug)) return null;
      const file = await readFile(env, phagePath(slug));
      if (!file) return null;
      const row = await env.DB.prepare(`SELECT name, synced_at FROM phages WHERE slug = ?1`).bind(slug).first<{ name: string; synced_at: number }>();
      return {
        id: slug,
        kind: "phage",
        title: row?.name ?? slug,
        status: "published",
        path: `${PHAGES_PAGE_PATH}#${slug}`,
        publishAt: null,
        publishedAt: null,
        updatedAt: row ? new Date(row.synced_at * 1000).toISOString() : null,
        format: "markdown",
        source: file.content,
        version: file.sha,
      };
    },

    // Creates a phage (no version) or edits one (its version), as every kind does; either way it is public.
    async saveDraft(slug, input) {
      if (!owns(slug)) throw new RefusedError(`"${slug}" is not a phage file key; a phage is named for its name in lower case, such as acorn15.`);
      const file = await readFile(env, phagePath(slug));
      if (input.expectedVersion === null && file) throw new VersionConflictError(file.sha);
      if (input.expectedVersion !== null && !file) throw new VersionConflictError(null);
      return write(slug, input.changeId, input.source, input.expectedVersion);
    },

    async publish(slug, input) {
      if (!owns(slug)) throw new RefusedError(`"${slug}" is not a phage file key; a phage is named for its name in lower case, such as acorn15.`);
      // Read even when source is sent: a version for an id that does not exist is a conflict.
      const file = await readFile(env, phagePath(slug));
      if (!file) throw new VersionConflictError(null);
      return write(slug, input.changeId, input.source ?? file.content, input.expectedVersion);
    },

    async unpublish() {
      throw new RefusedError(
        "A phage has no draft state, so it cannot be unpublished. To remove a phage, delete its file from " +
          "content/phages/ in a commit; the next content sync removes its row and redraws the table.",
      );
    },

    revisions(slug) {
      return fileRevisions(env, owns(slug) ? phagePath(slug) : null);
    },

    revisionSource(slug, version) {
      return fileRevisionSource(env, owns(slug) ? phagePath(slug) : null, version);
    },
  };
}