@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.

Potentially problematic release.


This version of @pvidaal07/heydb might be problematic. Click here for more details.

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,214 @@
1
+ import { pickConnection } from "../config/operations.ts";
2
+ import { loadConfig } from "../config/store.ts";
3
+ import {
4
+ type AnnotationInput,
5
+ type KnowledgeOptions,
6
+ type RelationshipInput,
7
+ addAnnotation,
8
+ addProjectNote,
9
+ addRelationship,
10
+ editAnnotation,
11
+ removeAnnotation,
12
+ removeRelationship,
13
+ } from "../knowledge/operations.ts";
14
+ import {
15
+ type ExternalRelationship,
16
+ type Lookup,
17
+ type Orphans,
18
+ type TableKnowledge,
19
+ findOrphans,
20
+ knowledgeForTable,
21
+ missingInSide,
22
+ relationshipsInto,
23
+ snapshotLookup,
24
+ } from "../knowledge/query.ts";
25
+ import type { Annotation, Relationship, RelationshipSide } from "../knowledge/types.ts";
26
+ import type { Snapshot } from "../model/snapshot.ts";
27
+ import {
28
+ knowledgeConnections,
29
+ loadKnowledge,
30
+ loadProjectKnowledge,
31
+ saveKnowledge,
32
+ saveProjectKnowledge,
33
+ } from "../store/knowledge.ts";
34
+ import { loadSnapshot } from "../store/snapshots.ts";
35
+
36
+ // Every write follows the same path: load, apply a pure operation, check the
37
+ // result against the stored snapshot when it names new objects, save
38
+ // atomically. A failure anywhere leaves the file as it was.
39
+ //
40
+ // New notes must point at objects that exist in the snapshot: a typo such as
41
+ // "orders.totla" would otherwise create knowledge nobody ever finds. Edits and
42
+ // removals work by id and skip that check, so orphaned entries can still be
43
+ // corrected or forgotten after a column is dropped.
44
+ //
45
+ // A relationship may cross connections (crm.clientes.cod_erp → erp's
46
+ // gcl.cod). It is stored in the file of its "from" connection, and its "to"
47
+ // side names the other connection and is checked against that snapshot.
48
+
49
+ export function annotate(
50
+ projectRoot: string,
51
+ connection: string,
52
+ input: AnnotationInput,
53
+ options: KnowledgeOptions = {},
54
+ ): Annotation {
55
+ const exists = snapshotLookup(requireSnapshot(projectRoot, connection));
56
+ const { knowledge, annotation } = addAnnotation(loadKnowledge(projectRoot, connection), input, options);
57
+ if (!exists(annotation.target)) throw notInSnapshot(annotation.target, connection);
58
+ saveKnowledge(projectRoot, connection, knowledge);
59
+ return annotation;
60
+ }
61
+
62
+ export function relate(
63
+ projectRoot: string,
64
+ connection: string,
65
+ input: RelationshipInput,
66
+ options: KnowledgeOptions = {},
67
+ ): Relationship {
68
+ if (input.from.connection !== undefined && input.from.connection !== connection) {
69
+ throw new Error(`a relationship is stored in its "from" connection: relate it from connection "${input.from.connection}"`);
70
+ }
71
+ const from = withoutConnection(input.from);
72
+ const to = input.to.connection === connection ? withoutConnection(input.to) : input.to;
73
+ const exists = snapshotLookup(requireSnapshot(projectRoot, connection));
74
+ const other = to.connection;
75
+ let otherExists: Lookup = exists;
76
+ if (other !== undefined) {
77
+ pickConnection(loadConfig(projectRoot), other); // an unknown name fails with the configured ones
78
+ otherExists = snapshotLookup(requireSnapshot(projectRoot, other));
79
+ }
80
+ const { knowledge, relationship } = addRelationship(loadKnowledge(projectRoot, connection), { ...input, from, to }, options);
81
+ const missingFrom = missingInSide(relationship.from, exists);
82
+ if (missingFrom !== null) throw notInSnapshot(missingFrom, connection);
83
+ const missingTo = missingInSide(relationship.to, otherExists);
84
+ if (missingTo !== null) throw notInSnapshot(missingTo, other ?? connection);
85
+ saveKnowledge(projectRoot, connection, knowledge);
86
+ return relationship;
87
+ }
88
+
89
+ /** Replaces the text of a note of the connection or, when the id is not there, of a project note. */
90
+ export function editNote(
91
+ projectRoot: string,
92
+ connection: string,
93
+ input: { readonly id: string; readonly text: string },
94
+ options: KnowledgeOptions = {},
95
+ ): Annotation {
96
+ const own = loadKnowledge(projectRoot, connection);
97
+ if (!hasAnnotation(own.annotations, input.id)) {
98
+ const project = loadProjectKnowledge(projectRoot);
99
+ if (hasAnnotation(project.annotations, input.id)) {
100
+ const { knowledge, annotation } = editAnnotation(project, input.id, input.text, options);
101
+ saveProjectKnowledge(projectRoot, knowledge);
102
+ return annotation;
103
+ }
104
+ }
105
+ const { knowledge, annotation } = editAnnotation(own, input.id, input.text, options);
106
+ saveKnowledge(projectRoot, connection, knowledge);
107
+ return annotation;
108
+ }
109
+
110
+ /** A note about the whole project, above any database. Needs no connection or snapshot. */
111
+ export function annotateProject(
112
+ projectRoot: string,
113
+ input: Omit<AnnotationInput, "target">,
114
+ options: KnowledgeOptions = {},
115
+ ): Annotation {
116
+ const { knowledge, annotation } = addProjectNote(loadProjectKnowledge(projectRoot), input, options);
117
+ saveProjectKnowledge(projectRoot, knowledge);
118
+ return annotation;
119
+ }
120
+
121
+ export function projectNotes(projectRoot: string): readonly Annotation[] {
122
+ return loadProjectKnowledge(projectRoot).annotations;
123
+ }
124
+
125
+ export function forgetProjectNote(projectRoot: string, id: string): Annotation {
126
+ const { knowledge, annotation } = removeAnnotation(loadProjectKnowledge(projectRoot), id);
127
+ saveProjectKnowledge(projectRoot, knowledge);
128
+ return annotation;
129
+ }
130
+
131
+ /** Removes a note and returns it, so the caller can show what was forgotten. */
132
+ export function forgetAnnotation(projectRoot: string, connection: string, id: string): Annotation {
133
+ const { knowledge, annotation } = removeAnnotation(loadKnowledge(projectRoot, connection), id);
134
+ saveKnowledge(projectRoot, connection, knowledge);
135
+ return annotation;
136
+ }
137
+
138
+ export function forgetRelationship(projectRoot: string, connection: string, id: string): Relationship {
139
+ const { knowledge, relationship } = removeRelationship(loadKnowledge(projectRoot, connection), id);
140
+ saveKnowledge(projectRoot, connection, knowledge);
141
+ return relationship;
142
+ }
143
+
144
+ /** Database, table and column notes plus relationships for one table. Needs no snapshot. */
145
+ export function tableKnowledge(projectRoot: string, connection: string, table: string): TableKnowledge {
146
+ return knowledgeForTable(loadKnowledge(projectRoot, connection), table);
147
+ }
148
+
149
+ /** Files read at most, so a folder full of stray files cannot slow every table down. */
150
+ export const MAX_OTHER_KNOWLEDGE_FILES = 100;
151
+
152
+ /**
153
+ * Relationships stored in other connections' knowledge files that point to
154
+ * `table` of `connection`. A file that cannot be read is reported in
155
+ * `unreadable`, not thrown: a broken crm.json must not hide erp's tables.
156
+ */
157
+ export function relationshipsFromOtherConnections(
158
+ projectRoot: string,
159
+ connection: string,
160
+ table: string,
161
+ ): { relationships: ExternalRelationship[]; unreadable: string[] } {
162
+ const relationships: ExternalRelationship[] = [];
163
+ const unreadable: string[] = [];
164
+ const others = knowledgeConnections(projectRoot).filter((name) => name !== connection);
165
+ for (const other of others.slice(0, MAX_OTHER_KNOWLEDGE_FILES)) {
166
+ try {
167
+ for (const relationship of relationshipsInto(loadKnowledge(projectRoot, other), connection, table)) {
168
+ relationships.push({ connection: other, relationship });
169
+ }
170
+ } catch (error) {
171
+ unreadable.push(error instanceof Error ? error.message : String(error));
172
+ }
173
+ }
174
+ const skipped = others.length - MAX_OTHER_KNOWLEDGE_FILES;
175
+ if (skipped > 0) unreadable.push(`${skipped} more knowledge files were not read (limit ${MAX_OTHER_KNOWLEDGE_FILES})`);
176
+ return { relationships, unreadable };
177
+ }
178
+
179
+ /**
180
+ * Knowledge whose target is gone from the stored snapshot, including
181
+ * relationships whose other connection is gone, unsynced or no longer has
182
+ * the table or columns. Reported only; the file is not changed.
183
+ */
184
+ export function orphanedKnowledge(projectRoot: string, connection: string): Orphans {
185
+ const snapshot = requireSnapshot(projectRoot, connection);
186
+ const configured = loadConfig(projectRoot).connections;
187
+ const lookups = new Map<string, Lookup | null>();
188
+ const others = (name: string): Lookup | null => {
189
+ if (!lookups.has(name)) {
190
+ const stored = Object.hasOwn(configured, name) ? loadSnapshot(projectRoot, name) : null;
191
+ lookups.set(name, stored === null ? null : snapshotLookup(stored));
192
+ }
193
+ return lookups.get(name) ?? null;
194
+ };
195
+ return findOrphans(loadKnowledge(projectRoot, connection), snapshot, others);
196
+ }
197
+
198
+ function requireSnapshot(projectRoot: string, connection: string): Snapshot {
199
+ const snapshot = loadSnapshot(projectRoot, connection);
200
+ if (snapshot === null) throw new Error(`connection "${connection}" was never synced: run sync first`);
201
+ return snapshot;
202
+ }
203
+
204
+ function hasAnnotation(annotations: readonly Annotation[], id: string): boolean {
205
+ return annotations.some((item) => item.id === id);
206
+ }
207
+
208
+ function withoutConnection(side: RelationshipSide): RelationshipSide {
209
+ return { table: side.table, columns: side.columns };
210
+ }
211
+
212
+ function notInSnapshot(name: string, connection: string): Error {
213
+ return new Error(`${name} does not exist in the snapshot of "${connection}"; if it was created recently, run sync first`);
214
+ }
@@ -0,0 +1,377 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { relative } from "node:path";
4
+
5
+ import { addConnection, pickConnection, removeConnection, setDefaultConnection } from "../config/operations.ts";
6
+ import { loadConfig, saveConfig } from "../config/store.ts";
7
+ import type { Config, ConnectionConfig } from "../config/types.ts";
8
+ import { validateConnection } from "../config/validate.ts";
9
+ import { introspect as introspectEngine } from "../engines/index.ts";
10
+ import { snapshotLookup } from "../knowledge/query.ts";
11
+ import type { Annotation, Knowledge, Relationship } from "../knowledge/types.ts";
12
+ import { displayNameOf, formatName, parseName, parseSegments } from "../model/names.ts";
13
+ import type { Snapshot } from "../model/snapshot.ts";
14
+ import { ENV_FILE, type SecretSource, referenceVariable, resolveSecret, secretSource, suggestedReference } from "../secrets.ts";
15
+ import { removeEnvValue, setEnvValue } from "../store/envfile.ts";
16
+ import { assertConnectionName } from "../store/guards.ts";
17
+ import { knowledgePath, loadKnowledge, loadProjectKnowledge } from "../store/knowledge.ts";
18
+ import { HEYDB_DIR, findProjectRoot } from "../store/project.ts";
19
+ import { loadSnapshot, storedSnapshotPath } from "../store/snapshots.ts";
20
+ import { ensureEnvIgnored } from "./init.ts";
21
+ import { forgetAnnotation, forgetProjectNote, forgetRelationship } from "./knowledge.ts";
22
+ import { resolveColumn, resolveTable } from "./resolve.ts";
23
+
24
+ // The steps every entry point (Pi today, a CLI or MCP server later) repeats
25
+ // before doing anything: find the project, pick the connection, load what is
26
+ // stored. Errors an entry point must answer with its own instructions ("run
27
+ // init", "run sync") are ProjectErrors with a code, so the wording of those
28
+ // instructions stays with the entry point and lib/ never names its commands.
29
+
30
+ export type ProjectErrorCode = "not_initialized" | "no_connections" | "never_synced";
31
+
32
+ export class ProjectError extends Error {
33
+ readonly code: ProjectErrorCode;
34
+ /** The connection concerned, when there is one. */
35
+ readonly connection: string | null;
36
+
37
+ constructor(code: ProjectErrorCode, message: string, connection: string | null = null) {
38
+ super(message);
39
+ this.name = "ProjectError";
40
+ this.code = code;
41
+ this.connection = connection;
42
+ }
43
+ }
44
+
45
+ export interface OpenedConnection {
46
+ readonly root: string;
47
+ readonly config: Config;
48
+ readonly name: string;
49
+ readonly connection: ConnectionConfig;
50
+ }
51
+
52
+ export interface OpenedSnapshot extends OpenedConnection {
53
+ readonly snapshot: Snapshot;
54
+ readonly knowledge: Knowledge;
55
+ }
56
+
57
+ /** Finds the project above `cwd` and picks a connection with pickConnection's rules. */
58
+ export function openConnection(cwd: string, name?: string): OpenedConnection {
59
+ const { root, config } = openProject(cwd);
60
+ return { root, config, ...pickConnection(config, name) };
61
+ }
62
+
63
+ /** The project above `cwd` and its config, which must have at least one connection. */
64
+ function openProject(cwd: string): { root: string; config: Config } {
65
+ const root = openProjectRoot(cwd);
66
+ const config = loadConfig(root);
67
+ if (Object.keys(config.connections).length === 0) {
68
+ throw new ProjectError("no_connections", "no connections are configured in this heydb project");
69
+ }
70
+ return { root, config };
71
+ }
72
+
73
+ /** Like openConnection, plus the stored snapshot and knowledge. Never connects to the server. */
74
+ export function openSnapshot(cwd: string, name?: string): OpenedSnapshot {
75
+ const opened = openConnection(cwd, name);
76
+ const snapshot = loadSnapshot(opened.root, opened.name);
77
+ if (snapshot === null) {
78
+ throw new ProjectError(
79
+ "never_synced",
80
+ `connection "${opened.name}" was never synced: there is no stored snapshot yet`,
81
+ opened.name,
82
+ );
83
+ }
84
+ return { ...opened, snapshot, knowledge: loadKnowledge(opened.root, opened.name) };
85
+ }
86
+
87
+ /** The root of the project above `cwd`, which may have no connections yet. */
88
+ export function openProjectRoot(cwd: string): string {
89
+ const root = findProjectRoot(cwd);
90
+ if (root === null) throw new ProjectError("not_initialized", "no heydb project here (no .heydb/ in this folder or above)");
91
+ return root;
92
+ }
93
+
94
+ /** The project above `cwd` and its connection names, sorted; null outside a project. */
95
+ export function projectConnections(cwd: string): { root: string; names: string[] } | null {
96
+ const root = findProjectRoot(cwd);
97
+ if (root === null) return null;
98
+ return { root, names: Object.keys(loadConfig(root).connections).sort() };
99
+ }
100
+
101
+ export interface CaptureOptions {
102
+ readonly env?: Readonly<Record<string, string | undefined>>;
103
+ /** The engine entry point; replaced in tests so no server is needed. */
104
+ readonly introspect?: (entry: ConnectionConfig, password: string) => Promise<Snapshot>;
105
+ }
106
+
107
+ /**
108
+ * Captures the live structure of an opened connection. The password is
109
+ * resolved here, at connect time, and only handed to the engine: it is never
110
+ * returned, stored or put in an error.
111
+ */
112
+ export async function captureLive(opened: OpenedConnection, options: CaptureOptions = {}): Promise<Snapshot> {
113
+ const { env = process.env, introspect = introspectEngine } = options;
114
+ const password = resolveSecret(opened.connection.password, { connection: opened.name, env, projectRoot: opened.root });
115
+ return introspect(opened.connection, password);
116
+ }
117
+
118
+ /** What a person typed when adding a connection; every field is text, as typed. */
119
+ export interface ConnectionAnswers {
120
+ readonly engine: string;
121
+ readonly host: string;
122
+ readonly port: string;
123
+ readonly user: string;
124
+ /** Comma-separated names; empty captures every database the user can see. */
125
+ readonly databases: string;
126
+ }
127
+
128
+ const DEFAULT_HOST = "127.0.0.1";
129
+ const DEFAULT_PORT = 3306;
130
+
131
+ /**
132
+ * Turns typed answers into a validated connection. There is no password
133
+ * answer: the password field is always the suggested reference, and the
134
+ * person sets that variable on their own machine.
135
+ */
136
+ export function buildConnection(name: string, answers: ConnectionAnswers): ConnectionConfig {
137
+ assertConnectionName(name);
138
+ const host = answers.host.trim();
139
+ const port = answers.port.trim();
140
+ const databases = answers.databases
141
+ .split(",")
142
+ .map((item) => item.trim())
143
+ .filter((item) => item !== "");
144
+ return validateConnection(
145
+ {
146
+ engine: answers.engine,
147
+ host: host === "" ? DEFAULT_HOST : host,
148
+ // Anything that is not plain digits becomes NaN, which validation rejects with its usual message.
149
+ port: port === "" ? DEFAULT_PORT : /^\d+$/.test(port) ? Number(port) : Number.NaN,
150
+ user: answers.user.trim(),
151
+ password: suggestedReference(name),
152
+ databases: databases.length === 0 ? null : databases,
153
+ },
154
+ name,
155
+ );
156
+ }
157
+
158
+ /** Adds a connection to config.json; the first one of a project becomes the default. */
159
+ export function saveNewConnection(root: string, name: string, entry: ConnectionConfig): { isDefault: boolean } {
160
+ const before = loadConfig(root);
161
+ let config = addConnection(before, name, entry);
162
+ if (before.defaultConnection === null && Object.keys(before.connections).length === 0) {
163
+ config = setDefaultConnection(config, name);
164
+ }
165
+ saveConfig(root, config);
166
+ return { isDefault: config.defaultConnection === name };
167
+ }
168
+
169
+ export interface ConnectionSummary {
170
+ readonly name: string;
171
+ readonly connection: ConnectionConfig;
172
+ readonly isDefault: boolean;
173
+ /** Where its password is set, never the value; "unreadable" when .heydb/.env cannot be parsed. */
174
+ readonly password: SecretSource | "missing" | "unreadable";
175
+ /** Whether a snapshot is stored for it. */
176
+ readonly synced: boolean;
177
+ }
178
+
179
+ /** Every connection of the project above `cwd`, sorted by name, with what a person needs to pick one. */
180
+ export function listConnections(
181
+ cwd: string,
182
+ env: Readonly<Record<string, string | undefined>>,
183
+ ): { root: string; connections: ConnectionSummary[] } {
184
+ const { root, config } = openProject(cwd);
185
+ const connections = Object.keys(config.connections)
186
+ .sort()
187
+ .map((name) => {
188
+ const connection = config.connections[name] as ConnectionConfig;
189
+ let password: ConnectionSummary["password"];
190
+ try {
191
+ password = secretSource(connection.password, { connection: name, env, projectRoot: root }) ?? "missing";
192
+ } catch {
193
+ // A broken .heydb/.env must not hide the list; the next sync explains the line at fault.
194
+ password = "unreadable";
195
+ }
196
+ const synced = storedSnapshotPath(root, name) !== null;
197
+ return { name, connection, isDefault: config.defaultConnection === name, password, synced };
198
+ });
199
+ return { root, connections };
200
+ }
201
+
202
+ /** Makes `name` the connection commands use when they are given none. */
203
+ export function chooseDefaultConnection(root: string, name: string): void {
204
+ saveConfig(root, setDefaultConnection(loadConfig(root), name));
205
+ }
206
+
207
+ /**
208
+ * Removes a connection from config.json and its password from .heydb/.env
209
+ * (unless another connection uses the same variable). Its snapshot and notes
210
+ * stay on disk: notes are written by people and cannot be recaptured, so
211
+ * deleting them is left to a person. Returns the paths kept, relative to the
212
+ * project root, so the caller can say where they are.
213
+ */
214
+ export function forgetConnection(root: string, name: string): { passwordRemoved: boolean; kept: string[] } {
215
+ const config = loadConfig(root);
216
+ const next = removeConnection(config, name);
217
+ const reference = (config.connections[name] as ConnectionConfig).password;
218
+ // The password line goes first: if .heydb/.env cannot be changed (a
219
+ // symbolic link, say), config.json is still as it was.
220
+ const shared = Object.values(next.connections).some((entry) => entry.password === reference);
221
+ const passwordRemoved = !shared && removeEnvValue(root, referenceVariable(reference, name));
222
+ saveConfig(root, next);
223
+
224
+ const kept = [storedSnapshotPath(root, name), knowledgePath(root, name)]
225
+ .filter((path): path is string => path !== null && existsSync(path))
226
+ .map((path) => relative(root, path).replaceAll("\\", "/"));
227
+ return { passwordRemoved, kept };
228
+ }
229
+
230
+ export interface SavePasswordOptions {
231
+ /** Whether git tracks .heydb/.env in `root`; replaced in tests so no git is needed. */
232
+ readonly isTracked?: (root: string) => boolean;
233
+ }
234
+
235
+ /**
236
+ * Stores a typed password in .heydb/.env under the variable `reference` names,
237
+ * after making sure git ignores that file. Ignoring does not untrack a file
238
+ * already committed (git add -f), so such a file is refused. Returns the
239
+ * variable name.
240
+ */
241
+ export function savePassword(root: string, reference: string, value: string, options: SavePasswordOptions = {}): string {
242
+ const { isTracked = gitTracksEnvFile } = options;
243
+ const variable = referenceVariable(reference);
244
+ if (isTracked(root)) {
245
+ throw new Error(
246
+ ".heydb/.env is tracked by git, so heydb will not write a password into it; untrack it with git rm --cached .heydb/.env first",
247
+ );
248
+ }
249
+ ensureEnvIgnored(root);
250
+ setEnvValue(root, variable, value);
251
+ return variable;
252
+ }
253
+
254
+ const GIT_TIMEOUT_MS = 2000;
255
+
256
+ /**
257
+ * Whether git tracks .heydb/.env in the project at `root`. Without git, or
258
+ * outside a repository, nothing is tracked. Any other failure (a timeout, a
259
+ * repository git refuses to open) is an error: heydb does not guess before
260
+ * writing a password. Synchronous because savePassword is; execFileSync with
261
+ * an argument list never goes through a shell.
262
+ */
263
+ export function gitTracksEnvFile(root: string): boolean {
264
+ try {
265
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", `${HEYDB_DIR}/${ENV_FILE}`], {
266
+ cwd: root,
267
+ timeout: GIT_TIMEOUT_MS,
268
+ shell: false,
269
+ windowsHide: true,
270
+ stdio: ["ignore", "ignore", "pipe"],
271
+ encoding: "utf8",
272
+ });
273
+ return true;
274
+ } catch (error) {
275
+ const failure = error as { code?: unknown; status?: unknown; stderr?: unknown };
276
+ const stderr = typeof failure.stderr === "string" ? failure.stderr : "";
277
+ if (failure.code === "ENOENT") return false; // git is not installed
278
+ if (failure.status === 1) return false; // --error-unmatch: not tracked
279
+ if (/not a git repository/i.test(stderr)) return false;
280
+ const reason = stderr.trim().split("\n")[0] || (error instanceof Error ? error.message : String(error));
281
+ throw new Error(`could not ask git whether it tracks ${HEYDB_DIR}/${ENV_FILE}: ${reason}`, { cause: error });
282
+ }
283
+ }
284
+
285
+ export type Forgotten =
286
+ | { readonly kind: "annotation"; readonly annotation: Annotation }
287
+ | { readonly kind: "relationship"; readonly relationship: Relationship };
288
+
289
+ /**
290
+ * Ids are unique across both lists and the project file (random UUIDs), so
291
+ * one id names one entry. The connection's file is searched first, then the
292
+ * project notes, which belong to no connection.
293
+ */
294
+ export function forgetById(root: string, connection: string, id: string): Forgotten {
295
+ const knowledge = loadKnowledge(root, connection);
296
+ if (knowledge.annotations.some((item) => item.id === id)) {
297
+ return { kind: "annotation", annotation: forgetAnnotation(root, connection, id) };
298
+ }
299
+ if (knowledge.relationships.some((item) => item.id === id)) {
300
+ return { kind: "relationship", relationship: forgetRelationship(root, connection, id) };
301
+ }
302
+ if (loadProjectKnowledge(root).annotations.some((item) => item.id === id)) {
303
+ return { kind: "annotation", annotation: forgetProjectNote(root, id) };
304
+ }
305
+ throw new Error(`no annotation or relationship has id "${id}"`);
306
+ }
307
+
308
+ export type TargetKind = "database" | "table" | "column";
309
+
310
+ /**
311
+ * What a note is about, from what an agent typed: a database ("shop",
312
+ * "shop.shop"), a table or a column in any form resolveTable and resolveColumn
313
+ * accept. A complete canonical name that exists is taken as written. Otherwise
314
+ * every reading is tried, and the target must be exactly one object.
315
+ *
316
+ * A name without a dot is a table (or a database), never a column when one
317
+ * of those matches: in a legacy ERP the articles table "gar" shares its name
318
+ * with a column postv.gar, and "gar" must still name the table. A column then
319
+ * needs its table: "postv.gar". Errors use display names.
320
+ */
321
+ export function resolveTarget(snapshot: Snapshot, input: string): { kind: TargetKind; name: string } {
322
+ const segments = parseSegments(input);
323
+ if (segments.length > 4) throw new Error(`expected a database, table or column name: got "${input}"`);
324
+ if (segments.length >= 2) {
325
+ const canonical = formatName(parseName(input));
326
+ if (snapshotLookup(snapshot)(canonical)) return { kind: KINDS[segments.length - 2] as TargetKind, name: canonical };
327
+ }
328
+
329
+ const found: { kind: TargetKind; name: string }[] = [];
330
+ const misses: string[] = [];
331
+ const ambiguities: string[] = [];
332
+ const attempt = (kind: TargetKind, resolve: () => string) => {
333
+ try {
334
+ found.push({ kind, name: resolve() });
335
+ } catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ // resolve.ts starts ambiguity errors with the quoted input and misses with "no ".
338
+ (message.startsWith('"') ? ambiguities : misses).push(message);
339
+ }
340
+ };
341
+ if (segments.length <= 2) found.push(...databaseMatches(snapshot, segments));
342
+ if (segments.length <= 3) attempt("table", () => resolveTable(snapshot, input).name);
343
+ const tableOrDatabase = found.length > 0 || ambiguities.length > 0;
344
+ if (segments.length > 1 || !tableOrDatabase) attempt("column", () => resolveColumn(snapshot, input).name);
345
+
346
+ const [ambiguity] = ambiguities;
347
+ if (ambiguity !== undefined) throw new Error(ambiguity);
348
+ const [only] = found;
349
+ if (only !== undefined && found.length === 1) return only;
350
+ if (found.length > 1) {
351
+ throw new Error(
352
+ [`"${input}" names more than one object; use the full name:`, ...found.map((item) => `- ${item.kind} ${displayNameOf(item.name)}`)].join(
353
+ "\n",
354
+ ),
355
+ );
356
+ }
357
+ const [miss] = misses;
358
+ if (misses.length === 1 && miss !== undefined) throw new Error(miss);
359
+ throw new Error([`nothing matches "${input}":`, ...misses.map((message) => `- ${message}`)].join("\n"));
360
+ }
361
+
362
+ const KINDS: readonly TargetKind[] = ["database", "table", "column"];
363
+
364
+ // "shop" names a database or its schema; "shop.shop" names both. Exact
365
+ // spelling first, then ignoring case, like resolve.ts.
366
+ function databaseMatches(snapshot: Snapshot, segments: readonly string[]): { kind: TargetKind; name: string }[] {
367
+ const pairs = snapshot.databases.flatMap((database) => database.schemas.map((schema) => [database.name, schema.name] as const));
368
+ const matching = (equal: (a: string, b: string) => boolean) =>
369
+ pairs.filter(([database, schema]) => {
370
+ const [first, second] = segments as [string, string?];
371
+ if (second === undefined) return equal(first, database) || equal(first, schema);
372
+ return equal(first, database) && equal(second, schema);
373
+ });
374
+ let matches = matching((a, b) => a === b);
375
+ if (matches.length === 0) matches = matching((a, b) => a.toLowerCase() === b.toLowerCase());
376
+ return matches.map(([database, schema]) => ({ kind: "database", name: formatName({ database, schema }) }));
377
+ }