Files
mesh-catalog/modules/records/records.ts
T
jschoubben 3b95d00afc records: a phrase that wraps, and one under emphasis, is found
The record is prose wrapped at a hundred columns; matched line by line, the first live search for a
sentence of ADR 0025 found nothing. A line is matched together with the next, emphasis marks are
ignored, and a hit still names the line it starts on.
2026-09-30 18:09:21 +02:00

278 lines
12 KiB
TypeScript

/**
* The record, read where it is written (novox/hq ADR 0025, ADR 0153).
*
* A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own
* forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit
* every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
* transformed, indexed or summarised on the way, so there is nothing that can drift from the source
* except by lagging behind it, and the lag is a number in every answer.
*
* What it answers: a search for a phrase, a document by path, what a folder holds, and where the
* checkout stands. The reasoning is in the documents; this only finds them.
*/
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { join, normalize, relative, sep } from "node:path";
import { promisify } from "node:util";
const run = promisify(execFile);
/** One place a phrase was found. */
export interface Hit {
/** The document, relative to the repository's root. */
path: string;
/** The line it was found on, from 1. */
line: number;
/** The nearest heading above it, so a hit reads as where in the document it is. */
heading: string;
/** The line itself, trimmed. */
text: string;
}
export interface Standing {
repository: string;
origin: string;
/** The commit the checkout is at, or empty before the first clone. */
commit: string;
/** When that commit was made, as the repository says. */
committed: string;
/** When this reader last brought the checkout up to date. */
fetched: string;
/** How many markdown documents the checkout holds. */
documents: number;
/** Why the last sync failed, if it did; the checkout stands where it was. */
lastError?: string;
}
/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */
export const MOST_HITS = 50;
/** A document longer than this is answered in part, and says so. */
export const MOST_BYTES = 200_000;
export class Records {
/** Where the checkout lives; the mesh gives the module the directory. */
readonly dir: string;
/** The forge's address, `scheme://host:port`, from the git provision's binding. */
readonly origin: string;
/** The repository's path on it, `owner/name`, from this module's settings. */
readonly repository: string;
private syncing: Promise<void> | undefined;
private fetched = "";
private lastError: string | undefined;
constructor(dir: string, origin: string, repository: string) {
this.dir = dir;
this.origin = origin;
this.repository = repository;
}
/** The clone URL: the forge, the repository. Public repositories only; a credential would be a
* secret this module has not asked for. */
get url(): string {
return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`;
}
/** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call
* while one runs joins it rather than racing it. Never throws — a failed sync is recorded in
* `standing()` and the checkout stands where it was, which is still an answer. */
sync(): Promise<void> {
if (!this.syncing) {
this.syncing = this.doSync().finally(() => {
this.syncing = undefined;
});
}
return this.syncing;
}
private async doSync(): Promise<void> {
try {
const cloned = await exists(join(this.dir, ".git"));
if (!cloned) {
await fs.mkdir(this.dir, { recursive: true });
await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]);
} else {
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has,
// rather than merging into something a hand may have touched.
await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]);
await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]);
}
this.fetched = new Date().toISOString();
this.lastError = undefined;
} catch (e) {
this.lastError = e instanceof Error ? e.message : String(e);
console.error(`[records] could not sync ${this.url}: ${this.lastError}`);
}
}
async standing(): Promise<Standing> {
let commit = "";
let committed = "";
if (await exists(join(this.dir, ".git"))) {
try {
commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim();
committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim();
} catch {
// A checkout without a commit yet: said as empty rather than thrown.
}
}
const documents = commit ? (await this.documents()).length : 0;
return {
repository: this.repository,
origin: this.origin,
commit,
committed,
fetched: this.fetched,
documents,
...(this.lastError ? { lastError: this.lastError } : {}),
};
}
/** Every markdown document, relative to the root, in a stable order. */
async documents(): Promise<string[]> {
const out: string[] = [];
const walk = async (at: string): Promise<void> => {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.name === ".git" || e.name === "node_modules") continue;
const full = join(at, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/"));
}
};
await walk(this.dir);
return out.sort();
}
/**
* Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
* design record is found by its own words and a reader deciding which words matter would be a
* second opinion about somebody else's document. Bounded, and says when it was.
*
* **The record is wrapped prose, and a phrase does not know where the line ends.** Every document
* here wraps at a hundred columns, so a phrase of six words is as likely to straddle a line break
* as not; matched line by line, the first live search for a sentence of ADR 0025 found nothing.
* So a line is matched together with the one after it, joined by a space, and emphasis marks
* are ignored — `**reachable**` is the word reachable. A hit still names the line it starts on.
*/
async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> {
const needle = plain(query).toLowerCase().replace(/\s+/g, " ").trim();
if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document");
const cap = Math.max(1, Math.min(limit, MOST_HITS));
const hits: Hit[] = [];
let more = false;
for (const path of await this.documents()) {
const text = await fs.readFile(join(this.dir, path), "utf8");
let heading = "";
const lines = text.split("\n");
const flat = lines.map((l) => plain(l).toLowerCase().replace(/\s+/g, " ").trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim();
const here = flat[i]!;
const next = i + 1 < flat.length ? flat[i + 1]! : "";
// On this line, or across the break into the next — but not a phrase that begins on the
// next line alone, which is that line's hit.
const onThis = here.includes(needle);
const acrossTheBreak = !onThis && next !== "" && `${here} ${next}`.includes(needle) && !next.includes(needle);
if (onThis || acrossTheBreak) {
if (hits.length >= cap) {
more = true;
break;
}
hits.push({ path, line: i + 1, heading, text: acrossTheBreak ? `${line.trim()} ${lines[i + 1]!.trim()}` : line.trim() });
}
}
if (more) break;
}
const commit = (await this.standing()).commit;
return { hits, more, commit };
}
/** One document, whole, or its first part with a note when it is very long. The path is kept
* inside the checkout: `..` and absolute paths are refused, not resolved. */
async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> {
const clean = normalize(path).split(sep).join("/");
if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) {
throw new Error(`"${path}" is not a path inside the repository`);
}
let content: string;
try {
content = await fs.readFile(join(this.dir, clean), "utf8");
} catch {
throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`);
}
const truncated = content.length > MOST_BYTES;
return {
path: clean,
content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content,
truncated,
commit: (await this.standing()).commit,
};
}
/** What a folder holds: its sub-folders and its documents, one level. */
async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> {
const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, "");
if (clean.startsWith("..") || clean.startsWith("/")) {
throw new Error(`"${folder}" is not a folder inside the repository`);
}
const at = clean ? join(this.dir, clean) : this.dir;
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
throw new Error(`the repository holds no folder ${clean || "/"}`);
}
const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort();
const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
return { folder: clean, folders, documents };
}
}
/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and
* the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */
export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<Records> {
const dir = env.MESH_RECORDS_DIR;
if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in");
const originFile = env.MESH_RECORDS_ORIGIN_FILE;
if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding");
const origin = (await fs.readFile(originFile, "utf8")).trim();
if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`);
const configFile = env.MESH_RECORDS_CONFIG_FILE;
if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset");
let config: { repository?: unknown } = {};
try {
config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown };
} catch (e) {
throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
}
const repository = typeof config.repository === "string" ? config.repository.trim() : "";
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(
"this module reads the repository its settings name, and none is set: " +
'`settings set records <file>` with {"repository": "<owner>/<name>"} — a module names no mesh (novox/hq ADR 0112)',
);
}
return new Records(dir, origin, repository);
}
/** A line without its markdown emphasis, so a phrase matches the words and not the marks. */
function plain(line: string): string {
return line.replace(/[*_`]/g, "");
}
async function exists(path: string): Promise<boolean> {
try {
await fs.stat(path);
return true;
} catch {
return false;
}
}