Skip to the content

Capsid menu

app/lib/dictionary/save.server.ts

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

Functions

The functions of save.server.ts
FunctionLinesComplexityCoveredCRAPMutation
constructor32 to 351No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
githubHost39 to 541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
audio41 to 525No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:46>46 to 462No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
otherEntries57 to 591No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:58>58 to 581No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:58>58 to 581No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
compile62 to 651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
fileIsDraft70 to 721No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readDictionary75 to 865No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
refreshPage94 to 1033No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
writeEntryRow106 to 1242No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
writeRow130 to 1332No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
deleteRow136 to 1391No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rowIsCurrent142 to 1471No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
saveDictionaryEntry162 to 23412No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rowIsCurrent189 to 1891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
commit190 to 1962No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
write213 to 2171No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
recordDivergence220 to 2201No 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/dictionary.ts, app/lib/cache-purge.server.ts, app/lib/carrel/errors.server.ts, app/lib/content/load-pipeline.server.ts, app/lib/dictionary-entries.mjs, app/lib/dictionary/compile.mjs, app/lib/dictionary/parse.mjs, 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. Imported by app/lib/carrel/dictionary-handler.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts.

Source

// A dictionary entry's write path, run for the Carrel adapter (app/lib/carrel/dictionary-handler.server.ts):
// compile the file the way CI does (compile.mjs), refuse it with the validator's own messages if it fails,
// commit it unless it is unchanged (commitUnlessUnchanged), then write the derived stores, so the page 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.
//
// A page's lead is drawn from this entry at request time, but its markdown twin and its search record carry
// the entry too, and those live in the page's own row. So a write refreshes that page row FIRST, through the
// page compile door, and the entry's own row LAST: the entry row's blob sha is what the drift check reads as
// "current", so a failure between the two leaves the entry 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 entry.

import { listDictionaryRows } from "~/db/dictionary";
import { ContentInvalid } from "~/lib/carrel/errors.server";
import { purgePages, type PurgeOutcome } from "~/lib/cache-purge.server";
import { loadPipeline } from "~/lib/content/load-pipeline.server";
import type { DictionaryEntry } from "~/lib/dictionary-entries.mjs";
import { convergeWithRetry } from "~/lib/editor/converge.mjs";
import { blobGuard, commitFiles, GitHubError, listDirectory, 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 { compile as compilePageFile, writeRow as writePageRow } from "~/lib/pages/save.server";

import { compileDictionaryEntry, type DictionaryHost } from "./compile.mjs";
import { dictionaryPath, parseDictionaryEntry } from "./parse.mjs";

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

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

/** The repository's clips, asked of GitHub: existence and size, never the bytes, which are binary. */
function githubHost(env: DictionaryEnv): DictionaryHost {
  return {
    async audio(sitePath) {
      const at = sitePath.lastIndexOf("/");
      const dir = `public${sitePath.slice(0, at)}`;
      const name = sitePath.slice(at + 1);
      try {
        const entry = (await listDirectory(env, dir)).find((e) => e.type === "file" && e.name === name);
        return entry ? { size: entry.size } : null;
      } catch (error) {
        if (error instanceof GitHubError && error.status === 404) return null;
        throw error;
      }
    },
  };
}

/** Every other entry's key and path, from D1: CI re-checks the whole set on the commit this makes. */
async function otherEntries(env: DictionaryEnv, key: string) {
  return (await listDictionaryRows(env)).filter((row) => row.key !== key).map(({ key: other, path }) => ({ key: other, path }));
}

/** The one compile door: the save, sync_dictionary and the reads all go through it. */
export async function compile(env: DictionaryEnv, key: string, raw: string) {
  const { findWideDashes } = await loadPipeline();
  return compileDictionaryEntry({ key, raw, pipeline: { findWideDashes }, host: githubHost(env), others: await otherEntries(env, key) });
}

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

/** Whether a stored file is a draft, read from the file itself, the only authority. */
export function fileIsDraft(raw: string) {
  return parseDictionaryEntry({ file: "", raw }).data.draft === true;
}

/** The file, as the operator's get_dictionary reads it: raw, the entry when it compiles, every error when not. */
export async function readDictionary(env: DictionaryEnv, key: string) {
  const file = await readFile(env, dictionaryPath(key));
  if (!file) return null;
  const compiled = await compile(env, key, file.content);
  return {
    key,
    raw: file.content,
    draft: compiled.ok ? compiled.draft : fileIsDraft(file.content),
    entry: compiled.ok ? compiled.entry : null,
    errors: compiled.ok ? [] : compiled.errors,
  };
}

/**
 * Re-derives the page an entry opens: its twin and its search records, compiled from the page's file with
 * `entry` (null for none) in place of whatever D1 holds. A page with no file has nothing derived to refresh. A
 * page whose file no longer compiles is an error, never skipped: the entry would otherwise read as converged
 * while the page's twin is stale.
 */
async function refreshPage(env: DictionaryEnv, path: string, entry: DictionaryEntry | null) {
  const slug = pageSlug(path);
  const file = await readFile(env, pageSourcePath(slug));
  if (!file) return;
  const compiled = await compilePageFile(env, slug, file.content, entry);
  if (!compiled.ok) {
    throw new Error(`the page ${path} does not compile, so its twin and search record were not refreshed: ${compiled.errors.join("; ")}`);
  }
  await writePageRow(env, compiled);
}

/** The entry's D1 row alone: the derived store the page reads its lead and JSON-LD from. */
async function writeEntryRow(env: DictionaryEnv, compiled: Compiled) {
  await env.DB.prepare(
    `INSERT INTO dictionary_entries (key, path, term, status, record, source_path, source_blob_sha, synced_at)
     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, unixepoch())
     ON CONFLICT(key) DO UPDATE SET path = excluded.path, term = excluded.term, status = excluded.status,
       record = excluded.record, source_path = excluded.source_path, source_blob_sha = excluded.source_blob_sha,
       synced_at = excluded.synced_at`,
  )
    .bind(
      compiled.key,
      compiled.entry.path,
      compiled.entry.term,
      compiled.draft ? "draft" : "published",
      JSON.stringify(compiled.entry),
      compiled.sourcePath,
      compiled.sourceBlobSha,
    )
    .run();
}

/**
 * Every derived store an entry feeds: its page's row first, then its own (see the header). Shared by the save
 * and sync_dictionary, so a repair writes what a save writes.
 */
export async function writeRow(env: DictionaryEnv, compiled: Compiled) {
  await refreshPage(env, compiled.entry.path, compiled.draft ? null : compiled.entry);
  await writeEntryRow(env, compiled);
}

/** An entry whose file is gone: its page loses the lead first, then its row goes (the row is the drift marker). */
export async function deleteRow(env: DictionaryEnv, row: { key: string; path: string }) {
  await refreshPage(env, row.path, null);
  await env.DB.prepare(`DELETE FROM dictionary_entries WHERE key = ?1`).bind(row.key).run();
}

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

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

/** validate -> policy -> commitUnlessUnchanged -> blob verify -> derived writes (convergeWithRetry) -> purge by tag. */
export async function saveDictionaryEntry(
  env: DictionaryEnv,
  options: { key: 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<SavedDictionaryEntry> {
  const { key, raw, actor } = options;
  const path = dictionaryPath(key);
  const existing = await readFile(env, path);
  if (options.isNew && existing) throw new DictionaryInvalid(key, [`a dictionary entry named ${key} already exists`]);
  if (!options.isNew && !existing) throw new DictionaryInvalid(key, [`no dictionary entry named ${key} exists; pass isNew to create it`]);

  const compiled = await compile(env, key, raw);
  if (!compiled.ok) throw new DictionaryInvalid(key, compiled.errors);
  decideFileWrite({
    actor,
    noun: "dictionary entry",
    incomingDraft: compiled.draft,
    priorRaw: existing?.content ?? null,
    isDraft: fileIsDraft,
  });

  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, key, compiled.sourceBlobSha),
    commit: () =>
      commitFiles(env, {
        expectedHeadSha: options.expectedHeadSha,
        expectedBlobs: blobGuard(path, options.expectedBlobSha),
        message: `${options.isNew ? "Add" : "Update"} dictionary entry: ${compiled.entry.term}${tag}`,
        changes: [{ path, content: raw }],
      }),
  });
  if (written.action === "noop") {
    return { key, commitSha: written.commitSha, sourceBlobSha: compiled.sourceBlobSha, unchanged: true, created: false, draft: compiled.draft, purged: null, note: UNCHANGED_NOTE };
  }
  const { commitSha } = written;
  const blobSha = written.blobShas[path];
  if (blobSha && blobSha !== compiled.sourceBlobSha) {
    throw new Error(
      `The dictionary entry "${key}" 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 lead and its twin embeds the entry, and both carry it.
      purged = await purgePages(`save dictionary entry ${key}`);
    },
    // Nothing kept in KV for an entry: 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: key,
    commitSha,
  });

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