@njeybe/nativ 2.0.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { isNoSqlEngine } from './types.js';
|
|
3
|
+
import { databaseNameFromUrl, detectEngine, maskConnectionString, MASK, parseFirestoreUrl, redactMediaUrl, sqlitePathFromUrl, } from './env-parser.js';
|
|
4
|
+
/**
|
|
5
|
+
* Multi-engine schema introspector.
|
|
6
|
+
* Reads structural metadata only (tables, columns, types, nullability, keys, indexes).
|
|
7
|
+
* Never issues data-reading queries against user tables.
|
|
8
|
+
*
|
|
9
|
+
* Drivers are imported lazily so plain CLI commands don't pay for pg/mysql2 load time
|
|
10
|
+
* or trigger node:sqlite's ExperimentalWarning.
|
|
11
|
+
*/
|
|
12
|
+
const CONNECT_TIMEOUT_MS = 8000;
|
|
13
|
+
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
|
14
|
+
class TableBuilder {
|
|
15
|
+
map = new Map();
|
|
16
|
+
get(name) {
|
|
17
|
+
let table = this.map.get(name);
|
|
18
|
+
if (!table) {
|
|
19
|
+
table = { name, columns: [], indexes: [], foreignKeys: [] };
|
|
20
|
+
this.map.set(name, table);
|
|
21
|
+
}
|
|
22
|
+
return table;
|
|
23
|
+
}
|
|
24
|
+
build() {
|
|
25
|
+
return [...this.map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Removes any trace of the raw connection string or password from driver error text. */
|
|
29
|
+
function sanitizeError(err, url) {
|
|
30
|
+
let message = err instanceof Error ? err.message : String(err);
|
|
31
|
+
if (!message)
|
|
32
|
+
message = 'Unknown database error';
|
|
33
|
+
message = message.split(url).join(maskConnectionString(url));
|
|
34
|
+
try {
|
|
35
|
+
const password = new URL(url).password;
|
|
36
|
+
if (password) {
|
|
37
|
+
message = message.split(password).join(MASK);
|
|
38
|
+
const decoded = decodeURIComponent(password);
|
|
39
|
+
if (decoded)
|
|
40
|
+
message = message.split(decoded).join(MASK);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Non-URL (e.g. SQLite path) — nothing further to scrub.
|
|
45
|
+
}
|
|
46
|
+
return message;
|
|
47
|
+
}
|
|
48
|
+
// ─── PostgreSQL ────────────────────────────────────────────────────────────────
|
|
49
|
+
const PG_ON_DELETE = { a: 'NO ACTION', r: 'RESTRICT', c: 'CASCADE', n: 'SET NULL', d: 'SET DEFAULT' };
|
|
50
|
+
function formatPgType(row) {
|
|
51
|
+
const dataType = row.data_type.toLowerCase();
|
|
52
|
+
if (dataType === 'user-defined')
|
|
53
|
+
return row.udt_name.toUpperCase();
|
|
54
|
+
if (dataType === 'array')
|
|
55
|
+
return `${row.udt_name.replace(/^_/, '').toUpperCase()}[]`;
|
|
56
|
+
const aliases = {
|
|
57
|
+
'character varying': 'VARCHAR',
|
|
58
|
+
character: 'CHAR',
|
|
59
|
+
'timestamp without time zone': 'TIMESTAMP',
|
|
60
|
+
'time without time zone': 'TIME',
|
|
61
|
+
};
|
|
62
|
+
const base = aliases[dataType] ?? dataType.toUpperCase();
|
|
63
|
+
if (row.character_maximum_length != null)
|
|
64
|
+
return `${base}(${row.character_maximum_length})`;
|
|
65
|
+
if (dataType === 'numeric' && row.numeric_precision != null) {
|
|
66
|
+
return row.numeric_scale ? `${base}(${row.numeric_precision},${row.numeric_scale})` : `${base}(${row.numeric_precision})`;
|
|
67
|
+
}
|
|
68
|
+
return base;
|
|
69
|
+
}
|
|
70
|
+
async function createPostgresDriver(url) {
|
|
71
|
+
const pg = (await import('pg')).default;
|
|
72
|
+
const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: CONNECT_TIMEOUT_MS, statement_timeout: 15000 });
|
|
73
|
+
await client.connect();
|
|
74
|
+
return {
|
|
75
|
+
async ping() {
|
|
76
|
+
await client.query('SELECT 1');
|
|
77
|
+
},
|
|
78
|
+
async databaseName() {
|
|
79
|
+
const res = await client.query('SELECT current_database() AS db');
|
|
80
|
+
return res.rows[0]?.db ?? '';
|
|
81
|
+
},
|
|
82
|
+
async tables() {
|
|
83
|
+
const builder = new TableBuilder();
|
|
84
|
+
const tables = await client.query(`
|
|
85
|
+
SELECT t.table_name, obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass, 'pg_class') AS description
|
|
86
|
+
FROM information_schema.tables t
|
|
87
|
+
WHERE t.table_schema = current_schema() AND t.table_type = 'BASE TABLE'
|
|
88
|
+
ORDER BY t.table_name`);
|
|
89
|
+
for (const row of tables.rows) {
|
|
90
|
+
const table = builder.get(row.table_name);
|
|
91
|
+
if (row.description)
|
|
92
|
+
table.description = row.description;
|
|
93
|
+
}
|
|
94
|
+
const pks = await client.query(`
|
|
95
|
+
SELECT kcu.table_name, kcu.column_name
|
|
96
|
+
FROM information_schema.table_constraints tc
|
|
97
|
+
JOIN information_schema.key_column_usage kcu
|
|
98
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
99
|
+
WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY'`);
|
|
100
|
+
const pkSet = new Set(pks.rows.map((r) => `${r.table_name}.${r.column_name}`));
|
|
101
|
+
const columns = await client.query(`
|
|
102
|
+
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.character_maximum_length,
|
|
103
|
+
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default
|
|
104
|
+
FROM information_schema.columns c
|
|
105
|
+
JOIN information_schema.tables t
|
|
106
|
+
ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'BASE TABLE'
|
|
107
|
+
WHERE c.table_schema = current_schema()
|
|
108
|
+
ORDER BY c.table_name, c.ordinal_position`);
|
|
109
|
+
for (const row of columns.rows) {
|
|
110
|
+
builder.get(row.table_name).columns.push({
|
|
111
|
+
name: row.column_name,
|
|
112
|
+
type: formatPgType(row),
|
|
113
|
+
primaryKey: pkSet.has(`${row.table_name}.${row.column_name}`),
|
|
114
|
+
nullable: row.is_nullable === 'YES',
|
|
115
|
+
default: row.column_default,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// information_schema has no index view — pg_catalog is the only source.
|
|
119
|
+
const indexes = await client.query(`
|
|
120
|
+
SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique,
|
|
121
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
122
|
+
FROM unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
|
|
123
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS columns
|
|
124
|
+
FROM pg_index ix
|
|
125
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
126
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
127
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
128
|
+
WHERE n.nspname = current_schema() AND NOT ix.indisprimary AND t.relkind IN ('r', 'p')
|
|
129
|
+
ORDER BY t.relname, i.relname`);
|
|
130
|
+
for (const row of indexes.rows) {
|
|
131
|
+
builder.get(row.table_name).indexes.push({ name: row.index_name, columns: row.columns ?? [], unique: row.is_unique });
|
|
132
|
+
}
|
|
133
|
+
const fks = await client.query(`
|
|
134
|
+
SELECT con.conname AS constraint_name, cl.relname AS table_name, a.attname AS column_name,
|
|
135
|
+
fcl.relname AS ref_table, fa.attname AS ref_column, con.confdeltype AS on_delete
|
|
136
|
+
FROM pg_constraint con
|
|
137
|
+
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
138
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
139
|
+
JOIN pg_class fcl ON fcl.oid = con.confrelid
|
|
140
|
+
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(attnum, fattnum)
|
|
141
|
+
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
|
142
|
+
JOIN pg_attribute fa ON fa.attrelid = con.confrelid AND fa.attnum = k.fattnum
|
|
143
|
+
WHERE n.nspname = current_schema() AND con.contype = 'f'
|
|
144
|
+
ORDER BY cl.relname, con.conname`);
|
|
145
|
+
for (const row of fks.rows) {
|
|
146
|
+
builder.get(row.table_name).foreignKeys.push({
|
|
147
|
+
name: row.constraint_name,
|
|
148
|
+
column: row.column_name,
|
|
149
|
+
referencedTable: row.ref_table,
|
|
150
|
+
referencedColumn: row.ref_column,
|
|
151
|
+
onDelete: PG_ON_DELETE[row.on_delete] ?? 'NO ACTION',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return builder.build();
|
|
155
|
+
},
|
|
156
|
+
async close() {
|
|
157
|
+
await client.end().catch(() => undefined);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// ─── MySQL / MariaDB ──────────────────────────────────────────────────────────
|
|
162
|
+
async function createMysqlDriver(url) {
|
|
163
|
+
const mysql = (await import('mysql2/promise')).default;
|
|
164
|
+
const normalizedUrl = url.replace(/^(mysql2|mariadb):\/\//i, 'mysql://');
|
|
165
|
+
const conn = await mysql.createConnection({ uri: normalizedUrl, connectTimeout: CONNECT_TIMEOUT_MS });
|
|
166
|
+
const rows = async (sql) => {
|
|
167
|
+
const [result] = await conn.query(sql);
|
|
168
|
+
return result;
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
async ping() {
|
|
172
|
+
await conn.query('SELECT 1');
|
|
173
|
+
},
|
|
174
|
+
async databaseName() {
|
|
175
|
+
const [row] = await rows('SELECT DATABASE() AS db');
|
|
176
|
+
return row?.db ?? '';
|
|
177
|
+
},
|
|
178
|
+
async tables() {
|
|
179
|
+
const builder = new TableBuilder();
|
|
180
|
+
for (const row of await rows(`
|
|
181
|
+
SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment
|
|
182
|
+
FROM information_schema.TABLES
|
|
183
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
184
|
+
ORDER BY TABLE_NAME`)) {
|
|
185
|
+
const table = builder.get(row.table_name);
|
|
186
|
+
if (row.table_comment)
|
|
187
|
+
table.description = row.table_comment;
|
|
188
|
+
}
|
|
189
|
+
for (const row of await rows(`
|
|
190
|
+
SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.COLUMN_TYPE AS column_type,
|
|
191
|
+
c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY AS column_key
|
|
192
|
+
FROM information_schema.COLUMNS c
|
|
193
|
+
JOIN information_schema.TABLES t
|
|
194
|
+
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE'
|
|
195
|
+
WHERE c.TABLE_SCHEMA = DATABASE()
|
|
196
|
+
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`)) {
|
|
197
|
+
builder.get(row.table_name).columns.push({
|
|
198
|
+
name: row.column_name,
|
|
199
|
+
type: String(row.column_type).toUpperCase(),
|
|
200
|
+
primaryKey: row.column_key === 'PRI',
|
|
201
|
+
nullable: row.is_nullable === 'YES',
|
|
202
|
+
default: row.column_default == null ? null : String(row.column_default),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const indexMap = new Map();
|
|
206
|
+
for (const row of await rows(`
|
|
207
|
+
SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name, NON_UNIQUE AS non_unique, COLUMN_NAME AS column_name
|
|
208
|
+
FROM information_schema.STATISTICS
|
|
209
|
+
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME <> 'PRIMARY'
|
|
210
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`)) {
|
|
211
|
+
const key = `${row.table_name}.${row.index_name}`;
|
|
212
|
+
let entry = indexMap.get(key);
|
|
213
|
+
if (!entry) {
|
|
214
|
+
entry = { table: row.table_name, index: { name: row.index_name, columns: [], unique: Number(row.non_unique) === 0 } };
|
|
215
|
+
indexMap.set(key, entry);
|
|
216
|
+
}
|
|
217
|
+
if (row.column_name)
|
|
218
|
+
entry.index.columns.push(row.column_name);
|
|
219
|
+
}
|
|
220
|
+
for (const { table, index } of indexMap.values())
|
|
221
|
+
builder.get(table).indexes.push(index);
|
|
222
|
+
for (const row of await rows(`
|
|
223
|
+
SELECT k.CONSTRAINT_NAME AS constraint_name, k.TABLE_NAME AS table_name, k.COLUMN_NAME AS column_name,
|
|
224
|
+
k.REFERENCED_TABLE_NAME AS ref_table, k.REFERENCED_COLUMN_NAME AS ref_column, r.DELETE_RULE AS delete_rule
|
|
225
|
+
FROM information_schema.KEY_COLUMN_USAGE k
|
|
226
|
+
JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
|
227
|
+
ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
|
|
228
|
+
WHERE k.TABLE_SCHEMA = DATABASE() AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
229
|
+
ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION`)) {
|
|
230
|
+
builder.get(row.table_name).foreignKeys.push({
|
|
231
|
+
name: row.constraint_name,
|
|
232
|
+
column: row.column_name,
|
|
233
|
+
referencedTable: row.ref_table,
|
|
234
|
+
referencedColumn: row.ref_column,
|
|
235
|
+
onDelete: row.delete_rule,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return builder.build();
|
|
239
|
+
},
|
|
240
|
+
async close() {
|
|
241
|
+
await conn.end().catch(() => undefined);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// ─── SQLite (node:sqlite) ─────────────────────────────────────────────────────
|
|
246
|
+
function quoteIdent(name) {
|
|
247
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
248
|
+
}
|
|
249
|
+
async function createSqliteDriver(url) {
|
|
250
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
251
|
+
const filePath = sqlitePathFromUrl(url);
|
|
252
|
+
// readOnly: never create a database file or mutate one while introspecting.
|
|
253
|
+
const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath, { readOnly: true });
|
|
254
|
+
return {
|
|
255
|
+
async ping() {
|
|
256
|
+
db.prepare('SELECT 1').get();
|
|
257
|
+
},
|
|
258
|
+
async databaseName() {
|
|
259
|
+
return databaseNameFromUrl(url);
|
|
260
|
+
},
|
|
261
|
+
async tables() {
|
|
262
|
+
const builder = new TableBuilder();
|
|
263
|
+
const tableRows = db
|
|
264
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
265
|
+
.all();
|
|
266
|
+
for (const { name } of tableRows) {
|
|
267
|
+
const table = builder.get(name);
|
|
268
|
+
const ident = quoteIdent(name);
|
|
269
|
+
const cols = db.prepare(`PRAGMA table_info(${ident})`).all();
|
|
270
|
+
table.columns = cols.map((c) => ({
|
|
271
|
+
name: c.name,
|
|
272
|
+
type: (c.type || 'ANY').toUpperCase(),
|
|
273
|
+
primaryKey: Number(c.pk) > 0,
|
|
274
|
+
// SQLite allows NULL in non-INTEGER primary keys unless NOT NULL is declared; report declared constraint.
|
|
275
|
+
nullable: Number(c.notnull) === 0 && Number(c.pk) === 0,
|
|
276
|
+
default: c.dflt_value ?? null,
|
|
277
|
+
}));
|
|
278
|
+
const idxList = db.prepare(`PRAGMA index_list(${ident})`).all();
|
|
279
|
+
for (const idx of idxList) {
|
|
280
|
+
if (idx.origin === 'pk')
|
|
281
|
+
continue;
|
|
282
|
+
const idxCols = db.prepare(`PRAGMA index_info(${quoteIdent(idx.name)})`).all();
|
|
283
|
+
table.indexes.push({
|
|
284
|
+
name: idx.name,
|
|
285
|
+
columns: idxCols.sort((a, b) => Number(a.seqno) - Number(b.seqno)).map((c) => c.name ?? '').filter(Boolean),
|
|
286
|
+
unique: Number(idx.unique) === 1,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const fkList = db.prepare(`PRAGMA foreign_key_list(${ident})`).all();
|
|
290
|
+
table.foreignKeys = fkList.map((fk) => ({
|
|
291
|
+
// SQLite doesn't retain FK constraint names; synthesize a stable one.
|
|
292
|
+
name: `fk_${name}_${fk.from}`,
|
|
293
|
+
column: fk.from,
|
|
294
|
+
referencedTable: fk.table,
|
|
295
|
+
referencedColumn: fk.to ?? 'rowid',
|
|
296
|
+
onDelete: fk.on_delete,
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
return builder.build();
|
|
300
|
+
},
|
|
301
|
+
async close() {
|
|
302
|
+
db.close();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// ─── Media / image detection ──────────────────────────────────────────────────
|
|
307
|
+
const IMAGE_EXT_MIME = {
|
|
308
|
+
png: 'image/png',
|
|
309
|
+
jpg: 'image/jpeg',
|
|
310
|
+
jpeg: 'image/jpeg',
|
|
311
|
+
gif: 'image/gif',
|
|
312
|
+
webp: 'image/webp',
|
|
313
|
+
avif: 'image/avif',
|
|
314
|
+
svg: 'image/svg+xml',
|
|
315
|
+
bmp: 'image/bmp',
|
|
316
|
+
ico: 'image/x-icon',
|
|
317
|
+
heic: 'image/heic',
|
|
318
|
+
tif: 'image/tiff',
|
|
319
|
+
tiff: 'image/tiff',
|
|
320
|
+
};
|
|
321
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|svg|bmp|ico|heic|tiff?)$/i;
|
|
322
|
+
const IMAGE_FIELD_HINT = /avatar|photo|image|img|picture|pic|thumbnail|thumb|logo|icon|banner|cover|selfie|portrait/i;
|
|
323
|
+
const IMAGE_HOSTS = /(^|\.)(gravatar\.com|images\.unsplash\.com|res\.cloudinary\.com|imgix\.net|i\.imgur\.com)$/i;
|
|
324
|
+
/** Only the first bytes are needed to sniff format and dimensions. */
|
|
325
|
+
const SNIFF_BYTES = 64 * 1024;
|
|
326
|
+
/** Identifies an image from its magic bytes and reads dimensions from the header (PNG/GIF/JPEG/WebP/BMP). */
|
|
327
|
+
function sniffImage(bytes) {
|
|
328
|
+
const b = bytes;
|
|
329
|
+
const len = b.length;
|
|
330
|
+
const be16 = (i) => (b[i] << 8) | b[i + 1];
|
|
331
|
+
const le16 = (i) => b[i] | (b[i + 1] << 8);
|
|
332
|
+
const be32 = (i) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3];
|
|
333
|
+
const le32 = (i) => b[i] + (b[i + 1] << 8) + (b[i + 2] << 16) + ((b[i + 3] << 24) >>> 0);
|
|
334
|
+
const ascii = (i, n) => String.fromCharCode(...b.subarray(i, i + n));
|
|
335
|
+
if (len >= 8 && b[0] === 0x89 && ascii(1, 3) === 'PNG') {
|
|
336
|
+
return { mimeType: 'image/png', width: len >= 24 ? be32(16) : null, height: len >= 24 ? be32(20) : null };
|
|
337
|
+
}
|
|
338
|
+
if (len >= 6 && ascii(0, 4) === 'GIF8') {
|
|
339
|
+
return { mimeType: 'image/gif', width: len >= 10 ? le16(6) : null, height: len >= 10 ? le16(8) : null };
|
|
340
|
+
}
|
|
341
|
+
if (len >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
342
|
+
let i = 2;
|
|
343
|
+
while (i + 9 < len) {
|
|
344
|
+
if (b[i] !== 0xff) {
|
|
345
|
+
i++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const marker = b[i + 1];
|
|
349
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
350
|
+
return { mimeType: 'image/jpeg', width: be16(i + 7), height: be16(i + 5) };
|
|
351
|
+
}
|
|
352
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
353
|
+
i += 2;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
i += 2 + be16(i + 2);
|
|
357
|
+
}
|
|
358
|
+
return { mimeType: 'image/jpeg', width: null, height: null };
|
|
359
|
+
}
|
|
360
|
+
if (len >= 16 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
|
|
361
|
+
const chunk = ascii(12, 4);
|
|
362
|
+
if (chunk === 'VP8 ' && len >= 30)
|
|
363
|
+
return { mimeType: 'image/webp', width: le16(26) & 0x3fff, height: le16(28) & 0x3fff };
|
|
364
|
+
if (chunk === 'VP8L' && len >= 25) {
|
|
365
|
+
return {
|
|
366
|
+
mimeType: 'image/webp',
|
|
367
|
+
width: 1 + (((b[22] & 0x3f) << 8) | b[21]),
|
|
368
|
+
height: 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (chunk === 'VP8X' && len >= 30) {
|
|
372
|
+
return { mimeType: 'image/webp', width: 1 + (b[24] | (b[25] << 8) | (b[26] << 16)), height: 1 + (b[27] | (b[28] << 8) | (b[29] << 16)) };
|
|
373
|
+
}
|
|
374
|
+
return { mimeType: 'image/webp', width: null, height: null };
|
|
375
|
+
}
|
|
376
|
+
if (len >= 26 && ascii(0, 2) === 'BM')
|
|
377
|
+
return { mimeType: 'image/bmp', width: le32(18), height: Math.abs(le32(22) | 0) };
|
|
378
|
+
if (len >= 4 && b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0)
|
|
379
|
+
return { mimeType: 'image/x-icon', width: null, height: null };
|
|
380
|
+
const head = ascii(0, Math.min(len, 256)).trimStart();
|
|
381
|
+
if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg')))
|
|
382
|
+
return { mimeType: 'image/svg+xml', width: null, height: null };
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
function mimeFromPath(pathname) {
|
|
386
|
+
const match = pathname.match(IMAGE_EXT);
|
|
387
|
+
return match ? IMAGE_EXT_MIME[match[1].toLowerCase()] ?? null : null;
|
|
388
|
+
}
|
|
389
|
+
function bytesMedia(bytes) {
|
|
390
|
+
const sniff = sniffImage(bytes.subarray(0, SNIFF_BYTES));
|
|
391
|
+
if (!sniff)
|
|
392
|
+
return null;
|
|
393
|
+
return { source: 'bytes', mimeType: sniff.mimeType, sizeBytes: bytes.length, width: sniff.width, height: sniff.height, sampleUrl: null, base64Head: null };
|
|
394
|
+
}
|
|
395
|
+
/** Decodes only a prefix of a base64 payload: enough to sniff, never the whole image. */
|
|
396
|
+
function base64Prefix(payload) {
|
|
397
|
+
const chunk = payload.slice(0, Math.ceil((SNIFF_BYTES * 4) / 3 / 4) * 4);
|
|
398
|
+
return new Uint8Array(Buffer.from(chunk, 'base64'));
|
|
399
|
+
}
|
|
400
|
+
function base64Size(payload) {
|
|
401
|
+
const clean = payload.replace(/\s/g, '');
|
|
402
|
+
const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
|
|
403
|
+
return Math.max(0, Math.floor((clean.length * 3) / 4) - padding);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Classifies a sampled value as an image when it is image Bytes, a Base64 image,
|
|
407
|
+
* an image URL, or a gs:// Cloud Storage URI. Returns metadata only.
|
|
408
|
+
*/
|
|
409
|
+
function detectMedia(fieldName, value) {
|
|
410
|
+
if (value instanceof Uint8Array)
|
|
411
|
+
return bytesMedia(value);
|
|
412
|
+
const bson = value;
|
|
413
|
+
if (bson && typeof bson === 'object' && bson._bsontype === 'Binary' && bson.sub_type !== 4 && bson.buffer instanceof Uint8Array) {
|
|
414
|
+
return bytesMedia(bson.buffer);
|
|
415
|
+
}
|
|
416
|
+
if (typeof value !== 'string' || value.length < 5)
|
|
417
|
+
return null;
|
|
418
|
+
const s = value.trim();
|
|
419
|
+
const nameHint = IMAGE_FIELD_HINT.test(fieldName);
|
|
420
|
+
const dataUri = s.match(/^data:(image\/[a-z0-9.+-]+);base64,/i);
|
|
421
|
+
if (dataUri) {
|
|
422
|
+
const payload = s.slice(dataUri[0].length);
|
|
423
|
+
const sniff = sniffImage(base64Prefix(payload));
|
|
424
|
+
return {
|
|
425
|
+
source: 'base64',
|
|
426
|
+
mimeType: sniff?.mimeType ?? dataUri[1].toLowerCase(),
|
|
427
|
+
sizeBytes: base64Size(payload),
|
|
428
|
+
width: sniff?.width ?? null,
|
|
429
|
+
height: sniff?.height ?? null,
|
|
430
|
+
sampleUrl: null,
|
|
431
|
+
base64Head: `${s.slice(0, dataUri[0].length + 16)}…`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (s.length >= 64 && /^[A-Za-z0-9+/]+={0,2}$/.test(s.slice(0, 4096).replace(/\s/g, ''))) {
|
|
435
|
+
const sniff = sniffImage(base64Prefix(s));
|
|
436
|
+
if (sniff) {
|
|
437
|
+
return { source: 'base64', mimeType: sniff.mimeType, sizeBytes: base64Size(s), width: sniff.width, height: sniff.height, sampleUrl: null, base64Head: `${s.slice(0, 24)}…` };
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
if (/^gs:\/\//i.test(s)) {
|
|
442
|
+
const mime = mimeFromPath(s.split(/[?#]/)[0]);
|
|
443
|
+
if (!mime && !nameHint)
|
|
444
|
+
return null;
|
|
445
|
+
return { source: 'gcs', mimeType: mime, sizeBytes: null, width: null, height: null, sampleUrl: s, base64Head: null };
|
|
446
|
+
}
|
|
447
|
+
if (/^https?:\/\//i.test(s)) {
|
|
448
|
+
let parsed;
|
|
449
|
+
try {
|
|
450
|
+
parsed = new URL(s);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
let pathname = parsed.pathname;
|
|
456
|
+
try {
|
|
457
|
+
// Firebase Storage encodes object paths: /o/avatars%2Fu1.png
|
|
458
|
+
pathname = decodeURIComponent(pathname);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// keep raw pathname
|
|
462
|
+
}
|
|
463
|
+
const mime = mimeFromPath(pathname);
|
|
464
|
+
if (!mime && !nameHint && !IMAGE_HOSTS.test(parsed.hostname))
|
|
465
|
+
return null;
|
|
466
|
+
return { source: 'url', mimeType: mime, sizeBytes: null, width: null, height: null, sampleUrl: redactMediaUrl(s), base64Head: null };
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
// ─── NoSQL document field inference ───────────────────────────────────────────
|
|
471
|
+
/** Max documents read per collection, purely to infer field types (see .ai/context.md guardrails). */
|
|
472
|
+
export const NOSQL_SAMPLE_LIMIT = 50;
|
|
473
|
+
const SUBCOLLECTION_PROBE_DOCS = 10;
|
|
474
|
+
class FieldCollector {
|
|
475
|
+
typeOf;
|
|
476
|
+
fields = new Map();
|
|
477
|
+
docs = 0;
|
|
478
|
+
constructor(typeOf) {
|
|
479
|
+
this.typeOf = typeOf;
|
|
480
|
+
}
|
|
481
|
+
get sampled() {
|
|
482
|
+
return this.docs;
|
|
483
|
+
}
|
|
484
|
+
observe(doc, refTarget) {
|
|
485
|
+
this.docs++;
|
|
486
|
+
for (const [name, value] of Object.entries(doc)) {
|
|
487
|
+
let stats = this.fields.get(name);
|
|
488
|
+
if (!stats) {
|
|
489
|
+
stats = { count: 0, nullCount: 0, types: new Map(), mediaHits: 0, media: null, refTargets: new Map() };
|
|
490
|
+
this.fields.set(name, stats);
|
|
491
|
+
}
|
|
492
|
+
stats.count++;
|
|
493
|
+
const type = this.typeOf(value);
|
|
494
|
+
if (type === 'NULL') {
|
|
495
|
+
stats.nullCount++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
stats.types.set(type, (stats.types.get(type) ?? 0) + 1);
|
|
499
|
+
const media = detectMedia(name, value);
|
|
500
|
+
if (media) {
|
|
501
|
+
stats.mediaHits++;
|
|
502
|
+
// Prefer a sample that can actually be previewed.
|
|
503
|
+
if (!stats.media || (!stats.media.sampleUrl && media.sampleUrl))
|
|
504
|
+
stats.media = media;
|
|
505
|
+
}
|
|
506
|
+
const target = refTarget?.(value);
|
|
507
|
+
if (target)
|
|
508
|
+
stats.refTargets.set(target, (stats.refTargets.get(target) ?? 0) + 1);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
build(primaryKey, primaryKeyType) {
|
|
512
|
+
const columns = [];
|
|
513
|
+
const references = [];
|
|
514
|
+
const docs = Math.max(this.docs, 1);
|
|
515
|
+
for (const [name, stats] of this.fields) {
|
|
516
|
+
const nonNull = stats.count - stats.nullCount;
|
|
517
|
+
const observed = [...stats.types.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t);
|
|
518
|
+
let type = observed.length === 0 ? 'NULL' : observed.length === 1 ? observed[0] : 'MIXED';
|
|
519
|
+
const isMedia = nonNull > 0 && stats.mediaHits >= Math.max(1, Math.ceil(nonNull * 0.5));
|
|
520
|
+
if (isMedia && stats.media && (stats.media.source === 'url' || stats.media.source === 'gcs') && type === 'STRING')
|
|
521
|
+
type = 'IMAGE_URL';
|
|
522
|
+
const presence = Math.round((stats.count / docs) * 100) / 100;
|
|
523
|
+
const column = {
|
|
524
|
+
name,
|
|
525
|
+
type,
|
|
526
|
+
primaryKey: false,
|
|
527
|
+
nullable: stats.nullCount > 0 || stats.count < this.docs,
|
|
528
|
+
default: null,
|
|
529
|
+
presence,
|
|
530
|
+
};
|
|
531
|
+
if (observed.length > 1)
|
|
532
|
+
column.observedTypes = observed;
|
|
533
|
+
if (isMedia && stats.media) {
|
|
534
|
+
column.isMedia = true;
|
|
535
|
+
column.media = stats.media;
|
|
536
|
+
}
|
|
537
|
+
columns.push(column);
|
|
538
|
+
const [topRef] = [...stats.refTargets.entries()].sort((a, b) => b[1] - a[1]);
|
|
539
|
+
if (topRef)
|
|
540
|
+
references.push({ column: name, table: topRef[0] });
|
|
541
|
+
}
|
|
542
|
+
let pk = columns.find((c) => c.name === primaryKey);
|
|
543
|
+
if (!pk) {
|
|
544
|
+
pk = { name: primaryKey, type: primaryKeyType ?? 'STRING', primaryKey: true, nullable: false, default: null, presence: 1 };
|
|
545
|
+
columns.unshift(pk);
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
columns.splice(columns.indexOf(pk), 1);
|
|
549
|
+
columns.unshift(pk);
|
|
550
|
+
}
|
|
551
|
+
pk.primaryKey = true;
|
|
552
|
+
pk.nullable = false;
|
|
553
|
+
if (primaryKeyType)
|
|
554
|
+
pk.type = primaryKeyType;
|
|
555
|
+
return { columns, references };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function mongoType(value) {
|
|
559
|
+
if (value === null || value === undefined)
|
|
560
|
+
return 'NULL';
|
|
561
|
+
switch (typeof value) {
|
|
562
|
+
case 'string':
|
|
563
|
+
return 'STRING';
|
|
564
|
+
case 'number':
|
|
565
|
+
case 'bigint':
|
|
566
|
+
return 'NUMBER';
|
|
567
|
+
case 'boolean':
|
|
568
|
+
return 'BOOLEAN';
|
|
569
|
+
}
|
|
570
|
+
if (value instanceof Date)
|
|
571
|
+
return 'TIMESTAMP';
|
|
572
|
+
if (Array.isArray(value))
|
|
573
|
+
return 'ARRAY';
|
|
574
|
+
if (value instanceof RegExp)
|
|
575
|
+
return 'REGEX';
|
|
576
|
+
const bson = value._bsontype;
|
|
577
|
+
if (bson) {
|
|
578
|
+
if (bson === 'ObjectId' || bson === 'ObjectID')
|
|
579
|
+
return 'OBJECTID';
|
|
580
|
+
if (bson === 'Binary')
|
|
581
|
+
return value.sub_type === 4 ? 'UUID' : 'BYTES';
|
|
582
|
+
if (['Decimal128', 'Long', 'Int32', 'Double'].includes(bson))
|
|
583
|
+
return 'NUMBER';
|
|
584
|
+
if (bson === 'Timestamp')
|
|
585
|
+
return 'TIMESTAMP';
|
|
586
|
+
if (bson === 'BSONRegExp')
|
|
587
|
+
return 'REGEX';
|
|
588
|
+
return bson.toUpperCase();
|
|
589
|
+
}
|
|
590
|
+
return 'MAP';
|
|
591
|
+
}
|
|
592
|
+
function firestoreType(value) {
|
|
593
|
+
if (value === null || value === undefined)
|
|
594
|
+
return 'NULL';
|
|
595
|
+
switch (typeof value) {
|
|
596
|
+
case 'string':
|
|
597
|
+
return 'STRING';
|
|
598
|
+
case 'number':
|
|
599
|
+
case 'bigint':
|
|
600
|
+
return 'NUMBER';
|
|
601
|
+
case 'boolean':
|
|
602
|
+
return 'BOOLEAN';
|
|
603
|
+
}
|
|
604
|
+
if (value instanceof Uint8Array)
|
|
605
|
+
return 'BYTES';
|
|
606
|
+
if (Array.isArray(value))
|
|
607
|
+
return 'ARRAY';
|
|
608
|
+
switch (value.constructor?.name) {
|
|
609
|
+
case 'Timestamp':
|
|
610
|
+
return 'TIMESTAMP';
|
|
611
|
+
case 'GeoPoint':
|
|
612
|
+
return 'GEOPOINT';
|
|
613
|
+
case 'DocumentReference':
|
|
614
|
+
return 'REFERENCE';
|
|
615
|
+
case 'VectorValue':
|
|
616
|
+
return 'VECTOR';
|
|
617
|
+
}
|
|
618
|
+
return 'MAP';
|
|
619
|
+
}
|
|
620
|
+
// ─── MongoDB ───────────────────────────────────────────────────────────────────
|
|
621
|
+
async function createMongoDriver(url) {
|
|
622
|
+
const { MongoClient } = await import('mongodb');
|
|
623
|
+
const client = new MongoClient(url, {
|
|
624
|
+
serverSelectionTimeoutMS: CONNECT_TIMEOUT_MS,
|
|
625
|
+
connectTimeoutMS: CONNECT_TIMEOUT_MS,
|
|
626
|
+
appName: 'nativ-studio',
|
|
627
|
+
});
|
|
628
|
+
await client.connect();
|
|
629
|
+
const db = client.db();
|
|
630
|
+
return {
|
|
631
|
+
async ping() {
|
|
632
|
+
await db.command({ ping: 1 });
|
|
633
|
+
},
|
|
634
|
+
async databaseName() {
|
|
635
|
+
return db.databaseName;
|
|
636
|
+
},
|
|
637
|
+
async tables() {
|
|
638
|
+
const infos = await db.listCollections({}, { nameOnly: false }).toArray();
|
|
639
|
+
const names = infos
|
|
640
|
+
.filter((c) => (c.type === 'collection' || c.type === 'timeseries' || !c.type) && !c.name.startsWith('system.'))
|
|
641
|
+
.map((c) => c.name)
|
|
642
|
+
.sort((a, b) => a.localeCompare(b));
|
|
643
|
+
return Promise.all(names.map(async (name) => {
|
|
644
|
+
const coll = db.collection(name);
|
|
645
|
+
const [documentCount, docs, indexInfo] = await Promise.all([
|
|
646
|
+
coll.estimatedDocumentCount().catch(() => undefined),
|
|
647
|
+
// Bounded sample for type inference only — never a full scan or dump.
|
|
648
|
+
coll.find({}, { limit: NOSQL_SAMPLE_LIMIT, maxTimeMS: 10000 }).toArray(),
|
|
649
|
+
coll.indexes().catch(() => []),
|
|
650
|
+
]);
|
|
651
|
+
const collector = new FieldCollector(mongoType);
|
|
652
|
+
for (const doc of docs)
|
|
653
|
+
collector.observe(doc);
|
|
654
|
+
const { columns } = collector.build('_id', docs.length ? undefined : 'OBJECTID');
|
|
655
|
+
return {
|
|
656
|
+
name,
|
|
657
|
+
entityType: 'collection',
|
|
658
|
+
columns,
|
|
659
|
+
indexes: indexInfo
|
|
660
|
+
.filter((i) => i.name && i.name !== '_id_')
|
|
661
|
+
.map((i) => ({ name: i.name, columns: Object.keys(i.key), unique: Boolean(i.unique) })),
|
|
662
|
+
foreignKeys: [],
|
|
663
|
+
...(documentCount !== undefined ? { documentCount } : {}),
|
|
664
|
+
sampledDocuments: collector.sampled,
|
|
665
|
+
};
|
|
666
|
+
}));
|
|
667
|
+
},
|
|
668
|
+
async close() {
|
|
669
|
+
await client.close().catch(() => undefined);
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
// ─── Firebase Firestore ───────────────────────────────────────────────────────
|
|
674
|
+
async function createFirestoreDriver(url) {
|
|
675
|
+
const config = parseFirestoreUrl(url);
|
|
676
|
+
const { Firestore } = await import('@google-cloud/firestore');
|
|
677
|
+
// Emulator: plain-text gRPC with the emulator's owner token. Real projects: Application
|
|
678
|
+
// Default Credentials resolved by the SDK — nativ never reads or stores key files.
|
|
679
|
+
const firestore = new Firestore({
|
|
680
|
+
projectId: config.projectId,
|
|
681
|
+
...(config.databaseId ? { databaseId: config.databaseId } : {}),
|
|
682
|
+
...(config.emulatorHost ? { host: config.emulatorHost, ssl: false } : {}),
|
|
683
|
+
});
|
|
684
|
+
return {
|
|
685
|
+
async ping() {
|
|
686
|
+
await firestore.listCollections();
|
|
687
|
+
},
|
|
688
|
+
async databaseName() {
|
|
689
|
+
return databaseNameFromUrl(url);
|
|
690
|
+
},
|
|
691
|
+
async tables() {
|
|
692
|
+
const collections = await firestore.listCollections();
|
|
693
|
+
const tables = await Promise.all(collections.map(async (col) => {
|
|
694
|
+
const [snapshot, countSnap] = await Promise.all([
|
|
695
|
+
// Bounded sample for type inference only.
|
|
696
|
+
col.limit(NOSQL_SAMPLE_LIMIT).get(),
|
|
697
|
+
col.count().get().catch(() => null),
|
|
698
|
+
]);
|
|
699
|
+
const collector = new FieldCollector(firestoreType);
|
|
700
|
+
const refTarget = (value) => firestoreType(value) === 'REFERENCE' ? (value.parent?.id ?? null) : null;
|
|
701
|
+
for (const doc of snapshot.docs)
|
|
702
|
+
collector.observe({ id: doc.id, ...doc.data() }, refTarget);
|
|
703
|
+
const { columns, references } = collector.build('id', 'DOCUMENT_ID');
|
|
704
|
+
// Probe the first few documents for nested subcollections.
|
|
705
|
+
const probe = snapshot.docs.slice(0, SUBCOLLECTION_PROBE_DOCS);
|
|
706
|
+
const subCounts = new Map();
|
|
707
|
+
const subLists = await Promise.all(probe.map((d) => d.ref.listCollections().catch(() => [])));
|
|
708
|
+
for (const list of subLists)
|
|
709
|
+
for (const sub of list)
|
|
710
|
+
subCounts.set(sub.id, (subCounts.get(sub.id) ?? 0) + 1);
|
|
711
|
+
for (const [subName, count] of [...subCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
712
|
+
columns.push({
|
|
713
|
+
name: subName,
|
|
714
|
+
type: 'SUBCOLLECTION',
|
|
715
|
+
primaryKey: false,
|
|
716
|
+
nullable: count < probe.length,
|
|
717
|
+
default: null,
|
|
718
|
+
isSubcollection: true,
|
|
719
|
+
presence: Math.round((count / Math.max(probe.length, 1)) * 100) / 100,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
const documentCount = countSnap?.data().count;
|
|
723
|
+
return {
|
|
724
|
+
name: col.id,
|
|
725
|
+
entityType: 'collection',
|
|
726
|
+
columns,
|
|
727
|
+
// Composite indexes are only visible through the Firestore Admin API, not the data SDK.
|
|
728
|
+
indexes: [],
|
|
729
|
+
foreignKeys: references.map((r) => ({
|
|
730
|
+
name: `ref_${col.id}_${r.column}`,
|
|
731
|
+
column: r.column,
|
|
732
|
+
referencedTable: r.table,
|
|
733
|
+
referencedColumn: 'id',
|
|
734
|
+
})),
|
|
735
|
+
...(typeof documentCount === 'number' ? { documentCount } : {}),
|
|
736
|
+
sampledDocuments: collector.sampled,
|
|
737
|
+
};
|
|
738
|
+
}));
|
|
739
|
+
return tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
740
|
+
},
|
|
741
|
+
async close() {
|
|
742
|
+
await firestore.terminate().catch(() => undefined);
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
// ─── Public API ────────────────────────────────────────────────────────────────
|
|
747
|
+
const PING_TIMEOUT_MS = 12000;
|
|
748
|
+
const INTROSPECT_TIMEOUT_MS = 60000;
|
|
749
|
+
/** Bounds SDK calls that retry internally for a long time (gRPC backoff) when a host is unreachable. */
|
|
750
|
+
function withTimeout(promise, ms, what) {
|
|
751
|
+
let timer;
|
|
752
|
+
const timeout = new Promise((_, reject) => {
|
|
753
|
+
timer = setTimeout(() => reject(new Error(`${what} timed out after ${Math.round(ms / 1000)}s`)), ms);
|
|
754
|
+
});
|
|
755
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
756
|
+
}
|
|
757
|
+
async function openDriver(url, engine) {
|
|
758
|
+
switch (engine) {
|
|
759
|
+
case 'postgresql':
|
|
760
|
+
return createPostgresDriver(url);
|
|
761
|
+
case 'mysql':
|
|
762
|
+
return createMysqlDriver(url);
|
|
763
|
+
case 'sqlite':
|
|
764
|
+
return createSqliteDriver(url);
|
|
765
|
+
case 'mongodb':
|
|
766
|
+
return createMongoDriver(url);
|
|
767
|
+
case 'firestore':
|
|
768
|
+
return createFirestoreDriver(url);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/** Status for an environment with no usable connection configured. */
|
|
772
|
+
export function disconnectedStatus(error, engine = 'postgresql', url = '', meta) {
|
|
773
|
+
return {
|
|
774
|
+
connected: false,
|
|
775
|
+
engine,
|
|
776
|
+
database: url ? databaseNameFromUrl(url) : '',
|
|
777
|
+
maskedUrl: maskConnectionString(url),
|
|
778
|
+
pingMs: 0,
|
|
779
|
+
entityCount: 0,
|
|
780
|
+
entityType: isNoSqlEngine(engine) ? 'collection' : 'table',
|
|
781
|
+
tableCount: 0,
|
|
782
|
+
error,
|
|
783
|
+
...(meta?.sourceKey ? { sourceKey: meta.sourceKey } : {}),
|
|
784
|
+
...(meta?.detectedFromExample !== undefined ? { detectedFromExample: meta.detectedFromExample } : {}),
|
|
785
|
+
...(meta?.exampleFile ? { exampleFile: meta.exampleFile } : {}),
|
|
786
|
+
...(meta?.suggestion ? { suggestion: meta.suggestion } : {}),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Connects, measures ping latency, and introspects the full table structure.
|
|
791
|
+
* Never throws: failures are reported through `status.error` with credentials scrubbed.
|
|
792
|
+
*/
|
|
793
|
+
export async function introspectDatabase(url) {
|
|
794
|
+
const engine = detectEngine(url);
|
|
795
|
+
if (!engine) {
|
|
796
|
+
return {
|
|
797
|
+
status: disconnectedStatus('Unrecognized connection URL scheme (expected postgres://, mysql://, sqlite:, file:, mongodb://, mongodb+srv:// or firestore://)', 'postgresql', url),
|
|
798
|
+
tables: [],
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
let driver = null;
|
|
802
|
+
try {
|
|
803
|
+
driver = await withTimeout(openDriver(url, engine), PING_TIMEOUT_MS, 'Connection');
|
|
804
|
+
const start = performance.now();
|
|
805
|
+
await withTimeout(driver.ping(), PING_TIMEOUT_MS, 'Ping');
|
|
806
|
+
const pingMs = Math.round((performance.now() - start) * 100) / 100;
|
|
807
|
+
const [database, tables] = await withTimeout(Promise.all([driver.databaseName(), driver.tables()]), INTROSPECT_TIMEOUT_MS, 'Schema introspection');
|
|
808
|
+
return {
|
|
809
|
+
status: {
|
|
810
|
+
connected: true,
|
|
811
|
+
engine,
|
|
812
|
+
database,
|
|
813
|
+
maskedUrl: maskConnectionString(url),
|
|
814
|
+
pingMs,
|
|
815
|
+
entityCount: tables.length,
|
|
816
|
+
entityType: isNoSqlEngine(engine) ? 'collection' : 'table',
|
|
817
|
+
tableCount: tables.length,
|
|
818
|
+
error: null,
|
|
819
|
+
},
|
|
820
|
+
tables,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
return { status: disconnectedStatus(sanitizeError(err, url), engine, url), tables: [] };
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
// Firestore's terminate() waits on in-flight gRPC retries; don't let cleanup stall the response.
|
|
828
|
+
if (driver)
|
|
829
|
+
await withTimeout(driver.close(), 3000, 'Close').catch(() => undefined);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/** Connection health only (ping + table count); same shape as GET /api/status entries. */
|
|
833
|
+
export async function getDatabaseStatus(url) {
|
|
834
|
+
return (await introspectDatabase(url)).status;
|
|
835
|
+
}
|