@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
package/lib/search.ts ADDED
@@ -0,0 +1,530 @@
1
+ import type { Annotation, Knowledge, RelationshipSide } from "./knowledge/types.ts";
2
+ import { displayName, formatName, parseName } from "./model/names.ts";
3
+ import { incomingReferences, namePatternTargets } from "./model/references.ts";
4
+ import type { Snapshot, Table } from "./model/snapshot.ts";
5
+
6
+ // Search over the stored snapshot and knowledge; it never touches the server.
7
+ //
8
+ // Matching ignores case and accents on both sides: Spanish legacy schemas mix
9
+ // "facturacion" and "facturación", "año" and "ano", and nobody remembers which
10
+ // one a table used. Text is decomposed (NFD: "ó" becomes "o" plus a combining
11
+ // accent), the combining marks are dropped, and the rest is lowercased.
12
+ //
13
+ // Results are tables (and views, databases, documented relationships), never
14
+ // loose columns: a column that matches is listed under its table, so 40
15
+ // matching columns of one table cost one line, not 40. Each result adds up
16
+ // the evidence it matched on, strongest first:
17
+ //
18
+ // exact table name 1000
19
+ // table comment or team note, the query as a word 500
20
+ // inferred meaning: the comments of the columns that
21
+ // reference the table contain the query as a word 200 + 10 per reference (up to 15)
22
+ // table name starts with the query 150
23
+ // table comment or note, inside a longer word 120
24
+ // table name contains the query 100
25
+ // view definition 60 as a word, 30 inside one
26
+ // its columns (name or comment or note) up to 50, + 1 per extra column (up to 4)
27
+ //
28
+ // Databases score like tables by name and notes; a documented relationship
29
+ // by its note (120 as a word, 30 inside one). A table name is also an exact
30
+ // match when only a plural differs: "cliente" finds a table called clientes.
31
+ //
32
+ // Inferred meaning is how "cliente" finds gcl, a table without comment that
33
+ // fifty columns called cod_gcl describe as "Cliente Habitual". It is evidence,
34
+ // not a fact: such results carry `inferred` so the agent says so. The
35
+ // evidence is the summary heydb_table shows ("Referencing columns are
36
+ // commented as", from incomingReferences), read for the MAX_INFERRED_TABLES
37
+ // tables most pointed to by matching columns: each summary scans a database,
38
+ // so the cost stays bounded on a 2,000-table schema.
39
+ //
40
+ // "As a word" folds simple plurals (see foldWord), so "clientes" finds
41
+ // "Cliente" and "articulos" finds "Artículo".
42
+
43
+ export type SearchObject = "database" | "table" | "view" | "relationship";
44
+
45
+ export interface ColumnMatch {
46
+ readonly name: string;
47
+ /** The matched comment or note, cut; "" when the name matched. */
48
+ readonly snippet: string;
49
+ }
50
+
51
+ export interface SearchResult {
52
+ /** Canonical name; for a relationship, "from(columns) → to(columns)". */
53
+ readonly target: string;
54
+ readonly object: SearchObject;
55
+ /**
56
+ * Why it matched, strongest first: "name", "table comment: …", "note: …",
57
+ * 'referenced as "Cliente Habitual" ×7 (inferred)', "columns: …".
58
+ */
59
+ readonly matchedIn: readonly string[];
60
+ /** At most SNIPPET_LENGTH characters of the strongest matched text, around the match; "" when only names matched. */
61
+ readonly snippet: string;
62
+ /** True when part of the match is meaning inferred from referencing columns: an inference, not a fact. */
63
+ readonly inferred: boolean;
64
+ /** Matching columns of a table or view, best first, at most MAX_COLUMNS. */
65
+ readonly columns: readonly ColumnMatch[];
66
+ /** Matching columns beyond those listed. */
67
+ readonly moreColumns: number;
68
+ /** The two sides of a relationship result, canonical. */
69
+ readonly relationship?: { readonly from: RelationshipSide; readonly to: RelationshipSide };
70
+ }
71
+
72
+ export interface SearchResults {
73
+ /** The best `limit` results. */
74
+ readonly results: readonly SearchResult[];
75
+ /** How many matched in total, so callers can say "showing 20 of 87". */
76
+ readonly total: number;
77
+ }
78
+
79
+ export interface SearchOptions {
80
+ readonly limit?: number;
81
+ /** Only this database: "eagle", "eagle.eagle", or in PostgreSQL "app" or "app.public". */
82
+ readonly database?: string;
83
+ }
84
+
85
+ export const SNIPPET_LENGTH = 120;
86
+ const COLUMN_SNIPPET_LENGTH = 60;
87
+ const MAX_COLUMNS = 5;
88
+ /** Tables whose referencing columns are summarized per search; each costs one scan of its database. */
89
+ const MAX_INFERRED_TABLES = 10;
90
+ const MAX_DATABASES_LISTED = 10;
91
+
92
+ const SCORE = {
93
+ exactName: 1000,
94
+ statedWord: 500,
95
+ inferred: 200,
96
+ perReference: 10,
97
+ maxReferences: 15,
98
+ namePrefix: 150,
99
+ statedText: 120,
100
+ nameContains: 100,
101
+ definitionWord: 60,
102
+ definitionText: 30,
103
+ columnExact: 50,
104
+ columnWord: 40,
105
+ columnPrefix: 30,
106
+ columnContains: 20,
107
+ columnText: 15,
108
+ maxExtraColumns: 4,
109
+ } as const;
110
+
111
+ const OBJECT_ORDER: readonly SearchObject[] = ["database", "table", "view", "relationship"];
112
+
113
+ /** Lowercase and without accents: "Facturación" becomes "facturacion". */
114
+ export function normalizeText(text: string): string {
115
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
116
+ }
117
+
118
+ /**
119
+ * The stem a normalized word is compared by, so a singular finds its plural:
120
+ * on words longer than 4 characters, one final "s" goes (not after another
121
+ * "s": "address"), then one final "e". clientes → cliente → client;
122
+ * proveedores → proveedore → proveedor; facturas → factura.
123
+ *
124
+ * Deliberately small: irregular plurals (lápiz/lápices, child/children) and
125
+ * synonyms are not folded, and two different words may share a stem now and
126
+ * then. It only decides what counts as "the same word"; substrings still match.
127
+ */
128
+ export function foldWord(word: string): string {
129
+ let stem = word;
130
+ if (stem.length > 4 && stem.endsWith("s") && !stem.endsWith("ss")) stem = stem.slice(0, -1);
131
+ if (stem.length > 4 && stem.endsWith("e")) stem = stem.slice(0, -1);
132
+ return stem;
133
+ }
134
+
135
+ export function searchSchema(snapshot: Snapshot, knowledge: Knowledge, query: string, options: SearchOptions = {}): SearchResults {
136
+ const { limit = 20 } = options;
137
+ const q = parseQuery(query);
138
+ const wanted = databaseFilter(snapshot, options.database);
139
+
140
+ const notes = notesByTarget(knowledge.annotations);
141
+ const results: Draft[] = [];
142
+ const tables = new Map<string, Draft>();
143
+ const draft = (target: string, object: SearchObject): Draft => {
144
+ const existing = tables.get(target);
145
+ if (existing !== undefined) return existing;
146
+ const created: Draft = { target, object, signals: [], columns: [], inferred: false };
147
+ tables.set(target, created);
148
+ results.push(created);
149
+ return created;
150
+ };
151
+
152
+ // Per database, lowercased table name → canonical names: where a column
153
+ // named cod_gcl may point. Two entries when names differ only by case.
154
+ const byName = new Map<string, Map<string, string[]>>();
155
+ const kinds = new Map<string, Table["kind"]>();
156
+ for (const database of snapshot.databases) {
157
+ const names = new Map<string, string[]>();
158
+ byName.set(database.name, names);
159
+ for (const schema of database.schemas) {
160
+ for (const table of schema.tables) {
161
+ const name = formatName({ database: database.name, schema: schema.name, table: table.name });
162
+ const lower = table.name.toLowerCase();
163
+ names.set(lower, [...(names.get(lower) ?? []), name]);
164
+ kinds.set(name, table.kind);
165
+ }
166
+ }
167
+ }
168
+ const documented = new Map<string, string>(); // table + column → table it points to, from the team's relationships
169
+ for (const { from, to } of knowledge.relationships) {
170
+ // A column pointing into another connection infers nothing about the tables of this one.
171
+ if (from.connection !== undefined || to.connection !== undefined) continue;
172
+ for (const name of from.columns) documented.set(columnKey(from.table, name), to.table);
173
+ }
174
+
175
+ // How many columns whose comment has the query as a word point to each table:
176
+ // the candidates for inferred meaning, before their summaries are read.
177
+ const pointedTo = new Map<string, number>();
178
+
179
+ for (const database of snapshot.databases) {
180
+ for (const schema of database.schemas) {
181
+ const included = wanted === null || wanted.has(schemaKey(database.name, schema.name));
182
+ const schemaName = formatName({ database: database.name, schema: schema.name });
183
+ if (included) {
184
+ const signals = [
185
+ ...nameSignals(database.name, q),
186
+ ...(schema.name === database.name ? [] : nameSignals(schema.name, q)),
187
+ ...noteSignals(notes.get(schemaName), q),
188
+ ];
189
+ if (signals.length > 0) draft(schemaName, "database").signals.push(...bestNameFirst(signals));
190
+ }
191
+
192
+ for (const table of schema.tables) {
193
+ const ref = { database: database.name, schema: schema.name, table: table.name };
194
+ const tableName = formatName(ref);
195
+ if (included) collectTable(table, tableName, notes, q, draft);
196
+
197
+ for (const column of table.columns) {
198
+ if (column.comment === "" || !isWordMatch(column.comment, q)) continue;
199
+ for (const target of pointsTo(table, tableName, column.name, database.name, byName, documented)) {
200
+ pointedTo.set(target, (pointedTo.get(target) ?? 0) + 1);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ const candidates = [...pointedTo.entries()]
208
+ .filter(([name]) => wanted === null || wanted.has(schemaKeyOf(name)))
209
+ .sort((a, b) => b[1] - a[1] || compareText(a[0], b[0]))
210
+ .slice(0, MAX_INFERRED_TABLES);
211
+ for (const [name] of candidates) {
212
+ const matching = incomingReferences(snapshot, knowledge, name).summary.filter((item) => isWordMatch(item.comment, q));
213
+ if (matching.length === 0) continue;
214
+ const references = matching.reduce((sum, item) => sum + item.count, 0);
215
+ const target = draft(name, kinds.get(name) ?? "table");
216
+ target.inferred = true;
217
+ target.signals.push({
218
+ score: SCORE.inferred + SCORE.perReference * Math.min(references, SCORE.maxReferences),
219
+ label: `referenced as ${matching.map((item) => `"${item.comment}" ×${item.count}`).join(", ")} (inferred)`,
220
+ snippet: "",
221
+ });
222
+ }
223
+
224
+ for (const relationship of knowledge.relationships) {
225
+ // Only a side in this connection can be in one of its databases.
226
+ const here = [relationship.from, relationship.to].filter((item) => item.connection === undefined);
227
+ if (wanted !== null && !here.some((item) => wanted.has(schemaKeyOf(item.table)))) continue;
228
+ const signals = textSignal("relationship note", textMatch(relationship.note, q, SNIPPET_LENGTH), SCORE.statedText, SCORE.definitionText);
229
+ if (signals.length === 0) continue;
230
+ results.push({
231
+ target: `${side(relationship.from)} → ${side(relationship.to)}`,
232
+ object: "relationship",
233
+ signals,
234
+ columns: [],
235
+ inferred: false,
236
+ relationship: { from: relationship.from, to: relationship.to },
237
+ });
238
+ }
239
+
240
+ const finished = results.map(finish).sort(compareRanked);
241
+ return { results: finished.slice(0, Math.max(0, limit)).map((item) => item.result), total: finished.length };
242
+ }
243
+
244
+ interface Query {
245
+ /** The normalized query, spaces collapsed. */
246
+ readonly needle: string;
247
+ /** Its words, folded; empty when it has no letters or digits. */
248
+ readonly words: readonly string[];
249
+ /** The folded query when it is one plain word ("clientes" → "client"), else null: names match it too. */
250
+ readonly folded: string | null;
251
+ /** `folded` when it differs from the needle, else null: prefixes and substrings to try besides the needle. */
252
+ readonly stem: string | null;
253
+ }
254
+
255
+ function parseQuery(query: string): Query {
256
+ const needle = normalizeText(collapse(query));
257
+ if (needle === "") throw new Error("search query must not be empty");
258
+ const words = splitWords(needle);
259
+ const folded = words.length === 1 && /^[\p{L}\p{N}]+$/u.test(needle) ? (words[0] as string) : null;
260
+ return { needle, words, folded, stem: folded !== needle ? folded : null };
261
+ }
262
+
263
+ interface Signal {
264
+ readonly score: number;
265
+ readonly label: string;
266
+ /** The matched text, for SearchResult.snippet; "" for names and inferences. */
267
+ readonly snippet: string;
268
+ }
269
+
270
+ interface Draft {
271
+ readonly target: string;
272
+ readonly object: SearchObject;
273
+ readonly signals: Signal[];
274
+ readonly columns: (ColumnMatch & { score: number; position: number })[];
275
+ inferred: boolean;
276
+ readonly relationship?: SearchResult["relationship"];
277
+ }
278
+
279
+ interface Ranked {
280
+ readonly score: number;
281
+ readonly result: SearchResult;
282
+ }
283
+
284
+ type Notes = ReadonlyMap<string, readonly Annotation[]>;
285
+
286
+ function collectTable(table: Table, tableName: string, notes: Notes, q: Query, draft: (target: string, object: SearchObject) => Draft): void {
287
+ const signals: Signal[] = [
288
+ ...nameSignals(table.name, q),
289
+ ...textSignal("table comment", textMatch(table.comment, q, SNIPPET_LENGTH), SCORE.statedWord, SCORE.statedText),
290
+ ...noteSignals(notes.get(tableName), q),
291
+ ...textSignal("definition", textMatch(table.viewDefinition ?? "", q, SNIPPET_LENGTH), SCORE.definitionWord, SCORE.definitionText),
292
+ ];
293
+
294
+ const columns: Draft["columns"] = [];
295
+ for (const column of table.columns) {
296
+ const name = nameRank(column.name, q);
297
+ const nameScore = name === "exact" ? SCORE.columnExact : name === "prefix" ? SCORE.columnPrefix : SCORE.columnContains;
298
+ let best: { score: number; snippet: string } | null = name === null ? null : { score: nameScore, snippet: "" };
299
+ const columnNotes = (notes.get(columnKey(tableName, column.name)) ?? []).map((note) => note.text);
300
+ for (const text of [column.comment, ...columnNotes]) {
301
+ const match = textMatch(text, q, COLUMN_SNIPPET_LENGTH);
302
+ const score = match === null ? 0 : match.word ? SCORE.columnWord : SCORE.columnText;
303
+ if (match !== null && (best === null || score > best.score)) best = { score, snippet: match.snippet };
304
+ }
305
+ if (best !== null) columns.push({ name: column.name, snippet: best.snippet, score: best.score, position: column.position });
306
+ }
307
+
308
+ if (signals.length === 0 && columns.length === 0) return;
309
+ const target = draft(tableName, table.kind);
310
+ target.signals.push(...signals);
311
+ target.columns.push(...columns);
312
+ }
313
+
314
+ /** The tables a column may point to: its foreign key, a documented relationship, or else its name pattern. */
315
+ function pointsTo(
316
+ table: Table,
317
+ tableName: string,
318
+ column: string,
319
+ database: string,
320
+ byName: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>>,
321
+ documented: ReadonlyMap<string, string>,
322
+ ): string[] {
323
+ const key = table.foreignKeys.find((item) => item.columns.includes(column));
324
+ if (key !== undefined) return [formatName(key.references)].filter((name) => name !== tableName);
325
+ const related = documented.get(columnKey(tableName, column));
326
+ if (related !== undefined) return related === tableName ? [] : [related];
327
+ // incomingReferences only reads name patterns from tables: views repeat their columns.
328
+ if (table.kind !== "table") return [];
329
+ const names = byName.get(database);
330
+ return namePatternTargets(column).flatMap((candidate) => names?.get(candidate) ?? []).filter((name) => name !== tableName);
331
+ }
332
+
333
+ function finish(draft: Draft): Ranked {
334
+ const columns = [...draft.columns].sort((a, b) => b.score - a.score || a.position - b.position);
335
+ const signals = [...draft.signals];
336
+ const [bestColumn] = columns;
337
+ if (bestColumn !== undefined) {
338
+ const shown = columns.slice(0, MAX_COLUMNS);
339
+ const more = columns.length - shown.length;
340
+ const list = shown.map((column) => (column.snippet === "" ? column.name : `${column.name} ("${column.snippet}")`)).join(", ");
341
+ signals.push({
342
+ score: bestColumn.score + Math.min(columns.length - 1, SCORE.maxExtraColumns),
343
+ label: `columns: ${list}${more > 0 ? ` (+${more} more)` : ""}`,
344
+ snippet: "",
345
+ });
346
+ }
347
+ // Stable sort: equal scores keep the order they were found in (name before comment).
348
+ signals.sort((a, b) => b.score - a.score);
349
+ const snippet = signals.find((signal) => signal.snippet !== "")?.snippet ?? "";
350
+ const result: SearchResult = {
351
+ target: draft.target,
352
+ object: draft.object,
353
+ matchedIn: signals.map((signal) => signal.label),
354
+ snippet,
355
+ inferred: draft.inferred,
356
+ columns: columns.slice(0, MAX_COLUMNS).map(({ name, snippet: text }) => ({ name, snippet: text })),
357
+ moreColumns: Math.max(0, columns.length - MAX_COLUMNS),
358
+ ...(draft.relationship !== undefined && { relationship: draft.relationship }),
359
+ };
360
+ return { score: signals.reduce((sum, signal) => sum + signal.score, 0), result };
361
+ }
362
+
363
+ function nameSignals(name: string, q: Query): Signal[] {
364
+ const rank = nameRank(name, q);
365
+ if (rank === null) return [];
366
+ const score = rank === "exact" ? SCORE.exactName : rank === "prefix" ? SCORE.namePrefix : SCORE.nameContains;
367
+ return [{ score, label: "name", snippet: "" }];
368
+ }
369
+
370
+ // A database matching by both its name and its schema name counts once.
371
+ function bestNameFirst(signals: readonly Signal[]): Signal[] {
372
+ const names = signals.filter((signal) => signal.label === "name").sort((a, b) => b.score - a.score);
373
+ return [...names.slice(0, 1), ...signals.filter((signal) => signal.label !== "name")];
374
+ }
375
+
376
+ function noteSignals(notes: readonly Annotation[] | undefined, q: Query): Signal[] {
377
+ return (notes ?? []).flatMap((note) => textSignal("note", textMatch(note.text, q, SNIPPET_LENGTH), SCORE.statedWord, SCORE.statedText));
378
+ }
379
+
380
+ function textSignal(label: string, match: { word: boolean; snippet: string } | null, wordScore: number, textScore: number): Signal[] {
381
+ if (match === null) return [];
382
+ return [{ score: match.word ? wordScore : textScore, label: `${label}: ${match.snippet}`, snippet: match.snippet }];
383
+ }
384
+
385
+ function nameRank(name: string, q: Query): "exact" | "prefix" | "contains" | null {
386
+ const normalized = normalizeText(name);
387
+ if (normalized === q.needle) return "exact";
388
+ if (q.folded !== null && /^[\p{L}\p{N}]+$/u.test(normalized) && foldWord(normalized) === q.folded) return "exact";
389
+ if (normalized.startsWith(q.needle) || (q.stem !== null && normalized.startsWith(q.stem))) return "prefix";
390
+ if (normalized.includes(q.needle) || (q.stem !== null && normalized.includes(q.stem))) return "contains";
391
+ return null;
392
+ }
393
+
394
+ /**
395
+ * Where the query is in `raw`: as whole words (word: true), or only inside
396
+ * longer words; null when it is not there. The snippet is cut around it.
397
+ */
398
+ function textMatch(raw: string, q: Query, length: number): { word: boolean; snippet: string } | null {
399
+ if (raw === "") return null;
400
+ const text = collapse(raw);
401
+ const word = hasWords(normalizeText(text), q);
402
+ if (word === null) return null;
403
+ return { word, snippet: snippetAround(text, word ? (q.words[0] as string) : q.needle, length) };
404
+ }
405
+
406
+ function isWordMatch(raw: string, q: Query): boolean {
407
+ return raw !== "" && hasWords(normalizeText(raw), q) === true;
408
+ }
409
+
410
+ /** true: the query is there as whole words; false: only inside longer words; null: not there. */
411
+ function hasWords(normalized: string, q: Query): boolean | null {
412
+ const first = q.words[0];
413
+ const inside = normalized.includes(q.needle);
414
+ // Cheap check first: a word match needs the stem of the first word somewhere.
415
+ if (!inside && (first === undefined || !normalized.includes(first))) return null;
416
+ const word = q.words.length > 0 && containsWords(splitWords(normalized), q.words);
417
+ return word ? true : inside ? false : null;
418
+ }
419
+
420
+ function splitWords(normalized: string): string[] {
421
+ return normalized
422
+ .split(/[^\p{L}\p{N}]+/u)
423
+ .filter((word) => word !== "")
424
+ .map(foldWord);
425
+ }
426
+
427
+ function containsWords(words: readonly string[], wanted: readonly string[]): boolean {
428
+ for (let start = 0; start + wanted.length <= words.length; start++) {
429
+ if (wanted.every((word, index) => words[start + index] === word)) return true;
430
+ }
431
+ return false;
432
+ }
433
+
434
+ /**
435
+ * The part of `text` around the first occurrence of `needle` in its
436
+ * normalized form, at most `length` characters including the "…" that mark
437
+ * cut ends. Matching happens on the normalized text, but the snippet is cut
438
+ * from the original, so accents are shown as written.
439
+ */
440
+ function snippetAround(text: string, needle: string, length: number): string {
441
+ if (text.length <= length) return text;
442
+ // normalized[i] came from text[origin[i]]: removing accents changes lengths.
443
+ let normalized = "";
444
+ const origin: number[] = [];
445
+ let index = 0;
446
+ for (const character of text) {
447
+ const part = normalizeText(character);
448
+ normalized += part;
449
+ for (let unit = 0; unit < part.length; unit++) origin.push(index);
450
+ index += character.length;
451
+ }
452
+ origin.push(text.length);
453
+
454
+ const at = Math.max(0, normalized.indexOf(needle));
455
+ const matchStart = origin[at] as number;
456
+ const room = length - 2; // two characters kept for the "…" at each end
457
+ const start = Math.max(0, Math.min(matchStart - Math.floor(length / 3), text.length - room));
458
+ const end = Math.min(text.length, start + room);
459
+ return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
460
+ }
461
+
462
+ /** Line breaks and runs of spaces become one space: a snippet is one line. */
463
+ function collapse(text: string): string {
464
+ return text.replace(/\s+/g, " ").trim();
465
+ }
466
+
467
+ /**
468
+ * The (database, schema) pairs `database` names, as a set of schemaKey;
469
+ * null without a filter. Exact spelling first, then ignoring case, like the
470
+ * resolve functions.
471
+ */
472
+ function databaseFilter(snapshot: Snapshot, database: string | undefined): Set<string> | null {
473
+ if (database === undefined) return null;
474
+ const typed = database.trim();
475
+ const pairs = snapshot.databases.flatMap((item) =>
476
+ item.schemas.map((schema) => {
477
+ const ref = { database: item.name, schema: schema.name };
478
+ return { key: schemaKey(item.name, schema.name), shown: displayName(ref), names: [item.name, formatName(ref), displayName(ref)] };
479
+ }),
480
+ );
481
+ let matches = pairs.filter((pair) => pair.names.includes(typed));
482
+ if (matches.length === 0) matches = pairs.filter((pair) => pair.names.some((name) => name.toLowerCase() === typed.toLowerCase()));
483
+ if (matches.length === 0) {
484
+ const known = [...new Set(pairs.map((pair) => pair.shown))].sort(compareText);
485
+ const shown = known.slice(0, MAX_DATABASES_LISTED).join(", ");
486
+ const more = known.length > MAX_DATABASES_LISTED ? ` and ${known.length - MAX_DATABASES_LISTED} more` : "";
487
+ throw new Error(`no database matches "${database}"; databases: ${shown}${more}`);
488
+ }
489
+ return new Set(matches.map((pair) => pair.key));
490
+ }
491
+
492
+ function notesByTarget(annotations: readonly Annotation[]): Map<string, Annotation[]> {
493
+ const notes = new Map<string, Annotation[]>();
494
+ for (const annotation of annotations) {
495
+ const { database, schema, table, column } = parseName(annotation.target);
496
+ const key = column === undefined ? annotation.target : columnKey(formatName({ database, schema, ...(table !== undefined && { table }) }), column);
497
+ notes.set(key, [...(notes.get(key) ?? []), annotation]);
498
+ }
499
+ return notes;
500
+ }
501
+
502
+ function side({ connection, table, columns }: RelationshipSide): string {
503
+ return `${connection === undefined ? "" : `${connection}: `}${table}(${columns.join(", ")})`;
504
+ }
505
+
506
+ function schemaKey(database: string, schema: string): string {
507
+ return `${database}\u0000${schema}`;
508
+ }
509
+
510
+ function schemaKeyOf(canonical: string): string {
511
+ const { database, schema } = parseName(canonical);
512
+ return schemaKey(database, schema);
513
+ }
514
+
515
+ function columnKey(table: string, column: string): string {
516
+ return `${table}\u0000${column}`;
517
+ }
518
+
519
+ function compareRanked(a: Ranked, b: Ranked): number {
520
+ return (
521
+ b.score - a.score ||
522
+ OBJECT_ORDER.indexOf(a.result.object) - OBJECT_ORDER.indexOf(b.result.object) ||
523
+ compareText(a.result.target, b.result.target)
524
+ );
525
+ }
526
+
527
+ // Plain code-unit comparison: the same order on every machine and locale.
528
+ function compareText(a: string, b: string): number {
529
+ return a < b ? -1 : a > b ? 1 : 0;
530
+ }
package/lib/secrets.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { assertNoSymlinks } from "./store/guards.ts";
5
+ import { HEYDB_DIR } from "./store/project.ts";
6
+
7
+ // Resolves a password reference from .heydb/config.json (ADR-0005). No
8
+ // committed file holds a secret: the value lives in the environment or in
9
+ // .heydb/.env, which git ignores (store/envfile.ts writes it when a person
10
+ // types the password). What remains is not leaking the values read here. No error built here ever contains a value:
11
+ // not the resolved password, not a line of .env, not even a reference that
12
+ // failed to parse (it might be a password typed in the wrong field).
13
+ //
14
+ // Lookup order for "env:HEYDB_NAME":
15
+ // 1. the process environment (shell, CI, a secrets manager that exports it);
16
+ // 2. the project's .heydb/.env, which .heydb/.gitignore excludes.
17
+ // The environment wins so a one-off `HEYDB_PROD_PASSWORD=… pi` overrides the file.
18
+
19
+ /**
20
+ * The only shape a password may take, here and in config validation. Just
21
+ * HEYDB_* variables: config.json is shared through git, and a reference to any
22
+ * variable would let a malicious config send, say, AWS_SECRET_ACCESS_KEY as the
23
+ * password to a host it chose.
24
+ */
25
+ export const PASSWORD_REFERENCE = /^env:(HEYDB_[A-Z0-9_]+)$/;
26
+
27
+ /** Why a reference was refused; shared so both checks explain it the same way. */
28
+ export const REFERENCE_RULE = "only HEYDB_* variables can be used, so a shared config cannot read unrelated secrets from your environment";
29
+
30
+ /** The reference heydb suggests for a connection: "prod-eu" → "env:HEYDB_PROD_EU_PASSWORD". */
31
+ export function suggestedReference(name: string): string {
32
+ return `env:HEYDB_${name.toUpperCase().replaceAll("-", "_")}_PASSWORD`;
33
+ }
34
+
35
+ /** The file under .heydb/ that holds local secrets; .heydb/.gitignore excludes it. */
36
+ export const ENV_FILE = ".env";
37
+ const ENV_LINE = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
38
+
39
+ export interface SecretOptions {
40
+ /** Connection name, used only to say which password is missing. */
41
+ readonly connection: string;
42
+ readonly env?: Readonly<Record<string, string | undefined>>;
43
+ /** Read <projectRoot>/.heydb/.env when the environment lacks the variable. */
44
+ readonly projectRoot?: string;
45
+ }
46
+
47
+ export function resolveSecret(reference: string, options: SecretOptions): string {
48
+ const found = lookup(reference, options);
49
+ if (found !== null) return found.value;
50
+ throw new Error(
51
+ `password for connection "${options.connection}" comes from ${reference}, which is not set. ` +
52
+ "Set it in your shell or in .heydb/.env (ignored by git).",
53
+ );
54
+ }
55
+
56
+ /** Where a password reference is set, in resolveSecret's order. Never the value. */
57
+ export type SecretSource = "environment" | "env-file";
58
+
59
+ /** Where resolveSecret would find the password; null when it is not set anywhere. */
60
+ export function secretSource(reference: string, options: SecretOptions): SecretSource | null {
61
+ return lookup(reference, options)?.source ?? null;
62
+ }
63
+
64
+ /**
65
+ * The variable a reference names: "env:HEYDB_LOCAL_PASSWORD" → "HEYDB_LOCAL_PASSWORD".
66
+ * `connection`, when known, makes the error name it and suggest a reference.
67
+ */
68
+ export function referenceVariable(reference: string, connection?: string): string {
69
+ const match = PASSWORD_REFERENCE.exec(reference);
70
+ if (match === null) {
71
+ const subject = connection === undefined ? "password" : `password for connection "${connection}"`;
72
+ const example = suggestedReference(connection ?? "name");
73
+ throw new Error(`${subject} must be a reference such as "${example}": ${REFERENCE_RULE}`);
74
+ }
75
+ return match[1] as string;
76
+ }
77
+
78
+ function lookup(reference: string, options: SecretOptions): { value: string; source: SecretSource } | null {
79
+ const { connection, env = process.env, projectRoot } = options;
80
+ const name = referenceVariable(reference, connection);
81
+
82
+ // An empty value counts as set: local servers often have no password.
83
+ const fromEnv = env[name];
84
+ if (fromEnv !== undefined) return { value: fromEnv, source: "environment" };
85
+
86
+ if (projectRoot !== undefined) {
87
+ const fromFile = readEnvFile(projectRoot);
88
+ if (fromFile !== null && Object.hasOwn(fromFile, name)) return { value: fromFile[name] as string, source: "env-file" };
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /** The parsed file, or null when it does not exist. */
94
+ function readEnvFile(projectRoot: string): Readonly<Record<string, string>> | null {
95
+ // A committed symbolic link could point .env at any file on this machine.
96
+ assertNoSymlinks(projectRoot, ENV_FILE);
97
+ let text: string;
98
+ try {
99
+ text = readFileSync(join(projectRoot, HEYDB_DIR, ENV_FILE), "utf8");
100
+ } catch (error) {
101
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
102
+ throw error; // a permission error names the path, never the content
103
+ }
104
+ return parseEnvFile(text);
105
+ }
106
+
107
+ /**
108
+ * A deliberately small .env reader. Supported: KEY=VALUE lines, an optional
109
+ * "export " prefix, "#" comment lines, blank lines, Windows line endings, a
110
+ * leading byte order mark, and one pair of matching single or double quotes
111
+ * around the value. The value is everything after the first "=", trimmed, so
112
+ * "a=b" and "#" are kept as part of it: passwords often contain them.
113
+ *
114
+ * Not supported, on purpose: multi-line values, escapes such as \n, variable
115
+ * expansion ($OTHER) and comments after a value. A line that is not KEY=VALUE
116
+ * is an error that names the line number only, never its content.
117
+ */
118
+ export function parseEnvFile(text: string): Readonly<Record<string, string>> {
119
+ // A Map, then Object.fromEntries: a key called "__proto__" stays a plain key.
120
+ const values = new Map<string, string>();
121
+ // Some Windows editors start the file with a byte order mark.
122
+ text.replace(/^\uFEFF/, "").split("\n").forEach((raw, index) => {
123
+ const entry = parseEnvLine(raw);
124
+ if (entry === undefined) {
125
+ throw new Error(`.heydb/.env line ${index + 1} is not KEY=VALUE (names use letters, digits and _)`);
126
+ }
127
+ if (entry !== null) values.set(entry.key, entry.value);
128
+ });
129
+ return Object.fromEntries(values);
130
+ }
131
+
132
+ /**
133
+ * One line as parseEnvFile reads it: null for a blank or comment line,
134
+ * undefined when it is not KEY=VALUE. Shared with the code that rewrites the
135
+ * file (store/envfile.ts), so both always agree on which line sets which name.
136
+ */
137
+ export function parseEnvLine(raw: string): { key: string; value: string } | null | undefined {
138
+ const line = raw.replace(/\r$/, "").trim();
139
+ if (line === "" || line.startsWith("#")) return null;
140
+ const match = ENV_LINE.exec(line);
141
+ if (match === null) return undefined;
142
+ return { key: match[1] as string, value: unquote((match[2] as string).trim()) };
143
+ }
144
+
145
+ function unquote(value: string): string {
146
+ const first = value[0];
147
+ if (value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first)) return value.slice(1, -1);
148
+ return value;
149
+ }