@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/extensions/commands.ts +732 -0
- package/extensions/context.ts +37 -0
- package/extensions/deps.ts +32 -0
- package/extensions/heydb.ts +20 -0
- package/extensions/secret-input.ts +120 -0
- package/extensions/tools.ts +402 -0
- package/lib/about.ts +7 -0
- package/lib/config/operations.ts +75 -0
- package/lib/config/store.ts +73 -0
- package/lib/config/types.ts +37 -0
- package/lib/config/validate.ts +136 -0
- package/lib/engines/index.ts +66 -0
- package/lib/engines/mysql/catalog.ts +371 -0
- package/lib/engines/mysql/introspect.ts +186 -0
- package/lib/engines/postgres/catalog.ts +390 -0
- package/lib/engines/postgres/introspect.ts +240 -0
- package/lib/knowledge/operations.ts +223 -0
- package/lib/knowledge/query.ts +129 -0
- package/lib/knowledge/types.ts +64 -0
- package/lib/knowledge/validate.ts +151 -0
- package/lib/model/diff.ts +218 -0
- package/lib/model/hash.ts +88 -0
- package/lib/model/names.ts +141 -0
- package/lib/model/order.ts +47 -0
- package/lib/model/references.ts +251 -0
- package/lib/model/snapshot.ts +128 -0
- package/lib/model/stats.ts +97 -0
- package/lib/model/validate.ts +240 -0
- package/lib/render/markdown.ts +508 -0
- package/lib/search.ts +530 -0
- package/lib/secrets.ts +149 -0
- package/lib/store/envfile.ts +124 -0
- package/lib/store/files.ts +57 -0
- package/lib/store/guards.ts +68 -0
- package/lib/store/knowledge.ts +139 -0
- package/lib/store/project.ts +28 -0
- package/lib/store/snapshots.ts +142 -0
- package/lib/usecases/author.ts +51 -0
- package/lib/usecases/code-scan.ts +435 -0
- package/lib/usecases/init.ts +111 -0
- package/lib/usecases/knowledge.ts +214 -0
- package/lib/usecases/project.ts +377 -0
- package/lib/usecases/resolve.ts +186 -0
- package/lib/usecases/sharing.ts +83 -0
- package/lib/usecases/status.ts +37 -0
- package/lib/usecases/sync.ts +33 -0
- package/package.json +69 -5
- 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
|
+
}
|