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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/extensions/commands.ts +732 -0
  5. package/extensions/context.ts +37 -0
  6. package/extensions/deps.ts +32 -0
  7. package/extensions/heydb.ts +20 -0
  8. package/extensions/secret-input.ts +120 -0
  9. package/extensions/tools.ts +402 -0
  10. package/lib/about.ts +7 -0
  11. package/lib/config/operations.ts +75 -0
  12. package/lib/config/store.ts +73 -0
  13. package/lib/config/types.ts +37 -0
  14. package/lib/config/validate.ts +136 -0
  15. package/lib/engines/index.ts +66 -0
  16. package/lib/engines/mysql/catalog.ts +371 -0
  17. package/lib/engines/mysql/introspect.ts +186 -0
  18. package/lib/engines/postgres/catalog.ts +390 -0
  19. package/lib/engines/postgres/introspect.ts +240 -0
  20. package/lib/knowledge/operations.ts +223 -0
  21. package/lib/knowledge/query.ts +129 -0
  22. package/lib/knowledge/types.ts +64 -0
  23. package/lib/knowledge/validate.ts +151 -0
  24. package/lib/model/diff.ts +218 -0
  25. package/lib/model/hash.ts +88 -0
  26. package/lib/model/names.ts +141 -0
  27. package/lib/model/order.ts +47 -0
  28. package/lib/model/references.ts +251 -0
  29. package/lib/model/snapshot.ts +128 -0
  30. package/lib/model/stats.ts +97 -0
  31. package/lib/model/validate.ts +240 -0
  32. package/lib/render/markdown.ts +508 -0
  33. package/lib/search.ts +530 -0
  34. package/lib/secrets.ts +149 -0
  35. package/lib/store/envfile.ts +124 -0
  36. package/lib/store/files.ts +57 -0
  37. package/lib/store/guards.ts +68 -0
  38. package/lib/store/knowledge.ts +139 -0
  39. package/lib/store/project.ts +28 -0
  40. package/lib/store/snapshots.ts +142 -0
  41. package/lib/usecases/author.ts +51 -0
  42. package/lib/usecases/code-scan.ts +435 -0
  43. package/lib/usecases/init.ts +111 -0
  44. package/lib/usecases/knowledge.ts +214 -0
  45. package/lib/usecases/project.ts +377 -0
  46. package/lib/usecases/resolve.ts +186 -0
  47. package/lib/usecases/sharing.ts +83 -0
  48. package/lib/usecases/status.ts +37 -0
  49. package/lib/usecases/sync.ts +33 -0
  50. package/package.json +69 -5
  51. package/skills/heydb/SKILL.md +67 -0
@@ -0,0 +1,75 @@
1
+ import { assertConnectionName } from "../store/guards.ts";
2
+ import type { Config, ConnectionConfig } from "./types.ts";
3
+ import { validateConnection } from "./validate.ts";
4
+
5
+ // Pure operations on Config, like the knowledge ones: each returns a new value
6
+ // and never mutates its input, and every entry they produce has passed the
7
+ // same validation as a file loaded from disk. Callers decide when to save.
8
+
9
+ export function emptyConfig(): Config {
10
+ return { formatVersion: 1, defaultConnection: null, connections: {} };
11
+ }
12
+
13
+ export function addConnection(config: Config, name: string, entry: ConnectionConfig): Config {
14
+ assertConnectionName(name);
15
+ if (Object.hasOwn(config.connections, name)) {
16
+ throw new Error(`connection "${name}" already exists; update or remove it instead`);
17
+ }
18
+ return withConnections(config, { ...config.connections, [name]: validateConnection(entry, name) });
19
+ }
20
+
21
+ /** Changes only the given fields; the merged entry is validated again. */
22
+ export function updateConnection(config: Config, name: string, changes: Partial<ConnectionConfig>): Config {
23
+ const existing = requireConnection(config, name);
24
+ return withConnections(config, { ...config.connections, [name]: validateConnection({ ...existing, ...changes }, name) });
25
+ }
26
+
27
+ /** Removes a connection; when it was the default, there is no default any more. */
28
+ export function removeConnection(config: Config, name: string): Config {
29
+ requireConnection(config, name);
30
+ const connections = Object.fromEntries(Object.entries(config.connections).filter(([key]) => key !== name));
31
+ const defaultConnection = config.defaultConnection === name ? null : config.defaultConnection;
32
+ return { formatVersion: 1, defaultConnection, connections };
33
+ }
34
+
35
+ export function setDefaultConnection(config: Config, name: string | null): Config {
36
+ if (name !== null) requireConnection(config, name);
37
+ return { ...config, defaultConnection: name };
38
+ }
39
+
40
+ /**
41
+ * The connection a command should use: the name it was given, else the
42
+ * default, else the only one configured. Anything else would be a guess, and
43
+ * a wrong guess here means reading the wrong server.
44
+ */
45
+ export function pickConnection(config: Config, name?: string): { name: string; connection: ConnectionConfig } {
46
+ const chosen = name ?? config.defaultConnection ?? onlyConnection(config);
47
+ return { name: chosen, connection: requireConnection(config, chosen) };
48
+ }
49
+
50
+ function onlyConnection(config: Config): string {
51
+ const names = sortedNames(config);
52
+ const [only] = names;
53
+ if (only === undefined) throw new Error("no connections are configured: add one first");
54
+ if (names.length > 1) {
55
+ throw new Error(
56
+ `several connections are configured (${names.join(", ")}) and none is the default: pass a connection name or set a default`,
57
+ );
58
+ }
59
+ return only;
60
+ }
61
+
62
+ function requireConnection(config: Config, name: string): ConnectionConfig {
63
+ if (Object.hasOwn(config.connections, name)) return config.connections[name] as ConnectionConfig;
64
+ const names = sortedNames(config);
65
+ const known = names.length === 0 ? "no connections are configured" : `configured: ${names.join(", ")}`;
66
+ throw new Error(`unknown connection "${name}"; ${known}`);
67
+ }
68
+
69
+ function withConnections(config: Config, connections: Record<string, ConnectionConfig>): Config {
70
+ return { ...config, connections };
71
+ }
72
+
73
+ function sortedNames(config: Config): string[] {
74
+ return Object.keys(config.connections).sort();
75
+ }
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { stableJson, writeFileAtomic } from "../store/files.ts";
5
+ import { assertNoSymlinks } from "../store/guards.ts";
6
+ import { HEYDB_DIR } from "../store/project.ts";
7
+ import { emptyConfig } from "./operations.ts";
8
+ import type { Config } from "./types.ts";
9
+ import { validateConfig } from "./validate.ts";
10
+
11
+ // .heydb/config.json: the connections of the project, committed, without
12
+ // secrets. Written like the snapshot (sorted keys, atomic rename), so a git
13
+ // diff of a config change shows only the changed fields.
14
+
15
+ const SUPPORTED_FORMAT_VERSION = 1;
16
+ const FILE_NAME = "config.json";
17
+
18
+ export function configPath(projectRoot: string): string {
19
+ return join(projectRoot, HEYDB_DIR, FILE_NAME);
20
+ }
21
+
22
+ export function saveConfig(projectRoot: string, config: Config): void {
23
+ assertNoSymlinks(projectRoot, FILE_NAME);
24
+ // Validated again on the way out: a config that could not be loaded back is never written.
25
+ writeFileAtomic(configPath(projectRoot), stableJson(validateConfig(config)));
26
+ }
27
+
28
+ /** Returns the stored config; a project without config.json has no connections yet. */
29
+ export function loadConfig(projectRoot: string): Config {
30
+ const path = configPath(projectRoot);
31
+ assertNoSymlinks(projectRoot, FILE_NAME);
32
+
33
+ let text: string;
34
+ try {
35
+ text = readFileSync(path, "utf8");
36
+ } catch (error) {
37
+ if (isMissingFile(error)) return emptyConfig();
38
+ throw error;
39
+ }
40
+
41
+ let data: unknown;
42
+ try {
43
+ data = JSON.parse(text);
44
+ } catch {
45
+ throw new Error(`${FILE_NAME} is not valid JSON (${path})`);
46
+ }
47
+ return checkConfig(data);
48
+ }
49
+
50
+ function checkConfig(data: unknown): Config {
51
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
52
+ throw new Error(`${FILE_NAME} is not a heydb config: expected a JSON object`);
53
+ }
54
+ const record = data as Record<string, unknown>;
55
+ if (!("formatVersion" in record)) {
56
+ throw new Error(`${FILE_NAME} is not a heydb config: missing "formatVersion"`);
57
+ }
58
+ if (record.formatVersion !== SUPPORTED_FORMAT_VERSION) {
59
+ throw new Error(
60
+ `${FILE_NAME}: unsupported config formatVersion ${JSON.stringify(record.formatVersion)} (expected ${SUPPORTED_FORMAT_VERSION}); update heydb`,
61
+ );
62
+ }
63
+ try {
64
+ return validateConfig(data);
65
+ } catch (error) {
66
+ const reason = error instanceof Error ? error.message : String(error);
67
+ throw new Error(`${FILE_NAME} is not a heydb config: ${reason}`, { cause: error });
68
+ }
69
+ }
70
+
71
+ function isMissingFile(error: unknown): boolean {
72
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
73
+ }
@@ -0,0 +1,37 @@
1
+ // The connections of a project live in .heydb/config.json, which is committed:
2
+ // teammates share host, port, engine and user through git. That only works
3
+ // because the file holds NO secrets. A password is always a reference such as
4
+ // "env:HEYDB_PROD_PASSWORD", resolved on the machine that connects (ADR-0005).
5
+
6
+ /** Engines a connection may use. */
7
+ export type ConnectionEngine = "mysql" | "mariadb" | "postgresql";
8
+
9
+ export interface ConnectionConfig {
10
+ readonly engine: ConnectionEngine;
11
+ readonly host: string;
12
+ readonly port: number;
13
+ readonly user: string;
14
+ /** Only a reference, "env:HEYDB_NAME"; never the password itself. */
15
+ readonly password: string;
16
+ /**
17
+ * Databases to capture; null captures every database the user can see.
18
+ * PostgreSQL: exactly one, the database to connect to, because a PostgreSQL
19
+ * connection only ever reaches the database it was opened on.
20
+ */
21
+ readonly databases: readonly string[] | null;
22
+ /**
23
+ * PostgreSQL only: schemas to capture inside that database; null captures
24
+ * every non-system schema. Absent for MySQL and MariaDB, where each database
25
+ * is its own schema.
26
+ */
27
+ readonly schemas?: readonly string[] | null;
28
+ }
29
+
30
+ export interface Config {
31
+ /** Bumped only when the shape of this file changes incompatibly. */
32
+ readonly formatVersion: 1;
33
+ /** Used when a command names no connection; null when none was chosen. */
34
+ readonly defaultConnection: string | null;
35
+ /** By connection name; names follow the same allow-list as snapshot files. */
36
+ readonly connections: Readonly<Record<string, ConnectionConfig>>;
37
+ }
@@ -0,0 +1,136 @@
1
+ import { PASSWORD_REFERENCE, REFERENCE_RULE, suggestedReference } from "../secrets.ts";
2
+ import { assertConnectionName } from "../store/guards.ts";
3
+ import type { Config, ConnectionConfig, ConnectionEngine } from "./types.ts";
4
+
5
+ // config.json is committed and edited by hand, so every field is checked at
6
+ // the boundary, like snapshots and knowledge. The result is rebuilt field by
7
+ // field, and an unknown field is an error, not dropped: a password typed under
8
+ // a misspelt key would otherwise stay in the committed file unnoticed. Errors
9
+ // name the exact path, e.g. "connections.prod.port must be an integer from 1 to 65535".
10
+ //
11
+ // The same check runs on every entry the pure operations add or update, so a
12
+ // config in memory is always one that could have been loaded from disk.
13
+ //
14
+ // No message ever repeats the value of a password field, or of an unknown
15
+ // field: either may be the real password.
16
+
17
+ const ENGINES: readonly ConnectionEngine[] = ["mysql", "mariadb", "postgresql"];
18
+ const ROOT_FIELDS = ["formatVersion", "defaultConnection", "connections"];
19
+ const CONNECTION_FIELDS = ["engine", "host", "port", "user", "password", "databases", "schemas"];
20
+
21
+ export function validateConfig(data: unknown): Config {
22
+ const root = object(data, "config");
23
+ onlyKnownFields(root, ROOT_FIELDS, "config");
24
+ if (root.formatVersion !== 1) throw new Error("formatVersion must be 1");
25
+
26
+ const defaultConnection = read(root, "defaultConnection");
27
+ if (defaultConnection !== null && typeof defaultConnection !== "string") {
28
+ throw new Error("defaultConnection must be null or a string");
29
+ }
30
+ const entries = object(read(root, "connections"), "connections");
31
+ // A Map, then Object.fromEntries: the result only has the names that were read.
32
+ const connections = new Map<string, ConnectionConfig>();
33
+ for (const [name, value] of Object.entries(entries)) {
34
+ try {
35
+ assertConnectionName(name);
36
+ } catch (error) {
37
+ throw new Error(`connections: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
38
+ }
39
+ connections.set(name, validateConnection(value, name));
40
+ }
41
+ if (defaultConnection !== null && !connections.has(defaultConnection)) {
42
+ throw new Error(`defaultConnection ${JSON.stringify(defaultConnection)} is not one of the configured connections`);
43
+ }
44
+ return { formatVersion: 1, defaultConnection, connections: Object.fromEntries(connections) };
45
+ }
46
+
47
+ export function validateConnection(data: unknown, name: string): ConnectionConfig {
48
+ const path = `connections.${name}`;
49
+ const entry = object(data, path);
50
+ onlyKnownFields(entry, CONNECTION_FIELDS, path);
51
+ const connection = {
52
+ engine: engine(read(entry, "engine", path), `${path}.engine`),
53
+ host: text(read(entry, "host", path), `${path}.host`),
54
+ port: port(read(entry, "port", path), `${path}.port`),
55
+ user: text(read(entry, "user", path), `${path}.user`),
56
+ password: password(read(entry, "password", path), `${path}.password`, name),
57
+ databases: names(read(entry, "databases", path), `${path}.databases`, "every database"),
58
+ };
59
+ if (connection.engine !== "postgresql") {
60
+ // An undefined value counts as absent: it is what spreading a partial update leaves behind.
61
+ if (entry.schemas !== undefined) {
62
+ throw new Error(
63
+ `${path}.schemas is only allowed for postgresql: in MySQL and MariaDB every database is its own schema, so choose them with "databases"`,
64
+ );
65
+ }
66
+ return connection;
67
+ }
68
+ if (connection.databases?.length !== 1) {
69
+ throw new Error(
70
+ `${path}.databases must list exactly one database for postgresql: a PostgreSQL connection only reaches ` +
71
+ 'the database it connects to, so add one connection per database (and use "schemas" to choose schemas inside it)',
72
+ );
73
+ }
74
+ // Written as an explicit null when absent, like every other "all" in this file.
75
+ return { ...connection, schemas: names(entry.schemas ?? null, `${path}.schemas`, "every non-system schema") };
76
+ }
77
+
78
+ function engine(value: unknown, path: string): ConnectionEngine {
79
+ const found = ENGINES.find((item) => item === value);
80
+ if (found === undefined) throw new Error(`${path} must be "mysql", "mariadb" or "postgresql"`);
81
+ return found;
82
+ }
83
+
84
+ function text(value: unknown, path: string): string {
85
+ if (typeof value !== "string" || value === "") throw new Error(`${path} must be a non-empty string`);
86
+ return value;
87
+ }
88
+
89
+ function port(value: unknown, path: string): number {
90
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) {
91
+ throw new Error(`${path} must be an integer from 1 to 65535`);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ function password(value: unknown, path: string, name: string): string {
97
+ if (typeof value === "string" && PASSWORD_REFERENCE.test(value)) return value;
98
+ const example = suggestedReference(name);
99
+ if (typeof value === "string" && value.startsWith("env:")) {
100
+ throw new Error(`${path} must be a reference such as "${example}": ${REFERENCE_RULE}`);
101
+ }
102
+ throw new Error(
103
+ `${path} must be a reference such as "${example}", never the password itself: ` +
104
+ ".heydb/config.json is committed to git, so anything written there is readable by everyone with the repository. " +
105
+ "Put the password in that environment variable (or in .heydb/.env, which git ignores).",
106
+ );
107
+ }
108
+
109
+ /** A list of unique names, or null for "all"; `all` says what null means in the message. */
110
+ function names(value: unknown, path: string, all: string): readonly string[] | null {
111
+ if (value === null) return null;
112
+ if (!Array.isArray(value) || value.length === 0) {
113
+ throw new Error(`${path} must be null (${all}) or a non-empty list of names`);
114
+ }
115
+ return value.map((item: unknown, index) => {
116
+ if (typeof item !== "string" || item === "") throw new Error(`${path}[${index}] must be a non-empty string`);
117
+ if (value.indexOf(item) !== index) throw new Error(`${path} repeats ${JSON.stringify(item)}`);
118
+ return item;
119
+ });
120
+ }
121
+
122
+ /** Names the first unknown field, never its value. */
123
+ function onlyKnownFields(record: Record<string, unknown>, known: readonly string[], path: string): void {
124
+ const unknown = Object.keys(record).find((key) => !known.includes(key));
125
+ if (unknown !== undefined) throw new Error(`${path} has an unknown field ${JSON.stringify(unknown)}`);
126
+ }
127
+
128
+ function read(record: Record<string, unknown>, key: string, path = ""): unknown {
129
+ if (!(key in record)) throw new Error(`${path === "" ? key : `${path}.${key}`} is missing`);
130
+ return record[key];
131
+ }
132
+
133
+ function object(value: unknown, path: string): Record<string, unknown> {
134
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
135
+ return value as Record<string, unknown>;
136
+ }
@@ -0,0 +1,66 @@
1
+ import type { ConnectionConfig } from "../config/types.ts";
2
+ import type { Snapshot } from "../model/snapshot.ts";
3
+ import type { MysqlConnectionOptions } from "./mysql/introspect.ts";
4
+ import type { PostgresConnectionOptions } from "./postgres/introspect.ts";
5
+
6
+ // The one entry point to every engine. Callers pass a config entry and the
7
+ // already resolved password (lib/secrets.ts); this file picks the engine.
8
+ //
9
+ // Drivers load only when used (ADR-0006): the engine module, and with it its
10
+ // driver, is imported with a dynamic import() inside the function, never at
11
+ // the top of this file. Reading the snapshot, searching or annotating never
12
+ // connect, so they never pay for mysql2 or pg. `import type` above is erased before
13
+ // the code runs and loads nothing; tests/engines/index.test.ts checks both.
14
+
15
+ // One shared promise per engine module: concurrent callers wait for the same
16
+ // import instead of starting their own. Pi's module loader breaks when two
17
+ // imports of mysql2 run at the same time (seen with two /heydb:status at once).
18
+ let mysqlEngine: Promise<typeof import("./mysql/introspect.ts")> | undefined;
19
+
20
+ function loadMysqlEngine(): Promise<typeof import("./mysql/introspect.ts")> {
21
+ mysqlEngine ??= import("./mysql/introspect.ts").catch((error: unknown) => {
22
+ mysqlEngine = undefined; // a failed load can be retried
23
+ throw error;
24
+ });
25
+ return mysqlEngine;
26
+ }
27
+
28
+ let postgresEngine: Promise<typeof import("./postgres/introspect.ts")> | undefined;
29
+
30
+ function loadPostgresEngine(): Promise<typeof import("./postgres/introspect.ts")> {
31
+ postgresEngine ??= import("./postgres/introspect.ts").catch((error: unknown) => {
32
+ postgresEngine = undefined; // a failed load can be retried
33
+ throw error;
34
+ });
35
+ return postgresEngine;
36
+ }
37
+
38
+ /** Captures the structure of the server described by `entry`. */
39
+ export async function introspect(entry: ConnectionConfig, password: string): Promise<Snapshot> {
40
+ switch (entry.engine) {
41
+ case "mysql":
42
+ case "mariadb": {
43
+ const { introspectMysql } = await loadMysqlEngine();
44
+ return introspectMysql(mysqlOptions(entry, password));
45
+ }
46
+ case "postgresql": {
47
+ // Built before the import, so a bad entry fails without loading pg.
48
+ const options = postgresOptions(entry, password);
49
+ const { introspectPostgres } = await loadPostgresEngine();
50
+ return introspectPostgres(options);
51
+ }
52
+ }
53
+ }
54
+
55
+ /** MySQL and MariaDB speak the same protocol, so both use the same driver options. */
56
+ export function mysqlOptions(entry: ConnectionConfig, password: string): MysqlConnectionOptions {
57
+ return { host: entry.host, port: entry.port, user: entry.user, password, databases: entry.databases };
58
+ }
59
+
60
+ /** A PostgreSQL connection reaches one database: the only entry of `databases`. */
61
+ export function postgresOptions(entry: ConnectionConfig, password: string): PostgresConnectionOptions {
62
+ const [database, ...others] = entry.databases ?? [];
63
+ // Config validation already requires exactly one; this guards entries built in code.
64
+ if (database === undefined || others.length > 0) throw new Error("a postgresql connection needs exactly one database");
65
+ return { host: entry.host, port: entry.port, user: entry.user, password, database, schemas: entry.schemas ?? null };
66
+ }