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