@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +153 -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 +143 -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,186 @@
|
|
|
1
|
+
import { displayNameOf, formatName, parseSegments } from "../model/names.ts";
|
|
2
|
+
import type { Column, Snapshot, Table } from "../model/snapshot.ts";
|
|
3
|
+
|
|
4
|
+
// Agents and people rarely type canonical names. They write "orders",
|
|
5
|
+
// "shop.orders" (how MySQL names a table) or the full "shop.shop.orders".
|
|
6
|
+
// These functions turn what they typed into exactly one object, or explain
|
|
7
|
+
// why they cannot.
|
|
8
|
+
//
|
|
9
|
+
// Error messages stay small whatever the size of the schema: at most 10
|
|
10
|
+
// candidates for an ambiguous name and 5 suggestions for an unknown one.
|
|
11
|
+
// They name objects by display name ("shop.orders"), which is shorter and
|
|
12
|
+
// typed back exactly like the canonical one.
|
|
13
|
+
// heydb v1 listed every table when one was not found (bug #7): with 3,000
|
|
14
|
+
// tables, each typo cost the agent thousands of tokens.
|
|
15
|
+
|
|
16
|
+
const MAX_CANDIDATES = 10;
|
|
17
|
+
const MAX_SUGGESTIONS = 5;
|
|
18
|
+
|
|
19
|
+
export interface ResolvedTable {
|
|
20
|
+
/** Canonical name: database.schema.table. */
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly database: string;
|
|
23
|
+
readonly schema: string;
|
|
24
|
+
readonly table: Table;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolvedColumn {
|
|
28
|
+
/** Canonical name: database.schema.table.column. */
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly table: ResolvedTable;
|
|
31
|
+
readonly column: Column;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Every addressable object once, with its segments from database to itself.
|
|
35
|
+
interface Entry<T> {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly segments: readonly string[];
|
|
38
|
+
readonly value: T;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Accepts "table", "database.table" (or "schema.table") and "database.schema.table". */
|
|
42
|
+
export function resolveTable(snapshot: Snapshot, input: string): ResolvedTable {
|
|
43
|
+
const segments = parseSegments(input);
|
|
44
|
+
if (segments.length > 3) {
|
|
45
|
+
throw new Error(`expected a table name (table, database.table or database.schema.table): got "${input}"`);
|
|
46
|
+
}
|
|
47
|
+
return pickOne(tableEntries(snapshot), segments, input, "table").value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Accepts "column", "table.column", "database.table.column" and the canonical name. */
|
|
51
|
+
export function resolveColumn(snapshot: Snapshot, input: string): ResolvedColumn {
|
|
52
|
+
const segments = parseSegments(input);
|
|
53
|
+
if (segments.length > 4) {
|
|
54
|
+
throw new Error(`expected a column name (column, table.column … database.schema.table.column): got "${input}"`);
|
|
55
|
+
}
|
|
56
|
+
const columns = tableEntries(snapshot).flatMap(({ segments: tableSegments, value: table }) =>
|
|
57
|
+
table.table.columns.map((column): Entry<ResolvedColumn> => {
|
|
58
|
+
const name = formatName({ database: table.database, schema: table.schema, table: table.table.name, column: column.name });
|
|
59
|
+
return { name, segments: [...tableSegments, column.name], value: { name, table, column } };
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
return pickOne(columns, segments, input, "column").value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tableEntries(snapshot: Snapshot): Entry<ResolvedTable>[] {
|
|
66
|
+
const entries: Entry<ResolvedTable>[] = [];
|
|
67
|
+
for (const database of snapshot.databases) {
|
|
68
|
+
for (const schema of database.schemas) {
|
|
69
|
+
for (const table of schema.tables) {
|
|
70
|
+
const name = formatName({ database: database.name, schema: schema.name, table: table.name });
|
|
71
|
+
entries.push({
|
|
72
|
+
name,
|
|
73
|
+
segments: [database.name, schema.name, table.name],
|
|
74
|
+
value: { name, database: database.name, schema: schema.name, table },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Exact spelling first; only when nothing matches exactly is case ignored,
|
|
83
|
+
// so "Orders" and "orders" in the same database stay distinguishable.
|
|
84
|
+
function pickOne<T>(entries: readonly Entry<T>[], input: readonly string[], text: string, kind: string): Entry<T> {
|
|
85
|
+
let matches = entries.filter((entry) => matchesTail(entry.segments, input, same));
|
|
86
|
+
if (matches.length === 0) matches = entries.filter((entry) => matchesTail(entry.segments, input, sameIgnoringCase));
|
|
87
|
+
|
|
88
|
+
const [first] = matches;
|
|
89
|
+
if (first !== undefined && matches.length === 1) return first;
|
|
90
|
+
if (matches.length > 1) throw ambiguous(text, kind, matches.map((entry) => entry.name).sort(compareText).map(displayNameOf));
|
|
91
|
+
throw notFound(text, kind, suggestions(entries, input));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether what was typed names this object. `segments` is [database, schema,
|
|
96
|
+
* table] or [database, schema, table, column]. The typed text ends with the
|
|
97
|
+
* object part ("orders", "orders.total", or just "total" for a column), and
|
|
98
|
+
* may start with a prefix: one segment is the database or the schema
|
|
99
|
+
* ("shop.orders": in MySQL both are "shop"; in PostgreSQL it would be
|
|
100
|
+
* "public.orders"), two are both, in order.
|
|
101
|
+
*/
|
|
102
|
+
function matchesTail(
|
|
103
|
+
segments: readonly string[],
|
|
104
|
+
input: readonly string[],
|
|
105
|
+
equal: (a: string, b: string) => boolean,
|
|
106
|
+
): boolean {
|
|
107
|
+
const objectDepth = segments.length - 2; // 1 for a table, 2 for a column
|
|
108
|
+
const prefixLength = Math.max(0, input.length - objectDepth);
|
|
109
|
+
const typedObject = input.slice(prefixLength);
|
|
110
|
+
const object = segments.slice(segments.length - typedObject.length);
|
|
111
|
+
if (!typedObject.every((part, index) => equal(part, object[index] as string))) return false;
|
|
112
|
+
|
|
113
|
+
const [database, schema] = segments as [string, string];
|
|
114
|
+
const [first, second] = input;
|
|
115
|
+
if (prefixLength === 0) return true;
|
|
116
|
+
if (prefixLength === 1) return equal(first as string, database) || equal(first as string, schema);
|
|
117
|
+
return prefixLength === 2 && equal(first as string, database) && equal(second as string, schema);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function same(a: string, b: string): boolean {
|
|
121
|
+
return a === b;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sameIgnoringCase(a: string, b: string): boolean {
|
|
125
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Up to 5 names close to what was typed. The last segment is compared, so a
|
|
130
|
+
* wrong database or table prefix still finds "orders"; close means a small
|
|
131
|
+
* edit distance (a typo) or one name containing the other.
|
|
132
|
+
*/
|
|
133
|
+
function suggestions<T>(entries: readonly Entry<T>[], input: readonly string[]): string[] {
|
|
134
|
+
const typed = (input[input.length - 1] ?? "").toLowerCase();
|
|
135
|
+
const limit = Math.min(3, Math.max(1, Math.floor(typed.length / 4)));
|
|
136
|
+
const scored: { name: string; distance: number }[] = [];
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const candidate = (entry.segments[entry.segments.length - 1] ?? "").toLowerCase();
|
|
139
|
+
const distance = editDistance(typed, candidate, limit);
|
|
140
|
+
const contains = typed.length >= 3 && (candidate.includes(typed) || typed.includes(candidate));
|
|
141
|
+
if (distance <= limit || contains) scored.push({ name: entry.name, distance });
|
|
142
|
+
}
|
|
143
|
+
return scored
|
|
144
|
+
.sort((a, b) => a.distance - b.distance || compareText(a.name, b.name))
|
|
145
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
146
|
+
.map((item) => displayNameOf(item.name));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Levenshtein distance: how many single-character insertions, deletions or
|
|
151
|
+
* substitutions turn `a` into `b`. Stops early and returns limit + 1 once the
|
|
152
|
+
* answer is known to exceed `limit`, so 3,000 tables cost almost nothing.
|
|
153
|
+
*/
|
|
154
|
+
function editDistance(a: string, b: string, limit: number): number {
|
|
155
|
+
if (Math.abs(a.length - b.length) > limit) return limit + 1;
|
|
156
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
157
|
+
for (let i = 1; i <= a.length; i++) {
|
|
158
|
+
const current = [i];
|
|
159
|
+
let best = i;
|
|
160
|
+
for (let j = 1; j <= b.length; j++) {
|
|
161
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
162
|
+
const value = Math.min((previous[j] as number) + 1, (current[j - 1] as number) + 1, (previous[j - 1] as number) + cost);
|
|
163
|
+
current.push(value);
|
|
164
|
+
best = Math.min(best, value);
|
|
165
|
+
}
|
|
166
|
+
if (best > limit) return limit + 1;
|
|
167
|
+
previous = current;
|
|
168
|
+
}
|
|
169
|
+
return previous[b.length] as number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ambiguous(text: string, kind: string, names: readonly string[]): Error {
|
|
173
|
+
const shown = names.slice(0, MAX_CANDIDATES).map((name) => `- ${name}`);
|
|
174
|
+
const more = names.length > MAX_CANDIDATES ? [`… and ${names.length - MAX_CANDIDATES} more`] : [];
|
|
175
|
+
return new Error([`"${text}" matches ${names.length} ${kind}s; use the full name:`, ...shown, ...more].join("\n"));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function notFound(text: string, kind: string, similar: readonly string[]): Error {
|
|
179
|
+
const hint = similar.length === 0 ? "" : `. Did you mean: ${similar.join(", ")}?`;
|
|
180
|
+
return new Error(`no ${kind} matches "${text}"${hint}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Plain code-unit comparison, like the rest of heydb: never locale-dependent.
|
|
184
|
+
function compareText(a: string, b: string): number {
|
|
185
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
186
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { HEYDB_DIR } from "../store/project.ts";
|
|
4
|
+
|
|
5
|
+
// Whether the team gets the notes. heydb keeps them in .heydb/knowledge/ and
|
|
6
|
+
// git shares them when the project commits that folder; but heydb v1 told
|
|
7
|
+
// projects to ignore .heydb/ entirely, and a project that still does keeps
|
|
8
|
+
// every note on one machine while the tool says it was saved. Nobody notices
|
|
9
|
+
// until a colleague asks why the notes are missing.
|
|
10
|
+
//
|
|
11
|
+
// Only git knows whether a path is ignored: rules come from every .gitignore
|
|
12
|
+
// on the way, .git/info/exclude and the global excludes file, and a later
|
|
13
|
+
// "!pattern" can re-include a path. So heydb asks `git check-ignore -v`,
|
|
14
|
+
// which also names the rule, and never edits any ignore file itself: what
|
|
15
|
+
// the project shares is the team's decision.
|
|
16
|
+
|
|
17
|
+
export type KnowledgeSharing =
|
|
18
|
+
| { readonly state: "shared" }
|
|
19
|
+
/** `rule` is "<source>:<line>: <pattern>", e.g. ".gitignore:26: .heydb/". */
|
|
20
|
+
| { readonly state: "ignored"; readonly rule: string }
|
|
21
|
+
| { readonly state: "no-git" };
|
|
22
|
+
|
|
23
|
+
export interface GitRun {
|
|
24
|
+
readonly exitCode: number;
|
|
25
|
+
readonly stdout: string;
|
|
26
|
+
readonly stderr: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Runs git with `args` in `cwd`; "missing" when git is not installed. */
|
|
30
|
+
export type RunGit = (args: readonly string[], cwd: string) => Promise<GitRun | "missing">;
|
|
31
|
+
|
|
32
|
+
export interface SharingOptions {
|
|
33
|
+
/** The connection whose notes file is checked; without one, the knowledge folder. */
|
|
34
|
+
readonly connection?: string;
|
|
35
|
+
/** Replaced in tests; the default runs the real git. */
|
|
36
|
+
readonly runGit?: RunGit;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SHARE_NOTES_HINT = `To share notes, commit ${HEYDB_DIR}/config.json, ${HEYDB_DIR}/snapshots/ and ${HEYDB_DIR}/knowledge/ (never ${HEYDB_DIR}/.env).`;
|
|
40
|
+
|
|
41
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether git would share the notes of the project at `projectRoot`:
|
|
45
|
+
* "shared" when git tracks them or does not ignore them, "ignored" with the
|
|
46
|
+
* rule that ignores them, "no-git" without git or outside a repository. Any
|
|
47
|
+
* other git failure is an error, so a broken repository is not reported as
|
|
48
|
+
* fine.
|
|
49
|
+
*/
|
|
50
|
+
export async function knowledgeSharing(projectRoot: string, options: SharingOptions = {}): Promise<KnowledgeSharing> {
|
|
51
|
+
const { connection, runGit = runRealGit } = options;
|
|
52
|
+
const path = `${HEYDB_DIR}/knowledge/${connection === undefined ? "" : `${connection}.json`}`;
|
|
53
|
+
// Tracked files are never reported as ignored, so tracked notes come back as shared.
|
|
54
|
+
const run = await runGit(["check-ignore", "-v", "--", path], projectRoot);
|
|
55
|
+
if (run === "missing") return { state: "no-git" };
|
|
56
|
+
if (run.exitCode === 1) return { state: "shared" };
|
|
57
|
+
if (run.exitCode === 0) {
|
|
58
|
+
// "<source>:<line>:<pattern>\t<path>"; a pattern starting with "!" re-includes the path.
|
|
59
|
+
const match = /^(.*):(\d+):([^\t]*)\t/.exec(run.stdout);
|
|
60
|
+
if (match === null) throw new Error(`could not read git check-ignore's answer for ${path}: ${firstLine(run.stdout)}`);
|
|
61
|
+
const [, source, line, pattern] = match as unknown as [string, string, string, string];
|
|
62
|
+
return pattern.startsWith("!") ? { state: "shared" } : { state: "ignored", rule: `${source}:${line}: ${pattern}` };
|
|
63
|
+
}
|
|
64
|
+
if (/not a git repository/i.test(run.stderr)) return { state: "no-git" };
|
|
65
|
+
throw new Error(`could not ask git whether it ignores ${path}: ${firstLine(run.stderr) || `exit code ${run.exitCode}`}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An argument list and no shell: nothing in a path is ever interpreted.
|
|
69
|
+
function runRealGit(args: readonly string[], cwd: string): Promise<GitRun | "missing"> {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
execFile("git", [...args], { cwd, timeout: GIT_TIMEOUT_MS, shell: false, windowsHide: true, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
72
|
+
if (error === null) return resolve({ exitCode: 0, stdout, stderr });
|
|
73
|
+
const failure = error as NodeJS.ErrnoException & { code?: unknown; killed?: boolean };
|
|
74
|
+
if (failure.code === "ENOENT") return resolve("missing");
|
|
75
|
+
if (typeof failure.code === "number" && !failure.killed) return resolve({ exitCode: failure.code, stdout, stderr });
|
|
76
|
+
reject(new Error(`git ${args[0]} failed: ${failure.killed ? `no answer within ${GIT_TIMEOUT_MS} ms` : error.message}`, { cause: error }));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function firstLine(text: string): string {
|
|
82
|
+
return text.trim().split("\n")[0] ?? "";
|
|
83
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Change, diffSnapshots } from "../model/diff.ts";
|
|
2
|
+
import { schemaHash } from "../model/hash.ts";
|
|
3
|
+
import type { Snapshot } from "../model/snapshot.ts";
|
|
4
|
+
import { loadSnapshot } from "../store/snapshots.ts";
|
|
5
|
+
|
|
6
|
+
// Use cases combine the store and the model into one answer for a user
|
|
7
|
+
// question. The engine is not called here: the caller captures the live
|
|
8
|
+
// snapshot (with introspectMysql today, other engines later) and passes it in,
|
|
9
|
+
// so these functions are tested with plain snapshots and no server, and lib/
|
|
10
|
+
// stays free of any particular entry point (Pi, a CLI, an MCP server).
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Exactly one of three states. `state` tells them apart, and TypeScript only
|
|
14
|
+
* lets callers read `changes` after checking that the state is "drifted".
|
|
15
|
+
*/
|
|
16
|
+
export type StatusResult =
|
|
17
|
+
| { readonly state: "never_synced" }
|
|
18
|
+
| { readonly state: "up_to_date"; readonly hash: string }
|
|
19
|
+
| {
|
|
20
|
+
readonly state: "drifted";
|
|
21
|
+
readonly storedHash: string;
|
|
22
|
+
readonly liveHash: string;
|
|
23
|
+
readonly changes: readonly Change[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Compares the live database with the stored snapshot of `connection`. */
|
|
27
|
+
export function checkStatus(projectRoot: string, connection: string, live: Snapshot): StatusResult {
|
|
28
|
+
const stored = loadSnapshot(projectRoot, connection);
|
|
29
|
+
if (stored === null) return { state: "never_synced" };
|
|
30
|
+
|
|
31
|
+
// The hash answers "did anything change?"; only when it did is the diff
|
|
32
|
+
// worth computing to answer "what?".
|
|
33
|
+
const storedHash = schemaHash(stored);
|
|
34
|
+
const liveHash = schemaHash(live);
|
|
35
|
+
if (storedHash === liveHash) return { state: "up_to_date", hash: liveHash };
|
|
36
|
+
return { state: "drifted", storedHash, liveHash, changes: diffSnapshots(stored, live) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Change, diffSnapshots } from "../model/diff.ts";
|
|
2
|
+
import { schemaHash } from "../model/hash.ts";
|
|
3
|
+
import type { Snapshot } from "../model/snapshot.ts";
|
|
4
|
+
import { saveSnapshot } from "../store/snapshots.ts";
|
|
5
|
+
import { checkStatus } from "./status.ts";
|
|
6
|
+
|
|
7
|
+
export interface SyncResult {
|
|
8
|
+
/** false when the stored snapshot already had the same structure. */
|
|
9
|
+
readonly written: boolean;
|
|
10
|
+
readonly hash: string;
|
|
11
|
+
/** What the new file changed; every database counts as added on the first sync. */
|
|
12
|
+
readonly changes: readonly Change[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stores the live snapshot of `connection`, but only when its structure
|
|
17
|
+
* differs from the stored one. Snapshot files are committed: rewriting them
|
|
18
|
+
* because the capture time, a row estimate or a warning changed would create
|
|
19
|
+
* a git change on every sync with nothing real to review.
|
|
20
|
+
*/
|
|
21
|
+
export function syncSnapshot(projectRoot: string, connection: string, live: Snapshot): SyncResult {
|
|
22
|
+
const status = checkStatus(projectRoot, connection, live);
|
|
23
|
+
switch (status.state) {
|
|
24
|
+
case "up_to_date":
|
|
25
|
+
return { written: false, hash: status.hash, changes: [] };
|
|
26
|
+
case "never_synced":
|
|
27
|
+
saveSnapshot(projectRoot, connection, live);
|
|
28
|
+
return { written: true, hash: schemaHash(live), changes: diffSnapshots({ ...live, databases: [] }, live) };
|
|
29
|
+
case "drifted":
|
|
30
|
+
saveSnapshot(projectRoot, connection, live);
|
|
31
|
+
return { written: true, hash: status.liveHash, changes: status.changes };
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@pvidaal07/heydb",
|
|
3
|
+
"version": "2.0.0-beta.1",
|
|
4
|
+
"description": "Database schema context for AI agents: introspect MySQL, MariaDB and PostgreSQL, keep a local snapshot, and share annotated knowledge through git.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "pvidaal07",
|
|
7
|
+
"homepage": "https://github.com/pvidaal07/heydb#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/pvidaal07/heydb/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/pvidaal07/heydb.git"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi",
|
|
18
|
+
"database",
|
|
19
|
+
"schema",
|
|
20
|
+
"mysql",
|
|
21
|
+
"mariadb",
|
|
22
|
+
"postgresql",
|
|
23
|
+
"ai-agent",
|
|
24
|
+
"legacy"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.19.0"
|
|
29
|
+
},
|
|
30
|
+
"packageManager": "pnpm@11.0.9",
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./extensions/heydb.ts"
|
|
34
|
+
],
|
|
35
|
+
"skills": [
|
|
36
|
+
"./skills"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"extensions",
|
|
41
|
+
"lib",
|
|
42
|
+
"skills",
|
|
43
|
+
"README.md",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test \"tests/**/*.test.ts\"",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"check": "tsc --noEmit && node --run test",
|
|
51
|
+
"db:up": "docker compose -f tests/integration/compose.yaml up -d --wait",
|
|
52
|
+
"db:down": "docker compose -f tests/integration/compose.yaml down",
|
|
53
|
+
"test:integration": "node --test --test-timeout=60000 \"tests/integration/**/*.integration.ts\""
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
57
|
+
"@earendil-works/pi-tui": "*"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
61
|
+
"@earendil-works/pi-tui": "^1.5.0",
|
|
62
|
+
"@types/node": "^22.19.0",
|
|
63
|
+
"@types/pg": "^8.23.1",
|
|
64
|
+
"typescript": "^5.9.3"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"mysql2": "^3.24.6",
|
|
68
|
+
"pg": "^8.23.1"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: heydb
|
|
3
|
+
description: Consult and enrich the database schema of a heydb project (.heydb/ folder) with the heydb_* tools. Use before writing, reviewing or debugging SQL, queries, migrations or ORM models; when asked what a table or column means or how tables join; and when you learn business meaning (valid values, units, legacy quirks) or a join without a foreign key that the team should keep.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# heydb
|
|
7
|
+
|
|
8
|
+
heydb keeps a snapshot of the project's database structure (`.heydb/snapshots/`) and the team's notes about it (`.heydb/knowledge/`). The team shares them only if the project commits `.heydb/`; some projects ignore it, so do not promise that a note reaches anyone else. The tools read those files; they never connect to the database.
|
|
9
|
+
|
|
10
|
+
## Read before you write SQL
|
|
11
|
+
|
|
12
|
+
1. Unknown schema: `heydb_overview` once. It shows, per database, how many tables have comments and which tables the rest point to (the core).
|
|
13
|
+
2. Looking for a concept ("invoices", "fecha de baja"): `heydb_search`. It ignores case, accents and simple plurals, lists tables (with their matching columns) before anything else, and can keep to one `database`. Results also include tables whose meaning is inferred from the columns that reference them, marked `(inferred)`: `cus` has no comment, but columns pointing to it say "Cliente Habitual". Present those as inferences, never as facts.
|
|
14
|
+
3. Before using a table: `heydb_table`. Use the real column names, types and joins it shows, including implicit relationships and notes. On wide tables it lists the 60 most telling columns; ask for `columns: "all"` or a `column_filter` when you need the rest.
|
|
15
|
+
|
|
16
|
+
Never guess table or column names from memory when heydb can answer.
|
|
17
|
+
|
|
18
|
+
## Never guess a meaning from a table name
|
|
19
|
+
|
|
20
|
+
Legacy names lie. A table named `art` looks like artwork; it holds the articles. Before you say what a table is, read the `heydb_table` header, strongest evidence first:
|
|
21
|
+
|
|
22
|
+
1. Team notes and the table comment.
|
|
23
|
+
2. Who points to it ("Referenced by"): foreign keys, documented relationships and the comments of columns like `ref_art`, `cod_art`. `Referencing columns are commented as: "Artículo" ×12` is strong evidence. Name-pattern guesses are not enforced; treat them as clues.
|
|
24
|
+
3. Its own columns.
|
|
25
|
+
|
|
26
|
+
When the meaning comes from 2 or 3, say it is an inference and ask the user to confirm. Once confirmed, save it with `heydb_annotate` so nobody has to guess again. When the overview says most tables have no comment, do not infer anything from names alone.
|
|
27
|
+
|
|
28
|
+
## Warnings mean the picture is incomplete
|
|
29
|
+
|
|
30
|
+
The overview lists warnings from the last sync. Take them literally:
|
|
31
|
+
|
|
32
|
+
- hidden triggers or routines: the user that synced could not see them. Writes may fire logic you cannot see; say so before relying on "nothing else happens".
|
|
33
|
+
- a view whose definition is not visible: do not assume what it selects.
|
|
34
|
+
- a stale snapshot: if a table or column you need is missing, the database may have changed. Ask the user to run `/heydb:sync`.
|
|
35
|
+
|
|
36
|
+
## Save what you learn
|
|
37
|
+
|
|
38
|
+
When the conversation or the code teaches you something the schema does not say, save it so the next person (or agent) does not have to rediscover it.
|
|
39
|
+
|
|
40
|
+
- `heydb_annotate` for meaning: valid values, units, encodings, who writes the column, legacy quirks.
|
|
41
|
+
- `heydb_relate` for joins without a foreign key: `orders.customer_id → customers.id`. Say how you know in `note`.
|
|
42
|
+
- `heydb_annotate` with `scope: "project"` and no `target` for facts above any one database: "the ERP uses one database per client; the central one is acme". `heydb_overview` shows them first. A table called `project` is still `target: "project"`.
|
|
43
|
+
- `heydb_relate` with `to_connection` when the referenced table is in another connection of the project (`crm.clientes.cod_erp → erp`'s `cus.cod`). Call it from the referencing connection; `heydb_table` on the other side shows it too, prefixed with the connection.
|
|
44
|
+
- A name without a dot is a table: `heydb_annotate` with `art` notes the table even if some column is also called `art`; write `pos.art` for the column.
|
|
45
|
+
- If the save result says git ignores the notes, tell the user: the note stays on their machine until the project shares `.heydb/knowledge/`.
|
|
46
|
+
- End every note with its source: `(code: src/Models/Order.php)` when code shows it, `(told by the user)` when the user said it. An inference is saved only after the user confirms it.
|
|
47
|
+
- After `/heydb:sync` (or `/heydb:learn-code`) the user may ask you to read the project's code: follow the rules in that message and stop after about 20 notes.
|
|
48
|
+
- Read the existing notes first (`heydb_table`). Fix a wrong note with `heydb_edit_note`; remove an obsolete one with `heydb_forget`. Do not add a second note that contradicts the first.
|
|
49
|
+
|
|
50
|
+
Good notes are short, factual and verifiable:
|
|
51
|
+
|
|
52
|
+
| Good | Bad |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `status: 0 draft, 1 paid, 2 refunded (code: src/OrderStatus.php)` | `Status of the order.` (the name already says it) |
|
|
55
|
+
| `Amount in cents, VAT included.` | `Probably the price.` (a guess) |
|
|
56
|
+
| `Legacy: 0000-00-00 means "never". Filter it out.` | A paragraph retelling the conversation |
|
|
57
|
+
| `Joined to customers.id by app code; no FK because the table is MyISAM.` | `I think this links to users.` |
|
|
58
|
+
|
|
59
|
+
Never invent. If you are not sure, ask the user or leave it unsaved. Notes you write are signed `<author> (via agent)` so readers know a person did not type them.
|
|
60
|
+
|
|
61
|
+
## Setup is the user's job
|
|
62
|
+
|
|
63
|
+
If a tool says the project is not initialized, has no connections or was never synced, tell the user which command to run: `/heydb:init`, `/heydb:connect`, `/heydb:sync`. You cannot run them.
|
|
64
|
+
|
|
65
|
+
A project may have several connections (say `dev` and `prod`), each with its own snapshot and notes. Tools use the default one; pass `connection` to read another, and save a note on the connection it is true for. `/heydb:connections` (the user's) lists them and changes the default.
|
|
66
|
+
|
|
67
|
+
Never ask for, repeat or store database passwords, not in notes, files, commands or chat. If one is missing, tell the user to run `/heydb:password <connection>`: it asks for it hidden and keeps it in `.heydb/.env`, which is never committed.
|