@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,435 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { incomingCounts } from "../model/references.ts";
5
+ import { displayName, formatName } from "../model/names.ts";
6
+ import type { Snapshot } from "../model/snapshot.ts";
7
+ import { HEYDB_DIR } from "../store/project.ts";
8
+
9
+ // Whether the project next to .heydb/ has code that uses this database, and
10
+ // where. After a sync heydb offers to let the agent read that code and note
11
+ // what it makes explicit (enum values, units, joins without a foreign key).
12
+ // Many projects have none (a heydb project made only to explore a database),
13
+ // and then the notes come from conversations instead.
14
+ //
15
+ // This only finds candidates: a table name in database context, that is SQL
16
+ // (`SELECT … FROM gar`) or an ORM declaration (`protected $table = 'gar'`).
17
+ // A name that is only mentioned (a comment, help text, a variable) does not
18
+ // count: tables called "config" or "para" are ordinary words. It never
19
+ // interprets the code; the agent does that, if the person agrees. The scan runs right after a sync, so it is bounded in files,
20
+ // bytes and time: a huge repository gives a partial answer, never a stuck Pi.
21
+
22
+ export interface ScanLimits {
23
+ /** Source files read at most. */
24
+ readonly maxFiles: number;
25
+ /** Bytes read at most, all files together. */
26
+ readonly maxBytes: number;
27
+ /** Bigger files are skipped (generated code, dumps, minified bundles). */
28
+ readonly maxFileBytes: number;
29
+ /** Wall time budget, measured with the `now` clock. */
30
+ readonly maxMillis: number;
31
+ /** In a huge schema only the most referenced tables are looked for. */
32
+ readonly maxTables: number;
33
+ }
34
+
35
+ export const DEFAULT_SCAN_LIMITS: ScanLimits = {
36
+ maxFiles: 5000,
37
+ maxBytes: 50 * 1024 * 1024,
38
+ maxFileBytes: 512 * 1024,
39
+ // A large legacy codebase (900 Harbour files) needs about 9 s; progress is shown meanwhile.
40
+ maxMillis: 10_000,
41
+ maxTables: 300,
42
+ };
43
+
44
+ /** Tables kept in the result, most used first, and files kept per table. */
45
+ const MAX_MATCHES = 30;
46
+ const MAX_FILES_PER_TABLE = 5;
47
+ /** Two-letter names ("id", "ab") are everywhere in code: they would only add noise. */
48
+ const MIN_NAME_LENGTH = 3;
49
+
50
+ /** Folders that hold someone else's code, build output, CI configuration or heydb's own files. */
51
+ const SKIPPED_FOLDERS = new Set([
52
+ "node_modules",
53
+ "vendor",
54
+ ".git",
55
+ HEYDB_DIR,
56
+ "dist",
57
+ "build",
58
+ "coverage",
59
+ ".github",
60
+ ".gitlab",
61
+ ".circleci",
62
+ ]);
63
+ /** Files that are never database code: lockfiles and CI configuration. Minified bundles (*.min.js) too. */
64
+ const SKIPPED_FILES = new Set([
65
+ "pnpm-lock.yaml",
66
+ "package-lock.json",
67
+ "yarn.lock",
68
+ "composer.lock",
69
+ "Gemfile.lock",
70
+ "poetry.lock",
71
+ "Cargo.lock",
72
+ "go.sum",
73
+ ".gitlab-ci.yml",
74
+ ]);
75
+ const SOURCE_EXTENSIONS = new Set([
76
+ "php",
77
+ "ts",
78
+ "tsx",
79
+ "js",
80
+ "jsx",
81
+ "py",
82
+ "java",
83
+ "cs",
84
+ "go",
85
+ "rb",
86
+ "sql",
87
+ "kt",
88
+ "scala",
89
+ "xml",
90
+ "yml",
91
+ "yaml",
92
+ // Legacy business code, where ERP databases are usually queried: xBase
93
+ // (Harbour, Clipper, FoxPro), Delphi, Visual Basic, COBOL, ABL and RPG.
94
+ "prg",
95
+ "ch",
96
+ "pas",
97
+ "dfm",
98
+ "vb",
99
+ "bas",
100
+ "frm",
101
+ "cls",
102
+ "cbl",
103
+ "cob",
104
+ "p",
105
+ "w",
106
+ "rpgle",
107
+ ]);
108
+
109
+ /** The project's files, with paths relative to its root and "/" separators. */
110
+ export interface CodeFiles {
111
+ /** Entries of a folder ("" is the root). Symbolic links are "other": never followed. */
112
+ list(dir: string): Promise<readonly { name: string; kind: "file" | "dir" | "other" }[]>;
113
+ /** Size in bytes of a regular file; null for anything else or a missing file. */
114
+ size(path: string): Promise<number | null>;
115
+ read(path: string): Promise<string>;
116
+ }
117
+
118
+ export interface ScanOptions {
119
+ /** Replaced in tests; the default reads the real folder. */
120
+ readonly files?: CodeFiles;
121
+ /** Milliseconds; replaced in tests. */
122
+ readonly now?: () => number;
123
+ readonly limits?: Partial<ScanLimits>;
124
+ }
125
+
126
+ export interface TableUsage {
127
+ /** Display name, e.g. "erp.gar"; tables with the same name in several databases are joined with " / ". */
128
+ readonly table: string;
129
+ /** The table name as the schema writes it, e.g. "gar". */
130
+ readonly name: string;
131
+ /** Files that use it most, at most 5. */
132
+ readonly files: readonly string[];
133
+ /** Occurrences in every scanned file. */
134
+ readonly hits: number;
135
+ }
136
+
137
+ export interface SchemaUsage {
138
+ /** Whether any source file was found at all. */
139
+ readonly hasCode: boolean;
140
+ readonly filesScanned: number;
141
+ /** Files that use at least one table. */
142
+ readonly filesMatched: number;
143
+ /** Tables used by at least one file; `matches` keeps the 30 most used. */
144
+ readonly tablesMatched: number;
145
+ readonly matches: readonly TableUsage[];
146
+ /** A limit stopped the scan: more code may use these tables. */
147
+ readonly truncated: boolean;
148
+ }
149
+
150
+ export async function findSchemaUsage(projectRoot: string, snapshot: Snapshot, options: ScanOptions = {}): Promise<SchemaUsage> {
151
+ const limits = { ...DEFAULT_SCAN_LIMITS, ...options.limits };
152
+ const files = options.files ?? realFiles(projectRoot);
153
+ const now = options.now ?? (() => performance.now());
154
+ const deadline = now() + limits.maxMillis;
155
+ const outOfTime = () => now() > deadline;
156
+
157
+ const names = tableNames(snapshot, limits.maxTables);
158
+ const listed = await listSources(files, limits.maxFiles, outOfTime);
159
+ let truncated = listed.truncated;
160
+
161
+ // lowercased table name → file → hits
162
+ const found = new Map<string, Map<string, number>>();
163
+ let filesScanned = 0;
164
+ let filesMatched = 0;
165
+ let bytes = 0;
166
+ for (const path of listed.paths) {
167
+ if (outOfTime()) {
168
+ truncated = true;
169
+ break;
170
+ }
171
+ const size = await files.size(path);
172
+ if (size === null || size > limits.maxFileBytes) continue;
173
+ if (bytes + size > limits.maxBytes) {
174
+ truncated = true;
175
+ break;
176
+ }
177
+ bytes += size;
178
+ let text: string;
179
+ try {
180
+ text = await files.read(path);
181
+ } catch {
182
+ continue; // deleted or unreadable since it was listed: not this scan's business
183
+ }
184
+ filesScanned += 1;
185
+ let matched = false;
186
+ for (const token of tableReferences(text)) {
187
+ const key = token.toLowerCase();
188
+ if (!names.has(key)) continue;
189
+ const perFile = found.get(key) ?? new Map<string, number>();
190
+ found.set(key, perFile);
191
+ perFile.set(path, (perFile.get(path) ?? 0) + 1);
192
+ matched = true;
193
+ }
194
+ if (matched) filesMatched += 1;
195
+ }
196
+
197
+ const matches = [...found].map(([key, perFile]): TableUsage => {
198
+ const ranked = [...perFile].sort((a, b) => b[1] - a[1] || compareText(a[0], b[0]));
199
+ const table = names.get(key) as TableName;
200
+ return {
201
+ table: table.display,
202
+ name: table.name,
203
+ files: ranked.slice(0, MAX_FILES_PER_TABLE).map(([path]) => path),
204
+ hits: ranked.reduce((sum, [, hits]) => sum + hits, 0),
205
+ };
206
+ });
207
+ matches.sort((a, b) => b.hits - a.hits || compareText(a.table, b.table));
208
+ return {
209
+ hasCode: filesScanned > 0,
210
+ filesScanned,
211
+ filesMatched,
212
+ tablesMatched: matches.length,
213
+ matches: matches.slice(0, MAX_MATCHES),
214
+ truncated,
215
+ };
216
+ }
217
+
218
+ // The patterns of database context. Each captures the table name as `name`:
219
+ // a whole identifier (letters, digits, "_"), so "gar" never matches in
220
+ // ref_gar or garage. Keywords are case-insensitive.
221
+
222
+ const ID = "[\\p{L}\\p{N}_]+";
223
+ /** A name as SQL writes it: optionally quoted (` " ' [ ]) and qualified (db.table, db.schema.table). */
224
+ const SQL_NAME = `(?:[\`"'\\[]?${ID}[\`"'\\]]?\\.){0,2}[\`"'\\[]?(?<name>${ID})(?![\\p{L}\\p{N}_])`;
225
+ /**
226
+ * A database prefix built by code in front of the table, as legacy and web
227
+ * code often write it: "FROM "+cEmp+".gar" (Harbour, JS), "FROM " . $db . ".gar"
228
+ * (PHP), `FROM ${db}.gar` (template strings). Bounded, so it stays cheap.
229
+ */
230
+ const BUILT_PREFIX = `(?:["'\`]\\s*(?:\\+|\\.|&|\\|\\|)\\s*[^"'\`\\n]{1,60}?\\s*(?:\\+|\\.|&|\\|\\|)\\s*["'\`]\\.|\\$\\{[^}\\n]{1,60}\\}\\.)`;
231
+ /** A table name after a SQL keyword: plain, quoted or qualified, or behind a built prefix. */
232
+ const SQL_TARGET = `(?:${BUILT_PREFIX})?${SQL_NAME}`;
233
+ /** A quoted string that holds only the name: 'gar', "gar", `gar`. */
234
+ const QUOTED_NAME = `["'\`](?<name>${ID})["'\`]`;
235
+ /** How far back, within one statement (no ";"), the verb that opens it may be. */
236
+ const STATEMENT_CHARS = 2000;
237
+
238
+ /**
239
+ * SQL, anywhere in a file (strings included). Each keyword needs the verb of
240
+ * its statement, so prose like "read it from config" or "update project"
241
+ * does not count:
242
+ * - FROM after SELECT or DELETE (DELETE FROM too)
243
+ * - JOIN after FROM, or after UPDATE when a SET follows
244
+ * - INTO after INSERT, REPLACE or MERGE
245
+ * - UPDATE followed by SET
246
+ * - TABLE after CREATE, ALTER, DROP, TRUNCATE, RENAME or LOCK
247
+ *
248
+ * ORM and mapping declarations:
249
+ * - `table = 'x'`, `$table = 'x'`, `protected $table = "x"`, `__tablename__ = "x"`
250
+ * (SQLAlchemy), `db_table = "x"` (Django), `tableName: "x"` / `tableName = "x"`,
251
+ * Doctrine XML `<entity … table="x">`
252
+ * - `->table('x')`, `DB::table('x')`, `.table("x")`, `->from('x')`, `.from("x")`, `knex("x")`
253
+ * - `@Table(name = "x")`, `@Entity(name = "x")` (JPA), `[Table("x")]` (C#)
254
+ * - `table: x` on its own line (YAML mappings)
255
+ */
256
+ /**
257
+ * A keyword match plus the context it needs. The keyword is searched first and
258
+ * the context is checked only around each match, in a bounded window cut at
259
+ * the statement end (";"). A lookbehind such as (?<=SELECT[^;]{0,2000}?) would
260
+ * be tried at every position of the file: on a real 500 KB Harbour source that
261
+ * meant minutes per file.
262
+ */
263
+ interface ReferenceRule {
264
+ readonly pattern: RegExp;
265
+ /** Must appear in the statement before the match, at most `before.chars` back. */
266
+ readonly before?: { readonly verb: RegExp; readonly chars: number };
267
+ /** Must appear in the statement after the match, at most `after.chars` ahead. */
268
+ readonly after?: { readonly verb: RegExp; readonly chars: number };
269
+ }
270
+
271
+ const REFERENCES: readonly ReferenceRule[] = [
272
+ { pattern: new RegExp(`\\bFROM\\s+${SQL_TARGET}`, "dgiu"), before: { verb: /\b(?:SELECT|DELETE)\b/iu, chars: STATEMENT_CHARS } },
273
+ { pattern: new RegExp(`\\bJOIN\\s+${SQL_TARGET}`, "dgiu"), before: { verb: /\bFROM\b/iu, chars: STATEMENT_CHARS } },
274
+ {
275
+ pattern: new RegExp(`\\bJOIN\\s+${SQL_TARGET}`, "dgiu"),
276
+ before: { verb: /\bUPDATE\b/iu, chars: STATEMENT_CHARS },
277
+ after: { verb: /\bSET\b/iu, chars: 500 },
278
+ },
279
+ { pattern: new RegExp(`\\bINTO\\s+${SQL_TARGET}`, "dgiu"), before: { verb: /\b(?:INSERT|REPLACE|MERGE)\b/iu, chars: 100 } },
280
+ { pattern: new RegExp(`\\bUPDATE\\s+${SQL_TARGET}`, "dgiu"), after: { verb: /\bSET\b/iu, chars: 500 } },
281
+ {
282
+ pattern: new RegExp(`\\bTABLES?\\s+(?:IF\\s+(?:NOT\\s+)?EXISTS\\s+)?${SQL_TARGET}`, "dgiu"),
283
+ before: { verb: /\b(?:CREATE|ALTER|DROP|TRUNCATE|RENAME|LOCK)\b/iu, chars: 40 },
284
+ },
285
+ // Any variable or property whose name says "table" or "tabla": $table, db_table,
286
+ // __tablename__, tableName, cTabla, oQuery:cTabla. `==` comparisons do not count.
287
+ {
288
+ pattern: new RegExp(
289
+ `(?<![\\p{L}\\p{N}_])\\$?[\\p{L}\\p{N}_]*(?:tabla|table)[\\p{L}\\p{N}_]*\\s*(?::=|=(?!=)|:)\\s*${QUOTED_NAME}`,
290
+ "dgiu",
291
+ ),
292
+ },
293
+ { pattern: new RegExp(`(?:->|::|\\.)\\s*(?:table|from)\\s*\\(\\s*${QUOTED_NAME}`, "dgu") },
294
+ { pattern: new RegExp(`\\bknex\\s*\\(\\s*${QUOTED_NAME}`, "dgu") },
295
+ { pattern: new RegExp(`(?:@(?:Table|Entity)\\s*\\([^)]*?\\bname\\s*=\\s*|\\[Table\\s*\\(\\s*)${QUOTED_NAME}`, "dgu") },
296
+ { pattern: new RegExp(`^[ \\t]*table:[ \\t]*(?<name>${ID})[ \\t]*\\r?$`, "dgmu") },
297
+ ];
298
+
299
+ /** The part of the statement before `index`: at most `chars` back, cut after the last ";". */
300
+ function statementBefore(text: string, index: number, chars: number): string {
301
+ const window = text.slice(Math.max(0, index - chars), index);
302
+ return window.slice(window.lastIndexOf(";") + 1);
303
+ }
304
+
305
+ /** The part of the statement after `index`: at most `chars` ahead, cut at the first ";". */
306
+ function statementAfter(text: string, index: number, chars: number): string {
307
+ const window = text.slice(index, index + chars);
308
+ const end = window.indexOf(";");
309
+ return end === -1 ? window : window.slice(0, end);
310
+ }
311
+
312
+ /** Every table name the file uses in database context; one per place, even when two patterns see it. */
313
+ function tableReferences(text: string): string[] {
314
+ const found = new Map<number, string>();
315
+ for (const rule of REFERENCES) {
316
+ for (const match of text.matchAll(rule.pattern)) {
317
+ const start = match.indices?.groups?.name?.[0];
318
+ const name = match.groups?.name;
319
+ if (start === undefined || name === undefined) continue;
320
+ const { before, after } = rule;
321
+ if (before !== undefined && !before.verb.test(statementBefore(text, match.index, before.chars))) continue;
322
+ if (after !== undefined && !after.verb.test(statementAfter(text, match.index + match[0].length, after.chars))) continue;
323
+ found.set(start, name);
324
+ }
325
+ }
326
+ return [...found.values()];
327
+ }
328
+
329
+ interface TableName {
330
+ readonly canonical: string;
331
+ readonly name: string;
332
+ readonly display: string;
333
+ }
334
+
335
+ /** Lowercased table name → how to show it, for the tables worth looking for. */
336
+ function tableNames(snapshot: Snapshot, maxTables: number): Map<string, TableName> {
337
+ const all: { canonical: string; name: string; display: string }[] = [];
338
+ for (const database of snapshot.databases) {
339
+ for (const schema of database.schemas) {
340
+ for (const table of schema.tables) {
341
+ if (table.name.length < MIN_NAME_LENGTH) continue;
342
+ const ref = { database: database.name, schema: schema.name, table: table.name };
343
+ all.push({ canonical: formatName(ref), name: table.name, display: displayName(ref) });
344
+ }
345
+ }
346
+ }
347
+ let kept = all;
348
+ if (all.length > maxTables) {
349
+ // The tables others point to are the core of a legacy schema, and the ones code uses most.
350
+ const counts = incomingCounts(snapshot, { formatVersion: 1, annotations: [], relationships: [] });
351
+ const incoming = (item: (typeof all)[number]) => counts.get(item.canonical) ?? 0;
352
+ kept = [...all].sort((a, b) => incoming(b) - incoming(a) || compareText(a.canonical, b.canonical)).slice(0, maxTables);
353
+ }
354
+ const names = new Map<string, TableName>();
355
+ for (const item of kept) {
356
+ const key = item.name.toLowerCase();
357
+ const previous = names.get(key);
358
+ names.set(key, previous === undefined ? item : { ...previous, display: `${previous.display} / ${item.display}` });
359
+ }
360
+ return names;
361
+ }
362
+
363
+ /**
364
+ * The source files to read, in a stable order. git knows which files belong
365
+ * to the project (and skips what it ignores); without a repository, the
366
+ * folder is walked. Either way the same folders and extensions are skipped,
367
+ * so nothing under .heydb/, its secrets file included, is ever read.
368
+ */
369
+ // The folders are walked directly, never listed through git: the code that
370
+ // uses a database is often a reference copy kept in a folder the project's
371
+ // .gitignore excludes (found on a real ERP: source-code/), and git would hide
372
+ // it. Generated and third-party folders are skipped by name, and the walk is
373
+ // bounded by the file and time limits.
374
+ async function listSources(
375
+ files: CodeFiles,
376
+ maxFiles: number,
377
+ outOfTime: () => boolean,
378
+ ): Promise<{ paths: string[]; truncated: boolean }> {
379
+ const paths: string[] = [];
380
+ const pending = [""];
381
+ while (pending.length > 0) {
382
+ if (outOfTime()) return { paths, truncated: true };
383
+ const dir = pending.shift() as string;
384
+ let entries: Awaited<ReturnType<CodeFiles["list"]>>;
385
+ try {
386
+ entries = await files.list(dir);
387
+ } catch {
388
+ continue; // a folder we may not read
389
+ }
390
+ for (const entry of [...entries].sort((a, b) => compareText(a.name, b.name))) {
391
+ const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
392
+ if (entry.kind === "dir" && !SKIPPED_FOLDERS.has(entry.name)) pending.push(path);
393
+ if (entry.kind !== "file" || !isSource(path)) continue;
394
+ if (paths.length === maxFiles) return { paths, truncated: true };
395
+ paths.push(path);
396
+ }
397
+ }
398
+ return { paths, truncated: false };
399
+ }
400
+
401
+ function isSource(path: string): boolean {
402
+ const segments = path.split("/");
403
+ if (segments.slice(0, -1).some((segment) => SKIPPED_FOLDERS.has(segment))) return false;
404
+ const name = segments.at(-1) ?? "";
405
+ if (SKIPPED_FILES.has(name) || name.toLowerCase().endsWith(".min.js")) return false;
406
+ const dot = name.lastIndexOf(".");
407
+ return dot > 0 && SOURCE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
408
+ }
409
+
410
+ function realFiles(root: string): CodeFiles {
411
+ return {
412
+ async list(dir) {
413
+ const entries = await readdir(join(root, dir), { withFileTypes: true });
414
+ return entries.map((entry) => ({
415
+ name: entry.name,
416
+ kind: entry.isFile() ? "file" : entry.isDirectory() ? "dir" : "other",
417
+ }));
418
+ },
419
+ async size(path) {
420
+ try {
421
+ const stats = await lstat(join(root, path));
422
+ return stats.isFile() ? stats.size : null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ },
427
+ read: (path) => readFile(join(root, path), "utf8"),
428
+ };
429
+ }
430
+
431
+
432
+ // Plain code-unit comparison: the same order on every machine and locale.
433
+ function compareText(a: string, b: string): number {
434
+ return a < b ? -1 : a > b ? 1 : 0;
435
+ }
@@ -0,0 +1,111 @@
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { emptyConfig } from "../config/operations.ts";
5
+ import { stableJson, writeFileAtomic } from "../store/files.ts";
6
+ import { assertNoSymlinks } from "../store/guards.ts";
7
+ import { HEYDB_DIR } from "../store/project.ts";
8
+
9
+ // .heydb/.gitignore keeps the two local-only things out of git, whatever the
10
+ // project's own .gitignore says: the secrets file and the leftovers of an
11
+ // interrupted atomic write (writeFileAtomic names them *.tmp). The .env rule
12
+ // comes last, which is what ensureEnvIgnored checks for.
13
+ const GITIGNORE = [
14
+ "# Written by heydb. Everything else in .heydb/ is meant to be committed.",
15
+ "# Leftovers of an interrupted write.",
16
+ "*.tmp",
17
+ "# .env holds local secrets for env:HEYDB_NAME password references; never commit it.",
18
+ ".env",
19
+ "",
20
+ ].join("\n");
21
+
22
+ export interface InitResult {
23
+ /** Paths created by this call, relative to the project root; empty when everything existed. */
24
+ readonly created: readonly string[];
25
+ }
26
+
27
+ /**
28
+ * Makes `root` a heydb project. Safe to run again: existing files are never
29
+ * overwritten (a team may have edited the .gitignore or added connections),
30
+ * so the only effect of a second run is filling in what is missing.
31
+ */
32
+ export function initProject(root: string): InitResult {
33
+ const files: [name: string, content: string][] = [
34
+ [".gitignore", GITIGNORE],
35
+ ["config.json", stableJson(emptyConfig())],
36
+ ];
37
+ // Every path is checked before anything is written.
38
+ for (const [name] of files) assertNoSymlinks(root, name);
39
+
40
+ const created: string[] = [];
41
+ if (createDirectory(join(root, HEYDB_DIR))) created.push(`${HEYDB_DIR}/`);
42
+ for (const [name, content] of files) {
43
+ if (createFile(join(root, HEYDB_DIR, name), content)) created.push(`${HEYDB_DIR}/${name}`);
44
+ }
45
+ return { created };
46
+ }
47
+
48
+ const ENV_RULE = "# Added by heydb: .env holds local secrets for env:HEYDB_NAME references; never commit it.\n.env\n";
49
+
50
+ /**
51
+ * Makes sure git ignores .heydb/.env before heydb writes a secret there. A
52
+ * team may have replaced the .gitignore heydb wrote, so it is checked every
53
+ * time, and the usual file is written when there is none. Returns whether the
54
+ * file changed.
55
+ *
56
+ * heydb does not try to read git's patterns (leading spaces count, "!*" or
57
+ * "!**\/.env" un-ignore it, and so on). It relies on one rule instead: git
58
+ * applies the last pattern of this file that matches. So the file is left
59
+ * alone only when its last rule is exactly ".env" or "/.env"; otherwise the
60
+ * rule is appended, which makes it the last.
61
+ */
62
+ export function ensureEnvIgnored(root: string): boolean {
63
+ assertNoSymlinks(root, ".gitignore");
64
+ const path = join(root, HEYDB_DIR, ".gitignore");
65
+ let text: string;
66
+ try {
67
+ text = readFileSync(path, "utf8");
68
+ } catch (error) {
69
+ if (!hasCode(error, "ENOENT")) throw error;
70
+ return createFile(path, GITIGNORE);
71
+ }
72
+ // Only trailing spaces are dropped: git drops them too, but a leading space
73
+ // is part of the pattern. Blank lines and comments are not rules.
74
+ const rules = text
75
+ .split(/\r?\n/)
76
+ .map((line) => line.replace(/ +$/, ""))
77
+ .filter((line) => line !== "" && !line.startsWith("#"));
78
+ const last = rules[rules.length - 1];
79
+ if (last === ".env" || last === "/.env") return false;
80
+ const separator = text === "" || text.endsWith("\n") ? "" : "\n";
81
+ writeFileAtomic(path, `${text}${separator}${ENV_RULE}`);
82
+ return true;
83
+ }
84
+
85
+ function createDirectory(path: string): boolean {
86
+ try {
87
+ mkdirSync(path);
88
+ return true;
89
+ } catch (error) {
90
+ if (!hasCode(error, "EEXIST")) throw error;
91
+ if (!statSync(path).isDirectory()) throw new Error(`${HEYDB_DIR} exists but is not a directory`);
92
+ return false;
93
+ }
94
+ }
95
+
96
+ // The "wx" flag creates the file only if it does not exist, in one system
97
+ // call: there is no gap between "check" and "write" in which another process
98
+ // could create it and then be overwritten.
99
+ function createFile(path: string, content: string): boolean {
100
+ try {
101
+ writeFileSync(path, content, { flag: "wx" });
102
+ return true;
103
+ } catch (error) {
104
+ if (hasCode(error, "EEXIST")) return false;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function hasCode(error: unknown, code: string): boolean {
110
+ return error instanceof Error && "code" in error && error.code === code;
111
+ }