/** * 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 | 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 { if (!this.syncing) { this.syncing = this.doSync().finally(() => { this.syncing = undefined; }); } return this.syncing; } private async doSync(): Promise { 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 { 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 { const out: string[] = []; const walk = async (at: string): Promise => { 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. */ async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> { const needle = query.trim().toLowerCase(); 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"); for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim(); if (line.toLowerCase().includes(needle)) { if (hits.length >= cap) { more = true; break; } hits.push({ path, line: i + 1, heading, text: 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 { 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 ` with {"repository": "/"} — a module names no mesh (novox/hq ADR 0112)', ); } return new Records(dir, origin, repository); } async function exists(path: string): Promise { try { await fs.stat(path); return true; } catch { return false; } }