Store
tcmts.store.Store · #znwy43cs · class
import { sameSpace } from "#codebase/tcmts/core/types";
import type { DefKind, DefRecord, Dep, InitKind, RefPos, Span } from "#codebase/tcmts/core/types";
import {
causalHash, contributionOf, deepFreeze, DEF_CACHE, EMPTY_BODY, EMPTY_NAMESPACE, entryText, jsxOf, LAYOUT,
namespaceHash, readEntryText, renamedSignature, ROOT_CAUSAL, SCHEMA, stateHashOf, textHash,
} from "#codebase/tcmts/store";
import type {
Causal, Comment, Contribution, LogEntry, NameRow, NamespaceBody, Release, Row, TestResult, TextVersion,
} from "#codebase/tcmts/store";
import { DatabaseSync } from "node:sqlite";
import type { StatementSync } from "node:sqlite";
export class Store {
readonly db: DatabaseSync;
private branchCache: { id: number; name: string } | undefined;
/** one batch per Store instance (per CLI command), or per editor command (endBatch); allocated on the first logged change */
private batch: number | undefined;
/** branches whose names changed since their head causal was made */
private dirty = new Set<number>();
/** causals this batch made, by branch: a command's changes are one causal however many transactions it takes */
private made = new Map<number, string>();
/** extra parents for a branch's next causal (what a merge brought in) */
private pendingParents = new Map<number, Set<string>>();
private depth = 0;
/** text versions this batch chose and hasn't recorded in history yet, by branch: definition → version (null: as first stored) */
private pendingTexts = new Map<number, Map<string, string | null>>();
/** a branch's text versions, replaced whole (a reset, a merge): definition → version */
private replacedTexts = new Map<number, Map<string, string>>();
/** the text versions this branch shows, read once (getDef asks for every definition it reads) */
private shown: Map<string, string> | undefined;
/** the last namespace whose text versions were read */
private textsRead: { namespace: string; texts: Map<string, string> } | undefined;
/** statements, prepared once: a command runs the same few queries over and over (a cached test run, millions of times) */
private statements = new Map<string, StatementSync>();
/** another branch this process only reads (`--branch`): what {@link branch} is, while no branch can change */
reading: string | undefined;
/** what this batch's history entry is for (`-m`): its message, kept with the entry the batch makes */
message: string | undefined;
/**
* Asked before a name is changed one at a time (added, removed, pointed elsewhere, or shown with other text): it
* throws to refuse. A branch's names replaced whole (a reset, a merge, a pull) aren't asked; nor is anything
* {@link unguarded} changes. The codebase uses it to keep libraries read-only.
*/
guard: ((path: string) => void) | undefined;
private guarding = true;
constructor(file: string) {
this.db = new DatabaseSync(file);
// several processes share a codebase (agents in sessions, an editor, a terminal): a write waits for another's.
// Said first, so the statements after it wait too: setting WAL while another process checkpoints (closing the
// codebase as the last one out) fails at once otherwise, and a session's server starting with it
this.db.exec(
"PRAGMA busy_timeout = 15000; PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;",
);
// a codebase whose tables another tcm laid out differently can't be read (tcm is v0: there are no
// migrations): say so, rather than fail on a missing column later
const laidOut = !!this.sql(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'definitions'",
).get();
if (laidOut) {
let was: string | undefined;
try {
was = (
this.sql("SELECT value FROM settings WHERE key = 'layout'").get() as
| { value: string }
| undefined
)?.value;
} catch {}
if (was !== LAYOUT) {
this.db.close();
throw new Error(
`${file} was made by a tcm that stores codebases differently, and this one can't read it; make the codebase again with this tcm (\`tcm import\` its files, or \`tcm pull\` it)`,
);
}
}
// a codebase laid out already (as this tcm lays them out) is only read in opening it: a write would wait for
// every other process's, a long one's too, before a command that only reads could start
if (
laidOut &&
this.sql("SELECT 1 FROM branches WHERE name = 'main'").get() &&
this.sql("SELECT 1 FROM settings WHERE key = 'branch'").get() &&
this.sql("SELECT 1 FROM causals WHERE hash = ?").get(ROOT_CAUSAL) &&
this.sql("SELECT 1 FROM namespaces WHERE hash = ?").get(EMPTY_NAMESPACE)
)
return;
this.db.exec(SCHEMA);
this.sql("INSERT OR REPLACE INTO settings (key, value) VALUES ('layout', ?)").run(LAYOUT);
this.sql("INSERT OR IGNORE INTO namespaces (hash, body) VALUES (?, ?)").run(
EMPTY_NAMESPACE,
EMPTY_BODY,
);
this.sql(
"INSERT OR IGNORE INTO causals (hash, namespace, ts, command) VALUES (?, ?, 0, 'init')",
).run(ROOT_CAUSAL, EMPTY_NAMESPACE);
this.sql("INSERT OR IGNORE INTO branches (name, head) VALUES ('main', ?)").run(ROOT_CAUSAL);
this.sql("INSERT OR IGNORE INTO settings (key, value) VALUES ('branch', 'main')").run();
}
close() {
this.db.close();
}
private sql(text: string): StatementSync {
let s = this.statements.get(text);
if (!s) this.statements.set(text, (s = this.db.prepare(text)));
return s;
}
/** Run `fn` with no {@link guard}: what changes names it guards, on purpose (adding a library, removing one). */
unguarded<T>(fn: () => T): T {
const was = this.guarding;
this.guarding = false;
try {
return fn();
} finally {
this.guarding = was;
}
}
private guarded(path: string) {
if (this.guarding) this.guard?.(path);
}
/** Run `fn` in a transaction (joining the enclosing one, if any); branches it changed get a new head causal. */
transaction<T>(fn: () => T): T {
if (this.depth) {
this.depth++;
try {
return fn();
} finally {
this.depth--;
}
}
// IMMEDIATE: a transaction writes, so it takes the write lock first, waiting for another process's (busy_timeout).
// One that read first and then wrote would fail at once if another process wrote in between (WAL can't move
// a reader's snapshot forward): two sessions starting together, each making its branch, would lose one
this.db.exec("BEGIN IMMEDIATE");
this.depth = 1;
try {
const r = fn();
this.commitHistory();
this.db.exec("COMMIT");
return r;
} catch (e) {
this.db.exec("ROLLBACK");
this.dirty.clear();
this.defs.clear();
this.pendingTexts.clear();
this.replacedTexts.clear();
this.shown = undefined;
throw e;
} finally {
this.depth = 0;
}
}
/**
* End this batch: the next change starts a new history entry. A CLI command is one batch; the language server,
* which lives across many commands, ends one after each.
*/
endBatch() {
this.batch = undefined;
this.made.clear();
// (another process may have moved the branch since: its text versions are read again)
this.shown = undefined;
}
// ---- content ---------------------------------------------------------------
putDefs(records: DefRecord[], tsVersion: string, irVersion: number) {
for (const r of records) this.defs.delete(r.hash);
const now = Date.now();
const comp = this.sql(
"INSERT OR IGNORE INTO components (hash, size, ir_version, created_at) VALUES (?, ?, ?, ?)",
);
const def = this.sql(
`INSERT OR IGNORE INTO definitions (hash, kind, component_hash, component_index, ir, source_text, ref_spans, reserved_names, init_kind, name_hint, nominal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const dep = this.sql(
"INSERT OR IGNORE INTO deps (from_hash, to_ref, ref_kind, strict) VALUES (?, ?, ?, ?)",
);
const ext = this.sql(
"INSERT OR IGNORE INTO externals (ref, module, subpath, export_name) VALUES (?, ?, ?, ?)",
);
const sig = this.sql(
"INSERT OR IGNORE INTO signatures (hash, display, ts_version) VALUES (?, ?, ?)",
);
const meta = this.sql("INSERT OR IGNORE INTO metadata (hash, key, value) VALUES (?, ?, ?)");
this.transaction(() => {
for (const r of records) {
comp.run(r.componentHash, r.componentSize, irVersion, now);
const inserted = def.run(
r.hash,
r.kind,
r.componentHash,
r.componentIndex,
r.ir,
r.sourceText,
JSON.stringify(r.spans),
JSON.stringify(r.reservedNames),
r.initKind,
r.nameHint,
r.nominal ? 1 : 0,
);
if (!inserted.changes) continue; // identical definition already stored: first text wins
// (whether it's called is the second bit: the column stays as it was)
for (const d of r.deps)
dep.run(r.hash, d.ref, d.pos, (d.strict ? 1 : 0) | (d.called ? 2 : 0));
for (const s of r.spans)
if (s.ext)
ext.run(
`${s.ext.module}${s.ext.subpath ? "/" + s.ext.subpath : ""}#${s.ext.export}`,
s.ext.module,
s.ext.subpath,
s.ext.export,
);
sig.run(r.hash, r.signature, tsVersion);
if (r.doc) meta.run(r.hash, "doc", r.doc);
if (r.params) meta.run(r.hash, "params", JSON.stringify(r.params));
if (r.shape) meta.run(r.hash, "shape", JSON.stringify(r.shape));
if (r.docRefs?.length) meta.run(r.hash, "docRefs", JSON.stringify(r.docRefs));
if (r.table) meta.run(r.hash, "table", JSON.stringify(r.table));
if (r.transform) meta.run(r.hash, "transform", "1");
if (r.checked) meta.run(r.hash, "checked", JSON.stringify(r.checked));
if (r.globalNames) meta.run(r.hash, "globals", JSON.stringify(r.globalNames));
}
});
}
/**
* The components `hashes` need: their own, and those of everything they use, from the stored dependencies alone (no
* definition is read: a sync asks this of tens of thousands).
*/
closureComponents(hashes: Iterable<string>): Set<string> {
const compOf = this.sql("SELECT component_hash AS c FROM definitions WHERE hash = ?");
const uses = this.sql(
"SELECT DISTINCT substr(d.to_ref, 2) AS h FROM definitions m JOIN deps d ON d.from_hash = m.hash WHERE m.component_hash = ? AND d.to_ref LIKE '#%'",
);
const comps = new Set<string>();
const queue = [...hashes];
while (queue.length) {
const row = compOf.get(queue.pop()!) as { c: string } | undefined;
if (!row || comps.has(row.c)) continue;
comps.add(row.c);
for (const { h } of uses.all(row.c) as { h: string }[]) queue.push(h);
}
return comps;
}
/** Hash format versions of stored definitions. */
irVersions(): number[] {
return (
this.sql("SELECT DISTINCT ir_version AS v FROM components").all() as { v: number }[]
).map((r) => r.v);
}
getMetadata(hash: string, key: string): string | undefined {
return (
this.sql("SELECT value FROM metadata WHERE hash = ? AND key = ?").get(hash, key) as
| { value: string }
| undefined
)?.value;
}
setMetadata(hash: string, key: string, value: string) {
this.sql("INSERT OR REPLACE INTO metadata (hash, key, value) VALUES (?, ?, ?)").run(
hash,
key,
value,
);
this.defs.delete(hash);
}
hasDef(hash: string): boolean {
return !!this.sql("SELECT 1 FROM definitions WHERE hash = ?").get(hash);
}
/**
* Definitions read so far. A definition never changes under its hash (only its metadata, and its text when
* reformatted, which drop it from here), and a large codebase reads the same ones many times over in one command.
* They're frozen: callers share them. By hash, then by the text version read ("": as first stored). Only the
* DEF_CACHE most recently read are kept: a sync or a long-running server reads tens of thousands, whose IR held in
* memory made every garbage collection slower.
*/
private defs = new Map<string, Map<string, DefRecord | undefined>>();
/** A definition, as this branch shows it (its text version, if it has one). */
getDef(hash: string): DefRecord | undefined {
return this.defWithText(hash, this.shownTexts().get(hash));
}
/** A definition with one of its text versions (none: as first stored). */
defWithText(hash: string, text: string | undefined): DefRecord | undefined {
let versions = this.defs.get(hash);
// (the most recently read last: what's read least recently goes first)
if (versions) this.defs.delete(hash);
else {
versions = new Map();
if (this.defs.size >= DEF_CACHE) this.defs.delete(this.defs.keys().next().value!);
}
this.defs.set(hash, versions);
const key = text ?? "";
if (versions.has(key)) return versions.get(key);
const rec = this.readDef(hash, text);
versions.set(key, rec && deepFreeze(rec));
return rec;
}
private readDef(hash: string, text?: string): DefRecord | undefined {
const rec = this.readStoredDef(hash);
const version = rec && text !== undefined ? this.textVersion(hash, text) : undefined;
if (!rec || !version) return rec;
// (the version's doc, or none: a doc removed is a version too)
const out: DefRecord = { ...rec, ...version };
if (version.doc === undefined) delete out.doc;
if (version.docRefs === undefined) delete out.docRefs;
return out;
}
private readStoredDef(hash: string): DefRecord | undefined {
const row = this.sql(
`SELECT d.*, c.size, s.display, m.value AS doc, p.value AS params, t.value AS table_schema, x.value AS transform, k.value AS checked, g.value AS globals, sh.value AS shape, dr.value AS doc_refs FROM definitions d
JOIN components c ON c.hash = d.component_hash
LEFT JOIN signatures s ON s.hash = d.hash
LEFT JOIN metadata m ON m.hash = d.hash AND m.key = 'doc'
LEFT JOIN metadata p ON p.hash = d.hash AND p.key = 'params'
LEFT JOIN metadata t ON t.hash = d.hash AND t.key = 'table'
LEFT JOIN metadata x ON x.hash = d.hash AND x.key = 'transform'
LEFT JOIN metadata k ON k.hash = d.hash AND k.key = 'checked'
LEFT JOIN metadata g ON g.hash = d.hash AND g.key = 'globals'
LEFT JOIN metadata sh ON sh.hash = d.hash AND sh.key = 'shape'
LEFT JOIN metadata dr ON dr.hash = d.hash AND dr.key = 'docRefs'
WHERE d.hash = ?`,
).get(hash) as Row | undefined;
return row && this.toRecord(row);
}
componentMembers(componentHash: string): DefRecord[] {
const hashes = this.sql(
"SELECT hash FROM definitions WHERE component_hash = ? ORDER BY component_index",
).all(componentHash) as { hash: string }[];
return hashes.map((h) => this.getDef(h.hash)!);
}
private toRecord(row: Row): DefRecord {
const deps = (
this.sql("SELECT to_ref, ref_kind, strict FROM deps WHERE from_hash = ?").all(
row.hash,
) as Row[]
).map((d): Dep => ({
ref: d.to_ref as string,
pos: d.ref_kind as RefPos,
strict: (Number(d.strict) & 1) === 1,
...(Number(d.strict) & 2 ? { called: true } : {}),
}));
return {
hash: row.hash as string,
kind: row.kind as DefKind,
componentHash: row.component_hash as string,
componentIndex: row.component_index as number,
componentSize: row.size as number,
ir: row.ir as string,
sourceText: row.source_text as string,
spans: JSON.parse(row.ref_spans as string) as Span[],
reservedNames: JSON.parse(row.reserved_names as string) as string[],
initKind: row.init_kind as InitKind,
nameHint: row.name_hint as string,
nominal: row.nominal === 1,
deps,
signature: (row.display as string | null) ?? "?",
doc: (row.doc as string | null) ?? undefined,
params: row.params ? JSON.parse(row.params as string) : undefined,
...(row.shape ? { shape: JSON.parse(row.shape as string) } : {}),
...(row.doc_refs ? { docRefs: JSON.parse(row.doc_refs as string) } : {}),
table: row.table_schema ? JSON.parse(row.table_schema as string) : undefined,
...(row.transform ? { transform: true } : {}),
...jsxOf(row.ir as string),
...(row.checked ? { checked: JSON.parse(row.checked as string) } : {}),
...(row.globals ? { globalNames: JSON.parse(row.globals as string) } : {}),
};
}
/** Full hashes starting with a prefix. */
hashesWithPrefix(prefix: string): string[] {
return (
this.sql("SELECT hash FROM definitions WHERE hash LIKE ? LIMIT 10").all(`${prefix}%`) as {
hash: string;
}[]
).map((r) => r.hash);
}
/** Hashes of stored definitions that reference `hash`. */
dependents(hash: string): string[] {
return (
this.sql("SELECT DISTINCT from_hash FROM deps WHERE to_ref = ?").all(`#${hash}`) as {
from_hash: string;
}[]
).map((r) => r.from_hash);
}
/**
* The dependents named on this branch, but `hash` itself: what uses it now, not every version that ever did (a
* codebase keeps them all, and a much-used definition has thousands).
*/
namedDependents(hash: string): string[] {
// read for every named definition at once, and kept until names change: asked of each definition in turn (a
// code browser's pages), one query each would sift every past version of a much-used one
const at = `${this.branch().id}:${this.logHead()}`;
if (this.dependentsNamed?.at !== at) {
const of = new Map<string, string[]>();
const rows = this.sql(
"SELECT DISTINCT d.to_ref AS t, d.from_hash AS h FROM names n JOIN deps d ON d.from_hash = n.hash WHERE n.branch_id = ?",
).all(this.branch().id) as { t: string; h: string }[];
for (const r of rows) {
if (!r.t.startsWith("#") || r.t.slice(1) === r.h) continue;
const users = of.get(r.t.slice(1));
if (users) users.push(r.h);
else of.set(r.t.slice(1), [r.h]);
}
this.dependentsNamed = { at, of };
}
return this.dependentsNamed.of.get(hash) ?? [];
}
private dependentsNamed: { at: string; of: Map<string, string[]> } | undefined;
// ---- text versions -----------------------------------------------------------
//
// A definition's text (its comments, its layout, its doc) isn't part of its hash, so the same code can read
// differently on different branches. A definition keeps the text it was first stored with; other versions are kept
// beside it, each by its own hash (metadata `text <hash>`), and a branch's namespace says which one each name shows
// (none: the first). Changing only the text is then a change like any: on its branch alone, an entry in its
// history that reset and revert undo, carried by merges.
/** One of a definition's text versions. */
textVersion(hash: string, text: string): TextVersion | undefined {
const v = this.getMetadata(hash, `text ${text}`);
return v === undefined ? undefined : (JSON.parse(v) as TextVersion);
}
/** The text versions kept for a definition, besides the one it was first stored with. */
textVersions(hash: string): string[] {
return (
this.sql("SELECT key FROM metadata WHERE hash = ? AND key LIKE 'text %' ORDER BY key").all(
hash,
) as { key: string }[]
).map((r) => r.key.slice(5));
}
/**
* Keep a text version of a stored definition (the caller has checked it's the same code); its hash, or none if it's
* the text the definition was first stored with.
*/
addText(hash: string, version: TextVersion): string | undefined {
const stored = this.readStoredDef(hash);
if (!stored || (stored.sourceText === version.sourceText && stored.doc === version.doc))
return undefined;
const text = textHash(version);
const kept: TextVersion = {
sourceText: version.sourceText,
spans: version.spans,
reservedNames: version.reservedNames,
...(version.doc !== undefined ? { doc: version.doc } : {}),
...(version.docRefs?.length ? { docRefs: version.docRefs } : {}),
};
this.sql("INSERT OR IGNORE INTO metadata (hash, key, value) VALUES (?, ?, ?)").run(
hash,
`text ${text}`,
JSON.stringify(kept),
);
return text;
}
/** The text versions a namespace's names show: definition → version. */
textsAt(namespace: string): Map<string, string> {
if (this.textsRead?.namespace === namespace) return this.textsRead.texts;
const texts = new Map<string, string>();
const node = this.sql("SELECT body FROM namespaces WHERE hash = ?");
const walk = (h: string) => {
const row = node.get(h) as { body: string } | undefined;
if (!row) return;
const [, , children, shown] = JSON.parse(row.body) as NamespaceBody;
for (const [, d, t] of shown ?? []) texts.set(d, t);
for (const [, c] of children) walk(c);
};
walk(namespace);
this.textsRead = { namespace, texts };
return texts;
}
/** The text versions a branch (this one by default) shows, with what this batch changed and hasn't recorded yet. */
texts(branch?: string): Map<string, string> {
return this.textsOf(branch === undefined ? this.branch().id : this.branchId(branch));
}
private textsOf(id: number): Map<string, string> {
const head = (this.sql("SELECT head FROM branches WHERE id = ?").get(id) as { head: string })
.head;
const base = this.replacedTexts.get(id) ?? this.textsAt(this.causal(head)!.namespace);
const pending = this.pendingTexts.get(id);
if (!pending?.size) return base;
const out = new Map(base);
for (const [d, t] of pending) {
if (t === null) out.delete(d);
else out.set(d, t);
}
return out;
}
private shownTexts(): Map<string, string> {
return (this.shown ??= this.textsOf(this.branch().id));
}
/**
* Show this text for a definition on this branch: kept as a version of its own, and recorded in the branch's
* history (an entry of `command`'s) where the branch names it. The definition's hash, and every other branch, are
* untouched. Text the definition was first stored with is no version: the branch shows it as stored.
*/
setText(rec: TextVersion & { hash: string }, command: string) {
this.transaction(() => {
const text = this.addText(rec.hash, rec) ?? null;
if ((this.shownTexts().get(rec.hash) ?? null) === text) return;
const b = this.branch().id;
if (!this.pendingTexts.has(b)) this.pendingTexts.set(b, new Map());
this.pendingTexts.get(b)!.set(rec.hash, text);
this.shown = undefined;
const named = this.sql("SELECT path, kind FROM names WHERE branch_id = ? AND hash = ?").all(
b,
rec.hash,
) as { path: string; kind: DefKind }[];
for (const n of named) this.guarded(n.path);
for (const n of named) this.log("update", n.path, rec.hash, rec.hash, n.kind, command);
});
}
// ---- branches --------------------------------------------------------------
/**
* The branch this process works on: a session's (`TCM_BRANCH`), which no other process can move, or else the
* codebase's own (`tcm switch`).
*/
branch(): { id: number; name: string } {
if (!this.branchCache) {
const session = this.reading ?? process.env.TCM_BRANCH;
const name =
session ||
(this.sql("SELECT value FROM settings WHERE key = 'branch'").get() as { value: string })
.value;
const row = this.sql("SELECT id FROM branches WHERE name = ?").get(name) as
| { id: number }
| undefined;
if (!row) throw new Error(`no branch named '${name}'${session ? " (TCM_BRANCH)" : ""}`);
this.branchCache = { id: row.id, name };
}
return this.branchCache;
}
/**
* Read another branch rather than this process's (`tcm view --branch <b>`), and change none: whatever would change
* a branch's names, history, patches or todo throws instead. The process's own branch (a session's) is untouched.
*/
readOnly(name: string) {
if (!this.hasBranch(name)) throw new Error(`no branch named '${name}' (--branch)`);
this.reading = name;
this.branchCache = undefined;
this.shown = undefined;
}
/** Refuse to change a branch while reading one (`--branch`). */
private changing() {
if (this.reading !== undefined)
throw new Error(
`${this.reading} is only read here (--branch): nothing changes it, or any branch (to change it, work on it: \`tcm switch\`, or a session's tcm_cli ["switch", "<branch>"])`,
);
}
/** Re-read the current branch on next use (another process may have switched it). */
forgetBranch() {
this.branchCache = undefined;
this.shown = undefined;
}
branches(): string[] {
return (this.sql("SELECT name FROM branches ORDER BY id").all() as { name: string }[]).map(
(r) => r.name,
);
}
createBranch(name: string) {
this.changing();
const from = this.branch();
this.transaction(() => {
const { lastInsertRowid } = this.sql("INSERT INTO branches (name, head) VALUES (?, ?)").run(
name,
this.head(from.name),
);
this.sql(
"INSERT INTO names (branch_id, path, hash, kind) SELECT ?, path, hash, kind FROM names WHERE branch_id = ?",
).run(lastInsertRowid, from.id);
this.sql(
"INSERT INTO patches (branch_id, old_hash, new_hash, ts) SELECT ?, old_hash, new_hash, ts FROM patches WHERE branch_id = ?",
).run(lastInsertRowid, from.id);
});
}
/**
* Delete a branch: its names, patches, todo and log. Not the branch this process works on, nor `main`; the history
* entries it made stay (other branches may have them).
*/
deleteBranch(name: string) {
this.changing();
if (name === "main") throw new Error("main can't be deleted");
if (name === this.branch().name)
throw new Error(`${name} is the branch this works on: switch to another first`);
const id = this.branchId(name);
this.transaction(() => {
for (const table of ["names", "patches", "todo", "name_log"])
this.sql(`DELETE FROM ${table} WHERE branch_id = ?`).run(id);
this.sql("DELETE FROM branches WHERE id = ?").run(id);
});
}
switchBranch(name: string) {
this.changing();
if (process.env.TCM_BRANCH)
throw new Error(
`this is a session on ${process.env.TCM_BRANCH} (TCM_BRANCH): it stays on its branch, and \`tcm switch\` would move the codebase's own, which it doesn't use (an agent's MCP session moves onto another branch with tcm_cli ["switch", "<branch>"])`,
);
if (!this.sql("SELECT 1 FROM branches WHERE name = ?").get(name))
throw new Error(`no branch named '${name}'`);
this.sql("UPDATE settings SET value = ? WHERE key = 'branch'").run(name);
this.branchCache = undefined;
this.shown = undefined;
}
// ---- names -----------------------------------------------------------------
/** A branch's names (this one's by default): its code's, and with `files`, the files kept with it too. */
names(branch?: string, files = false): NameRow[] {
const id = branch === undefined ? this.branch().id : this.branchId(branch);
return this.sql(
`SELECT path, hash, kind FROM names WHERE branch_id = ?${files ? "" : " AND kind != 'file'"} ORDER BY path, hash`,
).all(id) as unknown as NameRow[];
}
/** Every hash reachable from a name on this branch (files' included), following references: one query. */
liveHashes(): Set<string> {
const rows = this.sql(
"WITH RECURSIVE live(h) AS (SELECT hash FROM names WHERE branch_id = ? UNION SELECT substr(d.to_ref, 2) FROM deps d JOIN live ON d.from_hash = live.h WHERE d.to_ref LIKE '#%') SELECT h FROM live",
).all(this.branch().id) as { h: string }[];
return new Set(rows.map((r) => r.h));
}
/**
* This branch's names (with `files`, the files' too) and each definition's signature as shown (`shownSignature`):
* one query, not a record each.
*/
signedNames(files = false): (NameRow & { signature: string })[] {
const rows = this.sql(
`SELECT n.path, n.hash, n.kind, coalesce(s.display, '?') AS signature FROM names n LEFT JOIN signatures s ON s.hash = n.hash WHERE n.branch_id = ?${files ? "" : " AND n.kind != 'file'"} ORDER BY n.path, n.hash`,
).all(this.branch().id) as unknown as (NameRow & { signature: string })[];
// (only a signature with a word some definition was called, and isn't now, needs a closer look)
const now = new Map<string, Set<string>>();
const hints = new Map<string, string>();
for (const r of this.sql(
"SELECT n.path, n.hash, d.name_hint AS hint FROM names n JOIN definitions d ON d.hash = n.hash WHERE n.branch_id = ? AND n.kind != 'file'",
).all(this.branch().id) as { path: string; hash: string; hint: string }[]) {
now.set(
r.hash,
(now.get(r.hash) ?? new Set()).add(r.path.slice(r.path.lastIndexOf(".") + 1)),
);
hints.set(r.hash, r.hint);
}
const known = this.namesKnown();
const renamed = new Set<string>();
for (const [h, leaves] of now)
for (const was of [hints.get(h)!, ...(known.get(h) ?? [])])
if (!leaves.has(was)) renamed.add(was);
if (!renamed.size) return rows;
for (const r of rows)
if (
r.kind !== "file" &&
r.kind !== "type" &&
r.signature.match(/[A-Za-z_$][\w$]*/g)?.some((w) => renamed.has(w))
)
r.signature = this.shownSignature(this.getDef(r.hash)!);
return rows;
}
/**
* A definition's signature as it's shown: naming the codebase's types as they're named on this branch now. The
* signature stored is the type as the checker printed it when the definition was made, which names a codebase type
* as it was called then (as its module declares it, the name it was made as; or as the file being saved did), so
* after `tcm mv` it would name it the old way. The types are those of its shape, by hash (or, where that stops,
* those its code mentions), and a class's own. A name one of them was made as or had is shown as what it's called
* now, unless that's still a name of one of them, or two of them have had it.
*/
shownSignature(rec: DefRecord): string {
if (rec.kind === "type" || rec.kind === "file") return rec.signature;
const hashes = new Set<string>(rec.kind === "class" ? [rec.hash] : []);
let whole = !!rec.shape;
const visit = (s: unknown): void => {
if (Array.isArray(s)) return s.forEach(visit);
if (!s || typeof s !== "object") return;
const o = s as { k?: string; hash?: string };
if (o.k === "ref" && o.hash) hashes.add(o.hash);
if (o.k === "opaque") whole = false;
Object.values(o).forEach(visit);
};
visit(rec.shape);
if (!whole)
for (const d of rec.deps)
if (d.pos === "type" && d.ref.startsWith("#")) hashes.add(d.ref.slice(1));
const now = new Map(
[...hashes].map((h) => [
h,
[...new Set(this.pathsOf(h).map((p) => p.slice(p.lastIndexOf(".") + 1)))],
]),
);
const current = new Set([...now.values()].flat());
const known = this.namesKnown();
const renames = new Map<string, string>();
const clashes = new Set<string>();
for (const [h, leaves] of now) {
// (one nothing names now, a past version say, keeps the name it's printed with; one with several, which?)
if (leaves.length !== 1) continue;
const hint = h === rec.hash ? rec.nameHint : this.getDef(h)?.nameHint;
for (const was of new Set([...(hint ? [hint] : []), ...(known.get(h) ?? [])])) {
if (current.has(was)) continue;
if (renames.has(was) && renames.get(was) !== leaves[0]) clashes.add(was);
renames.set(was, leaves[0]!);
}
}
for (const c of clashes) renames.delete(c);
return renamedSignature(rec.signature, renames);
}
/**
* The last segment of every name each definition named on this branch has had, on any branch (from the name log):
* how a signature made then may name it. Read once, and kept until names change.
*/
private namesKnown(): Map<string, Set<string>> {
const at = `${this.branch().id}:${this.logHead()}`;
if (this.known?.at !== at) {
const of = new Map<string, Set<string>>();
// (only those named on this branch, the ones asked about: the whole log is every version ever named, tens of
// thousands, read on each command that shows a signature)
for (const r of this.sql(
"SELECT DISTINCT new_hash AS hash, path FROM name_log WHERE new_hash IN (SELECT hash FROM names WHERE branch_id = ?)",
).all(this.branch().id) as { hash: string; path: string }[])
of.set(
r.hash,
(of.get(r.hash) ?? new Set()).add(r.path.slice(r.path.lastIndexOf(".") + 1)),
);
this.known = { at, of };
}
return this.known.of;
}
private known: { at: string; of: Map<string, Set<string>> } | undefined;
private branchId(name: string): number {
const row = this.sql("SELECT id FROM branches WHERE name = ?").get(name) as
| { id: number }
| undefined;
if (!row) throw new Error(`no branch named '${name}'`);
return row.id;
}
ensureBranch(name: string) {
this.changing();
this.sql("INSERT OR IGNORE INTO branches (name, head) VALUES (?, ?)").run(name, ROOT_CAUSAL);
}
/** Fingerprint of a branch's namespace, for detecting concurrent changes during sync. */
stateHash(branch?: string): string {
return stateHashOf(this.names(branch, true));
}
/**
* Make `branch` name exactly `rows`, showing the text versions they say, logging each change on that branch (a new
* causal follows).
*/
setBranchNames(
branch: string,
rows: NameRow[],
command: string,
): { added: number; removed: number; texts: number } {
this.ensureBranch(branch);
const id = this.branchId(branch);
return this.transaction(() => this.replaceBranchNames(id, rows, command));
}
hasBranch(name: string): boolean {
return !!this.sql("SELECT 1 FROM branches WHERE name = ?").get(name);
}
/** This branch's files whose own name is `leaf` (`libraries`: `lib.libraries`, `a.lib.libraries`, ...): one query. */
filesNamed(leaf: string): NameRow[] {
return this.sql(
"SELECT path, hash, kind FROM names WHERE branch_id = ? AND kind = 'file' AND (path = ? OR path LIKE ?) ORDER BY path",
).all(this.branch().id, leaf, `%.${leaf}`) as unknown as NameRow[];
}
/** Hashes named `path`; with `kind`, only those in the same space (a type and a value can share a path). */
hashesAt(path: string, kind?: DefKind): string[] {
const rows = this.sql(
"SELECT hash, kind FROM names WHERE branch_id = ? AND path = ? ORDER BY hash",
).all(this.branch().id, path) as { hash: string; kind: DefKind }[];
return rows.filter((r) => kind === undefined || sameSpace(r.kind, kind)).map((r) => r.hash);
}
/** Point `path` at `newHash` instead of `oldHashes`, leaving other hashes on it (the other space) alone. */
replaceNames(path: string, oldHashes: string[], newHash: string, kind: DefKind, command: string) {
this.guarded(path);
this.transaction(() => {
const b = this.branch().id;
for (const o of oldHashes)
this.sql("DELETE FROM names WHERE branch_id = ? AND path = ? AND hash = ?").run(b, path, o);
this.sql("INSERT OR IGNORE INTO names (branch_id, path, hash, kind) VALUES (?, ?, ?, ?)").run(
b,
path,
newHash,
kind,
);
for (const o of oldHashes.length ? oldHashes : [null])
this.log("update", path, o, newHash, kind, command);
});
}
pathsOf(hash: string): string[] {
// (through names_by_hash, said outright: left to choose, SQLite reads every name of the branch through the primary
// key, which holds the path, rather than the few with this hash; rendering asks this for every reference)
return (
this.sql(
"SELECT path FROM names INDEXED BY names_by_hash WHERE branch_id = ? AND hash = ?",
).all(this.branch().id, hash) as { path: string }[]
)
.map((r) => r.path)
.sort();
}
addName(path: string, hash: string, kind: DefKind, command: string) {
this.guarded(path);
this.transaction(() => {
const b = this.branch().id;
const r = this.sql(
"INSERT OR IGNORE INTO names (branch_id, path, hash, kind) VALUES (?, ?, ?, ?)",
).run(b, path, hash, kind);
if (r.changes) this.log("add", path, null, hash, kind, command);
});
}
removeName(path: string, hash: string, command: string) {
this.guarded(path);
this.transaction(() => {
const b = this.branch().id;
const row = this.sql(
"SELECT kind FROM names WHERE branch_id = ? AND path = ? AND hash = ?",
).get(b, path, hash) as { kind: DefKind } | undefined;
if (!row) return;
this.sql("DELETE FROM names WHERE branch_id = ? AND path = ? AND hash = ?").run(
b,
path,
hash,
);
this.log("delete", path, hash, null, row.kind, command);
});
}
/** Point `path` at `newHash`, replacing whatever it pointed to. */
updateName(path: string, newHash: string, kind: DefKind, command: string) {
this.guarded(path);
this.transaction(() => {
const b = this.branch().id;
const old = this.hashesAt(path);
this.sql("DELETE FROM names WHERE branch_id = ? AND path = ?").run(b, path);
this.sql("INSERT INTO names (branch_id, path, hash, kind) VALUES (?, ?, ?, ?)").run(
b,
path,
newHash,
kind,
);
for (const o of old.length ? old : [null])
this.log("update", path, o, newHash, kind, command);
});
}
/** Swap one hash on `path` for another, leaving any other hashes there (a conflict) alone. */
replaceName(path: string, oldHash: string, newHash: string, kind: DefKind, command: string) {
this.guarded(path);
this.transaction(() => {
const b = this.branch().id;
const r = this.sql("DELETE FROM names WHERE branch_id = ? AND path = ? AND hash = ?").run(
b,
path,
oldHash,
);
if (!r.changes) return;
this.sql("INSERT OR IGNORE INTO names (branch_id, path, hash, kind) VALUES (?, ?, ?, ?)").run(
b,
path,
newHash,
kind,
);
this.log("update", path, oldHash, newHash, kind, command);
});
}
private log(
op: LogEntry["op"],
path: string,
oldHash: string | null,
newHash: string | null,
kind: DefKind,
command: string,
branchId = this.branch().id,
) {
this.changing();
this.dirty.add(branchId);
this.batch ??= (
this.sql("SELECT coalesce(max(batch), 0) + 1 AS b FROM name_log").get() as { b: number }
).b;
this.sql(
"INSERT INTO name_log (branch_id, ts, op, path, old_hash, new_hash, kind, command, batch) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(branchId, Date.now(), op, path, oldHash, newHash, kind, command, this.batch);
}
// ---- history ---------------------------------------------------------------
/** A branch's head causal (default: the current branch's). */
head(branch?: string): string {
const id = branch === undefined ? this.branch().id : this.branchId(branch);
return (this.sql("SELECT head FROM branches WHERE id = ?").get(id) as { head: string }).head;
}
causal(hash: string): Causal | undefined {
const row = this.sql("SELECT * FROM causals WHERE hash = ?").get(hash) as Row | undefined;
if (!row) return undefined;
// in the order they were recorded: a merge's own branch first
const parents = (
this.sql("SELECT parent FROM causal_parents WHERE child = ? ORDER BY rowid").all(hash) as {
parent: string;
}[]
).map((r) => r.parent);
return {
hash,
namespace: row.namespace as string,
parents,
ts: row.ts as number,
...readEntryText(row.command as string),
};
}
hasCausal(hash: string): boolean {
return !!this.sql("SELECT 1 FROM causals WHERE hash = ?").get(hash);
}
/** Causals whose hash starts with `prefix`. */
causalsWithPrefix(prefix: string): string[] {
return (
this.sql("SELECT hash FROM causals WHERE hash LIKE ? LIMIT 10").all(`${prefix}%`) as {
hash: string;
}[]
).map((r) => r.hash);
}
/** Store a namespace for these names (and the text versions they show), node by node; its hash. */
namespaceOf(rows: { path: string; hash: string; text?: string }[]): string {
interface Tree {
names: Map<string, Set<string>>;
texts: [string, string, string][];
children: Map<string, Tree>;
}
const tree = (): Tree => ({ names: new Map(), texts: [], children: new Map() });
const root = tree();
for (const r of rows) {
const segs = r.path.split(".");
let t = root;
for (const seg of segs.slice(0, -1)) {
if (!t.children.has(seg)) t.children.set(seg, tree());
t = t.children.get(seg)!;
}
const last = segs.at(-1)!;
t.names.set(last, (t.names.get(last) ?? new Set()).add(r.hash));
if (r.text && !t.texts.some(([n, h]) => n === last && h === r.hash))
t.texts.push([last, r.hash, r.text]);
}
const put = this.sql("INSERT OR IGNORE INTO namespaces (hash, body) VALUES (?, ?)");
const write = (t: Tree): string => {
const names = [...t.names]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([n, hs]): [string, string[]] => [n, [...hs].sort()]);
const children = [...t.children]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([n, c]): [string, string] => [n, write(c)]);
const texts = t.texts.sort(([a, x], [b, y]) =>
a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0,
);
// (a node showing no text versions is written as it was before there were any: its hash is the same)
const body = JSON.stringify(
(texts.length
? ["ns", names, children, texts]
: ["ns", names, children]) satisfies NamespaceBody,
);
const h = namespaceHash(body);
put.run(h, body);
return h;
};
return write(root);
}
/** The names a stored namespace holds, with each definition's kind (and the text version it shows, if any). */
namespaceRows(namespace: string): NameRow[] {
const kind = this.sql("SELECT kind FROM definitions WHERE hash = ?");
const out: NameRow[] = [];
const walk = (h: string, prefix: string) => {
const row = this.sql("SELECT body FROM namespaces WHERE hash = ?").get(h) as
| { body: string }
| undefined;
if (!row) throw new Error(`namespace ${h.slice(0, 8)} is missing from this codebase`);
const [, names, children, texts] = JSON.parse(row.body) as NamespaceBody;
const shown = new Map((texts ?? []).map(([n, d, t]) => [`${n} ${d}`, t]));
for (const [n, hs] of names)
for (const d of hs) {
const text = shown.get(`${n} ${d}`);
out.push({
path: prefix + n,
hash: d,
kind: ((kind.get(d) as { kind: DefKind } | undefined)?.kind ?? "term") as DefKind,
...(text ? { text } : {}),
});
}
for (const [n, c] of children) walk(c, `${prefix}${n}.`);
};
walk(namespace, "");
return out.sort((a, b) =>
a.path < b.path ? -1 : a.path > b.path ? 1 : a.hash < b.hash ? -1 : 1,
);
}
/** Record a causal (its hash follows from namespace and parents); returns the hash. */
putCausal(
namespace: string,
parents: string[],
ts: number,
command: string,
message?: string,
): string {
const h = causalHash(namespace, parents);
const r = this.sql(
"INSERT OR IGNORE INTO causals (hash, namespace, ts, command) VALUES (?, ?, ?, ?)",
).run(h, namespace, ts, entryText(command, message));
if (r.changes)
for (const p of new Set(parents))
this.sql("INSERT OR IGNORE INTO causal_parents (child, parent) VALUES (?, ?)").run(h, p);
return h;
}
/**
* Say what a history entry is for: its message (none takes it away). The message is in no hash, so the entry, and
* all history after it, stay as they are; a codebase that has the entry already (a remote it was pushed to) keeps
* the message it had.
*/
describe(hash: string, message: string | undefined) {
const c = this.causal(hash);
if (!c) throw new Error(`no history entry ${hash.slice(0, 8)} in this codebase`);
this.sql("UPDATE causals SET command = ? WHERE hash = ?").run(
entryText(c.command, message),
hash,
);
}
/** Make the next causal on the current branch also descend from `causal` (a merge brings its history in). */
addParent(causal: string) {
this.changing();
const b = this.branch().id;
this.pendingParents.set(b, (this.pendingParents.get(b) ?? new Set()).add(causal));
this.dirty.add(b);
}
/**
* A new head causal for each branch whose names changed. Within a batch (a command) there is one: a later
* transaction replaces the causal an earlier one made, keeping its parents.
*/
private commitHistory() {
for (const id of this.dirty) {
const name = (
this.sql("SELECT name FROM branches WHERE id = ?").get(id) as { name: string } | undefined
)?.name;
if (name === undefined) continue;
const head = this.head(name);
const current = this.causal(head)!;
// (files kept as written are history like code: they travel with it; so do the text versions names show)
const texts = this.textsOf(id);
const namespace = this.namespaceOf(
this.names(name, true).map((r) => {
const text = texts.get(r.hash);
return text ? { ...r, text } : r;
}),
);
const extra = [...(this.pendingParents.get(id) ?? [])].filter((p) => p !== head);
if (namespace === current.namespace && !extra.length) continue;
const mine = this.made.get(id) === head;
const parents = [...new Set([...(mine ? current.parents : [head]), ...extra])];
const command = mine ? current.command : this.batchCommand(id);
const message = this.message ?? (mine ? current.message : undefined);
const next = this.putCausal(namespace, parents, Date.now(), command, message);
// (the same change from the same point is an entry here already, as a branch's one step landed squashed is:
// told what it's for, it says so)
if (this.message && this.causal(next)!.message !== this.message)
this.describe(next, this.message);
this.sql("UPDATE branches SET head = ? WHERE id = ?").run(next, id);
// the causal this one replaces was only ever this batch's: drop it unless something else points at it
if (
mine &&
next !== head &&
!this.sql("SELECT 1 FROM branches WHERE head = ?").get(head) &&
!this.sql("SELECT 1 FROM causal_parents WHERE parent = ?").get(head)
) {
this.sql("DELETE FROM causal_parents WHERE child = ?").run(head);
this.sql("DELETE FROM causals WHERE hash = ?").run(head);
}
this.made.set(id, next);
this.pendingParents.delete(id);
}
this.dirty.clear();
// (what's recorded is read from the heads now; a version chosen for something no branch names is no change)
this.pendingTexts.clear();
this.replacedTexts.clear();
this.shown = undefined;
}
/** The command that made this batch's changes on a branch: the first logged. */
private batchCommand(branchId: number): string {
const row = this.sql(
"SELECT command FROM name_log WHERE branch_id = ? AND batch = ? ORDER BY id LIMIT 1",
).get(branchId, this.batch ?? -1) as { command: string } | undefined;
return row?.command ?? "change";
}
/**
* Point a branch at a causal this codebase has: its names become that causal's namespace (logged as changes,
* like any), and its head becomes that causal, with no new history entry.
*/
setHead(branch: string, causal: string, command: string, quiet = false) {
const c = this.causal(causal);
if (!c) throw new Error(`no history entry ${causal.slice(0, 8)} in this codebase`);
this.ensureBranch(branch);
const id = this.branchId(branch);
this.transaction(() => {
this.replaceBranchNames(id, this.namespaceRows(c.namespace), command, quiet);
this.sql("UPDATE branches SET head = ? WHERE id = ?").run(causal, id);
this.dirty.delete(id);
this.made.delete(id);
// (the text versions it shows are the causal's)
this.replacedTexts.delete(id);
this.pendingTexts.delete(id);
this.shown = undefined;
});
}
/** Whether `ancestor` is `of` or in its history. */
isAncestor(ancestor: string, of: string): boolean {
if (ancestor === of || ancestor === ROOT_CAUSAL) return true;
const parents = this.sql("SELECT parent FROM causal_parents WHERE child = ?");
const seen = new Set<string>();
const queue = [of];
while (queue.length) {
const h = queue.pop()!;
if (h === ancestor) return true;
if (seen.has(h)) continue;
seen.add(h);
for (const r of parents.all(h) as { parent: string }[]) queue.push(r.parent);
}
return false;
}
/** Every causal in a causal's history, itself included (newest first, by time). */
ancestors(of: string): string[] {
const parents = this.sql("SELECT parent FROM causal_parents WHERE child = ?");
const seen = new Set<string>();
const queue = [of];
while (queue.length) {
const h = queue.pop()!;
if (seen.has(h) || !this.hasCausal(h)) continue;
seen.add(h);
for (const r of parents.all(h) as { parent: string }[]) queue.push(r.parent);
}
return [...seen];
}
/**
* The best common ancestors of two histories (each one causal or several), which a three-way merge starts from:
* the causals both contain that aren't behind another such one. Usually one; criss-cross merges (two branches
* that merged each other) leave several. Oldest first; with nothing in common, the root.
*/
mergeBases(a: string | string[], b: string | string[]): string[] {
const inA = new Set([a].flat().flatMap((h) => this.ancestors(h)));
const parents = this.sql("SELECT parent FROM causal_parents WHERE child = ?");
const candidates: string[] = [];
const seen = new Set<string>();
const queue = [b].flat();
while (queue.length) {
const h = queue.shift()!;
if (seen.has(h)) continue;
seen.add(h);
if (inA.has(h)) {
candidates.push(h);
continue;
}
for (const r of parents.all(h) as { parent: string }[]) queue.push(r.parent);
}
const best = candidates.filter(
(c) => !candidates.some((o) => o !== c && this.isAncestor(c, o)),
);
if (!best.length) return [ROOT_CAUSAL];
return best
.map((h) => this.causal(h)!)
.sort((x, y) => x.ts - y.ts || (x.hash < y.hash ? -1 : 1))
.map((c) => c.hash);
}
/** The newest of two causals' best common ancestors ({@link mergeBases}): where one branch left the other. */
mergeBase(a: string, b: string): string {
return this.mergeBases(a, b).at(-1)!;
}
/**
* A branch's history from its head back, first parents only: what happened on it, with each merge as one step.
* A merge's first parent is the head of the branch it was made on.
*/
causalLog(limit = 50, branch?: string): Causal[] {
const out: Causal[] = [];
for (
let h: string | undefined = this.head(branch);
h && out.length < limit;
h = out.at(-1)!.parents[0]
) {
const c = this.causal(h);
if (!c) break;
out.push(c);
}
return out;
}
// ---- history, between codebases -------------------------------------------
missingCausals(hashes: string[]): string[] {
return hashes.filter((h) => !this.hasCausal(h));
}
missingNamespaces(hashes: string[]): string[] {
const q = this.sql("SELECT 1 FROM namespaces WHERE hash = ?");
return hashes.filter((h) => !q.get(h));
}
/** Causals to send another codebase: each message in its command, as the column holds it, so any tcm keeps it. */
getCausals(hashes: string[]): Causal[] {
return hashes
.map((h) => this.causal(h))
.filter((c): c is Causal => !!c)
.map(({ message, ...c }) => ({ ...c, command: entryText(c.command, message) }));
}
getNamespaces(hashes: string[]): { hash: string; body: string }[] {
const q = this.sql("SELECT body FROM namespaces WHERE hash = ?");
return hashes.flatMap((h) => {
const r = q.get(h) as { body: string } | undefined;
return r ? [{ hash: h, body: r.body }] : [];
});
}
/** The nodes a namespace node points at. */
namespaceChildren(hash: string): string[] {
const r = this.sql("SELECT body FROM namespaces WHERE hash = ?").get(hash) as
| { body: string }
| undefined;
return r ? (JSON.parse(r.body) as NamespaceBody)[2].map(([, c]) => c) : [];
}
/** The definitions a namespace node names directly. */
namespaceDefinitions(hash: string): string[] {
const r = this.sql("SELECT body FROM namespaces WHERE hash = ?").get(hash) as
| { body: string }
| undefined;
return r ? (JSON.parse(r.body) as NamespaceBody)[1].flatMap(([, hs]) => hs) : [];
}
/** Namespace nodes from another codebase: each must hash to what it claims, and be well formed. */
putNamespaces(rows: { hash: string; body: string }[]) {
this.transaction(() => {
for (const r of rows) {
if (namespaceHash(r.body) !== r.hash)
throw new Error(
`namespace ${String(r.hash).slice(0, 8)} doesn't hash to its claimed hash`,
);
const b = JSON.parse(r.body) as NamespaceBody;
if (b[0] !== "ns" || !Array.isArray(b[1]) || !Array.isArray(b[2]))
throw new Error(`namespace ${r.hash.slice(0, 8)} is malformed`);
this.sql("INSERT OR IGNORE INTO namespaces (hash, body) VALUES (?, ?)").run(r.hash, r.body);
}
});
}
/** Causals from another codebase, oldest first: each must hash to what it claims, with its namespace and parents here. */
putCausals(rows: Causal[]) {
this.transaction(() => {
for (const c of rows) {
if (causalHash(c.namespace, c.parents) !== c.hash)
throw new Error(
`history entry ${String(c.hash).slice(0, 8)} doesn't hash to its claimed hash`,
);
if (this.missingNamespaces([c.namespace]).length)
throw new Error(`history entry ${c.hash.slice(0, 8)}: its namespace wasn't sent`);
for (const p of c.parents)
if (!this.hasCausal(p))
throw new Error(
`history entry ${c.hash.slice(0, 8)}: its parent ${p.slice(0, 8)} wasn't sent`,
);
this.putCausal(
c.namespace,
c.parents,
Number(c.ts) || 0,
String(c.command ?? ""),
typeof c.message === "string" ? c.message : undefined,
);
}
});
}
/**
* Make a branch's names exactly `rows`, and the text versions they show exactly theirs, logging each change on that
* branch. `texts`: the definitions whose text changed, that are still named.
*/
private replaceBranchNames(
id: number,
rows: NameRow[],
command: string,
// (moving a head and back, as a merge not finished is stepped into for a command: no names changed for good)
quiet = false,
): { added: number; removed: number; texts: number } {
this.changing();
const key = (r: { path: string; hash: string }) => `${r.path} ${r.hash}`;
const current = new Map(
(
this.sql("SELECT path, hash, kind FROM names WHERE branch_id = ?").all(
id,
) as unknown as NameRow[]
).map((r) => [key(r), r]),
);
const wanted = new Map(rows.map((r) => [key(r), r]));
let added = 0;
let removed = 0;
const was = this.textsOf(id);
const shown = new Map(rows.flatMap((r) => (r.text ? [[r.hash, r.text] as const] : [])));
const retexted = new Set<string>();
for (const [k, r] of wanted)
if (current.has(k) && was.get(r.hash) !== shown.get(r.hash)) {
if (!quiet) this.log("update", r.path, r.hash, r.hash, r.kind, command, id);
retexted.add(r.hash);
}
this.replacedTexts.set(id, shown);
this.pendingTexts.delete(id);
this.shown = undefined;
for (const [k, r] of current) {
if (wanted.has(k)) continue;
this.sql("DELETE FROM names WHERE branch_id = ? AND path = ? AND hash = ?").run(
id,
r.path,
r.hash,
);
if (!quiet) this.log("delete", r.path, r.hash, null, r.kind, command, id);
removed++;
}
for (const [k, r] of wanted) {
if (current.has(k)) continue;
this.sql("INSERT INTO names (branch_id, path, hash, kind) VALUES (?, ?, ?, ?)").run(
id,
r.path,
r.hash,
r.kind,
);
if (!quiet) this.log("add", r.path, null, r.hash, r.kind, command, id);
added++;
}
return { added, removed, texts: retexted.size };
}
// ---- releases ------------------------------------------------------------
release(pkg: string, version: string): Release | undefined {
return this.sql("SELECT * FROM releases WHERE package = ? AND version = ?").get(
pkg,
version,
) as unknown as Release | undefined;
}
/** Releases (of one package, or all), newest first. */
releases(pkg?: string): Release[] {
const q =
"SELECT * FROM releases" +
(pkg === undefined ? "" : " WHERE package = ?") +
" ORDER BY ts DESC, version DESC";
return (pkg === undefined ? this.sql(q).all() : this.sql(q).all(pkg)) as unknown as Release[];
}
/** Record a release; one that exists must be identical (a version names one thing, for good). */
putRelease(r: Release) {
const had = this.release(r.package, r.version);
if (had) {
if (had.causal !== r.causal || had.notes !== r.notes)
throw new Error(
`${r.package}@${r.version} is already released here as ${had.causal.slice(0, 8)}, differently; a version can't change`,
);
return;
}
if (!this.hasCausal(r.causal))
throw new Error(
`release ${r.package}@${r.version}: its history entry ${r.causal.slice(0, 8)} isn't here`,
);
this.sql(
"INSERT INTO releases (package, version, namespace, causal, notes, author, ts) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(r.package, r.version, r.namespace, r.causal, r.notes, r.author, r.ts);
}
// ---- contributions -------------------------------------------------------
createContribution(c: {
title: string;
description: string;
source: string;
target: string;
author: string;
}): number {
const now = Date.now();
return Number(
this.sql(
"INSERT INTO contributions (title, description, source, target, author, status, created, updated) VALUES (?, ?, ?, ?, ?, 'open', ?, ?)",
).run(c.title, c.description, c.source, c.target, c.author, now, now).lastInsertRowid,
);
}
contribution(id: number): Contribution | undefined {
const r = this.sql("SELECT * FROM contributions WHERE id = ?").get(id) as Row | undefined;
return r && contributionOf(r);
}
/** Contributions, newest first; by status if given. */
contributions(status?: Contribution["status"]): Contribution[] {
const rows = (
status
? this.sql("SELECT * FROM contributions WHERE status = ? ORDER BY id DESC").all(status)
: this.sql("SELECT * FROM contributions ORDER BY id DESC").all()
) as Row[];
return rows.map(contributionOf);
}
/** The open contribution of `source` into `target`, if there is one. */
openContribution(source: string, target: string): Contribution | undefined {
const r = this.sql(
"SELECT * FROM contributions WHERE source = ? AND target = ? AND status = 'open'",
).get(source, target) as Row | undefined;
return r && contributionOf(r);
}
setContributionStatus(
id: number,
status: Contribution["status"],
merged?: { from: string; as: string },
) {
this.sql(
"UPDATE contributions SET status = ?, merged_from = ?, merged_as = ?, updated = ? WHERE id = ?",
).run(status, merged?.from ?? null, merged?.as ?? null, Date.now(), id);
}
touchContribution(id: number) {
this.sql("UPDATE contributions SET updated = ? WHERE id = ?").run(Date.now(), id);
}
addComment(
contribution: number,
c: { author: string; body: string; about?: string | null },
): number {
this.touchContribution(contribution);
return Number(
this.sql(
"INSERT INTO contribution_comments (contribution, author, body, about, ts) VALUES (?, ?, ?, ?, ?)",
).run(contribution, c.author, c.body, c.about ?? null, Date.now()).lastInsertRowid,
);
}
comments(contribution: number): Comment[] {
return this.sql(
"SELECT id, author, body, about, ts FROM contribution_comments WHERE contribution = ? ORDER BY id",
).all(contribution) as unknown as Comment[];
}
/** The most recent batch of changes on this branch: its first entry and the command that made it. */
lastBatch(): { firstId: number; command: string } | undefined {
const b = this.branch().id;
const { first } = this.sql(
"SELECT min(id) AS first FROM name_log WHERE branch_id = ? AND batch = (SELECT max(batch) FROM name_log WHERE branch_id = ?)",
).get(b, b) as { first: number | null };
if (first === null) return undefined;
const { command } = this.sql("SELECT command FROM name_log WHERE id = ?").get(first) as {
command: string;
};
return { firstId: first, command };
}
logHead(): number {
return ((this.sql("SELECT max(id) AS id FROM name_log").get() as { id: number | null }).id ??
0) as number;
}
/** This branch's names whose definitions have docs, with them: one query, not a record each. */
documentedNames(): { path: string; hash: string; doc: string }[] {
const rows = this.sql(
"SELECT n.path, n.hash, m.value AS doc FROM names n JOIN metadata m ON m.hash = n.hash AND m.key = 'doc' WHERE n.branch_id = ? AND n.kind != 'file' ORDER BY n.path, n.hash",
).all(this.branch().id) as { path: string; hash: string; doc: string }[];
// (a definition showing another text version has that version's doc, or none)
const texts = this.shownTexts();
if (!texts.size) return rows;
const out = rows.filter((r) => !texts.has(r.hash));
for (const n of this.names())
if (texts.has(n.hash)) {
const doc = this.getDef(n.hash)?.doc;
if (doc) out.push({ path: n.path, hash: n.hash, doc });
}
return out.sort((a, b) =>
a.path < b.path ? -1 : a.path > b.path ? 1 : a.hash < b.hash ? -1 : 1,
);
}
/**
* Named definitions that use a definition nothing names on this branch (an old version, or one removed with
* `--force`): each such use, with the name the used one was made as.
*/
unnamedUses(): { path: string; hash: string; uses: string; madeAs: string }[] {
return this.sql(
"SELECT DISTINCT n.path, n.hash, substr(d.to_ref, 2) AS uses, coalesce(u.name_hint, '?') AS madeAs FROM names n JOIN deps d ON d.from_hash = n.hash AND d.to_ref LIKE '#%' LEFT JOIN definitions u ON u.hash = substr(d.to_ref, 2) WHERE n.branch_id = ? AND NOT EXISTS (SELECT 1 FROM names m WHERE m.branch_id = n.branch_id AND m.hash = substr(d.to_ref, 2)) ORDER BY n.path",
).all(this.branch().id) as { path: string; hash: string; uses: string; madeAs: string }[];
}
/** The names that changed on this branch after log entry `afterId`: each once. */
pathsChangedSince(afterId: number): string[] {
return (
this.sql("SELECT DISTINCT path FROM name_log WHERE branch_id = ? AND id > ?").all(
this.branch().id,
afterId,
) as { path: string }[]
).map((r) => r.path);
}
/** Id of the latest name change on a branch, 0 if none. */
branchLogHead(branch: string): number {
return ((
this.sql("SELECT max(id) AS id FROM name_log WHERE branch_id = ?").get(
this.branchId(branch),
) as { id: number | null }
).id ?? 0) as number;
}
/** Name changes on a branch (default: the current one), newest first. */
history(limit = 50, afterId = 0, branch?: string): LogEntry[] {
const id = branch === undefined ? this.branch().id : this.branchId(branch);
return (
this.sql(
"SELECT * FROM name_log WHERE branch_id = ? AND id > ? ORDER BY id DESC LIMIT ?",
).all(id, afterId, limit) as Row[]
).map((r) => ({
id: r.id as number,
batch: r.batch as number,
ts: r.ts as number,
op: r.op as LogEntry["op"],
path: r.path as string,
oldHash: r.old_hash as string | null,
newHash: r.new_hash as string | null,
kind: r.kind as DefKind,
command: r.command as string,
}));
}
getSetting(key: string): string | undefined {
return (
this.sql("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined
)?.value;
}
settings(prefix: string): [string, string][] {
return (
this.sql("SELECT key, value FROM settings WHERE key LIKE ? ORDER BY key").all(
`${prefix}%`,
) as { key: string; value: string }[]
).map((r) => [r.key, r.value]);
}
deleteSetting(key: string) {
this.sql("DELETE FROM settings WHERE key = ?").run(key);
}
setSetting(key: string, value: string) {
this.sql("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);
}
// ---- lint results --------------------------------------------------------------------------------------------
lintResult(hash: string, key: string): string | undefined {
return (
this.sql("SELECT issues FROM lint_results WHERE hash = ? AND key = ?").get(hash, key) as
| { issues: string }
| undefined
)?.issues;
}
/** The keys a definition has lint findings under (the linter's version and configuration): what another codebase could use. */
lintKeysOf(hash: string): string[] {
return (
this.sql("SELECT key FROM lint_results WHERE hash = ? ORDER BY key").all(hash) as {
key: string;
}[]
).map((r) => r.key);
}
putLintResult(hash: string, key: string, issues: string) {
this.sql("INSERT OR REPLACE INTO lint_results (hash, key, issues) VALUES (?, ?, ?)").run(
hash,
key,
issues,
);
}
// ---- the package lock ----------------------------------------------------------------------------------------
/** The version the codebase runs for package `name` within compatible range `range`. */
lockedVersion(name: string, range: string): string | undefined {
return (
this.sql("SELECT version FROM packages WHERE name = ? AND range = ?").get(name, range) as
| { version: string }
| undefined
)?.version;
}
/** Lock `name` within `range` to `version`; the integrity recorded for a previous version doesn't carry over. */
lockVersion(name: string, range: string, version: string, integrity?: string) {
const was = this.sql(
"SELECT version, integrity FROM packages WHERE name = ? AND range = ?",
).get(name, range) as { version: string; integrity: string | null } | undefined;
const keep = was?.version === version ? (was.integrity ?? undefined) : undefined;
this.sql(
"INSERT OR REPLACE INTO packages (name, range, version, integrity) VALUES (?, ?, ?, ?)",
).run(name, range, version, integrity ?? keep ?? null);
}
/** The integrity recorded for the locked version of `name` within `range`. */
lockedIntegrity(name: string, range: string): string | undefined {
return (
(
this.sql("SELECT integrity FROM packages WHERE name = ? AND range = ?").get(name, range) as
| { integrity: string | null }
| undefined
)?.integrity ?? undefined
);
}
/**
* The ranges of package `name` that named definitions use (all, or those at `prefix` and below), with how many
* definitions use each.
*/
packageRanges(name: string, prefix = ""): Map<string, number> {
const rows = this.sql(
`SELECT d.to_ref AS ref, COUNT(DISTINCT n.hash) AS n FROM names n JOIN deps d ON d.from_hash = n.hash
WHERE n.branch_id = ? AND (? = '' OR n.path = ? OR n.path LIKE ?) AND d.to_ref LIKE ? GROUP BY d.to_ref`,
).all(this.branch().id, prefix, prefix, `${prefix}.%`, `npm:${name}@%`) as {
ref: string;
n: number;
}[];
const out = new Map<string, number>();
for (const r of rows) {
const range = /^[^/#]+/.exec(r.ref.slice(`npm:${name}@`.length))![0];
out.set(range, (out.get(range) ?? 0) + r.n);
}
return out;
}
/** Every locked package, by name then range. */
lockedPackages(): { name: string; range: string; version: string; integrity?: string }[] {
return (
this.sql(
"SELECT name, range, version, integrity FROM packages ORDER BY name, range",
).all() as { name: string; range: string; version: string; integrity: string | null }[]
).map((r) => ({
name: r.name,
range: r.range,
version: r.version,
...(r.integrity ? { integrity: r.integrity } : {}),
}));
}
// ---- test results ----------------------------------------------------------
/** A cached result. A test's hash covers everything it depends on, so a result stays valid for that hash. */
testResult(hash: string, nodeVersion: string): TestResult | undefined {
const r = this.sql("SELECT * FROM test_results WHERE hash = ? AND node_version = ?").get(
hash,
nodeVersion,
) as Row | undefined;
return (
r && {
hash,
ok: r.ok === 1,
message: r.message as string,
output: r.output as string,
ms: r.duration_ms as number,
}
);
}
putTestResult(r: TestResult, nodeVersion: string) {
this.sql(
"INSERT OR REPLACE INTO test_results (hash, node_version, ok, message, output, duration_ms, ts) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(r.hash, nodeVersion, r.ok ? 1 : 0, r.message, r.output, r.ms, Date.now());
}
/** The keys a test has results under (runtimes, test frameworks, platforms): what another codebase could use. */
resultKeysOf(hash: string): string[] {
return (
this.sql(
"SELECT node_version AS key FROM test_results WHERE hash = ? ORDER BY node_version",
).all(hash) as {
key: string;
}[]
).map((r) => r.key);
}
/** A watch expression's cached value: like a test, its hash covers everything it evaluates. */
watchResult(hash: string, nodeVersion: string): { ok: boolean; value: string } | undefined {
const r = this.sql(
"SELECT ok, value FROM watch_results WHERE hash = ? AND node_version = ?",
).get(hash, nodeVersion) as Row | undefined;
return r && { ok: r.ok === 1, value: r.value as string };
}
putWatchResult(hash: string, nodeVersion: string, r: { ok: boolean; value: string }) {
this.sql(
"INSERT OR REPLACE INTO watch_results (hash, node_version, ok, value, ts) VALUES (?, ?, ?, ?, ?)",
).run(hash, nodeVersion, r.ok ? 1 : 0, r.value, Date.now());
}
// ---- patches & todo --------------------------------------------------------
/**
* Record that `newHash` replaces `oldHash`. A version can come back (a change undone hashes as the code before it
* did): what replaced it then doesn't now, so that patch goes.
*/
addPatch(oldHash: string, newHash: string) {
this.changing();
const branch = this.branch().id;
this.transaction(() => {
if (newHash !== oldHash)
this.sql("DELETE FROM patches WHERE branch_id = ? AND old_hash = ?").run(branch, newHash);
this.sql(
"INSERT OR REPLACE INTO patches (branch_id, old_hash, new_hash, ts) VALUES (?, ?, ?, ?)",
).run(branch, oldHash, newHash, Date.now());
});
}
/**
* Follow this branch's patches from `hash` to the newest replacement: as far as one the branch names, which is
* current (a patch out of it is from before it came back, or a reset named it again).
*/
patched(hash: string): string {
const stmt = this.sql("SELECT new_hash FROM patches WHERE branch_id = ? AND old_hash = ?");
const named = this.sql("SELECT 1 FROM names WHERE branch_id = ? AND hash = ? LIMIT 1");
const seen = new Set<string>();
for (
let row;
!seen.has(hash) &&
!named.get(this.branch().id, hash) &&
(row = stmt.get(this.branch().id, hash) as { new_hash: string } | undefined);
) {
seen.add(hash);
hash = row.new_hash;
}
return hash;
}
setTodo(hash: string, message: string) {
this.changing();
this.sql("INSERT OR REPLACE INTO todo (branch_id, hash, message) VALUES (?, ?, ?)").run(
this.branch().id,
hash,
message,
);
}
clearTodo(hash: string) {
this.changing();
this.sql("DELETE FROM todo WHERE branch_id = ? AND hash = ?").run(this.branch().id, hash);
}
todos(): { hash: string; message: string }[] {
return this.sql("SELECT hash, message FROM todo WHERE branch_id = ?").all(this.branch().id) as {
hash: string;
message: string;
}[];
}
/**
* A branch's patches and what it has to fix, as they are, and put back: what a merge not finished keeps apart from
* the branch it's into ({@link setHead} moves only names and history).
*/
workOf(branch: string): {
patches: { old: string; new: string; ts: number }[];
todo: { hash: string; message: string }[];
} {
const id = this.branchId(branch);
return {
patches: (
this.sql("SELECT old_hash, new_hash, ts FROM patches WHERE branch_id = ?").all(id) as {
old_hash: string;
new_hash: string;
ts: number;
}[]
).map((r) => ({ old: r.old_hash, new: r.new_hash, ts: r.ts })),
todo: this.sql("SELECT hash, message FROM todo WHERE branch_id = ?").all(id) as {
hash: string;
message: string;
}[],
};
}
/** Put back what {@link workOf} said of a branch. */
setWorkOf(branch: string, work: ReturnType<Store["workOf"]>) {
this.changing();
const id = this.branchId(branch);
this.transaction(() => {
this.sql("DELETE FROM patches WHERE branch_id = ?").run(id);
for (const p of work.patches)
this.sql("INSERT INTO patches (branch_id, old_hash, new_hash, ts) VALUES (?, ?, ?, ?)").run(
id,
p.old,
p.new,
p.ts,
);
this.sql("DELETE FROM todo WHERE branch_id = ?").run(id);
for (const t of work.todo)
this.sql("INSERT INTO todo (branch_id, hash, message) VALUES (?, ?, ?)").run(
id,
t.hash,
t.message,
);
});
}
}
uses
used by