import { readFile } from "node:fs/promises";
import path from "node:path";
import { keyOf, readJson } from "./common.mjs";
// the knot writes <generation>/hook.json and its diff against the generation before it.
// a renderer that was stopped or skipped generations unions the diffs back to the
// generation it last rendered; a missing link means it can't know, so it renders again
const chainTo = (hookPath, hook, since) => {
const generations = path.dirname(path.dirname(hookPath));
const chain = [];
for (let at = { path: hookPath, hook }; at.hook.generation !== since; ) {
if (!at.hook.previous || !at.hook.paths?.diff) return null;
chain.push(path.resolve(path.dirname(at.path), at.hook.paths.diff));
const previous = path.join(generations, at.hook.previous, "hook.json");
const hookThere = readJson(previous);
if (!hookThere || hookThere.generation !== at.hook.previous) return null;
at = { path: previous, hook: hookThere };
}
return chain;
};
// a ticket, its comments and its patches name the repo as the authority of the ticket's uri
const AT_URI = /^at:\/\/(did:[^/]+)\//;
const repoOfParams = (params) => {
if (!params) return null;
if (params.repo?.startsWith("did:")) return params.repo;
if (params.repoDid?.startsWith("did:")) return params.repoDid;
for (const name of ["subject", "uri", "ticket"]) {
const value = params[name];
if (typeof value !== "string") continue;
if (value.startsWith("did:")) return value;
const m = AT_URI.exec(value);
if (m) return m[1];
}
return null;
};
const parse = async (files) => {
const diffs = { entries: 0, exact: new Set(), defaults: new Set(), any: new Map(), resets: new Set(), repos: new Set(), global: false };
for (const file of files) {
let text;
try {
text = await readFile(file, "utf8");
} catch {
return null;
}
for (let start = 0, end = 0; start < text.length; start = end + 1) {
end = text.indexOf("\n", start);
if (end === -1) end = text.length;
if (end === start) continue;
const line = text.slice(start, end);
diffs.entries++;
const entry = JSON.parse(line);
if (entry.op === "reset") {
diffs.resets.add(entry.params.repo);
diffs.repos.add(entry.params.repo);
} else if (entry.default) {
diffs.defaults.add(entry.nsid);
diffs.global = true;
} else if (entry.any) {
const repo = repoOfParams(entry.params);
if (repo) diffs.repos.add(repo);
else diffs.global = true;
const params = { ...entry.params };
for (const name of entry.any) delete params[name];
// lines of one nsid can leave out different params (a bucketed search vs a plain list)
const names = [...entry.any].sort();
const byNames = diffs.any.get(entry.nsid) ?? new Map();
diffs.any.set(entry.nsid, byNames);
if (!byNames.has(names.join())) byNames.set(names.join(), { names, keys: new Set() });
byNames.get(names.join()).keys.add(keyOf(entry.nsid, params));
} else {
const repo = repoOfParams(entry.params);
if (repo) diffs.repos.add(repo);
else diffs.global = true;
// an added key is one a page may have read while the map default answered it
diffs.exact.add(keyOf(entry.nsid, entry.params));
}
}
}
return diffs;
};
const cache = new Map();
export const readDiff = async (hookPath, hook, since) => {
const key = `${hookPath}\0${since}`;
if (!cache.has(key)) {
const chain = chainTo(hookPath, hook, since);
cache.set(key, chain === null ? null : await parse(chain));
}
return cache.get(key);
};
const nsidOf = (key) => key.slice(0, key.indexOf("?"));
// a key spelled by keyOf is decoded, so split it by hand rather than as a query string
const splitKey = (key) => {
const query = key.slice(key.indexOf("?") + 1);
const params = {};
if (query === "") return params;
for (const pair of query.split("&")) {
const at = pair.indexOf("=");
params[pair.slice(0, at)] = pair.slice(at + 1);
}
return params;
};
const touched = (diffs, key) => {
if (diffs.exact.has(key)) return true;
const nsid = nsidOf(key);
if (diffs.defaults.has(nsid)) return true;
// a line with `any` matches a read whose params, less those, are the line's
for (const { names, keys } of diffs.any.get(nsid)?.values() ?? []) {
const params = splitKey(key);
for (const name of names) delete params[name];
if (keys.has(keyOf(nsid, params))) return true;
}
return false;
};
// whether any of these reads changed
export const stale = (keys, diffs) => keys.some((key) => touched(diffs, key));
// whether the open ticket counts the repo publishes apart from its pages changed
export const countsChanged = (prior, diffs) => stale(prior.counts ?? [], diffs);
// the routes whose reads changed: pages to render again, and pages the budget or a failed
// read left out, which may stand now
export const dirtyPages = (prior, diffs) => {
if (diffs.resets.has(prior.did)) return "all";
if (!diffs.global && diffs.repos && !diffs.repos.has(prior.did)) return new Set();
const dirty = new Set();
for (const [route, page] of Object.entries(prior.pages)) {
if (stale(page.deps, diffs)) dirty.add(route);
}
for (const [route, deps] of Object.entries(prior.left ?? {})) {
if (stale(deps, diffs)) dirty.add(route);
}
return dirty;
};