@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -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 +143 -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,124 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { ENV_FILE, PASSWORD_REFERENCE, REFERENCE_RULE, parseEnvFile, parseEnvLine } from "../secrets.ts";
5
+ import { writeFileAtomic } from "./files.ts";
6
+ import { assertNoSymlinks } from "./guards.ts";
7
+ import { HEYDB_DIR } from "./project.ts";
8
+
9
+ // Writes .heydb/.env, the one file where heydb keeps a secret: the password a
10
+ // person typed, so they do not have to set a variable by hand. The file is
11
+ // local (.heydb/.gitignore excludes it; callers make sure of that before
12
+ // writing) and readable by its owner only. Every line heydb does not own is
13
+ // kept byte for byte: the file is also the person's own.
14
+ //
15
+ // What it does not protect: anyone who can read files as this user, or the
16
+ // disk itself, can read the password. The system keychain would; it remains
17
+ // a future option (ADR-0005).
18
+
19
+ /** Owner read and write only. Windows ignores it: there the folder's permissions apply. */
20
+ const OWNER_ONLY = 0o600;
21
+
22
+ /**
23
+ * The line that stores `value` under `name`, encoded so parseEnvFile reads
24
+ * back exactly `value`. That reader trims the value and strips one pair of
25
+ * matching quotes around it, without escapes. So quotes are needed only when
26
+ * something would otherwise be lost: whitespace at either end, or a quote at
27
+ * the start that could be taken for a pair. A leading "#" is quoted too, so no
28
+ * .env reader mistakes the value for a comment. Inside the quotes nothing is
29
+ * special (parseEnvFile only looks at the first and last character), so a
30
+ * value with both kinds of quote is fine. A line break is the one thing the
31
+ * format cannot hold.
32
+ */
33
+ export function envLine(name: string, value: string): string {
34
+ assertVariable(name);
35
+ if (/[\r\n]/.test(value)) {
36
+ // Never the value: it is a password.
37
+ throw new Error(`the value for ${name} has a line break, which .heydb/.env cannot hold: set ${name} in your shell instead`);
38
+ }
39
+ if (value === value.trim() && !/^["'#]/.test(value)) return `${name}=${value}`;
40
+ const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
41
+ return `${name}=${quote}${value}${quote}`;
42
+ }
43
+
44
+ /**
45
+ * Sets `name` in .heydb/.env: replaces the definition that counts (the last)
46
+ * and removes the earlier ones, so an old password does not stay in the file;
47
+ * appends one when there is none.
48
+ */
49
+ export function setEnvValue(projectRoot: string, name: string, value: string): void {
50
+ const line = envLine(name, value);
51
+ const text = readEnvText(projectRoot) ?? "";
52
+ const lines = text.split("\n");
53
+ const index = lines.findLastIndex((raw, at) => keyOf(raw, at) === name);
54
+
55
+ let next: string;
56
+ if (index === -1) {
57
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
58
+ const separator = text === "" || text.endsWith("\n") ? "" : newline;
59
+ next = `${text}${separator}${line}${newline}`;
60
+ } else {
61
+ const old = lines[index] as string;
62
+ const bom = index === 0 && old.startsWith("\uFEFF") ? "\uFEFF" : "";
63
+ lines[index] = `${bom}${line}${old.endsWith("\r") ? "\r" : ""}`;
64
+ next = withoutDefinitions(lines, name, index);
65
+ }
66
+
67
+ // The proof, before anything is written: the file as heydb will read it
68
+ // gives back exactly this value. It also refuses a file with lines that
69
+ // are not KEY=VALUE, reporting their number only.
70
+ if (parseEnvFile(next)[name] !== value) {
71
+ throw new Error(`${name} could not be written to .heydb/.env so that it reads back the same: set it in your shell instead`);
72
+ }
73
+ writeFileAtomic(envPath(projectRoot), next, OWNER_ONLY);
74
+ }
75
+
76
+ /** Removes every definition of `name`; false when there was none (or no file). */
77
+ export function removeEnvValue(projectRoot: string, name: string): boolean {
78
+ assertVariable(name);
79
+ const text = readEnvText(projectRoot);
80
+ if (text === null) return false;
81
+ const lines = text.split("\n");
82
+ if (!lines.some((raw, at) => keyOf(raw, at) === name)) return false;
83
+ writeFileAtomic(envPath(projectRoot), withoutDefinitions(lines, name), OWNER_ONLY);
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * The file without the lines that define `name`, except the one at `keep`.
89
+ * Every other line is kept byte for byte, and the byte order mark stays at
90
+ * the start of the file even when the first line goes.
91
+ */
92
+ function withoutDefinitions(lines: readonly string[], name: string, keep = -1): string {
93
+ const bom = lines[0]?.startsWith("\uFEFF") ? "\uFEFF" : "";
94
+ const kept = lines
95
+ .map((raw, at) => (at === 0 ? raw.slice(bom.length) : raw))
96
+ .filter((raw, at) => at === keep || parseEnvLine(raw)?.key !== name);
97
+ return `${bom}${kept.join("\n")}`;
98
+ }
99
+
100
+ function envPath(projectRoot: string): string {
101
+ return join(projectRoot, HEYDB_DIR, ENV_FILE);
102
+ }
103
+
104
+ /** The file's text, or null when it does not exist. */
105
+ function readEnvText(projectRoot: string): string | null {
106
+ // A committed symbolic link could point .env at any file on this machine.
107
+ assertNoSymlinks(projectRoot, ENV_FILE);
108
+ try {
109
+ return readFileSync(envPath(projectRoot), "utf8");
110
+ } catch (error) {
111
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /** The name a line defines, read exactly as parseEnvFile reads it (byte order mark included). */
117
+ function keyOf(raw: string, index: number): string | undefined {
118
+ return parseEnvLine(index === 0 ? raw.replace(/^\uFEFF/, "") : raw)?.key;
119
+ }
120
+
121
+ // The same rule as references in config.json: heydb only ever touches its own variables.
122
+ function assertVariable(name: string): void {
123
+ if (!PASSWORD_REFERENCE.test(`env:${name}`)) throw new Error(`${JSON.stringify(name)} is not a heydb variable: ${REFERENCE_RULE}`);
124
+ }
@@ -0,0 +1,57 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ /**
6
+ * Writes the whole file or nothing. The content goes to a temporary file in
7
+ * the same directory, is flushed to disk (fsync), and is then renamed over the
8
+ * target. A rename within one directory is atomic, so if heydb crashes the
9
+ * target is either the old file or the new one, never half of each. After a
10
+ * power loss the rename itself may be lost (the old file remains): the
11
+ * directory is not flushed, because Windows does not support that.
12
+ *
13
+ * `mode` sets the permissions of the new file (0o600 for secrets). It is
14
+ * applied when the temporary file is created, so the content is never
15
+ * readable by others, not even for an instant. Windows only honors the
16
+ * read-only bit, so there the file keeps the folder's permissions.
17
+ *
18
+ * A string is written as UTF-8; bytes (a compressed file) as they are.
19
+ */
20
+ export function writeFileAtomic(path: string, content: string | Uint8Array, mode?: number): void {
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ const temporary = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
23
+ try {
24
+ const fd = openSync(temporary, "w", mode);
25
+ try {
26
+ if (typeof content === "string") writeSync(fd, content, null, "utf8");
27
+ else writeSync(fd, content);
28
+ fsyncSync(fd);
29
+ } finally {
30
+ closeSync(fd);
31
+ }
32
+ renameSync(temporary, path);
33
+ } catch (error) {
34
+ rmSync(temporary, { force: true });
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * JSON with object keys sorted alphabetically, two-space indentation and a
41
+ * final newline. The same data always produces the same bytes, whatever order
42
+ * the code built the objects in, so git diffs only show real changes.
43
+ * Array order is kept: sort arrays before calling this when their order is
44
+ * not meaningful.
45
+ */
46
+ export function stableJson(value: unknown): string {
47
+ return `${JSON.stringify(value, sortKeys, 2)}\n`;
48
+ }
49
+
50
+ function sortKeys(_key: string, value: unknown): unknown {
51
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
52
+ const sorted: Record<string, unknown> = {};
53
+ for (const key of Object.keys(value).sort()) {
54
+ sorted[key] = (value as Record<string, unknown>)[key];
55
+ }
56
+ return sorted;
57
+ }
@@ -0,0 +1,68 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { HEYDB_DIR } from "./project.ts";
5
+
6
+ // Safety checks shared by every file heydb keeps under .heydb/. Keeping them
7
+ // in one place means snapshots and knowledge can never drift apart.
8
+
9
+ // The connection name becomes a file name, so it is an allow-list:
10
+ // - no path separators or dots, so it can never point outside .heydb/ ("../evil");
11
+ // - lowercase only, because "Prod" and "prod" are the same file on macOS and Windows;
12
+ // - none of the device names Windows reserves (a file "nul.json" cannot be checked out there).
13
+ const CONNECTION_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
14
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/;
15
+
16
+ export function assertConnectionName(name: string): void {
17
+ if (!CONNECTION_NAME.test(name)) {
18
+ throw new Error(
19
+ `invalid connection name ${JSON.stringify(name)}: use 1-64 lowercase letters, digits, "-" or "_", starting with a letter or digit`,
20
+ );
21
+ }
22
+ if (WINDOWS_RESERVED.test(name)) {
23
+ throw new Error(`invalid connection name ${JSON.stringify(name)}: reserved name on Windows`);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Turns what a person typed ("Acme", "Prod EU", "Producción") into a name
29
+ * assertConnectionName accepts ("acme", "prod-eu", "produccion"), so the
30
+ * rule protects the files without getting in the user's way. Returns null
31
+ * when nothing usable is left.
32
+ */
33
+ export function toConnectionName(input: string): string | null {
34
+ let name = input
35
+ .normalize("NFD")
36
+ .replace(/\p{M}/gu, "") // accents: "ó" → "o"
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9_-]+/g, "-") // spaces, dots and anything else become a dash
39
+ .replace(/-{2,}/g, "-")
40
+ .replace(/^[-_]+|[-_]+$/g, "")
41
+ .slice(0, 64)
42
+ .replace(/[-_]+$/, "");
43
+ if (name === "") return null;
44
+ if (WINDOWS_RESERVED.test(name)) name = `${name}-db`;
45
+ return name;
46
+ }
47
+
48
+ /**
49
+ * .heydb/ is committed, and git can commit symbolic links. A link named .heydb
50
+ * or .heydb/<entry> (a subdirectory such as "snapshots", or a file such as
51
+ * ".env") pointing elsewhere would make heydb read or overwrite files outside
52
+ * the project, so links are refused, never followed.
53
+ */
54
+ export function assertNoSymlinks(projectRoot: string, entry: string): void {
55
+ for (const relative of [HEYDB_DIR, join(HEYDB_DIR, entry)]) {
56
+ if (isSymlink(join(projectRoot, relative))) {
57
+ throw new Error(`${relative.replaceAll("\\", "/")} is a symbolic link; heydb refuses to follow it`);
58
+ }
59
+ }
60
+ }
61
+
62
+ function isSymlink(path: string): boolean {
63
+ try {
64
+ return lstatSync(path).isSymbolicLink();
65
+ } catch {
66
+ return false; // does not exist yet
67
+ }
68
+ }
@@ -0,0 +1,139 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { emptyKnowledge, sortKnowledge } from "../knowledge/operations.ts";
5
+ import type { Knowledge, RelationshipSide } from "../knowledge/types.ts";
6
+ import { validateKnowledge, validateProjectKnowledge } from "../knowledge/validate.ts";
7
+ import { stableJson, writeFileAtomic } from "./files.ts";
8
+ import { assertConnectionName, assertNoSymlinks } from "./guards.ts";
9
+ import { HEYDB_DIR } from "./project.ts";
10
+
11
+ // Knowledge lives in <project>/.heydb/knowledge/<connection>.json, next to the
12
+ // snapshot, and is committed too: a teammate gets the notes with a git pull.
13
+ // One file per connection, not per table: table names are not safe file names
14
+ // (dots, case, names Windows reserves). See ADR-0012.
15
+ //
16
+ // Project notes live next to them in _project.json: a connection name must
17
+ // start with a letter or digit, so no connection can ever own that file.
18
+
19
+ const SUPPORTED_FORMAT_VERSION = 1;
20
+ const PROJECT_FILE = "_project.json";
21
+
22
+ export function knowledgePath(projectRoot: string, connection: string): string {
23
+ assertConnectionName(connection);
24
+ return join(projectRoot, HEYDB_DIR, "knowledge", `${connection}.json`);
25
+ }
26
+
27
+ export function saveKnowledge(projectRoot: string, connection: string, knowledge: Knowledge): void {
28
+ const path = knowledgePath(projectRoot, connection);
29
+ assertNoSymlinks(projectRoot, "knowledge");
30
+ writeFileAtomic(path, stableJson(sortKnowledge(knowledge)));
31
+ }
32
+
33
+ /**
34
+ * Returns the stored knowledge; a connection nobody annotated yet has empty
35
+ * knowledge. A relationship side naming this same connection is the same as
36
+ * one naming none, so it comes back without it: "no connection" is the only
37
+ * way the rest of heydb has to say "this connection".
38
+ */
39
+ export function loadKnowledge(projectRoot: string, connection: string): Knowledge {
40
+ // "knowledge/" in messages tells it apart from the snapshot, also called <connection>.json.
41
+ const knowledge = readKnowledge(projectRoot, knowledgePath(projectRoot, connection), `knowledge/${connection}.json`, validateKnowledge);
42
+ const own = (side: RelationshipSide): RelationshipSide =>
43
+ side.connection === connection ? { table: side.table, columns: side.columns } : side;
44
+ return {
45
+ ...knowledge,
46
+ relationships: knowledge.relationships.map((item) => ({ ...item, from: own(item.from), to: own(item.to) })),
47
+ };
48
+ }
49
+
50
+ export function projectKnowledgePath(projectRoot: string): string {
51
+ return join(projectRoot, HEYDB_DIR, "knowledge", PROJECT_FILE);
52
+ }
53
+
54
+ /** Notes about the whole project; empty when there are none yet. */
55
+ export function loadProjectKnowledge(projectRoot: string): Knowledge {
56
+ return readKnowledge(projectRoot, projectKnowledgePath(projectRoot), `knowledge/${PROJECT_FILE}`, validateProjectKnowledge);
57
+ }
58
+
59
+ /** Checked before writing, so a bug elsewhere can never store a database note where nobody looks for it. */
60
+ export function saveProjectKnowledge(projectRoot: string, knowledge: Knowledge): void {
61
+ const checked = validateProjectKnowledge(sortKnowledge(knowledge));
62
+ assertNoSymlinks(projectRoot, "knowledge");
63
+ writeFileAtomic(projectKnowledgePath(projectRoot), stableJson(checked));
64
+ }
65
+
66
+ /**
67
+ * The connections that have a knowledge file, sorted: every <name>.json whose
68
+ * name is a valid connection name, whether or not it is still configured.
69
+ * The project file and anything else in the folder are left out.
70
+ */
71
+ export function knowledgeConnections(projectRoot: string): string[] {
72
+ assertNoSymlinks(projectRoot, "knowledge");
73
+ let entries: string[];
74
+ try {
75
+ entries = readdirSync(join(projectRoot, HEYDB_DIR, "knowledge"));
76
+ } catch (error) {
77
+ if (isMissingFile(error)) return [];
78
+ throw error;
79
+ }
80
+ return entries
81
+ .filter((entry) => entry.endsWith(".json"))
82
+ .map((entry) => entry.slice(0, -".json".length))
83
+ .filter(isConnectionName)
84
+ .sort();
85
+ }
86
+
87
+ function isConnectionName(name: string): boolean {
88
+ try {
89
+ assertConnectionName(name);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ function readKnowledge(projectRoot: string, path: string, fileName: string, validate: (data: unknown) => Knowledge): Knowledge {
97
+ assertNoSymlinks(projectRoot, "knowledge");
98
+
99
+ let text: string;
100
+ try {
101
+ text = readFileSync(path, "utf8");
102
+ } catch (error) {
103
+ if (isMissingFile(error)) return emptyKnowledge();
104
+ throw error;
105
+ }
106
+
107
+ let data: unknown;
108
+ try {
109
+ data = JSON.parse(text);
110
+ } catch {
111
+ throw new Error(`${fileName} is not valid JSON (${path})`);
112
+ }
113
+ return checkKnowledge(data, fileName, validate);
114
+ }
115
+
116
+ function checkKnowledge(data: unknown, fileName: string, validate: (data: unknown) => Knowledge): Knowledge {
117
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
118
+ throw new Error(`${fileName} is not a heydb knowledge file: expected a JSON object`);
119
+ }
120
+ const record = data as Record<string, unknown>;
121
+ if (!("formatVersion" in record)) {
122
+ throw new Error(`${fileName} is not a heydb knowledge file: missing "formatVersion"`);
123
+ }
124
+ if (record.formatVersion !== SUPPORTED_FORMAT_VERSION) {
125
+ throw new Error(
126
+ `${fileName}: unsupported knowledge formatVersion ${JSON.stringify(record.formatVersion)} (expected ${SUPPORTED_FORMAT_VERSION}); update heydb`,
127
+ );
128
+ }
129
+ try {
130
+ return validate(data);
131
+ } catch (error) {
132
+ const reason = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`${fileName} is not a heydb knowledge file: ${reason}`, { cause: error });
134
+ }
135
+ }
136
+
137
+ function isMissingFile(error: unknown): boolean {
138
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
139
+ }
@@ -0,0 +1,28 @@
1
+ import { statSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ /** Directory that marks a heydb project, like .git marks a git repository. */
5
+ export const HEYDB_DIR = ".heydb";
6
+
7
+ /**
8
+ * Walks up from `start` until it finds a directory containing `.heydb/`.
9
+ * Returns that directory, or null when no ancestor is a heydb project.
10
+ * This is how git finds its repository, so heydb works from any subfolder.
11
+ */
12
+ export function findProjectRoot(start: string): string | null {
13
+ let current = start;
14
+ while (true) {
15
+ if (isDirectory(join(current, HEYDB_DIR))) return current;
16
+ const parent = dirname(current);
17
+ if (parent === current) return null; // reached the filesystem root
18
+ current = parent;
19
+ }
20
+ }
21
+
22
+ function isDirectory(path: string): boolean {
23
+ try {
24
+ return statSync(path).isDirectory();
25
+ } catch {
26
+ return false; // does not exist or cannot be read
27
+ }
28
+ }
@@ -0,0 +1,143 @@
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { constants, gunzipSync, gzipSync } from "node:zlib";
4
+
5
+ import { sortSnapshot } from "../model/order.ts";
6
+ import type { Snapshot } from "../model/snapshot.ts";
7
+ import { validateSnapshot } from "../model/validate.ts";
8
+ import { stableJson, writeFileAtomic } from "./files.ts";
9
+ import { assertConnectionName, assertNoSymlinks } from "./guards.ts";
10
+ import { HEYDB_DIR } from "./project.ts";
11
+
12
+ // Snapshots live in <project>/.heydb/snapshots/<connection>.json.gz and are
13
+ // meant to be committed: teammates without database credentials still get the
14
+ // schema. The content is the same stable JSON earlier builds wrote as plain
15
+ // <connection>.json, gzip-compressed: a schema with about 1,500 tables was
16
+ // about 400,000 lines of JSON, which nobody reviews in a pull request.
17
+ // Reading decompresses on every load: on 1,500 tables × 60 columns that is
18
+ // ~13 ms of the ~75 ms a load takes (JSON.parse and validation are the rest),
19
+ // so a decompressed local copy would save little and add a second file to
20
+ // keep in step.
21
+
22
+ const SUPPORTED_FORMAT_VERSION = 1;
23
+
24
+ /** Where saveSnapshot writes: <connection>.json.gz. */
25
+ export function snapshotPath(projectRoot: string, connection: string): string {
26
+ assertConnectionName(connection);
27
+ return join(projectRoot, HEYDB_DIR, "snapshots", `${connection}.json.gz`);
28
+ }
29
+
30
+ /** The plain <connection>.json earlier builds wrote; still read, replaced on the next save. */
31
+ function legacySnapshotPath(projectRoot: string, connection: string): string {
32
+ assertConnectionName(connection);
33
+ return join(projectRoot, HEYDB_DIR, "snapshots", `${connection}.json`);
34
+ }
35
+
36
+ /** The file the snapshot of `connection` is read from, or null when it was never synced. */
37
+ export function storedSnapshotPath(projectRoot: string, connection: string): string | null {
38
+ for (const path of [snapshotPath(projectRoot, connection), legacySnapshotPath(projectRoot, connection)]) {
39
+ if (existsSync(path)) return path;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ export function saveSnapshot(projectRoot: string, connection: string, snapshot: Snapshot): void {
45
+ const path = snapshotPath(projectRoot, connection);
46
+ assertNoSymlinks(projectRoot, "snapshots");
47
+ writeFileAtomic(path, gzipStable(stableJson(sortSnapshot(snapshot))));
48
+ // Only after the new file is in place, so a failed write keeps the old one.
49
+ rmSync(legacySnapshotPath(projectRoot, connection), { force: true });
50
+ }
51
+
52
+ /**
53
+ * gzip with the header fields that depend on the machine fixed, so the same
54
+ * snapshot gives the same bytes everywhere: modification time 0 and no file
55
+ * name (what gzipSync already writes), and operating system 255, "unknown"
56
+ * (zlib writes the one it was built for: 3 on Linux, 19 on macOS). The
57
+ * compressed data itself depends only on the zlib build; a different one may
58
+ * pick other bytes for the same JSON, which is harmless because the file is
59
+ * only rewritten when the structure changed.
60
+ */
61
+ function gzipStable(text: string): Buffer {
62
+ const bytes = gzipSync(text, { level: constants.Z_BEST_COMPRESSION });
63
+ bytes.writeUInt32LE(0, 4);
64
+ bytes[9] = 0xff;
65
+ return bytes;
66
+ }
67
+
68
+ /** Returns the stored snapshot, or null when this connection was never synced. */
69
+ export function loadSnapshot(projectRoot: string, connection: string): Snapshot | null {
70
+ const path = snapshotPath(projectRoot, connection);
71
+ const legacyPath = legacySnapshotPath(projectRoot, connection);
72
+ assertNoSymlinks(projectRoot, "snapshots");
73
+
74
+ const compressed = readIfExists(path);
75
+ if (compressed !== null) {
76
+ const fileName = `${connection}.json.gz`;
77
+ let text: string;
78
+ try {
79
+ text = gunzipSync(compressed).toString("utf8");
80
+ } catch {
81
+ throw new Error(`${fileName} is not a valid gzip file (${path})`);
82
+ }
83
+ return parseSnapshot(text, fileName, path);
84
+ }
85
+ const plain = readIfExists(legacyPath);
86
+ if (plain === null) return null;
87
+ return parseSnapshot(plain.toString("utf8"), `${connection}.json`, legacyPath);
88
+ }
89
+
90
+ function readIfExists(path: string): Buffer | null {
91
+ try {
92
+ return readFileSync(path);
93
+ } catch (error) {
94
+ if (isMissingFile(error)) return null;
95
+ throw error;
96
+ }
97
+ }
98
+
99
+ function parseSnapshot(text: string, fileName: string, path: string): Snapshot {
100
+ let data: unknown;
101
+ try {
102
+ data = JSON.parse(text);
103
+ } catch {
104
+ throw new Error(`${fileName} is not valid JSON (${path})`);
105
+ }
106
+ return checkSnapshot(data, fileName);
107
+ }
108
+
109
+ // First the top-level checks, whose messages say what kind of file this is
110
+ // (not a snapshot at all, or one from a newer heydb); then validateSnapshot
111
+ // checks every field down to each column.
112
+ function checkSnapshot(data: unknown, fileName: string): Snapshot {
113
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
114
+ throw new Error(`${fileName} is not a heydb snapshot: expected a JSON object`);
115
+ }
116
+ const record = data as Record<string, unknown>;
117
+ if (!("formatVersion" in record)) {
118
+ throw new Error(`${fileName} is not a heydb snapshot: missing "formatVersion"`);
119
+ }
120
+ if (record.formatVersion !== SUPPORTED_FORMAT_VERSION) {
121
+ throw new Error(
122
+ `${fileName}: unsupported snapshot formatVersion ${JSON.stringify(record.formatVersion)} (expected ${SUPPORTED_FORMAT_VERSION}); update heydb`,
123
+ );
124
+ }
125
+ for (const field of ["engine", "capturedAt", "databases", "warnings"]) {
126
+ if (!(field in record)) {
127
+ throw new Error(`${fileName} is not a heydb snapshot: missing "${field}"`);
128
+ }
129
+ }
130
+ if (!Array.isArray(record.databases) || !Array.isArray(record.warnings)) {
131
+ throw new Error(`${fileName} is not a heydb snapshot: "databases" and "warnings" must be lists`);
132
+ }
133
+ try {
134
+ return validateSnapshot(data);
135
+ } catch (error) {
136
+ const reason = error instanceof Error ? error.message : String(error);
137
+ throw new Error(`${fileName} is not a heydb snapshot: ${reason}`, { cause: error });
138
+ }
139
+ }
140
+
141
+ function isMissingFile(error: unknown): boolean {
142
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
143
+ }
@@ -0,0 +1,51 @@
1
+ import { execFile } from "node:child_process";
2
+ import { userInfo } from "node:os";
3
+
4
+ // Every note carries its author, so a team can ask whoever wrote it. The name
5
+ // comes from where people already set it, in this order:
6
+ // 1. git user.name, read in the project folder (it may differ per repository);
7
+ // 2. the HEYDB_AUTHOR environment variable, for machines without git config;
8
+ // 3. the operating system user.
9
+ // Entry points add their own suffix: notes written by an agent end in
10
+ // "(via agent)", so a reader knows a person did not type them.
11
+
12
+ const GIT_TIMEOUT_MS = 2000;
13
+
14
+ export interface AuthorOptions {
15
+ readonly cwd: string;
16
+ readonly env?: Readonly<Record<string, string | undefined>>;
17
+ /** Returns git user.name for `cwd`, or null; replaced in tests so no git is needed. */
18
+ readonly runGit?: (cwd: string) => Promise<string | null>;
19
+ readonly osUser?: () => string;
20
+ }
21
+
22
+ export async function resolveAuthor(options: AuthorOptions): Promise<string> {
23
+ const { cwd, env = process.env, runGit = gitUserName, osUser = () => userInfo().username } = options;
24
+ const fromGit = (await runGit(cwd))?.trim();
25
+ if (fromGit) return fromGit;
26
+ const fromEnv = env.HEYDB_AUTHOR?.trim();
27
+ if (fromEnv) return fromEnv;
28
+ try {
29
+ const fromOs = osUser().trim();
30
+ if (fromOs) return fromOs;
31
+ } catch {
32
+ // Some containers have no user entry for the current uid.
33
+ }
34
+ return "unknown";
35
+ }
36
+
37
+ /**
38
+ * `git config user.name` in `cwd`, or null when git is missing, the folder is
39
+ * gone, the name is not set or git takes too long. execFile with an argument
40
+ * list never goes through a shell, so nothing in the path is interpreted.
41
+ */
42
+ export function gitUserName(cwd: string): Promise<string | null> {
43
+ return new Promise((resolve) => {
44
+ execFile(
45
+ "git",
46
+ ["config", "user.name"],
47
+ { cwd, timeout: GIT_TIMEOUT_MS, shell: false, windowsHide: true, encoding: "utf8" },
48
+ (error, stdout) => resolve(error === null ? stdout : null),
49
+ );
50
+ });
51
+ }