@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.1

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 (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -2
  4. package/extensions/commands.ts +732 -0
  5. package/extensions/context.ts +37 -0
  6. package/extensions/deps.ts +32 -0
  7. package/extensions/heydb.ts +20 -0
  8. package/extensions/secret-input.ts +120 -0
  9. package/extensions/tools.ts +402 -0
  10. package/lib/about.ts +7 -0
  11. package/lib/config/operations.ts +75 -0
  12. package/lib/config/store.ts +73 -0
  13. package/lib/config/types.ts +37 -0
  14. package/lib/config/validate.ts +136 -0
  15. package/lib/engines/index.ts +66 -0
  16. package/lib/engines/mysql/catalog.ts +371 -0
  17. package/lib/engines/mysql/introspect.ts +186 -0
  18. package/lib/engines/postgres/catalog.ts +390 -0
  19. package/lib/engines/postgres/introspect.ts +240 -0
  20. package/lib/knowledge/operations.ts +223 -0
  21. package/lib/knowledge/query.ts +129 -0
  22. package/lib/knowledge/types.ts +64 -0
  23. package/lib/knowledge/validate.ts +151 -0
  24. package/lib/model/diff.ts +218 -0
  25. package/lib/model/hash.ts +88 -0
  26. package/lib/model/names.ts +141 -0
  27. package/lib/model/order.ts +47 -0
  28. package/lib/model/references.ts +251 -0
  29. package/lib/model/snapshot.ts +128 -0
  30. package/lib/model/stats.ts +97 -0
  31. package/lib/model/validate.ts +240 -0
  32. package/lib/render/markdown.ts +508 -0
  33. package/lib/search.ts +530 -0
  34. package/lib/secrets.ts +149 -0
  35. package/lib/store/envfile.ts +124 -0
  36. package/lib/store/files.ts +57 -0
  37. package/lib/store/guards.ts +68 -0
  38. package/lib/store/knowledge.ts +139 -0
  39. package/lib/store/project.ts +28 -0
  40. package/lib/store/snapshots.ts +143 -0
  41. package/lib/usecases/author.ts +51 -0
  42. package/lib/usecases/code-scan.ts +435 -0
  43. package/lib/usecases/init.ts +111 -0
  44. package/lib/usecases/knowledge.ts +214 -0
  45. package/lib/usecases/project.ts +377 -0
  46. package/lib/usecases/resolve.ts +186 -0
  47. package/lib/usecases/sharing.ts +83 -0
  48. package/lib/usecases/status.ts +37 -0
  49. package/lib/usecases/sync.ts +33 -0
  50. package/package.json +69 -5
  51. package/skills/heydb/SKILL.md +67 -0
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { formatName, parseName } from "../model/names.ts";
4
+ import { type Annotation, type Knowledge, PROJECT_TARGET, type Relationship, type RelationshipSide } from "./types.ts";
5
+
6
+ // Pure operations on Knowledge: each returns a new value and never mutates its
7
+ // input, so a failed validation can never leave half-applied changes behind,
8
+ // and callers decide when (and whether) to save.
9
+ //
10
+ // Entries are kept sorted, so the file written to disk depends only on its
11
+ // content, never on the order in which notes were added. That keeps git diffs
12
+ // small and lets two people annotating different objects merge line by line.
13
+
14
+ /** Long enough for a real explanation, short enough to keep the file reviewable. */
15
+ export const MAX_TEXT_LENGTH = 4000;
16
+
17
+ /**
18
+ * Where time and ids come from. Production uses the real clock and random
19
+ * UUIDs; tests pass fixed sequences so every result can be compared exactly.
20
+ */
21
+ export interface KnowledgeOptions {
22
+ readonly now?: () => string;
23
+ readonly newId?: () => string;
24
+ }
25
+
26
+ const realClock = (): string => new Date().toISOString();
27
+
28
+ export interface AnnotationInput {
29
+ readonly target: string;
30
+ readonly text: string;
31
+ readonly author: string;
32
+ }
33
+
34
+ export interface RelationshipInput {
35
+ readonly from: RelationshipSide;
36
+ readonly to: RelationshipSide;
37
+ /** Why the relationship exists or how it was found; may be empty. */
38
+ readonly note: string;
39
+ readonly author: string;
40
+ }
41
+
42
+ export function emptyKnowledge(): Knowledge {
43
+ return { formatVersion: 1, annotations: [], relationships: [] };
44
+ }
45
+
46
+ export function addAnnotation(
47
+ knowledge: Knowledge,
48
+ input: AnnotationInput,
49
+ options: KnowledgeOptions = {},
50
+ ): { knowledge: Knowledge; annotation: Annotation } {
51
+ // Stored in canonical form, so '"shop".shop' and 'shop.shop' are the same target.
52
+ return withAnnotation(knowledge, formatName(parseName(input.target)), input, options);
53
+ }
54
+
55
+ /** A note about the whole project, for the project file; it has no target to resolve. */
56
+ export function addProjectNote(
57
+ knowledge: Knowledge,
58
+ input: Omit<AnnotationInput, "target">,
59
+ options: KnowledgeOptions = {},
60
+ ): { knowledge: Knowledge; annotation: Annotation } {
61
+ return withAnnotation(knowledge, PROJECT_TARGET, input, options);
62
+ }
63
+
64
+ function withAnnotation(
65
+ knowledge: Knowledge,
66
+ target: string,
67
+ input: Omit<AnnotationInput, "target">,
68
+ options: KnowledgeOptions,
69
+ ): { knowledge: Knowledge; annotation: Annotation } {
70
+ const { now = realClock, newId = randomUUID } = options;
71
+ const text = cleanText(input.text, "annotation text", { allowEmpty: false });
72
+ const author = cleanAuthor(input.author);
73
+ const timestamp = now();
74
+ const annotation: Annotation = { id: newId(), target, text, author, createdAt: timestamp, updatedAt: timestamp };
75
+ return { knowledge: withEntries(knowledge, [...knowledge.annotations, annotation]), annotation };
76
+ }
77
+
78
+ /** Replaces the text of a note. Who wrote it and when stay as they were. */
79
+ export function editAnnotation(
80
+ knowledge: Knowledge,
81
+ id: string,
82
+ text: string,
83
+ options: KnowledgeOptions = {},
84
+ ): { knowledge: Knowledge; annotation: Annotation } {
85
+ const { now = realClock } = options;
86
+ const existing = findById(knowledge.annotations, id, "annotation");
87
+ const annotation: Annotation = { ...existing, text: cleanText(text, "annotation text", { allowEmpty: false }), updatedAt: now() };
88
+ const annotations = knowledge.annotations.map((item) => (item.id === id ? annotation : item));
89
+ return { knowledge: withEntries(knowledge, annotations), annotation };
90
+ }
91
+
92
+ export function removeAnnotation(knowledge: Knowledge, id: string): { knowledge: Knowledge; annotation: Annotation } {
93
+ const annotation = findById(knowledge.annotations, id, "annotation");
94
+ const annotations = knowledge.annotations.filter((item) => item.id !== id);
95
+ return { knowledge: withEntries(knowledge, annotations), annotation };
96
+ }
97
+
98
+ export function addRelationship(
99
+ knowledge: Knowledge,
100
+ input: RelationshipInput,
101
+ options: KnowledgeOptions = {},
102
+ ): { knowledge: Knowledge; relationship: Relationship } {
103
+ const { now = realClock, newId = randomUUID } = options;
104
+ const from = cleanSide(input.from, "from");
105
+ const to = cleanSide(input.to, "to");
106
+ if (from.columns.length !== to.columns.length) {
107
+ throw new Error(
108
+ `relationship sides must have the same number of columns: from has ${from.columns.length}, to has ${to.columns.length}`,
109
+ );
110
+ }
111
+ // A table may point to itself (staff.manager_id → staff.id); a column may not.
112
+ if (sameSide(from, to)) throw new Error("relationship from and to are the same columns");
113
+ const duplicate = knowledge.relationships.find((item) => sameSide(item.from, from) && sameSide(item.to, to));
114
+ if (duplicate !== undefined) throw new Error(`relationship already exists (id "${duplicate.id}")`);
115
+
116
+ const relationship: Relationship = {
117
+ id: newId(),
118
+ from,
119
+ to,
120
+ note: cleanText(input.note, "relationship note", { allowEmpty: true }),
121
+ author: cleanAuthor(input.author),
122
+ createdAt: now(),
123
+ };
124
+ return { knowledge: withEntries(knowledge, undefined, [...knowledge.relationships, relationship]), relationship };
125
+ }
126
+
127
+ export function removeRelationship(
128
+ knowledge: Knowledge,
129
+ id: string,
130
+ ): { knowledge: Knowledge; relationship: Relationship } {
131
+ const relationship = findById(knowledge.relationships, id, "relationship");
132
+ const relationships = knowledge.relationships.filter((item) => item.id !== id);
133
+ return { knowledge: withEntries(knowledge, undefined, relationships), relationship };
134
+ }
135
+
136
+ /**
137
+ * A copy with every entry in canonical order: annotations by target, creation
138
+ * time and id; relationships by source table, destination table and id.
139
+ * Sorting by target groups a table's notes with its columns' notes, so a diff
140
+ * reads like the schema. Plain code-unit comparison: the same on every machine.
141
+ */
142
+ export function sortKnowledge(knowledge: Knowledge): Knowledge {
143
+ return {
144
+ formatVersion: 1,
145
+ annotations: [...knowledge.annotations].sort((a, b) =>
146
+ compareKeys([a.target, a.createdAt, a.id], [b.target, b.createdAt, b.id]),
147
+ ),
148
+ relationships: [...knowledge.relationships].sort((a, b) =>
149
+ compareKeys([a.from.table, a.to.table, a.id], [b.from.table, b.to.table, b.id]),
150
+ ),
151
+ };
152
+ }
153
+
154
+ function withEntries(
155
+ knowledge: Knowledge,
156
+ annotations: readonly Annotation[] = knowledge.annotations,
157
+ relationships: readonly Relationship[] = knowledge.relationships,
158
+ ): Knowledge {
159
+ return sortKnowledge({ formatVersion: 1, annotations, relationships });
160
+ }
161
+
162
+ function findById<T extends { readonly id: string }>(items: readonly T[], id: string, kind: string): T {
163
+ const found = items.find((item) => item.id === id);
164
+ if (found === undefined) throw new Error(`unknown ${kind} id "${id}"`);
165
+ return found;
166
+ }
167
+
168
+ function cleanText(value: string, label: string, { allowEmpty }: { allowEmpty: boolean }): string {
169
+ const text = value.trim();
170
+ if (!allowEmpty && text === "") throw new Error(`${label} must not be empty`);
171
+ // Spreading a string splits it by code point, so an emoji counts as one character.
172
+ const length = [...text].length;
173
+ if (length > MAX_TEXT_LENGTH) {
174
+ throw new Error(`${label} is too long: ${length} characters (max ${MAX_TEXT_LENGTH})`);
175
+ }
176
+ return text;
177
+ }
178
+
179
+ function cleanAuthor(value: string): string {
180
+ const author = value.trim();
181
+ if (author === "") throw new Error("author must not be empty");
182
+ return author;
183
+ }
184
+
185
+ function cleanSide(side: RelationshipSide, label: "from" | "to"): RelationshipSide {
186
+ const table = canonicalTable(side.table);
187
+ if (table === null) {
188
+ throw new Error(`relationship ${label} must be a table name (database.schema.table): got "${side.table}"`);
189
+ }
190
+ if (side.columns.length === 0) throw new Error(`relationship ${label}.columns must not be empty`);
191
+ side.columns.forEach((column, index) => {
192
+ if (column === "") throw new Error(`relationship ${label}.columns[${index}] must not be empty`);
193
+ if (side.columns.indexOf(column) !== index) throw new Error(`relationship ${label}.columns repeats "${column}"`);
194
+ });
195
+ if (side.connection === undefined) return { table, columns: [...side.columns] };
196
+ // Whether the connection exists is checked by the caller, which has the config.
197
+ const connection = side.connection.trim();
198
+ if (connection === "") throw new Error(`relationship ${label}.connection must not be empty`);
199
+ return { connection, table, columns: [...side.columns] };
200
+ }
201
+
202
+ /**
203
+ * The canonical form of a table name, or null when the name is valid but
204
+ * names a database or a column instead. Invalid names throw parseName's error.
205
+ */
206
+ export function canonicalTable(name: string): string | null {
207
+ const ref = parseName(name);
208
+ if (ref.table === undefined || ref.column !== undefined) return null;
209
+ return formatName(ref);
210
+ }
211
+
212
+ function sameSide(a: RelationshipSide, b: RelationshipSide): boolean {
213
+ return a.connection === b.connection && a.table === b.table && a.columns.length === b.columns.length && a.columns.every((column, index) => column === b.columns[index]);
214
+ }
215
+
216
+ function compareKeys(a: readonly string[], b: readonly string[]): number {
217
+ for (let index = 0; index < a.length; index++) {
218
+ const left = a[index] ?? "";
219
+ const right = b[index] ?? "";
220
+ if (left !== right) return left < right ? -1 : 1;
221
+ }
222
+ return 0;
223
+ }
@@ -0,0 +1,129 @@
1
+ import { formatName, parseName } from "../model/names.ts";
2
+ import type { Snapshot } from "../model/snapshot.ts";
3
+ import { canonicalTable } from "./operations.ts";
4
+ import type { Annotation, Knowledge, Relationship, RelationshipSide } from "./types.ts";
5
+
6
+ // Pure reads over Knowledge. Nothing here changes or deletes an entry.
7
+
8
+ /** Everything known about one table, at every level that applies to it. */
9
+ export interface TableKnowledge {
10
+ /** Notes on the table's database (target "db.schema"). */
11
+ readonly database: readonly Annotation[];
12
+ readonly table: readonly Annotation[];
13
+ /** Notes per column name; only columns that have notes appear. */
14
+ readonly columns: Readonly<Record<string, readonly Annotation[]>>;
15
+ /** Implicit foreign keys where the table is on either side. */
16
+ readonly relationships: readonly Relationship[];
17
+ }
18
+
19
+ /** A relationship read from another connection's knowledge file. */
20
+ export interface ExternalRelationship {
21
+ /** The connection whose file stores it; its sides without a connection are tables of that one. */
22
+ readonly connection: string;
23
+ readonly relationship: Relationship;
24
+ }
25
+
26
+ /**
27
+ * Returns database, table and column notes together. heydb v1 only returned
28
+ * table notes, so an agent asked to annotate columns could never read them back.
29
+ */
30
+ export function knowledgeForTable(knowledge: Knowledge, tableName: string): TableKnowledge {
31
+ const table = canonicalTable(tableName);
32
+ if (table === null) throw new Error(`expected a table name (database.schema.table): got "${tableName}"`);
33
+ const ref = parseName(table);
34
+
35
+ const database: Annotation[] = [];
36
+ const tableNotes: Annotation[] = [];
37
+ // A Map, not a plain object: a legacy column called "__proto__" must still be a normal key.
38
+ const columns = new Map<string, Annotation[]>();
39
+ for (const annotation of knowledge.annotations) {
40
+ // Compared segment by segment, never by prefix: "shop.shop.orders" is
41
+ // not a prefix match for "shop.shop.orders_archive".
42
+ const target = parseName(annotation.target);
43
+ if (target.database !== ref.database || target.schema !== ref.schema) continue;
44
+ if (target.table === undefined) database.push(annotation);
45
+ else if (target.table !== ref.table) continue;
46
+ else if (target.column === undefined) tableNotes.push(annotation);
47
+ else columns.set(target.column, [...(columns.get(target.column) ?? []), annotation]);
48
+ }
49
+
50
+ return {
51
+ database,
52
+ table: tableNotes,
53
+ columns: Object.fromEntries(columns),
54
+ // A side in another connection is never a table of this one, even with the same name.
55
+ relationships: knowledge.relationships.filter((item) => isHere(item.from, table) || isHere(item.to, table)),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The relationships of a knowledge file that point to `table` of another
61
+ * connection, `connection`: crm's file stores crm.clientes(cod_erp) → erp's
62
+ * cus(cod), and erp's cus must show it.
63
+ */
64
+ export function relationshipsInto(knowledge: Knowledge, connection: string, table: string): Relationship[] {
65
+ const into = (side: RelationshipSide) => side.connection === connection && side.table === table;
66
+ return knowledge.relationships.filter((item) => into(item.from) || into(item.to));
67
+ }
68
+
69
+ function isHere(side: RelationshipSide, table: string): boolean {
70
+ return side.connection === undefined && side.table === table;
71
+ }
72
+
73
+ export interface Orphans {
74
+ readonly annotations: readonly Annotation[];
75
+ readonly relationships: readonly Relationship[];
76
+ }
77
+
78
+ /** Answers "does this canonical name exist?" for one snapshot. */
79
+ export type Lookup = (name: string) => boolean;
80
+
81
+ /** The lookup of another connection, or null when it is gone or was never synced. */
82
+ export type OtherConnections = (connection: string) => Lookup | null;
83
+
84
+ /**
85
+ * Knowledge whose database, table or column is no longer in the snapshot.
86
+ * Only reported, never deleted: a note on a dropped column may still be the
87
+ * only explanation of old rows, backups or the code that used it.
88
+ *
89
+ * A side in another connection is checked against `others`: a relationship
90
+ * whose other connection is gone or unsynced is reported too. Without
91
+ * `others`, those sides are not checked.
92
+ */
93
+ export function findOrphans(knowledge: Knowledge, snapshot: Snapshot, others?: OtherConnections): Orphans {
94
+ const exists = snapshotLookup(snapshot);
95
+ const missing = (side: RelationshipSide): boolean => {
96
+ if (side.connection === undefined) return missingInSide(side, exists) !== null;
97
+ if (others === undefined) return false;
98
+ const lookup = others(side.connection);
99
+ return lookup === null || missingInSide(side, lookup) !== null;
100
+ };
101
+ return {
102
+ annotations: knowledge.annotations.filter((annotation) => !exists(annotation.target)),
103
+ relationships: knowledge.relationships.filter((relationship) => missing(relationship.from) || missing(relationship.to)),
104
+ };
105
+ }
106
+
107
+ /** Answers "does this canonical name exist in the snapshot?" for databases, tables and columns. */
108
+ export function snapshotLookup(snapshot: Snapshot): Lookup {
109
+ const names = new Set<string>();
110
+ for (const database of snapshot.databases) {
111
+ for (const schema of database.schemas) {
112
+ const base = { database: database.name, schema: schema.name };
113
+ names.add(formatName(base));
114
+ for (const table of schema.tables) {
115
+ names.add(formatName({ ...base, table: table.name }));
116
+ for (const column of table.columns) names.add(formatName({ ...base, table: table.name, column: column.name }));
117
+ }
118
+ }
119
+ }
120
+ // Re-formatting the parsed name accepts any valid spelling of it.
121
+ return (name) => names.has(formatName(parseName(name)));
122
+ }
123
+
124
+ /** The table, or else the first column, of one relationship side missing from a snapshot; null when all exist. */
125
+ export function missingInSide(side: RelationshipSide, exists: Lookup): string | null {
126
+ const ref = parseName(side.table);
127
+ const names = [side.table, ...side.columns.map((column) => formatName({ ...ref, column }))];
128
+ return names.find((name) => !exists(name)) ?? null;
129
+ }
@@ -0,0 +1,64 @@
1
+ // Knowledge is what people and agents learn about a database that its
2
+ // structure does not say: what a column really means, which columns join two
3
+ // tables when there is no foreign key. It lives in
4
+ // .heydb/knowledge/<connection>.json and is committed, like the snapshot.
5
+ //
6
+ // Every entry is anchored to a canonical name (database.schema.table.column),
7
+ // never to a bare table name, so "clientes" in two databases never share notes.
8
+ // Timestamps are ISO 8601 strings; ids come from crypto.randomUUID.
9
+ //
10
+ // Notes about the whole project, above any database ("the ERP uses one
11
+ // database per client"), live in .heydb/knowledge/_project.json with the same
12
+ // shape: only annotations, all with the target PROJECT_TARGET. The leading
13
+ // underscore can never be a connection name, which must start with a letter
14
+ // or digit.
15
+
16
+ /** The target of every note in the project file. Not a canonical name: those have 2 to 4 segments. */
17
+ export const PROJECT_TARGET = "project";
18
+
19
+ export interface Knowledge {
20
+ /** Bumped only when the shape of this file changes incompatibly. */
21
+ readonly formatVersion: 1;
22
+ readonly annotations: readonly Annotation[];
23
+ readonly relationships: readonly Relationship[];
24
+ }
25
+
26
+ export interface Annotation {
27
+ readonly id: string;
28
+ /**
29
+ * Canonical name of what the note is about: "db.schema" for the whole
30
+ * database, "db.schema.table" for a table, "db.schema.table.column" for a
31
+ * column; PROJECT_TARGET in the project file.
32
+ */
33
+ readonly target: string;
34
+ readonly text: string;
35
+ readonly author: string;
36
+ readonly createdAt: string;
37
+ readonly updatedAt: string;
38
+ }
39
+
40
+ /**
41
+ * An implicit foreign key: the database does not enforce it, but the data
42
+ * follows it (typical in legacy schemas and MyISAM tables). Column lists allow
43
+ * composite keys; both sides have the same length, matched by position.
44
+ */
45
+ export interface Relationship {
46
+ readonly id: string;
47
+ readonly from: RelationshipSide;
48
+ readonly to: RelationshipSide;
49
+ readonly note: string;
50
+ readonly author: string;
51
+ readonly createdAt: string;
52
+ }
53
+
54
+ export interface RelationshipSide {
55
+ /**
56
+ * The connection the table belongs to when it is not the one whose file
57
+ * stores the relationship (crm.clientes.cod_erp → erp's cus.cod). Absent
58
+ * means that connection, so files written before this field stay valid.
59
+ */
60
+ readonly connection?: string;
61
+ /** Canonical table name: database.schema.table. */
62
+ readonly table: string;
63
+ readonly columns: readonly string[];
64
+ }
@@ -0,0 +1,151 @@
1
+ import { formatName, parseName } from "../model/names.ts";
2
+ import { canonicalTable } from "./operations.ts";
3
+ import { type Annotation, type Knowledge, PROJECT_TARGET, type Relationship, type RelationshipSide } from "./types.ts";
4
+
5
+ // Knowledge files are committed and edited by people, merges and older
6
+ // heydb versions, so every field is checked once, at the boundary, like
7
+ // snapshots are in lib/model/validate.ts. The result is rebuilt field by field:
8
+ // unknown fields are dropped and names come back in canonical form. Errors
9
+ // name the exact path, e.g. "relationships[0].to.columns must not be empty".
10
+ //
11
+ // This file has its own small reader because the one in lib/model/validate.ts
12
+ // is private to that module; sharing one is a follow-up.
13
+
14
+ export function validateKnowledge(data: unknown): Knowledge {
15
+ return validate(data, (target, path) => canonical(target, path, (name) => formatName(parseName(name))));
16
+ }
17
+
18
+ /**
19
+ * The project file: the same shape, but only notes about the whole project.
20
+ * A note on a database there would be found by nobody looking at that
21
+ * database, and relationships always belong to a connection.
22
+ */
23
+ export function validateProjectKnowledge(data: unknown): Knowledge {
24
+ const knowledge = validate(data, (target, path) => {
25
+ if (target !== PROJECT_TARGET) throw new Error(`${path} must be "${PROJECT_TARGET}" in the project file: got "${target}"`);
26
+ return target;
27
+ });
28
+ if (knowledge.relationships.length > 0) throw new Error("relationships must be empty: the project file holds only project notes");
29
+ return knowledge;
30
+ }
31
+
32
+ type TargetReader = (target: string, path: string) => string;
33
+
34
+ function validate(data: unknown, readTarget: TargetReader): Knowledge {
35
+ const root = object(data, "knowledge");
36
+ if (root.formatVersion !== 1) throw new Error("formatVersion must be 1");
37
+ const annotations = list(root, "annotations", (record, path) => validateAnnotation(record, path, readTarget));
38
+ const relationships = list(root, "relationships", validateRelationship);
39
+ unique(annotations, "annotations");
40
+ unique(relationships, "relationships");
41
+ return { formatVersion: 1, annotations, relationships };
42
+ }
43
+
44
+ function validateAnnotation(record: Record<string, unknown>, path: string, readTarget: TargetReader): Annotation {
45
+ const target = string(record, "target", path);
46
+ return {
47
+ id: id(record, path),
48
+ target: readTarget(target, `${path}.target`),
49
+ text: string(record, "text", path),
50
+ author: string(record, "author", path),
51
+ createdAt: string(record, "createdAt", path),
52
+ updatedAt: string(record, "updatedAt", path),
53
+ };
54
+ }
55
+
56
+ function validateRelationship(record: Record<string, unknown>, path: string): Relationship {
57
+ const from = side(record, "from", path);
58
+ const to = side(record, "to", path);
59
+ if (to.columns.length !== from.columns.length) {
60
+ throw new Error(`${path}.to.columns must have as many columns as from.columns (${from.columns.length})`);
61
+ }
62
+ return {
63
+ id: id(record, path),
64
+ from,
65
+ to,
66
+ note: string(record, "note", path),
67
+ author: string(record, "author", path),
68
+ createdAt: string(record, "createdAt", path),
69
+ };
70
+ }
71
+
72
+ function side(parent: Record<string, unknown>, key: "from" | "to", parentPath: string): RelationshipSide {
73
+ const path = `${parentPath}.${key}`;
74
+ const record = object(read(parent, key, parentPath), path);
75
+ const name = string(record, "table", path);
76
+ const table = canonical(name, `${path}.table`, canonicalTable);
77
+ if (table === null) {
78
+ throw new Error(`${path}.table must be a table name (database.schema.table): got "${name}"`);
79
+ }
80
+ const columns = read(record, "columns", path);
81
+ if (!Array.isArray(columns)) throw new Error(`${path}.columns must be a list`);
82
+ if (columns.length === 0) throw new Error(`${path}.columns must not be empty`);
83
+ // Optional: files written before relationships could cross connections have none.
84
+ const connection = record.connection;
85
+ if (connection !== undefined && (typeof connection !== "string" || connection === "")) {
86
+ throw new Error(`${path}.connection must be a non-empty string`);
87
+ }
88
+ return {
89
+ ...(connection !== undefined && { connection }),
90
+ table,
91
+ columns: columns.map((column, index) => {
92
+ if (typeof column !== "string") throw new Error(`${path}.columns[${index}] must be a string`);
93
+ return column;
94
+ }),
95
+ };
96
+ }
97
+
98
+ // Prefixes parseName's own message with the path, so it is clear which entry is bad.
99
+ function canonical<T>(name: string, path: string, convert: (name: string) => T): T {
100
+ try {
101
+ return convert(name);
102
+ } catch (error) {
103
+ throw new Error(`${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
104
+ }
105
+ }
106
+
107
+ function id(record: Record<string, unknown>, path: string): string {
108
+ const value = string(record, "id", path);
109
+ if (value === "") throw new Error(`${path}.id must not be empty`);
110
+ return value;
111
+ }
112
+
113
+ // Edits and removals find entries by id; a bad merge that duplicates one
114
+ // would make them touch the wrong entry.
115
+ function unique(items: readonly { readonly id: string }[], path: string): void {
116
+ const seen = new Map<string, number>();
117
+ items.forEach((item, index) => {
118
+ const first = seen.get(item.id);
119
+ if (first !== undefined) throw new Error(`${path}[${index}].id "${item.id}" is already used by ${path}[${first}]`);
120
+ seen.set(item.id, index);
121
+ });
122
+ }
123
+
124
+ function list<T>(record: Record<string, unknown>, key: string, readItem: (item: Record<string, unknown>, path: string) => T): T[] {
125
+ const value = read(record, key, "");
126
+ if (!Array.isArray(value)) throw new Error(`${key} must be a list`);
127
+ return value.map((item, index) => {
128
+ const path = `${key}[${index}]`;
129
+ return readItem(object(item, path), path);
130
+ });
131
+ }
132
+
133
+ function string(record: Record<string, unknown>, key: string, path: string): string {
134
+ const value = read(record, key, path);
135
+ if (typeof value !== "string") throw new Error(`${join(path, key)} must be a string`);
136
+ return value;
137
+ }
138
+
139
+ function read(record: Record<string, unknown>, key: string, path: string): unknown {
140
+ if (!(key in record)) throw new Error(`${join(path, key)} is missing`);
141
+ return record[key];
142
+ }
143
+
144
+ function join(path: string, key: string): string {
145
+ return path === "" ? key : `${path}.${key}`;
146
+ }
147
+
148
+ function object(value: unknown, path: string): Record<string, unknown> {
149
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
150
+ return value as Record<string, unknown>;
151
+ }