records: the record is read where it is written
A module keeping a checkout of a repository of decisions, designs and issues from the git seat, current on every announced merge and on a timer, answering records_search / records_read / records_list / records_status / records_sync at the commit it read (novox/hq ADR 0025, ADR 0153). The repository is a setting; it names no mesh.
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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<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);
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user