@henols/c64-re-tools 2.0.0-rc.11 → 2.0.0-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +11 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +4 -4
  5. package/skills/c64-basic/SKILL.md +8 -1
  6. package/skills/c64-basic/scripts/basic.ts +3 -3
  7. package/skills/c64-disk/SKILL.md +10 -3
  8. package/skills/c64-disk/scripts/disk.ts +4 -4
  9. package/skills/c64-emulator/SKILL.md +4 -0
  10. package/skills/c64-knowledge/SKILL.md +62 -12
  11. package/skills/c64-knowledge/scripts/knowledge.ts +352 -102
  12. package/skills/c64-memory-map/SKILL.md +7 -0
  13. package/skills/c64-memory-map/scripts/memmap.ts +1 -1
  14. package/skills/c64-provenance/SKILL.md +56 -3
  15. package/skills/c64-provenance/scripts/library.ts +584 -0
  16. package/skills/c64-provenance/scripts/provenance.ts +201 -9
  17. package/skills/c64-reverse-engineering/SKILL.md +16 -2
  18. package/skills/c64-source/SKILL.md +38 -7
  19. package/skills/c64-source/scripts/generate.ts +213 -29
  20. package/skills/c64-source/scripts/loader.ts +3 -1
  21. package/skills/c64-source/scripts/opcodes.ts +19 -0
  22. package/skills/c64-source/scripts/source.ts +387 -35
  23. package/skills/c64-static-analysis/SKILL.md +11 -2
  24. package/skills/c64-static-analysis/scripts/analyze.ts +95 -8
  25. package/skills/c64-static-analysis/scripts/macros.ts +233 -0
  26. package/skills/c64-testing/SKILL.md +8 -1
  27. package/skills/c64-testing/references/scenario-format.md +1 -1
  28. package/skills/c64-testing/scripts/checklist.ts +2 -2
  29. package/skills/c64-testing/scripts/run.ts +4 -4
  30. package/skills/c64-testing/scripts/scenario.ts +1 -1
  31. package/skills/c64-testing/scripts/test.ts +4 -4
  32. package/skills/c64-unpacker/SKILL.md +11 -4
  33. package/skills/c64-unpacker/scripts/unpack.ts +4 -4
  34. package/src/cli/main.ts +5 -3
  35. package/src/host-client/image.ts +1 -1
  36. package/src/host-client/transfer.ts +34 -10
  37. package/src/host-client/vice-session.ts +9 -9
  38. package/src/knowledge/conflicts.ts +157 -0
  39. package/src/knowledge/database.ts +150 -38
  40. package/src/knowledge/history.ts +54 -20
  41. package/src/knowledge/import.ts +3 -2
  42. package/src/knowledge/read.ts +169 -20
  43. package/src/knowledge/schema.ts +115 -72
  44. package/src/knowledge/text.ts +391 -0
  45. package/src/knowledge/write.ts +508 -51
  46. package/src/mcp/output.ts +14 -7
  47. package/src/mcp/tools/debug.ts +8 -6
  48. package/src/mcp/tools/media.ts +17 -13
  49. package/src/mcp/tools/memory.ts +9 -5
  50. package/src/mcp/tools/video.ts +6 -5
  51. package/src/platform.ts +28 -5
  52. package/src/project.ts +166 -3
  53. package/src/protocol/tools.ts +1 -1
  54. package/src/script.ts +46 -7
@@ -4,11 +4,35 @@
4
4
  import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, extname, join, relative, sep } from "node:path";
6
6
 
7
- import { isInside, projectRoot, resolveProjectFile, resolveProjectPath } from "../project.ts";
7
+ import { isInside, ProjectError, projectRoot, resolveProject, resolveProjectFile, resolveProjectPath } from "../project.ts";
8
8
  import { MAX_ATTACHMENT_BYTES } from "../protocol/framing.ts";
9
9
  import { WireFailure } from "../protocol/messages.ts";
10
10
  import { MAX_TREE_FILES, type SourceTree } from "../protocol/tools.ts";
11
11
 
12
+ /** Runs a project lookup and gives a refusal of the project rules as a WireFailure with the same code and message. */
13
+ function asWireFailure<T>(lookup: () => T): T {
14
+ try {
15
+ return lookup();
16
+ } catch (error) {
17
+ if (error instanceof ProjectError) throw new WireFailure(error.code, error.message);
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * The project folder of a call that names it with `project` (relative to the
24
+ * working directory) or not (D67, D68). Throws WireFailure when the rules
25
+ * refuse it, for example at a folder with several projects below it.
26
+ */
27
+ export function projectFolder(project: string | undefined): string {
28
+ return asWireFailure(() => resolveProject(project, process.cwd(), { remedy: "Give project: <folder>" }));
29
+ }
30
+
31
+ /** The project folder of this process (--project or the D67 lookup), or a WireFailure. */
32
+ function currentProject(): string {
33
+ return asWireFailure(projectRoot);
34
+ }
35
+
12
36
  export interface ProjectFile {
13
37
  bytes: Buffer;
14
38
  /** Lowercase extension without the dot; empty when the file has none. */
@@ -21,16 +45,16 @@ export interface ProjectFile {
21
45
  * the file is too large to transfer.
22
46
  */
23
47
  export function readProjectFile(path: string, options: { root?: string } = {}): ProjectFile {
24
- const root = options.root ?? projectRoot();
48
+ const root = options.root ?? currentProject();
25
49
  let real: string;
26
50
  try {
27
51
  real = resolveProjectFile(path, root);
28
52
  } catch (error) {
29
53
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
30
- throw new WireFailure("not-found", `There is no file at ${path} in the project directory.`);
54
+ throw new WireFailure("not-found", `There is no file at ${path} in the project folder.`);
31
55
  }
32
56
  if (error instanceof TypeError) {
33
- throw new WireFailure("invalid-input", `${path}: give a path relative to the project directory that stays inside it.`);
57
+ throw new WireFailure("invalid-input", `${path}: give a path relative to the project folder that stays inside it.`);
34
58
  }
35
59
  throw error;
36
60
  }
@@ -48,8 +72,8 @@ export function readProjectFile(path: string, options: { root?: string } = {}):
48
72
  * project, also one that leaves it through a symbolic link.
49
73
  */
50
74
  export function writeProjectFile(path: string, bytes: Uint8Array, options: { root?: string; what?: string } = {}): void {
51
- const root = options.root ?? projectRoot();
52
- const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project directory that stays inside it, not ${path}.`);
75
+ const root = options.root ?? currentProject();
76
+ const refuse = () => new WireFailure("invalid-input", `${options.what ?? path}: give a path relative to the project folder that stays inside it, not ${path}.`);
53
77
  let file: string;
54
78
  try {
55
79
  file = resolveProjectPath(path, root);
@@ -72,18 +96,18 @@ export function writeProjectFile(path: string, bytes: Uint8Array, options: { roo
72
96
  const SKIPPED_DIRECTORIES = new Set([".git", ".hg", ".svn", ".c64-re-tools"]);
73
97
 
74
98
  /**
75
- * Reads a project directory as a source tree for staging: ordinary
99
+ * Reads a project folder as a source tree for staging: ordinary
76
100
  * files with relative POSIX paths. A symbolic link is followed only when its
77
101
  * target stays inside the source root; otherwise the tree is refused.
78
102
  */
79
103
  export function readProjectTree(path: string, options: { root?: string } = {}): { files: SourceTree; contents: Buffer[] } {
80
- const projectRootPath = options.root ?? projectRoot();
104
+ const projectRootPath = options.root ?? currentProject();
81
105
  let sourceRoot: string;
82
106
  try {
83
107
  sourceRoot = resolveProjectFile(path, projectRootPath);
84
108
  } catch (error) {
85
- if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new WireFailure("not-found", `There is no directory ${path} in the project directory.`);
86
- if (error instanceof TypeError) throw new WireFailure("invalid-input", `${path}: give a directory relative to the project directory that stays inside it.`);
109
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new WireFailure("not-found", `There is no directory ${path} in the project folder.`);
110
+ if (error instanceof TypeError) throw new WireFailure("invalid-input", `${path}: give a directory relative to the project folder that stays inside it.`);
87
111
  throw error;
88
112
  }
89
113
  if (!statSync(sourceRoot).isDirectory()) throw new WireFailure("invalid-input", `${path} is not a directory.`);
@@ -127,21 +127,21 @@ export class ViceSessionClient {
127
127
  return this.#request("runUntil", params);
128
128
  }
129
129
 
130
- /** Loads a project PRG into memory without reset or start; finishes stopped. */
131
- async programLoad(params: { path: string; address?: number }): Promise<{ state: RunState; loadAddress: number; size: number }> {
132
- const file = readProjectFile(params.path);
130
+ /** Loads a project PRG into memory without reset or start; finishes stopped. `root` is the project folder; it stays on this side. */
131
+ async programLoad(params: { path: string; address?: number; root?: string }): Promise<{ state: RunState; loadAddress: number; size: number }> {
132
+ const file = readProjectFile(params.path, params.root === undefined ? {} : { root: params.root });
133
133
  return this.#request("programLoad", params.address === undefined ? {} : { address: params.address }, [file.bytes]);
134
134
  }
135
135
 
136
- /** Autostarts a project program or image, as VICE's autostart does. */
137
- async autostart(params: { path: string; index: number; run: boolean }): Promise<{ state: RunState; emulatorFlaw?: string }> {
138
- const file = readProjectFile(params.path);
136
+ /** Autostarts a project program or image, as VICE's autostart does. `root` is the project folder; it stays on this side. */
137
+ async autostart(params: { path: string; index: number; run: boolean; root?: string }): Promise<{ state: RunState; emulatorFlaw?: string }> {
138
+ const file = readProjectFile(params.path, params.root === undefined ? {} : { root: params.root });
139
139
  return this.#request("autostart", { type: file.type, index: params.index, run: params.run }, [file.bytes]);
140
140
  }
141
141
 
142
- /** Attaches a project disk image to drive 8. */
143
- async diskAttach(params: { path: string }): Promise<{ attached: boolean }> {
144
- const file = readProjectFile(params.path);
142
+ /** Attaches a project disk image to drive 8. `root` is the project folder; it stays on this side. */
143
+ async diskAttach(params: { path: string; root?: string }): Promise<{ attached: boolean }> {
144
+ const file = readProjectFile(params.path, params.root === undefined ? {} : { root: params.root });
145
145
  return this.#request("diskAttach", { type: file.type }, [file.bytes]);
146
146
  }
147
147
 
@@ -0,0 +1,157 @@
1
+ // Conflicts (D69): current rows that break a current-row rule. One computer
2
+ // never writes them, but git joins the knowledge of two computers line by
3
+ // line, so after a merge two names can be current at one address, or two
4
+ // regions can overlap. Reads show them; writes are refused until a merge
5
+ // revision decides them.
6
+
7
+ import type { DatabaseSync } from "node:sqlite";
8
+
9
+ import type { HistoryEntry } from "./history.ts";
10
+ import {
11
+ COMMENT_COLUMNS,
12
+ MACRO_COLUMNS,
13
+ MACRO_USE_COLUMNS,
14
+ MODULE_COLUMNS,
15
+ OPERAND_COLUMNS,
16
+ PATCH_COLUMNS,
17
+ REFERENCE_COLUMNS,
18
+ REGION_COLUMNS,
19
+ SUPPRESSION_COLUMNS,
20
+ SYMBOL_COLUMNS,
21
+ toComment,
22
+ toMacro,
23
+ toMacroUse,
24
+ toModule,
25
+ toOperand,
26
+ toPatch,
27
+ toReference,
28
+ toRegion,
29
+ toSuppression,
30
+ toSymbol,
31
+ } from "./read.ts";
32
+ import type { Space } from "./schema.ts";
33
+ import { compareText, sqlName } from "./text.ts";
34
+
35
+ /** The current-row rules, each with what breaks it. */
36
+ export const CONFLICT_RULES = {
37
+ "symbol-address": "two symbols at one address",
38
+ "symbol-name": "one name at two addresses",
39
+ region: "regions that overlap",
40
+ comment: "two comments at one place",
41
+ reference: "the same reference two times",
42
+ operand: "two operand expressions at one address",
43
+ "module-start": "two modules at one address",
44
+ "module-file": "one module file at two addresses",
45
+ "suppression-symbol": "the same symbol suppression two times",
46
+ "suppression-reference": "the same reference suppression two times",
47
+ "suppression-region": "region suppressions that overlap",
48
+ patch: "patches that overlap",
49
+ macro: "two macros with one name",
50
+ "macro-use": "macro uses that overlap",
51
+ } as const;
52
+ export type ConflictRule = keyof typeof CONFLICT_RULES;
53
+
54
+ /** One row of a conflict: the row with its revisions and its id. */
55
+ export type ConflictRow = HistoryEntry & { id: string };
56
+
57
+ /** Current rows that break one rule together, oldest first. */
58
+ export interface Conflict {
59
+ rule: ConflictRule;
60
+ space: Space;
61
+ rows: ConflictRow[];
62
+ }
63
+
64
+ type Raw = Record<string, unknown>;
65
+
66
+ interface TableRule {
67
+ rule: ConflictRule;
68
+ table: string;
69
+ entity: HistoryEntry["entity"];
70
+ columns: string;
71
+ map: (raw: Raw) => HistoryEntry["row"];
72
+ /** The columns that must differ between current rows, or "overlap" for address ranges. */
73
+ key: readonly string[] | "overlap";
74
+ /** Rows of the table that the rule is about. */
75
+ where?: string;
76
+ }
77
+
78
+ const RULES: readonly TableRule[] = [
79
+ { rule: "symbol-address", table: "symbols", entity: "symbol", columns: SYMBOL_COLUMNS, map: toSymbol, key: ["address"] },
80
+ { rule: "symbol-name", table: "symbols", entity: "symbol", columns: SYMBOL_COLUMNS, map: toSymbol, key: ["name"] },
81
+ { rule: "region", table: "regions", entity: "region", columns: REGION_COLUMNS, map: toRegion, key: "overlap" },
82
+ { rule: "comment", table: "comments", entity: "comment", columns: COMMENT_COLUMNS, map: toComment, key: ["address", "placement"] },
83
+ { rule: "reference", table: "references", entity: "reference", columns: REFERENCE_COLUMNS, map: toReference, key: ["from_address", "to_address", "kind"] },
84
+ { rule: "operand", table: "operands", entity: "operand", columns: OPERAND_COLUMNS, map: toOperand, key: ["address"] },
85
+ { rule: "module-start", table: "modules", entity: "module", columns: MODULE_COLUMNS, map: toModule, key: ["start_address"] },
86
+ { rule: "module-file", table: "modules", entity: "module", columns: MODULE_COLUMNS, map: toModule, key: ["file"] },
87
+ { rule: "suppression-symbol", table: "suppressions", entity: "suppression", columns: SUPPRESSION_COLUMNS, map: toSuppression, key: ["start_address"], where: "entity = 'symbol'" },
88
+ {
89
+ rule: "suppression-reference",
90
+ table: "suppressions",
91
+ entity: "suppression",
92
+ columns: SUPPRESSION_COLUMNS,
93
+ map: toSuppression,
94
+ key: ["start_address", "to_address", "kind"],
95
+ where: "entity = 'reference'",
96
+ },
97
+ { rule: "suppression-region", table: "suppressions", entity: "suppression", columns: SUPPRESSION_COLUMNS, map: toSuppression, key: "overlap", where: "entity = 'region'" },
98
+ { rule: "patch", table: "patches", entity: "patch", columns: PATCH_COLUMNS, map: toPatch, key: "overlap" },
99
+ { rule: "macro", table: "macros", entity: "macro", columns: MACRO_COLUMNS, map: toMacro, key: ["name"] },
100
+ { rule: "macro-use", table: "macro_uses", entity: "macro-use", columns: MACRO_USE_COLUMNS, map: toMacroUse, key: "overlap" },
101
+ ];
102
+
103
+ function conflictRow(rule: TableRule, raw: Raw): ConflictRow {
104
+ return { entity: rule.entity, row: rule.map(raw), from: raw.valid_from_revision as string, id: raw.id as string } as ConflictRow;
105
+ }
106
+
107
+ const byAge = (a: ConflictRow, b: ConflictRow) => compareText(a.from, b.from) || compareText(a.id, b.id);
108
+
109
+ /** The current rows of one rule that share a key, in groups. */
110
+ function sameKey(db: DatabaseSync, rule: TableRule, key: readonly string[]): Conflict[] {
111
+ const where = `valid_to_revision IS NULL${rule.where === undefined ? "" : ` AND ${rule.where}`}`;
112
+ const columns = ["space", ...key].join(", ");
113
+ const rows = db
114
+ .prepare(`SELECT ${rule.columns}, id FROM ${sqlName(rule.table)} WHERE ${where} AND (${columns}) IN (SELECT ${columns} FROM ${sqlName(rule.table)} WHERE ${where} GROUP BY ${columns} HAVING count(*) > 1)`)
115
+ .all() as Raw[];
116
+ const groups = new Map<string, Conflict>();
117
+ for (const raw of rows) {
118
+ const id = JSON.stringify(["space", ...key].map((column) => raw[column]));
119
+ const group = groups.get(id) ?? { rule: rule.rule, space: raw.space as Space, rows: [] };
120
+ group.rows.push(conflictRow(rule, raw));
121
+ groups.set(id, group);
122
+ }
123
+ return [...groups.values()];
124
+ }
125
+
126
+ /** The current rows of one rule whose address ranges overlap, in groups of rows that overlap in a chain. */
127
+ function overlapping(db: DatabaseSync, rule: TableRule): Conflict[] {
128
+ const rows = db
129
+ .prepare(`SELECT ${rule.columns}, id FROM ${sqlName(rule.table)} WHERE valid_to_revision IS NULL${rule.where === undefined ? "" : ` AND ${rule.where}`} ORDER BY space, start_address, end_address`)
130
+ .all() as Raw[];
131
+ const conflicts: Conflict[] = [];
132
+ let group: Conflict | undefined;
133
+ let end = -1;
134
+ for (const raw of rows) {
135
+ if (group !== undefined && raw.space === group.space && (raw.start_address as number) <= end) {
136
+ group.rows.push(conflictRow(rule, raw));
137
+ end = Math.max(end, raw.end_address as number);
138
+ continue;
139
+ }
140
+ if (group !== undefined && group.rows.length > 1) conflicts.push(group);
141
+ group = { rule: rule.rule, space: raw.space as Space, rows: [conflictRow(rule, raw)] };
142
+ end = raw.end_address as number;
143
+ }
144
+ if (group !== undefined && group.rows.length > 1) conflicts.push(group);
145
+ return conflicts;
146
+ }
147
+
148
+ /** Every conflict of the knowledge, rule by rule; the rows of each, oldest first. */
149
+ export function conflicts(db: DatabaseSync | undefined): Conflict[] {
150
+ if (db === undefined) return [];
151
+ return RULES.flatMap((rule) => (rule.key === "overlap" ? overlapping(db, rule) : sameKey(db, rule, rule.key))).map((conflict) => ({ ...conflict, rows: conflict.rows.sort(byAge) }));
152
+ }
153
+
154
+ /** The table of a conflict rule. */
155
+ export function conflictTable(rule: ConflictRule): string {
156
+ return RULES.find((item) => item.rule === rule)!.table;
157
+ }
@@ -1,14 +1,23 @@
1
- // The one place that opens .c64-re-tools/knowledge.db.
1
+ // The one place that opens the knowledge. The knowledge is text in
2
+ // .c64-re-tools/knowledge/ (D69); .c64-re-tools/local/knowledge.db is an index
3
+ // that is made again from the text when the text changed.
2
4
 
3
- import { existsSync, mkdirSync } from "node:fs";
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
6
  import { DatabaseSync } from "node:sqlite";
5
7
  import { join } from "node:path";
6
8
 
7
- import { projectRoot } from "../project.ts";
8
- import { checkVersion, ensureSchema, schemaVersion } from "./schema.ts";
9
+ import { ensureLocal, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRoot } from "../project.ts";
10
+ import { ensureSchema } from "./schema.ts";
11
+ import { compareText, loadText, readText, REVISIONS_FILE, revisionsText, sha256, sqlName, TABLES, tableText, writeAtomic } from "./text.ts";
9
12
 
10
- export const KNOWLEDGE_DIRECTORY = ".c64-re-tools";
11
- export const KNOWLEDGE_FILE = "knowledge.db";
13
+ export const KNOWLEDGE_DIRECTORY = PROJECT_DIRECTORY;
14
+ /** The folder of the committed knowledge text. */
15
+ export const TEXT_DIRECTORY = "knowledge";
16
+ /** The index file in .c64-re-tools/local/. */
17
+ export const INDEX_FILE = "knowledge.db";
18
+ /** Git keeps the lines of both sides when two computers changed the same knowledge file. */
19
+ // eol=lf: with core.autocrlf (the Windows default) git would check the files out with CRLF, so two computers would hold other bytes.
20
+ export const GIT_ATTRIBUTES = "# The knowledge of c64-re-tools: git keeps the lines of both sides of a merge, with LF line ends on every system.\n*.jsonl merge=union text eol=lf\n.gitattributes text eol=lf\n";
12
21
 
13
22
  export const KNOWLEDGE_ERROR_CODES = [
14
23
  "invalid-input",
@@ -31,12 +40,27 @@ export class KnowledgeError extends Error {
31
40
  }
32
41
  }
33
42
 
34
- export function knowledgePath(root: string = projectRoot()): string {
35
- return join(root, KNOWLEDGE_DIRECTORY, KNOWLEDGE_FILE);
43
+ /** The folder of the knowledge text: .c64-re-tools/knowledge/. */
44
+ export function textPath(root: string = projectRoot()): string {
45
+ return join(root, KNOWLEDGE_DIRECTORY, TEXT_DIRECTORY);
46
+ }
47
+
48
+ /** The index: .c64-re-tools/local/knowledge.db. */
49
+ export function indexPath(root: string = projectRoot()): string {
50
+ return join(root, KNOWLEDGE_DIRECTORY, LOCAL_DIRECTORY, INDEX_FILE);
51
+ }
52
+
53
+ /** Refuses a project that has only the knowledge.db of an older c64-re-tools. */
54
+ function refuseOldLayout(root: string): void {
55
+ if (!existsSync(textPath(root)) && existsSync(join(root, KNOWLEDGE_DIRECTORY, INDEX_FILE))) {
56
+ throw new KnowledgeError(
57
+ "unsupported-schema",
58
+ `${KNOWLEDGE_DIRECTORY}/${INDEX_FILE} is the knowledge of an older c64-re-tools. This c64-re-tools keeps the knowledge as text in ${KNOWLEDGE_DIRECTORY}/${TEXT_DIRECTORY}/. Convert it one time with distribution/convert-knowledge.ts of the c64-re-tools source repository.`,
59
+ );
60
+ }
36
61
  }
37
62
 
38
63
  function configure(db: DatabaseSync): void {
39
- // A committed write must be complete in knowledge.db itself: rollback journal, never WAL.
40
64
  db.exec("PRAGMA journal_mode = DELETE; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
41
65
  }
42
66
 
@@ -47,28 +71,23 @@ function open(path: string, readOnly: boolean): DatabaseSync {
47
71
  configure(db);
48
72
  db.prepare("SELECT count(*) FROM sqlite_master").get();
49
73
  } catch (error) {
50
- throw new KnowledgeError("invalid-database", `${KNOWLEDGE_DIRECTORY}/${KNOWLEDGE_FILE} is not a readable SQLite database: ${(error as Error).message}`);
74
+ throw new KnowledgeError("invalid-database", `${KNOWLEDGE_DIRECTORY}/${LOCAL_DIRECTORY}/${INDEX_FILE} is not a readable SQLite database: ${(error as Error).message} Delete it: it is made again from the knowledge text.`);
51
75
  }
52
76
  return db;
53
77
  }
54
78
 
55
- /**
56
- * Opens the project's knowledge database read-only, as it is: a read never
57
- * migrates and never writes. Returns undefined when the database does not
58
- * exist or has no schema yet: that reads as empty knowledge, and a read
59
- * creates nothing. Refuses a schema version other than this build's.
60
- */
61
- export function openForRead(root: string = projectRoot()): DatabaseSync | undefined {
62
- const path = knowledgePath(root);
63
- if (!existsSync(path)) return undefined;
64
- const db = open(path, true);
79
+ /** Opens the index for a write and gives it the schema of this build. A file that is no database is made again. */
80
+ function openIndex(root: string): DatabaseSync {
81
+ ensureLocal(root);
82
+ let db: DatabaseSync;
65
83
  try {
66
- const version = schemaVersion(db);
67
- if (version === 0) {
68
- db.close();
69
- return undefined;
70
- }
71
- checkVersion(version);
84
+ db = open(indexPath(root), false);
85
+ } catch {
86
+ writeFileSync(indexPath(root), "");
87
+ db = open(indexPath(root), false);
88
+ }
89
+ try {
90
+ ensureSchema(db);
72
91
  } catch (error) {
73
92
  db.close();
74
93
  throw error;
@@ -76,16 +95,75 @@ export function openForRead(root: string = projectRoot()): DatabaseSync | undefi
76
95
  return db;
77
96
  }
78
97
 
98
+ function storedHashes(db: DatabaseSync): Record<string, string> {
99
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'text_hashes'").get() as { value: string } | undefined;
100
+ return row === undefined ? {} : (JSON.parse(row.value) as Record<string, string>);
101
+ }
102
+
103
+ function storeHashes(db: DatabaseSync, hashes: Record<string, string>): void {
104
+ const sorted = Object.fromEntries(Object.entries(hashes).sort(([a], [b]) => compareText(a, b)));
105
+ db.prepare("INSERT INTO meta (key, value) VALUES ('text_hashes', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(JSON.stringify(sorted));
106
+ }
107
+
108
+ /** The SHA-256 of each knowledge file, by file name, in name order. */
109
+ function textHashes(files: ReadonlyMap<string, { sha256: string }>): Record<string, string> {
110
+ return Object.fromEntries([...files].map(([name, file]): [string, string] => [name, file.sha256]).sort(([a], [b]) => compareText(a, b)));
111
+ }
112
+
113
+ const sameHashes = (a: Record<string, string>, b: Record<string, string>) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
114
+
79
115
  /**
80
- * Opens the project's knowledge database for a write, creating
81
- * .c64-re-tools/knowledge.db and its schema when needed. Refuses a schema
82
- * version other than this build's.
116
+ * Makes the index equal to the text when a knowledge file has another
117
+ * SHA-256 than the index holds, or a file came or went. Runs inside a write
118
+ * transaction, so two processes never make the index at the same time.
119
+ */
120
+ function freshen(db: DatabaseSync, root: string): void {
121
+ const files = readText(textPath(root));
122
+ const hashes = textHashes(files);
123
+ if (sameHashes(hashes, storedHashes(db))) return;
124
+ db.exec("DELETE FROM evidence");
125
+ for (const spec of TABLES) db.exec(`DELETE FROM ${sqlName(spec.table)}`);
126
+ db.exec("DELETE FROM revisions");
127
+ loadText(db, files);
128
+ storeHashes(db, hashes);
129
+ }
130
+
131
+ /** The project of each index opened for a write: its writes go to the text too. */
132
+ const projects = new WeakMap<DatabaseSync, string>();
133
+
134
+ /**
135
+ * Opens the project's knowledge read-only, made fresh from the text first. A
136
+ * read never changes the knowledge. Returns undefined when the project has no
137
+ * knowledge text: that reads as empty knowledge, and a read creates nothing.
138
+ */
139
+ export function openForRead(root: string = projectRoot()): DatabaseSync | undefined {
140
+ refuseOldLayout(root);
141
+ if (!existsSync(textPath(root))) return undefined;
142
+ const index = openIndex(root);
143
+ try {
144
+ // The lock is taken only when the text changed: a read does not wait for a long write that keeps the index fresh.
145
+ if (!sameHashes(textHashes(readText(textPath(root))), storedHashes(index))) transaction(index, () => freshen(index, root));
146
+ } finally {
147
+ index.close();
148
+ }
149
+ return open(indexPath(root), true);
150
+ }
151
+
152
+ /**
153
+ * Opens the project's knowledge for a write: it creates
154
+ * .c64-re-tools/knowledge/ with its .gitattributes when needed, and the index.
155
+ * Every committed transaction on the database writes the changed knowledge
156
+ * files.
83
157
  */
84
158
  export function openForWrite(root: string = projectRoot()): DatabaseSync {
85
- mkdirSync(join(root, KNOWLEDGE_DIRECTORY), { recursive: true });
86
- const db = open(knowledgePath(root), false);
159
+ refuseOldLayout(root);
160
+ mkdirSync(textPath(root), { recursive: true });
161
+ const attributes = join(textPath(root), ".gitattributes");
162
+ if (!existsSync(attributes)) writeFileSync(attributes, GIT_ATTRIBUTES);
163
+ const db = openIndex(root);
164
+ projects.set(db, root);
87
165
  try {
88
- ensureSchema(db);
166
+ transaction(db, () => undefined);
89
167
  } catch (error) {
90
168
  db.close();
91
169
  throw error;
@@ -94,13 +172,14 @@ export function openForWrite(root: string = projectRoot()): DatabaseSync {
94
172
  }
95
173
 
96
174
  /**
97
- * Runs one write on the project's knowledge database and closes it. When the
98
- * project has no knowledge.db yet, `work` first runs on an empty database in
99
- * memory: a write that is refused there creates no .c64-re-tools/. `work`
100
- * must change nothing outside the database it gets.
175
+ * Runs one write on the project's knowledge and closes it. When the project
176
+ * has no knowledge yet, `work` first runs on an empty database in memory: a
177
+ * write that is refused there creates no .c64-re-tools/. `work` must change
178
+ * nothing outside the database it gets.
101
179
  */
102
180
  export function withWrite<T>(work: (db: DatabaseSync) => T, root: string = projectRoot()): T {
103
- if (!existsSync(knowledgePath(root))) {
181
+ refuseOldLayout(root);
182
+ if (!existsSync(textPath(root))) {
104
183
  const trial = new DatabaseSync(":memory:");
105
184
  try {
106
185
  configure(trial);
@@ -118,11 +197,44 @@ export function withWrite<T>(work: (db: DatabaseSync) => T, root: string = proje
118
197
  }
119
198
  }
120
199
 
121
- /** Runs `work` in one immediate transaction: all of it commits, or none of it does. */
200
+ /** The newest revision id, or "0" when there is none. */
201
+ function newest(db: DatabaseSync): string {
202
+ return (db.prepare("SELECT max(id) AS id FROM revisions").get() as { id: string | null }).id ?? "0";
203
+ }
204
+
205
+ /**
206
+ * Writes the knowledge files that the revisions after `before` changed, then
207
+ * keeps their SHA-256 in the index. The text is written before the index
208
+ * commits: when the commit fails, the next open makes the index from the text.
209
+ */
210
+ function writeText(db: DatabaseSync, root: string, before: string): void {
211
+ const directory = textPath(root);
212
+ const hashes = storedHashes(db);
213
+ const write = (file: string, text: string) => {
214
+ writeAtomic(join(directory, file), text);
215
+ hashes[file] = sha256(text);
216
+ };
217
+ write(REVISIONS_FILE, revisionsText(db));
218
+ for (const spec of TABLES) {
219
+ const changed = db.prepare(`SELECT 1 FROM ${sqlName(spec.table)} WHERE valid_from_revision > ? OR valid_to_revision > ? LIMIT 1`).get(before, before);
220
+ if (changed !== undefined) write(spec.file, tableText(db, spec));
221
+ }
222
+ storeHashes(db, hashes);
223
+ }
224
+
225
+ /**
226
+ * Runs `work` in one immediate transaction: all of it commits, or none of it
227
+ * does. On the knowledge of a project, the index is made fresh from the text
228
+ * first, and a work that adds a revision writes the changed knowledge files.
229
+ */
122
230
  export function transaction<T>(db: DatabaseSync, work: () => T): T {
231
+ const root = projects.get(db);
123
232
  db.exec("BEGIN IMMEDIATE");
124
233
  try {
234
+ if (root !== undefined) freshen(db, root);
235
+ const before = root === undefined ? "0" : newest(db);
125
236
  const result = work();
237
+ if (root !== undefined && newest(db) !== before) writeText(db, root, before);
126
238
  db.exec("COMMIT");
127
239
  return result;
128
240
  } catch (error) {