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

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 +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -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 +142 -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,508 @@
1
+ import type { ExternalRelationship, TableKnowledge } from "../knowledge/query.ts";
2
+ import type { Annotation, Knowledge, Relationship, RelationshipSide } from "../knowledge/types.ts";
3
+ import type { Change } from "../model/diff.ts";
4
+ import { displayName, displayNameOf, formatName, parseName } from "../model/names.ts";
5
+ import type { IncomingReferences } from "../model/references.ts";
6
+ import type { Column, Snapshot, Table } from "../model/snapshot.ts";
7
+ import { type SchemaStats, schemaStats } from "../model/stats.ts";
8
+ import { normalizeText, type SearchResults } from "../search.ts";
9
+ import type { StatusResult } from "../usecases/status.ts";
10
+
11
+ // Markdown written for agents. Every character returned here is read by a
12
+ // model and paid for in its context window, so the format is a budget:
13
+ // - sections and table columns that would be empty are left out;
14
+ // - long lists stop at a limit and say how many were left and how to find them;
15
+ // - every note carries its id, because editing or forgetting one needs it;
16
+ // - names are display names ("shop.orders" for MySQL's "shop.shop.orders"):
17
+ // shorter to read and still accepted back by every tool.
18
+ // The output is plain markdown, readable by a person too, with no trailing
19
+ // newline: callers join pieces as they like.
20
+
21
+ const NOTE_PREVIEW_LENGTH = 80;
22
+ const MAX_WARNINGS = 20;
23
+
24
+ /** Pipes would end a table cell and line breaks would end the row. */
25
+ export function escapeCell(text: string): string {
26
+ return text.replaceAll("|", "\\|").replace(/\r\n|\r|\n/g, "<br>");
27
+ }
28
+
29
+ /** "~1.2k", "~15k", "~2.5M": the precision an estimate deserves, in few characters. */
30
+ export function approximateRows(rows: number): string {
31
+ const units = ["", "k", "M", "B"];
32
+ let value = rows;
33
+ let unit = 0;
34
+ while (unit < units.length - 1 && Number(round(value)) >= 1000) {
35
+ value /= 1000;
36
+ unit += 1;
37
+ }
38
+ return `~${round(value)}${units[unit]}`;
39
+ }
40
+
41
+ // One decimal below 10 ("1.2"), none above ("15"); "1.0" prints as "1".
42
+ function round(value: number): string {
43
+ const text = value < 10 ? value.toFixed(1) : value.toFixed(0);
44
+ return text.endsWith(".0") ? text.slice(0, -2) : text;
45
+ }
46
+
47
+ export interface OverviewOptions {
48
+ readonly connection: string;
49
+ readonly maxTables?: number;
50
+ /** Notes about the whole project, shown before anything else. */
51
+ readonly projectNotes?: readonly Annotation[];
52
+ }
53
+
54
+ /** Under this share of commented tables, comments are not a reliable map of the schema. */
55
+ const LOW_COMMENT_COVERAGE = 0.2;
56
+
57
+ export function renderOverview(snapshot: Snapshot, knowledge: Knowledge, options: OverviewOptions): string {
58
+ const { connection, maxTables = 50, projectNotes = [] } = options;
59
+ const firstNote = firstNotes(knowledge);
60
+ const stats = schemaStats(snapshot, knowledge);
61
+ const blocks: string[][] = [
62
+ [`# heydb · ${connection}`, `${snapshot.engine.kind} ${snapshot.engine.version} · captured ${snapshot.capturedAt}`],
63
+ ];
64
+
65
+ // What holds for every database ("one database per client") frames the rest.
66
+ if (projectNotes.length > 0) blocks.push(["## Project notes", ...projectNotes.map((note) => `- ${indented(note.text)} ${noteTag(note)}`)]);
67
+
68
+ if (stats.length > 0) blocks.push(["## Databases", ...stats.flatMap((schema) => databaseLines(schema, firstNote))]);
69
+
70
+ if (snapshot.warnings.length > 0) {
71
+ const shown = snapshot.warnings
72
+ .slice(0, MAX_WARNINGS)
73
+ .map((warning) => `- ${warning.message}${warning.target === undefined ? "" : ` (${displayText(warning.target)})`}`);
74
+ blocks.push(["## Warnings", ...shown, ...more(snapshot.warnings.length - MAX_WARNINGS, "warning")]);
75
+ }
76
+
77
+ // Notes first (the team already explained them), then the core of each
78
+ // database, then the rest by name: when the list is cut, what is cut is
79
+ // what says least.
80
+ const tables = snapshot.databases
81
+ .flatMap((database) =>
82
+ database.schemas.flatMap((schema) =>
83
+ schema.tables.map((table) => ({ name: formatName({ database: database.name, schema: schema.name, table: table.name }), table })),
84
+ ),
85
+ )
86
+ .sort((a, b) => compareText(a.name, b.name));
87
+ const main = new Map(stats.flatMap((schema) => schema.mainTables).map((item, index) => [item.name, index]));
88
+ const group = (name: string) => (firstNote.has(name) ? 0 : main.has(name) ? 1 : 2);
89
+ tables.sort((a, b) => group(a.name) - group(b.name) || (main.get(a.name) ?? 0) - (main.get(b.name) ?? 0) || compareText(a.name, b.name));
90
+ if (tables.length > 0) {
91
+ const rows = tables.slice(0, maxTables).map(({ name, table }) => {
92
+ const note = firstNote.get(name) ?? preview(table.comment);
93
+ return row([displayNameOf(name), table.kind, tableRows(table), note]);
94
+ });
95
+ const rest = more(tables.length - maxTables, "table").map((line) => `${line} — use heydb_search`);
96
+ blocks.push([
97
+ "## Tables",
98
+ "Tables with notes first, then the most referenced, then by name; rows are engine estimates.",
99
+ row(["table", "kind", "rows", "note"]),
100
+ "|---|---|---|---|",
101
+ ...rows,
102
+ ...rest,
103
+ ]);
104
+ }
105
+ return render(blocks);
106
+ }
107
+
108
+ // One list item per database, its details indented below it. Comment coverage
109
+ // says how far comments can be trusted as a map; the most referenced tables
110
+ // are the core of the database, whatever their names.
111
+ function databaseLines(stats: SchemaStats, firstNote: ReadonlyMap<string, string>): string[] {
112
+ const ref = { database: stats.database, schema: stats.schema };
113
+ const counts = [count(stats.tables, "table"), ...(stats.views > 0 ? [count(stats.views, "view")] : [])].join(", ");
114
+ const note = firstNote.get(formatName(ref));
115
+ const lines: string[] = [];
116
+ if (stats.tables > 0) {
117
+ lines.push(
118
+ `Comment coverage: ${percent(stats.tablesWithComment, stats.tables)} of ${count(stats.tables, "table")}, ${percent(stats.columnsWithComment, stats.columns)} of ${count(stats.columns, "column")}`,
119
+ );
120
+ }
121
+ if (stats.prefixes.length > 0) {
122
+ lines.push(`Name prefixes: ${stats.prefixes.map(({ prefix, count: n }) => `${prefix} ×${n}`).join(", ")}`);
123
+ }
124
+ if (stats.mainTables.length > 0) {
125
+ lines.push(
126
+ "Most referenced tables:",
127
+ ...stats.mainTables.map(({ name, table, incoming }) => {
128
+ const rows = table.rowEstimate === null ? "rows unknown" : `${approximateRows(table.rowEstimate)} rows`;
129
+ const about = firstNote.get(name) ?? (preview(table.comment) || "no comment");
130
+ return `- ${displayNameOf(name)} · ${count(incoming, "reference")} · ${rows} · ${about}`;
131
+ }),
132
+ );
133
+ }
134
+ if (stats.tables > 0 && stats.tablesWithComment / stats.tables < LOW_COMMENT_COVERAGE) {
135
+ lines.push("Most tables have no comment: do not infer meaning from table names alone.");
136
+ }
137
+ return [`- ${displayName(ref)}: ${counts}${note === undefined ? "" : ` · ${note}`}`, ...lines.map((line) => ` ${line}`)];
138
+ }
139
+
140
+ export interface TableOptions {
141
+ /** How many columns to list: a number, or "all". Default DEFAULT_COLUMN_LIMIT. */
142
+ readonly columns?: "all" | number;
143
+ /** Only columns whose name or comment contains this text, ignoring case and accents. */
144
+ readonly columnFilter?: string;
145
+ /** The connection the table belongs to; sides in other connections are prefixed with theirs. */
146
+ readonly connection?: string;
147
+ /** Relationships stored in other connections' knowledge files that point to this table. */
148
+ readonly external?: readonly ExternalRelationship[];
149
+ /** Why some of those files could not be read. */
150
+ readonly unreadable?: readonly string[];
151
+ }
152
+
153
+ /** Enough for a normal table whole; a 540-column legacy table shows its most telling 60. */
154
+ export const DEFAULT_COLUMN_LIMIT = 60;
155
+ const MAX_DEFINITION_LENGTH = 3000;
156
+ const MAX_INCOMING = 20;
157
+ const MAX_GUESSES_SHOWN = 15;
158
+
159
+ const NO_EVIDENCE =
160
+ "No table comment or team note: meaning must be inferred from columns and references; say it is an inference and ask the user to confirm.";
161
+
162
+ /**
163
+ * One table, in the order an agent should read it: first what says what the
164
+ * table is (comment, notes, who points to it), then its shape. On a huge
165
+ * legacy table the columns come last and are cut, so the evidence of meaning
166
+ * is never what gets lost.
167
+ */
168
+ export function renderTable(
169
+ table: Table,
170
+ canonicalName: string,
171
+ knowledge: TableKnowledge,
172
+ incoming: IncomingReferences = documentedOnly(knowledge, canonicalName),
173
+ options: TableOptions = {},
174
+ ): string {
175
+ const header = [`# ${table.kind} ${displayNameOf(canonicalName)}`];
176
+ const rows = table.kind === "view" ? null : table.rowEstimate === null ? "rows unknown" : `${approximateRows(table.rowEstimate)} rows (engine estimate)`;
177
+ const facts = [table.storageEngine, rows].filter((fact) => fact !== null);
178
+ if (facts.length > 0) header.push(facts.join(" · "));
179
+ if (table.comment.trim() !== "") header.push(`Comment: ${oneLine(table.comment)}`);
180
+ else header.push(knowledge.table.length > 0 ? "No table comment." : NO_EVIDENCE);
181
+ if (table.kind === "table") header.push(`Primary key: ${table.primaryKey.length === 0 ? "none" : list(table.primaryKey)}`);
182
+ const blocks: string[][] = [header];
183
+
184
+ const notes = [
185
+ ...knowledge.database.map((note) => noteLine("database", note)),
186
+ ...knowledge.table.map((note) => noteLine("table", note)),
187
+ ];
188
+ if (notes.length > 0) blocks.push(["## Notes", ...notes]);
189
+
190
+ const here = (side: RelationshipSide) => side.connection === undefined && side.table === canonicalName;
191
+ const outgoing = knowledge.relationships.filter((relationship) => here(relationship.from));
192
+ const external = options.external ?? [];
193
+ const relationships = [
194
+ ...table.foreignKeys.map((key) => {
195
+ const { database, schema, table: target, columns } = key.references;
196
+ const to = `${displayName({ database, schema, table: target })}${list(columns)}`;
197
+ return `- ${key.name}: ${list(key.columns)} → ${to} · on delete ${key.onDelete} · on update ${key.onUpdate}`;
198
+ }),
199
+ ...outgoing.map((relationship) => implicitRelationship(relationship, canonicalName, options.connection)),
200
+ ];
201
+ if (relationships.length > 0) blocks.push(["## Relationships", ...relationships]);
202
+
203
+ const fromOtherConnections = [
204
+ ...external
205
+ .slice(0, MAX_INCOMING)
206
+ .map(({ connection, relationship }) => implicitRelationship(relationship, canonicalName, options.connection, connection)),
207
+ ...more(external.length - MAX_INCOMING, "relationship from another connection"),
208
+ ...(options.unreadable ?? []).map((reason) => `- not read: ${reason}`),
209
+ ];
210
+ const referencedBy = incomingBlock(incoming, canonicalName, options.connection, fromOtherConnections);
211
+ if (referencedBy.length > 0) blocks.push(["## Referenced by", ...referencedBy]);
212
+
213
+ if (table.triggers.length > 0) {
214
+ blocks.push(["## Triggers", ...table.triggers.map((trigger) => `- ${trigger.name}: ${trigger.timing} ${trigger.events.join(", ")}`)]);
215
+ }
216
+ if (table.kind === "view") blocks.push(definitionBlock(table.viewDefinition));
217
+
218
+ // Columns a key or a relationship uses rank right after the primary key.
219
+ const related = new Set([
220
+ ...table.foreignKeys.flatMap((key) => key.columns),
221
+ ...outgoing.flatMap((relationship) => relationship.from.columns),
222
+ ...knowledge.relationships.filter((relationship) => here(relationship.to)).flatMap((relationship) => relationship.to.columns),
223
+ ...external.flatMap(({ relationship }) => [relationship.from, relationship.to].filter((side) => side.connection === options.connection)).flatMap((side) => side.columns),
224
+ ...incoming.declared.flatMap((reference) => reference.toColumns),
225
+ ]);
226
+ blocks.push(columnsBlock(table, knowledge.columns, related, options));
227
+
228
+ if (table.indexes.length > 0) {
229
+ blocks.push([
230
+ "## Indexes",
231
+ ...table.indexes.map((index) => `- ${index.name} ${list(index.columns)}${index.unique ? " unique" : ""} ${index.method}`),
232
+ ]);
233
+ }
234
+ return render(blocks);
235
+ }
236
+
237
+ // The comments of the columns that point here come first: on a table without
238
+ // comment, "Artículo" ×12 says more than any name.
239
+ function incomingBlock(
240
+ incoming: IncomingReferences,
241
+ canonicalName: string,
242
+ connection: string | undefined,
243
+ fromOtherConnections: readonly string[],
244
+ ): string[] {
245
+ const lines: string[] = [];
246
+ if (incoming.summary.length > 0) {
247
+ lines.push(`Referencing columns are commented as: ${incoming.summary.map(({ comment, count: n }) => `"${comment}" ×${n}`).join(", ")}`);
248
+ }
249
+ lines.push(
250
+ ...incoming.declared
251
+ .slice(0, MAX_INCOMING)
252
+ .map((reference) => `- ${displayNameOf(reference.table)}${list(reference.columns)} → ${list(reference.toColumns)} · foreign key ${reference.name}`),
253
+ ...more(incoming.declared.length - MAX_INCOMING, "foreign key"),
254
+ ...incoming.implicit.slice(0, MAX_INCOMING).map(({ relationship }) => implicitRelationship(relationship, canonicalName, connection)),
255
+ ...more(incoming.implicit.length - MAX_INCOMING, "implicit relationship"),
256
+ ...fromOtherConnections,
257
+ );
258
+ if (incoming.guesses.length > 0) {
259
+ lines.push(
260
+ "Name pattern, not enforced (guesses):",
261
+ ...incoming.guesses.slice(0, MAX_GUESSES_SHOWN).map((guess) => {
262
+ const name = displayName({ ...parseName(guess.table), column: guess.column });
263
+ return guess.comment.trim() === "" ? `- ${name}` : `- ${name} · "${oneLine(guess.comment)}"`;
264
+ }),
265
+ ...more(incoming.guesses.length + incoming.moreGuesses - MAX_GUESSES_SHOWN, "name-pattern guess"),
266
+ );
267
+ }
268
+ return lines;
269
+ }
270
+
271
+ // Without the snapshot, only what the team documented can point here.
272
+ function documentedOnly(knowledge: TableKnowledge, canonicalName: string): IncomingReferences {
273
+ const local = (side: RelationshipSide) => side.connection === undefined;
274
+ const implicit = knowledge.relationships
275
+ .filter(({ from, to }) => local(from) && local(to) && to.table === canonicalName && from.table !== canonicalName)
276
+ .map((relationship) => ({ relationship, comment: "" }));
277
+ return { declared: [], implicit, guesses: [], moreGuesses: 0, summary: [] };
278
+ }
279
+
280
+ function definitionBlock(definition: string | null): string[] {
281
+ if (definition === null) return ["## Definition", "not visible to this user (see the snapshot warnings)"];
282
+ if (definition.length <= MAX_DEFINITION_LENGTH) return ["## Definition", "```sql", definition, "```"];
283
+ return [
284
+ "## Definition",
285
+ "```sql",
286
+ definition.slice(0, MAX_DEFINITION_LENGTH),
287
+ "```",
288
+ `… definition cut at ${MAX_DEFINITION_LENGTH} of ${definition.length} characters; the full text is in the stored snapshot (.heydb/snapshots/)`,
289
+ ];
290
+ }
291
+
292
+ export function renderStatus(status: StatusResult): string {
293
+ switch (status.state) {
294
+ case "never_synced":
295
+ return "Never synced: there is no stored snapshot for this connection yet.";
296
+ case "up_to_date":
297
+ return `Up to date: the live database matches the snapshot (hash ${status.hash.slice(0, 12)}).`;
298
+ case "drifted":
299
+ return `Drifted: the live database differs from the snapshot in ${count(status.changes.length, "change")}.\n\n${renderChanges(status.changes)}`;
300
+ }
301
+ }
302
+
303
+ export function renderChanges(changes: readonly Change[], { max = 50 }: { readonly max?: number } = {}): string {
304
+ if (changes.length === 0) return "No changes.";
305
+ const lines = changes.slice(0, max).map((change) => {
306
+ const object = change.object === "foreignKey" ? "foreign key" : change.object;
307
+ const details = change.details.length === 0 ? "" : `: ${change.details.join("; ")}`;
308
+ return `- ${change.kind} ${object} ${displayText(change.target)}${details}`;
309
+ });
310
+ return [...lines, ...more(changes.length - max, "change")].join("\n");
311
+ }
312
+
313
+ export function renderSearch(search: SearchResults): string {
314
+ if (search.total === 0) return "No matches.";
315
+ const header =
316
+ search.results.length < search.total
317
+ ? `Showing ${search.results.length} of ${search.total} matches; refine the query to narrow them.`
318
+ : `${count(search.total, "match")}.`;
319
+ const lines = search.results.map((result) => {
320
+ const { relationship } = result;
321
+ const target =
322
+ relationship === undefined
323
+ ? displayText(result.target)
324
+ : `${sideText(relationship.from)} → ${sideText(relationship.to)}`;
325
+ return [`- ${result.object} ${target}`, ...result.matchedIn].join(" · ");
326
+ });
327
+ const inferred = search.results.some((result) => result.inferred) ? [INFERRED_NOTE] : [];
328
+ return [header, ...lines, ...inferred].join("\n");
329
+ }
330
+
331
+ // Said once under the results, only when one of them is inferred.
332
+ const INFERRED_NOTE =
333
+ "(inferred): only the comments of columns that reference the table match; present that meaning as an inference and ask the user to confirm it.";
334
+
335
+ // Which columns, and in which order: the filter first; then, when there are
336
+ // more than the limit, the ones that say most about the table (primary key,
337
+ // keys and relationships, commented or noted), shown in table order with
338
+ // their position so gaps are visible. Comment and notes columns are only
339
+ // printed when a shown column has something in them.
340
+ function columnsBlock(table: Table, notes: TableKnowledge["columns"], related: ReadonlySet<string>, options: TableOptions): string[] {
341
+ const notesOf = (column: Column) => (Object.hasOwn(notes, column.name) ? (notes[column.name] ?? []) : []);
342
+ const total = table.columns.length;
343
+ const filter = options.columnFilter?.trim() ?? "";
344
+ const needle = normalizeText(filter);
345
+ const matching =
346
+ filter === ""
347
+ ? table.columns
348
+ : table.columns.filter((column) => normalizeText(column.name).includes(needle) || normalizeText(column.comment).includes(needle));
349
+ const limit = options.columns === "all" ? Number.POSITIVE_INFINITY : (options.columns ?? DEFAULT_COLUMN_LIMIT);
350
+
351
+ let shown = matching;
352
+ if (matching.length > limit) {
353
+ const keys = new Set(table.primaryKey);
354
+ const rank = (column: Column) =>
355
+ keys.has(column.name) ? 0 : related.has(column.name) ? 1 : column.comment.trim() !== "" || notesOf(column).length > 0 ? 2 : 3;
356
+ shown = [...matching]
357
+ .sort((a, b) => rank(a) - rank(b) || a.position - b.position)
358
+ .slice(0, limit)
359
+ .sort((a, b) => a.position - b.position);
360
+ }
361
+
362
+ const heading =
363
+ filter !== ""
364
+ ? `## Columns matching "${filter}" (${matching.length} of ${total})`
365
+ : shown.length < total
366
+ ? `## Columns (${shown.length} of ${total}; keys, relationships and commented first)`
367
+ : "## Columns";
368
+ if (shown.length === 0) {
369
+ return [heading, filter === "" ? "none" : 'No column matches; try another column_filter or columns: "all".'];
370
+ }
371
+
372
+ const withComments = shown.some((column) => column.comment !== "");
373
+ const withNotes = shown.some((column) => notesOf(column).length > 0);
374
+ const headers = ["#", "column", "type", "null", "default", ...(withComments ? ["comment"] : []), ...(withNotes ? ["notes"] : [])];
375
+ const rows = shown.map((column) =>
376
+ row([
377
+ String(column.position),
378
+ column.name,
379
+ columnType(column),
380
+ column.nullable ? "yes" : "no",
381
+ // An empty-string default is shown as '' so it differs from no default at all.
382
+ column.default === null ? "" : column.default === "" ? "''" : column.default,
383
+ ...(withComments ? [column.comment] : []),
384
+ ...(withNotes ? [notesOf(column).map((note) => `${note.text} ${noteTag(note)}`).join("\n")] : []),
385
+ ]),
386
+ );
387
+ const hidden = matching.length - shown.length;
388
+ const footer = hidden > 0 ? [`… ${count(hidden, "more column")} — call heydb_table with columns: "all" or a column_filter`] : [];
389
+ return [heading, row(headers), `|${headers.map(() => "---").join("|")}|`, ...rows, ...footer];
390
+ }
391
+
392
+ function columnType(column: Column): string {
393
+ const parts = [column.type];
394
+ if (column.autoIncrement) parts.push("auto_increment");
395
+ if (column.generated !== null) {
396
+ parts.push(`generated (${column.generated.expression}) ${column.generated.stored ? "stored" : "virtual"}`);
397
+ }
398
+ return parts.join(" ");
399
+ }
400
+
401
+ /**
402
+ * Seen from this table of `connection`: its own side is written without the
403
+ * table name, and a side in any other connection starts with that
404
+ * connection's name. `storedIn` is the connection whose file stores the
405
+ * relationship, when it is not this one; its id is only usable there.
406
+ */
407
+ function implicitRelationship(relationship: Relationship, tableName: string, connection?: string, storedIn?: string): string {
408
+ const { from, to } = relationship;
409
+ const connectionOf = (side: RelationshipSide) => side.connection ?? storedIn ?? connection;
410
+ const isThisTable = (side: RelationshipSide) => side.table === tableName && connectionOf(side) === connection;
411
+ const named = (side: RelationshipSide) => sideText({ ...side, connection: connectionOf(side) }, connection);
412
+ const left = isThisTable(from) ? list(from.columns) : named(from);
413
+ const right = isThisTable(to) && !isThisTable(from) ? list(to.columns) : named(to);
414
+ const note = relationship.note === "" ? "" : ` · ${oneLine(relationship.note)}`;
415
+ const where = storedIn === undefined ? "" : ` · connection ${storedIn}`;
416
+ return `- ${left} → ${right} · implicit, documented by ${relationship.author}${note} [id ${relationship.id}${where} · ${day(relationship.createdAt)}]`;
417
+ }
418
+
419
+ /** "erp: erp.gcl(cod)" for a side in another connection than `current`, "shop.orders(id)" otherwise. */
420
+ function sideText(side: RelationshipSide, current?: string): string {
421
+ const prefix = side.connection === undefined || side.connection === current ? "" : `${side.connection}: `;
422
+ return `${prefix}${displayText(side.table)}${list(side.columns)}`;
423
+ }
424
+
425
+ function noteLine(scope: string, note: Annotation): string {
426
+ return `- ${scope}: ${indented(note.text)} ${noteTag(note)}`;
427
+ }
428
+
429
+ // Multi-line notes keep their lines, indented so they stay inside the list item.
430
+ function indented(text: string): string {
431
+ return text.split(/\r\n|\r|\n/).join("\n ");
432
+ }
433
+
434
+ function noteTag(note: Annotation): string {
435
+ return `[id ${note.id} · ${note.author} · ${day(note.createdAt)}]`;
436
+ }
437
+
438
+ /** First line of the first note of every target, for one-line summaries. */
439
+ function firstNotes(knowledge: Knowledge): Map<string, string> {
440
+ const notes = new Map<string, string>();
441
+ for (const annotation of knowledge.annotations) {
442
+ const target = formatName(parseName(annotation.target));
443
+ if (!notes.has(target)) notes.set(target, preview(annotation.text));
444
+ }
445
+ return notes;
446
+ }
447
+
448
+ function preview(text: string): string {
449
+ const line = text.split(/\r\n|\r|\n/)[0]?.trim() ?? "";
450
+ const characters = [...line];
451
+ return characters.length <= NOTE_PREVIEW_LENGTH ? line : `${characters.slice(0, NOTE_PREVIEW_LENGTH - 1).join("")}…`;
452
+ }
453
+
454
+ /** The display name of a canonical name; text that is not one is returned as it is. */
455
+ function displayText(name: string): string {
456
+ try {
457
+ return displayNameOf(name);
458
+ } catch {
459
+ return name;
460
+ }
461
+ }
462
+
463
+ function percent(part: number, whole: number): string {
464
+ return `${whole === 0 ? 0 : Math.round((100 * part) / whole)}%`;
465
+ }
466
+
467
+ function tableRows(table: Table): string {
468
+ if (table.kind === "view" || table.rowEstimate === null) return "";
469
+ return approximateRows(table.rowEstimate);
470
+ }
471
+
472
+ function row(cells: readonly string[]): string {
473
+ return `| ${cells.map(escapeCell).join(" | ")} |`;
474
+ }
475
+
476
+ function list(items: readonly string[]): string {
477
+ return `(${items.join(", ")})`;
478
+ }
479
+
480
+ function oneLine(text: string): string {
481
+ return text.replace(/\s*(\r\n|\r|\n)\s*/g, " ").trim();
482
+ }
483
+
484
+ function day(timestamp: string): string {
485
+ return timestamp.slice(0, 10);
486
+ }
487
+
488
+ function count(n: number, noun: string): string {
489
+ return `${n} ${plural(n, noun)}`;
490
+ }
491
+
492
+ function plural(n: number, noun: string): string {
493
+ if (n === 1) return noun;
494
+ return noun.endsWith("ch") || noun.endsWith("s") ? `${noun}es` : `${noun}s`;
495
+ }
496
+
497
+ /** "… 3 more tables" when a list was cut, nothing when it was not. */
498
+ function more(n: number, noun: string): string[] {
499
+ return n > 0 ? [`… ${n} more ${plural(n, noun)}`] : [];
500
+ }
501
+
502
+ function render(blocks: readonly string[][]): string {
503
+ return blocks.map((block) => block.join("\n")).join("\n\n");
504
+ }
505
+
506
+ function compareText(a: string, b: string): number {
507
+ return a < b ? -1 : a > b ? 1 : 0;
508
+ }