@duckcodeailabs/dql-cli 1.6.3 → 1.6.5
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/dist/apps-api.d.ts +44 -0
- package/dist/apps-api.d.ts.map +1 -1
- package/dist/apps-api.js +952 -0
- package/dist/apps-api.js.map +1 -1
- package/dist/args.d.ts +1 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +4 -0
- package/dist/args.js.map +1 -1
- package/dist/assets/dql-notebook/assets/index-L-zyCapt.js +3636 -0
- package/dist/assets/dql-notebook/index.html +1 -1
- package/dist/block-studio-import.d.ts +1 -1
- package/dist/block-studio-import.d.ts.map +1 -1
- package/dist/block-studio-import.js +64 -8
- package/dist/block-studio-import.js.map +1 -1
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +80 -11
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +13 -6
- package/dist/commands/init.js.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
- package/dist/llm/providers/dql-agent-provider.js +32 -2
- package/dist/llm/providers/dql-agent-provider.js.map +1 -1
- package/dist/local-runtime.d.ts +32 -1
- package/dist/local-runtime.d.ts.map +1 -1
- package/dist/local-runtime.js +694 -87
- package/dist/local-runtime.js.map +1 -1
- package/dist/package.json +10 -10
- package/package.json +11 -11
- package/dist/assets/dql-notebook/assets/index-CIMLd3Cb.js +0 -3289
package/dist/local-runtime.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, wat
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
|
|
6
6
|
import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
|
|
7
|
-
import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, buildManifest, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, } from '@duckcodeailabs/dql-core';
|
|
7
|
+
import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, getDialect, Parser, buildLineageGraph, buildManifest, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, } from '@duckcodeailabs/dql-core';
|
|
8
8
|
import { load as loadYaml } from 'js-yaml';
|
|
9
9
|
import { listBlockTemplates } from './block-templates.js';
|
|
10
10
|
import { getRunner as getLLMRunner } from './llm/index.js';
|
|
@@ -239,12 +239,67 @@ export async function startLocalServer(opts) {
|
|
|
239
239
|
FROM information_schema.columns
|
|
240
240
|
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
241
241
|
ORDER BY table_schema, table_name, ordinal_position`, [], runtimeVariables({}), connection);
|
|
242
|
-
|
|
242
|
+
const schemaContext = buildAgentSchemaContext(question, result.rows);
|
|
243
|
+
return enrichAgentSchemaContextWithValueMatches(question, schemaContext, executor, connection);
|
|
243
244
|
}
|
|
244
245
|
catch {
|
|
245
246
|
return [];
|
|
246
247
|
}
|
|
247
248
|
};
|
|
249
|
+
const generateInvestigationSqlForApp = async (input) => {
|
|
250
|
+
const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
|
|
251
|
+
const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
|
|
252
|
+
if (!resolvedProvider || !runner) {
|
|
253
|
+
throw new Error('No AI provider is configured. Configure OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
|
|
254
|
+
}
|
|
255
|
+
let governedAnswer;
|
|
256
|
+
let providerError;
|
|
257
|
+
const contextEnvelope = {
|
|
258
|
+
mode: 'app_research',
|
|
259
|
+
intent: input.intent,
|
|
260
|
+
appId: input.appId,
|
|
261
|
+
dashboardId: input.dashboardId,
|
|
262
|
+
sourceTileId: input.sourceTileId,
|
|
263
|
+
sourceBlockId: input.sourceBlockId,
|
|
264
|
+
title: input.title,
|
|
265
|
+
instruction: 'Generate review-required read-only SQL when certified blocks do not exactly answer the requested research grain. Execute only through the bounded generated SQL preview path.',
|
|
266
|
+
context: input.context,
|
|
267
|
+
};
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
await runner.run({
|
|
270
|
+
provider: resolvedProvider,
|
|
271
|
+
messages: [{ role: 'user', content: input.question }],
|
|
272
|
+
upstream: {
|
|
273
|
+
cellId: `app-research:${input.appId}:${input.dashboardId ?? 'app'}`,
|
|
274
|
+
sql: JSON.stringify(contextEnvelope, null, 2),
|
|
275
|
+
},
|
|
276
|
+
projectRoot,
|
|
277
|
+
executeCertifiedBlock: executeCertifiedBlockForAgent,
|
|
278
|
+
executeGeneratedSql: executeGeneratedSqlForAgent,
|
|
279
|
+
getSchemaContext: getSchemaContextForAgent,
|
|
280
|
+
}, (turn) => {
|
|
281
|
+
if (turn.kind === 'tool_result' && turn.id === 'governed_answer') {
|
|
282
|
+
governedAnswer = turn.output;
|
|
283
|
+
}
|
|
284
|
+
if (turn.kind === 'error') {
|
|
285
|
+
providerError = turn.message;
|
|
286
|
+
}
|
|
287
|
+
}, controller.signal);
|
|
288
|
+
if (!governedAnswer) {
|
|
289
|
+
throw new Error(providerError ?? 'The AI provider did not return a governed answer.');
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
sql: governedAnswer.proposedSql ?? governedAnswer.sql,
|
|
293
|
+
answer: governedAnswer.answer ?? governedAnswer.text,
|
|
294
|
+
result: governedAnswer.result,
|
|
295
|
+
analysisPlan: governedAnswer.analysisPlan,
|
|
296
|
+
evidence: governedAnswer.evidence,
|
|
297
|
+
citations: governedAnswer.citations,
|
|
298
|
+
suggestedViz: governedAnswer.suggestedViz,
|
|
299
|
+
executionError: governedAnswer.executionError,
|
|
300
|
+
providerUsed: governedAnswer.providerUsed,
|
|
301
|
+
};
|
|
302
|
+
};
|
|
248
303
|
// SSE clients for /api/watch hot-reload
|
|
249
304
|
const sseClients = new Set();
|
|
250
305
|
// Watch notebooks/, workbooks/, semantic-layer/, and data/ dirs for changes
|
|
@@ -303,6 +358,17 @@ export async function startLocalServer(opts) {
|
|
|
303
358
|
...candidate,
|
|
304
359
|
validation: validateBlockStudioSource(candidate.dqlSource, semanticLayer),
|
|
305
360
|
});
|
|
361
|
+
const validateImportCandidateForSave = (candidate) => {
|
|
362
|
+
const validated = validateImportCandidate(candidate);
|
|
363
|
+
const diagnostics = (validated.validation?.diagnostics ?? []);
|
|
364
|
+
const errors = diagnostics
|
|
365
|
+
.filter((diagnostic) => diagnostic.severity === 'error')
|
|
366
|
+
.map((diagnostic) => diagnostic.message || 'Candidate validation failed.');
|
|
367
|
+
if (validated.reviewStatus === 'rejected') {
|
|
368
|
+
errors.unshift('Candidate was rejected.');
|
|
369
|
+
}
|
|
370
|
+
return { candidate: validated, errors };
|
|
371
|
+
};
|
|
306
372
|
const runBlockStudioPreviewSource = async (source, targetConnection = connection) => {
|
|
307
373
|
let tableMapping;
|
|
308
374
|
if (semanticLayer) {
|
|
@@ -334,10 +400,10 @@ export async function startLocalServer(opts) {
|
|
|
334
400
|
throw new Error(message);
|
|
335
401
|
}
|
|
336
402
|
const plan = buildExecutionPlan({ id: 'block-studio', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver, tableMapping });
|
|
337
|
-
const
|
|
338
|
-
const result = await executor.executeQuery(sql, plan?.sqlParams ?? [], runtimeVariables(plan?.variables ?? {}),
|
|
403
|
+
const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan?.sql ?? executableSql, targetConnection, projectRoot, projectConfig);
|
|
404
|
+
const result = await executor.executeQuery(prepared.sql, plan?.sqlParams ?? [], runtimeVariables(plan?.variables ?? {}), prepared.connection);
|
|
339
405
|
return {
|
|
340
|
-
sql:
|
|
406
|
+
sql: prepared.sql,
|
|
341
407
|
result: normalizeQueryResult(result),
|
|
342
408
|
chartConfig: plan?.chartConfig ?? validation.chartConfig ?? null,
|
|
343
409
|
};
|
|
@@ -777,6 +843,7 @@ export async function startLocalServer(opts) {
|
|
|
777
843
|
path,
|
|
778
844
|
projectRoot,
|
|
779
845
|
executeSql: executeLocalSqlForStoredResult,
|
|
846
|
+
generateInvestigationSql: generateInvestigationSqlForApp,
|
|
780
847
|
runNotebook: (appId, notebookPath) => runNotebookForApp(appId, notebookPath),
|
|
781
848
|
});
|
|
782
849
|
if (handled)
|
|
@@ -1435,10 +1502,7 @@ export async function startLocalServer(opts) {
|
|
|
1435
1502
|
return;
|
|
1436
1503
|
}
|
|
1437
1504
|
const result = await certifyBlockStudioSource(source, blockPath);
|
|
1438
|
-
const blockers =
|
|
1439
|
-
...result.checklist.blockers,
|
|
1440
|
-
...result.certification.errors.map((error) => `${error.rule}: ${error.message}`),
|
|
1441
|
-
];
|
|
1505
|
+
const blockers = Array.from(new Set(result.checklist.blockers));
|
|
1442
1506
|
if (!result.certification.certified || blockers.length > 0) {
|
|
1443
1507
|
res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1444
1508
|
res.end(serializeJSON({ ok: false, ...result, blockers }));
|
|
@@ -1514,25 +1578,32 @@ export async function startLocalServer(opts) {
|
|
|
1514
1578
|
const nextCandidates = [...session.candidates];
|
|
1515
1579
|
for (let i = 0; i < nextCandidates.length; i += 1) {
|
|
1516
1580
|
const candidate = nextCandidates[i];
|
|
1517
|
-
if (candidate.reviewStatus === 'saved' || candidate.reviewStatus === 'rejected'
|
|
1581
|
+
if (candidate.reviewStatus === 'saved' || candidate.reviewStatus === 'rejected')
|
|
1582
|
+
continue;
|
|
1583
|
+
const readiness = validateImportCandidateForSave(candidate);
|
|
1584
|
+
nextCandidates[i] = readiness.candidate;
|
|
1585
|
+
writeBlockStudioImportCandidate(projectRoot, importId, readiness.candidate);
|
|
1586
|
+
if (readiness.errors.length > 0) {
|
|
1587
|
+
errors.push({ candidateId: candidate.id, error: readiness.errors.join(' ') });
|
|
1518
1588
|
continue;
|
|
1589
|
+
}
|
|
1519
1590
|
try {
|
|
1520
1591
|
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
1521
|
-
source: candidate.dqlSource,
|
|
1522
|
-
name: candidate.name,
|
|
1523
|
-
domain: candidate.domain,
|
|
1524
|
-
description: candidate.description,
|
|
1525
|
-
owner: candidate.owner,
|
|
1526
|
-
tags: candidate.tags,
|
|
1527
|
-
lineage: candidate.lineage.sourceTables,
|
|
1592
|
+
source: readiness.candidate.dqlSource,
|
|
1593
|
+
name: readiness.candidate.name,
|
|
1594
|
+
domain: readiness.candidate.domain,
|
|
1595
|
+
description: readiness.candidate.description,
|
|
1596
|
+
owner: readiness.candidate.owner,
|
|
1597
|
+
tags: readiness.candidate.tags,
|
|
1598
|
+
lineage: readiness.candidate.lineage.sourceTables,
|
|
1528
1599
|
importMeta: {
|
|
1529
1600
|
importId,
|
|
1530
|
-
candidateId: candidate.id,
|
|
1531
|
-
sourceKind: candidate.sourceKind,
|
|
1532
|
-
sourcePath: candidate.sourcePath,
|
|
1601
|
+
candidateId: readiness.candidate.id,
|
|
1602
|
+
sourceKind: readiness.candidate.sourceKind,
|
|
1603
|
+
sourcePath: readiness.candidate.sourcePath,
|
|
1533
1604
|
},
|
|
1534
1605
|
});
|
|
1535
|
-
nextCandidates[i] = { ...candidate, reviewStatus: 'saved', savedPath };
|
|
1606
|
+
nextCandidates[i] = { ...readiness.candidate, reviewStatus: 'saved', savedPath };
|
|
1536
1607
|
writeBlockStudioImportCandidate(projectRoot, importId, nextCandidates[i]);
|
|
1537
1608
|
saved.push({ candidateId: candidate.id, path: savedPath });
|
|
1538
1609
|
}
|
|
@@ -1625,22 +1696,39 @@ export async function startLocalServer(opts) {
|
|
|
1625
1696
|
}
|
|
1626
1697
|
if (req.method === 'POST' && candidateId && action === 'save') {
|
|
1627
1698
|
const candidate = readBlockStudioImportCandidate(projectRoot, importId, candidateId);
|
|
1699
|
+
if (candidate.reviewStatus === 'saved' && candidate.savedPath) {
|
|
1700
|
+
const payload = openBlockStudioDocument(projectRoot, candidate.savedPath, semanticLayer);
|
|
1701
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1702
|
+
res.end(serializeJSON({ candidate, block: payload }));
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const readiness = validateImportCandidateForSave(candidate);
|
|
1706
|
+
if (readiness.errors.length > 0) {
|
|
1707
|
+
writeBlockStudioImportCandidate(projectRoot, importId, readiness.candidate);
|
|
1708
|
+
res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1709
|
+
res.end(serializeJSON({
|
|
1710
|
+
error: readiness.errors.join(' '),
|
|
1711
|
+
candidate: readiness.candidate,
|
|
1712
|
+
diagnostics: readiness.candidate.validation?.diagnostics ?? [],
|
|
1713
|
+
}));
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1628
1716
|
const savedPath = saveBlockStudioArtifacts(projectRoot, {
|
|
1629
|
-
source: candidate.dqlSource,
|
|
1630
|
-
name: candidate.name,
|
|
1631
|
-
domain: candidate.domain,
|
|
1632
|
-
description: candidate.description,
|
|
1633
|
-
owner: candidate.owner,
|
|
1634
|
-
tags: candidate.tags,
|
|
1635
|
-
lineage: candidate.lineage.sourceTables,
|
|
1717
|
+
source: readiness.candidate.dqlSource,
|
|
1718
|
+
name: readiness.candidate.name,
|
|
1719
|
+
domain: readiness.candidate.domain,
|
|
1720
|
+
description: readiness.candidate.description,
|
|
1721
|
+
owner: readiness.candidate.owner,
|
|
1722
|
+
tags: readiness.candidate.tags,
|
|
1723
|
+
lineage: readiness.candidate.lineage.sourceTables,
|
|
1636
1724
|
importMeta: {
|
|
1637
1725
|
importId,
|
|
1638
1726
|
candidateId,
|
|
1639
|
-
sourceKind: candidate.sourceKind,
|
|
1640
|
-
sourcePath: candidate.sourcePath,
|
|
1727
|
+
sourceKind: readiness.candidate.sourceKind,
|
|
1728
|
+
sourcePath: readiness.candidate.sourcePath,
|
|
1641
1729
|
},
|
|
1642
1730
|
});
|
|
1643
|
-
const next = { ...candidate, reviewStatus: 'saved', savedPath };
|
|
1731
|
+
const next = { ...readiness.candidate, reviewStatus: 'saved', savedPath };
|
|
1644
1732
|
writeBlockStudioImportCandidate(projectRoot, importId, next);
|
|
1645
1733
|
const payload = openBlockStudioDocument(projectRoot, savedPath, semanticLayer);
|
|
1646
1734
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
@@ -1664,11 +1752,8 @@ export async function startLocalServer(opts) {
|
|
|
1664
1752
|
if (req.method === 'GET' && path === '/api/block-studio/catalog') {
|
|
1665
1753
|
try {
|
|
1666
1754
|
const cfg = loadProjectConfig(projectRoot);
|
|
1667
|
-
const connections = cfg
|
|
1668
|
-
|
|
1669
|
-
connections.default = cfg.defaultConnection;
|
|
1670
|
-
}
|
|
1671
|
-
const defaultKey = cfg.defaultConnection ? 'default' : Object.keys(connections)[0] ?? 'default';
|
|
1755
|
+
const connections = getProjectConnectionsForApi(cfg);
|
|
1756
|
+
const defaultKey = resolveDefaultConnectionKey(cfg, connections) ?? Object.keys(connections)[0] ?? 'default';
|
|
1672
1757
|
const userPrefs = readUserPrefs(userPrefsPath);
|
|
1673
1758
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1674
1759
|
res.end(serializeJSON({
|
|
@@ -1800,15 +1885,10 @@ export async function startLocalServer(opts) {
|
|
|
1800
1885
|
}
|
|
1801
1886
|
if (req.method === 'GET' && path === '/api/connections') {
|
|
1802
1887
|
const cfg = loadProjectConfig(projectRoot);
|
|
1803
|
-
const
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
connections['default'] = cfg.defaultConnection;
|
|
1808
|
-
}
|
|
1809
|
-
const defaultKey = raw.defaultConnection
|
|
1810
|
-
? 'default'
|
|
1811
|
-
: Object.keys(connections)[0] ?? 'default';
|
|
1888
|
+
const connections = getProjectConnectionsForApi(cfg);
|
|
1889
|
+
const defaultKey = resolveDefaultConnectionKey(cfg, connections)
|
|
1890
|
+
?? Object.keys(connections)[0]
|
|
1891
|
+
?? 'default';
|
|
1812
1892
|
const dbtProfiles = discoverDbtProfileConnections(projectRoot, cfg);
|
|
1813
1893
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1814
1894
|
res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles }));
|
|
@@ -1826,6 +1906,22 @@ export async function startLocalServer(opts) {
|
|
|
1826
1906
|
if (body.connections && typeof body.connections === 'object') {
|
|
1827
1907
|
raw.connections = body.connections;
|
|
1828
1908
|
}
|
|
1909
|
+
const connections = getStoredConnections(raw);
|
|
1910
|
+
if (body.connections && typeof body.connections === 'object') {
|
|
1911
|
+
const requestedDefault = typeof body.defaultConnectionName === 'string'
|
|
1912
|
+
? body.defaultConnectionName
|
|
1913
|
+
: typeof body.default === 'string'
|
|
1914
|
+
? body.default
|
|
1915
|
+
: undefined;
|
|
1916
|
+
const defaultConnectionName = resolveDefaultConnectionKey(requestedDefault ? { ...raw, defaultConnectionName: requestedDefault } : raw, connections);
|
|
1917
|
+
delete raw.defaultConnection;
|
|
1918
|
+
if (defaultConnectionName) {
|
|
1919
|
+
raw.defaultConnectionName = defaultConnectionName;
|
|
1920
|
+
}
|
|
1921
|
+
else {
|
|
1922
|
+
delete raw.defaultConnectionName;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1829
1925
|
writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n', 'utf-8');
|
|
1830
1926
|
// Hot-swap: re-read the config and re-initialize the active connection
|
|
1831
1927
|
projectConfig = loadProjectConfig(projectRoot);
|
|
@@ -2840,23 +2936,20 @@ export async function startLocalServer(opts) {
|
|
|
2840
2936
|
return;
|
|
2841
2937
|
}
|
|
2842
2938
|
if (req.method === 'POST' && path === '/api/test-connection') {
|
|
2939
|
+
let target = connection;
|
|
2843
2940
|
try {
|
|
2844
2941
|
const body = await readJSON(req);
|
|
2845
|
-
|
|
2942
|
+
target = normalizeProjectConnection(isConnectionConfig(body.connection) ? body.connection : connection, projectRoot);
|
|
2846
2943
|
const connector = await executor.getConnector(target);
|
|
2847
|
-
const
|
|
2848
|
-
|
|
2849
|
-
res.
|
|
2850
|
-
res.end(serializeJSON({
|
|
2851
|
-
ok,
|
|
2852
|
-
message: ok ? `Connected to ${driver} successfully` : `Connection to ${driver} failed`,
|
|
2853
|
-
}));
|
|
2944
|
+
const result = await validateConnectionForTest(connector, target);
|
|
2945
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
2946
|
+
res.end(serializeJSON(result));
|
|
2854
2947
|
}
|
|
2855
2948
|
catch (error) {
|
|
2856
|
-
res.writeHead(
|
|
2949
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
2857
2950
|
res.end(serializeJSON({
|
|
2858
2951
|
ok: false,
|
|
2859
|
-
message:
|
|
2952
|
+
message: formatConnectionTestError(target, error),
|
|
2860
2953
|
}));
|
|
2861
2954
|
}
|
|
2862
2955
|
return;
|
|
@@ -3229,6 +3322,102 @@ export function formatLocalQueryRuntimeError(connection, error) {
|
|
|
3229
3322
|
}
|
|
3230
3323
|
return `Local query runtime is unavailable for driver "${driver}": ${detail}`;
|
|
3231
3324
|
}
|
|
3325
|
+
export async function validateConnectionForTest(connector, connection) {
|
|
3326
|
+
if (connection.driver === 'snowflake') {
|
|
3327
|
+
return validateSnowflakeConnectionForTest(connector, connection);
|
|
3328
|
+
}
|
|
3329
|
+
const ok = await connector.ping();
|
|
3330
|
+
const label = connectionDriverLabel(connection);
|
|
3331
|
+
return {
|
|
3332
|
+
ok,
|
|
3333
|
+
message: ok
|
|
3334
|
+
? `Connected to ${label} successfully.`
|
|
3335
|
+
: `Connection to ${label} failed. Check credentials, network access, and database availability.`,
|
|
3336
|
+
};
|
|
3337
|
+
}
|
|
3338
|
+
function formatConnectionTestError(connection, error) {
|
|
3339
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3340
|
+
const label = connectionDriverLabel(connection);
|
|
3341
|
+
if (connection.driver === 'snowflake') {
|
|
3342
|
+
const cleaned = detail.replace(/^Snowflake (?:connection|query) failed:\s*/i, '').trim();
|
|
3343
|
+
return `Snowflake connection failed: ${cleaned || 'Check account, user, password/auth method, role, and network access.'}`;
|
|
3344
|
+
}
|
|
3345
|
+
return `Connection to ${label} failed: ${detail}`;
|
|
3346
|
+
}
|
|
3347
|
+
async function validateSnowflakeConnectionForTest(connector, connection) {
|
|
3348
|
+
const warehouse = connection.warehouse?.trim();
|
|
3349
|
+
if (!warehouse) {
|
|
3350
|
+
return {
|
|
3351
|
+
ok: false,
|
|
3352
|
+
message: 'Snowflake connection requires a warehouse before it can be tested.',
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
const warehouseRow = await findSnowflakeWarehouse(connector, warehouse);
|
|
3356
|
+
if (!warehouseRow) {
|
|
3357
|
+
return {
|
|
3358
|
+
ok: false,
|
|
3359
|
+
message: `Snowflake warehouse "${warehouse}" was not found or is not visible to this role.`,
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
const state = String(readRowField(warehouseRow, 'state') ?? '').trim();
|
|
3363
|
+
const normalizedState = state.toUpperCase();
|
|
3364
|
+
if (normalizedState && normalizedState !== 'STARTED') {
|
|
3365
|
+
return {
|
|
3366
|
+
ok: false,
|
|
3367
|
+
message: `Snowflake warehouse "${warehouse}" is ${state}. Start or resume it, then test again.`,
|
|
3368
|
+
details: {
|
|
3369
|
+
warehouse,
|
|
3370
|
+
state,
|
|
3371
|
+
},
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
const context = await connector.execute(`SELECT
|
|
3375
|
+
CURRENT_ACCOUNT() AS account_name,
|
|
3376
|
+
CURRENT_USER() AS user_name,
|
|
3377
|
+
CURRENT_ROLE() AS role_name,
|
|
3378
|
+
CURRENT_DATABASE() AS database_name,
|
|
3379
|
+
CURRENT_SCHEMA() AS schema_name,
|
|
3380
|
+
CURRENT_WAREHOUSE() AS warehouse_name`);
|
|
3381
|
+
const row = context.rows[0] ?? {};
|
|
3382
|
+
const user = String(readRowField(row, 'user_name') ?? connection.username ?? '').trim();
|
|
3383
|
+
const role = String(readRowField(row, 'role_name') ?? connection.role ?? '').trim();
|
|
3384
|
+
const activeWarehouse = String(readRowField(row, 'warehouse_name') ?? warehouse).trim();
|
|
3385
|
+
return {
|
|
3386
|
+
ok: true,
|
|
3387
|
+
message: `Connected to Snowflake${user ? ` as ${user}` : ''} using warehouse ${activeWarehouse || warehouse}.`,
|
|
3388
|
+
details: {
|
|
3389
|
+
warehouse: activeWarehouse || warehouse,
|
|
3390
|
+
warehouseState: state || 'STARTED',
|
|
3391
|
+
role: role || undefined,
|
|
3392
|
+
database: readRowField(row, 'database_name') ?? connection.database,
|
|
3393
|
+
schema: readRowField(row, 'schema_name') ?? connection.schema,
|
|
3394
|
+
},
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
async function findSnowflakeWarehouse(connector, warehouse) {
|
|
3398
|
+
const candidates = Array.from(new Set([warehouse, warehouse.toUpperCase()]));
|
|
3399
|
+
for (const candidate of candidates) {
|
|
3400
|
+
const result = await connector.execute(`SHOW WAREHOUSES LIKE '${escapeSqlString(candidate)}'`);
|
|
3401
|
+
const row = result.rows.find((item) => {
|
|
3402
|
+
const name = String(readRowField(item, 'name') ?? '').trim();
|
|
3403
|
+
return name.localeCompare(warehouse, undefined, { sensitivity: 'accent' }) === 0;
|
|
3404
|
+
});
|
|
3405
|
+
if (row)
|
|
3406
|
+
return row;
|
|
3407
|
+
}
|
|
3408
|
+
return null;
|
|
3409
|
+
}
|
|
3410
|
+
function readRowField(row, field) {
|
|
3411
|
+
const expected = field.toLowerCase();
|
|
3412
|
+
const entry = Object.entries(row).find(([key]) => key.toLowerCase() === expected);
|
|
3413
|
+
return entry?.[1];
|
|
3414
|
+
}
|
|
3415
|
+
function escapeSqlString(value) {
|
|
3416
|
+
return value.replace(/'/g, "''");
|
|
3417
|
+
}
|
|
3418
|
+
function connectionDriverLabel(connection) {
|
|
3419
|
+
return connection.driver === 'snowflake' ? 'Snowflake' : connection.driver ?? 'database';
|
|
3420
|
+
}
|
|
3232
3421
|
/**
|
|
3233
3422
|
* Normalize connector QueryResult → SPA-friendly shape.
|
|
3234
3423
|
* Connector returns columns as ColumnMeta[] ({name,type,driverType}).
|
|
@@ -3361,31 +3550,203 @@ export function loadProjectConfig(projectRoot) {
|
|
|
3361
3550
|
}
|
|
3362
3551
|
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
3363
3552
|
const config = raw;
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
const
|
|
3368
|
-
if (
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3553
|
+
const connections = getStoredConnections(raw);
|
|
3554
|
+
const defaultConnectionName = resolveDefaultConnectionKey(raw, connections);
|
|
3555
|
+
if (defaultConnectionName) {
|
|
3556
|
+
const selected = normalizeStoredConnection(connections[defaultConnectionName]);
|
|
3557
|
+
if (selected) {
|
|
3558
|
+
config.defaultConnection = selected;
|
|
3559
|
+
config.defaultConnectionName = defaultConnectionName;
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
else if (config.defaultConnection) {
|
|
3563
|
+
const normalized = normalizeStoredConnection(config.defaultConnection);
|
|
3564
|
+
if (normalized) {
|
|
3565
|
+
config.defaultConnection = normalized;
|
|
3376
3566
|
}
|
|
3377
3567
|
}
|
|
3378
3568
|
return config;
|
|
3379
3569
|
}
|
|
3570
|
+
function getProjectConnectionsForApi(config) {
|
|
3571
|
+
const connections = getStoredConnections(config);
|
|
3572
|
+
if (Object.keys(connections).length === 0 && isConnectionLike(config.defaultConnection)) {
|
|
3573
|
+
return { default: config.defaultConnection };
|
|
3574
|
+
}
|
|
3575
|
+
return connections;
|
|
3576
|
+
}
|
|
3577
|
+
function getStoredConnections(raw) {
|
|
3578
|
+
const value = raw.connections;
|
|
3579
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
3580
|
+
return {};
|
|
3581
|
+
}
|
|
3582
|
+
return { ...value };
|
|
3583
|
+
}
|
|
3584
|
+
function resolveDefaultConnectionKey(raw, connections) {
|
|
3585
|
+
const keys = Object.keys(connections).filter((key) => isConnectionLike(connections[key]));
|
|
3586
|
+
if (keys.length === 0)
|
|
3587
|
+
return undefined;
|
|
3588
|
+
const configured = readConfiguredDefaultConnectionName(raw);
|
|
3589
|
+
if (configured && keys.includes(configured)) {
|
|
3590
|
+
return configured;
|
|
3591
|
+
}
|
|
3592
|
+
if (keys.includes('default') && !isPlaceholderLocalConnection(connections.default)) {
|
|
3593
|
+
return 'default';
|
|
3594
|
+
}
|
|
3595
|
+
const realConnections = keys.filter((key) => !isPlaceholderLocalConnection(connections[key]));
|
|
3596
|
+
if (keys.includes('default') && isPlaceholderLocalConnection(connections.default) && realConnections.length === 1) {
|
|
3597
|
+
return realConnections[0];
|
|
3598
|
+
}
|
|
3599
|
+
if (keys.length === 1) {
|
|
3600
|
+
return keys[0];
|
|
3601
|
+
}
|
|
3602
|
+
return keys.includes('default') ? 'default' : keys[0];
|
|
3603
|
+
}
|
|
3604
|
+
function readConfiguredDefaultConnectionName(raw) {
|
|
3605
|
+
for (const key of ['defaultConnectionName', 'defaultConnectionKey', 'currentConnection']) {
|
|
3606
|
+
const value = raw[key];
|
|
3607
|
+
if (typeof value === 'string' && value.trim())
|
|
3608
|
+
return value.trim();
|
|
3609
|
+
}
|
|
3610
|
+
return typeof raw.default === 'string' && raw.default.trim() ? raw.default.trim() : undefined;
|
|
3611
|
+
}
|
|
3612
|
+
function normalizeStoredConnection(value) {
|
|
3613
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3614
|
+
return null;
|
|
3615
|
+
const raw = value;
|
|
3616
|
+
const driver = raw.driver ?? raw.type;
|
|
3617
|
+
if (typeof driver !== 'string' || !driver.trim())
|
|
3618
|
+
return null;
|
|
3619
|
+
const { path: legacyPath, type: _type, ...rest } = raw;
|
|
3620
|
+
const filepath = typeof raw.filepath === 'string'
|
|
3621
|
+
? raw.filepath
|
|
3622
|
+
: typeof legacyPath === 'string'
|
|
3623
|
+
? legacyPath
|
|
3624
|
+
: undefined;
|
|
3625
|
+
return {
|
|
3626
|
+
...rest,
|
|
3627
|
+
driver: driver.trim(),
|
|
3628
|
+
...(filepath ? { filepath } : {}),
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
function isConnectionLike(value) {
|
|
3632
|
+
return normalizeStoredConnection(value) !== null;
|
|
3633
|
+
}
|
|
3634
|
+
function isPlaceholderLocalConnection(value) {
|
|
3635
|
+
const connection = normalizeStoredConnection(value);
|
|
3636
|
+
if (!connection)
|
|
3637
|
+
return false;
|
|
3638
|
+
if (connection.driver !== 'duckdb' && connection.driver !== 'file')
|
|
3639
|
+
return false;
|
|
3640
|
+
return !connection.filepath || connection.filepath === ':memory:';
|
|
3641
|
+
}
|
|
3380
3642
|
export function prepareLocalExecution(sql, connection, projectRoot, projectConfig) {
|
|
3381
3643
|
const normalizedConnection = normalizeProjectConnection(connection, projectRoot);
|
|
3644
|
+
const dbtResolvedSql = resolveDbtMacrosForExecution(sql, projectRoot, projectConfig);
|
|
3382
3645
|
return {
|
|
3383
3646
|
sql: shouldResolveProjectPaths(normalizedConnection)
|
|
3384
|
-
? resolveProjectRelativeSqlPaths(
|
|
3385
|
-
:
|
|
3647
|
+
? resolveProjectRelativeSqlPaths(dbtResolvedSql, projectRoot, projectConfig.dataDir)
|
|
3648
|
+
: dbtResolvedSql,
|
|
3386
3649
|
connection: normalizedConnection,
|
|
3387
3650
|
};
|
|
3388
3651
|
}
|
|
3652
|
+
export function resolveDbtMacrosForExecution(sql, projectRoot, projectConfig = {}) {
|
|
3653
|
+
if (!/\{\{\s*(?:ref|source)\s*\(/i.test(sql))
|
|
3654
|
+
return sql;
|
|
3655
|
+
const manifestPath = resolveDbtManifestPath(projectRoot, projectConfig);
|
|
3656
|
+
if (!manifestPath) {
|
|
3657
|
+
throw new Error('dbt ref/source macros were found, but target/manifest.json was not available. Run dbt parse or dbt compile, then retry.');
|
|
3658
|
+
}
|
|
3659
|
+
const manifest = readJsonFile(manifestPath);
|
|
3660
|
+
const refs = buildDbtRelationLookup(manifest);
|
|
3661
|
+
const unresolved = new Set();
|
|
3662
|
+
let rendered = sql.replace(/\{\{\s*ref\(\s*(?:(['"])([^'"]+)\1\s*,\s*)?(['"])([^'"]+)\3(?:\s*,[^)]*)?\)\s*\}\}/gi, (match, _pkgQuote, packageName, _modelQuote, modelName) => {
|
|
3663
|
+
const key = normalizeDbtLookupKey(modelName);
|
|
3664
|
+
const scopedKey = packageName ? normalizeDbtLookupKey(`${packageName}.${modelName}`) : key;
|
|
3665
|
+
const relation = refs.models.get(scopedKey) ?? refs.models.get(key);
|
|
3666
|
+
if (!relation) {
|
|
3667
|
+
unresolved.add(packageName ? `ref('${packageName}', '${modelName}')` : `ref('${modelName}')`);
|
|
3668
|
+
return match;
|
|
3669
|
+
}
|
|
3670
|
+
return relation;
|
|
3671
|
+
});
|
|
3672
|
+
rendered = rendered.replace(/\{\{\s*source\(\s*(['"])([^'"]+)\1\s*,\s*(['"])([^'"]+)\3\s*\)\s*\}\}/gi, (match, _sourceQuote, sourceName, _tableQuote, tableName) => {
|
|
3673
|
+
const key = normalizeDbtLookupKey(`${sourceName}.${tableName}`);
|
|
3674
|
+
const relation = refs.sources.get(key) ?? refs.sources.get(normalizeDbtLookupKey(tableName));
|
|
3675
|
+
if (!relation) {
|
|
3676
|
+
unresolved.add(`source('${sourceName}', '${tableName}')`);
|
|
3677
|
+
return match;
|
|
3678
|
+
}
|
|
3679
|
+
return relation;
|
|
3680
|
+
});
|
|
3681
|
+
if (unresolved.size > 0) {
|
|
3682
|
+
throw new Error(`Could not resolve dbt macro${unresolved.size === 1 ? '' : 's'} from manifest.json: ${Array.from(unresolved).join(', ')}.`);
|
|
3683
|
+
}
|
|
3684
|
+
return rendered;
|
|
3685
|
+
}
|
|
3686
|
+
function buildDbtRelationLookup(manifest) {
|
|
3687
|
+
const models = new Map();
|
|
3688
|
+
const sources = new Map();
|
|
3689
|
+
const root = manifest && typeof manifest === 'object' ? manifest : {};
|
|
3690
|
+
const nodes = root.nodes && typeof root.nodes === 'object' ? root.nodes : {};
|
|
3691
|
+
const manifestSources = root.sources && typeof root.sources === 'object' ? root.sources : {};
|
|
3692
|
+
for (const [uniqueId, rawNode] of Object.entries(nodes)) {
|
|
3693
|
+
const node = rawNode && typeof rawNode === 'object' ? rawNode : null;
|
|
3694
|
+
if (!node || node.resource_type !== 'model')
|
|
3695
|
+
continue;
|
|
3696
|
+
const relation = dbtRelationName(node);
|
|
3697
|
+
if (!relation)
|
|
3698
|
+
continue;
|
|
3699
|
+
const name = stringField(node, 'name');
|
|
3700
|
+
const alias = stringField(node, 'alias');
|
|
3701
|
+
const packageName = uniqueId.split('.')[1];
|
|
3702
|
+
for (const key of [name, alias, packageName && name ? `${packageName}.${name}` : null, uniqueId]) {
|
|
3703
|
+
if (key)
|
|
3704
|
+
models.set(normalizeDbtLookupKey(key), relation);
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
for (const [uniqueId, rawSource] of Object.entries(manifestSources)) {
|
|
3708
|
+
const source = rawSource && typeof rawSource === 'object' ? rawSource : null;
|
|
3709
|
+
if (!source)
|
|
3710
|
+
continue;
|
|
3711
|
+
const relation = dbtRelationName(source);
|
|
3712
|
+
if (!relation)
|
|
3713
|
+
continue;
|
|
3714
|
+
const sourceName = stringField(source, 'source_name');
|
|
3715
|
+
const name = stringField(source, 'name');
|
|
3716
|
+
const identifier = stringField(source, 'identifier');
|
|
3717
|
+
for (const key of [
|
|
3718
|
+
sourceName && name ? `${sourceName}.${name}` : null,
|
|
3719
|
+
sourceName && identifier ? `${sourceName}.${identifier}` : null,
|
|
3720
|
+
name,
|
|
3721
|
+
identifier,
|
|
3722
|
+
uniqueId,
|
|
3723
|
+
]) {
|
|
3724
|
+
if (key)
|
|
3725
|
+
sources.set(normalizeDbtLookupKey(key), relation);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
return { models, sources };
|
|
3729
|
+
}
|
|
3730
|
+
function dbtRelationName(node) {
|
|
3731
|
+
const relationName = stringField(node, 'relation_name');
|
|
3732
|
+
if (relationName)
|
|
3733
|
+
return relationName;
|
|
3734
|
+
const database = stringField(node, 'database');
|
|
3735
|
+
const schema = stringField(node, 'schema');
|
|
3736
|
+
const alias = stringField(node, 'alias') ?? stringField(node, 'identifier') ?? stringField(node, 'name');
|
|
3737
|
+
if (database && schema && alias)
|
|
3738
|
+
return `${database}.${schema}.${alias}`;
|
|
3739
|
+
if (schema && alias)
|
|
3740
|
+
return `${schema}.${alias}`;
|
|
3741
|
+
return alias ?? null;
|
|
3742
|
+
}
|
|
3743
|
+
function stringField(source, key) {
|
|
3744
|
+
const value = source[key];
|
|
3745
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
3746
|
+
}
|
|
3747
|
+
function normalizeDbtLookupKey(value) {
|
|
3748
|
+
return value.trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
|
3749
|
+
}
|
|
3389
3750
|
const AGENT_PREVIEW_FORBIDDEN_SQL = [
|
|
3390
3751
|
'alter',
|
|
3391
3752
|
'analyze',
|
|
@@ -3416,19 +3777,25 @@ export function buildAgentPreviewSql(sql) {
|
|
|
3416
3777
|
if (!trimmed)
|
|
3417
3778
|
throw new Error('Generated SQL preview is empty.');
|
|
3418
3779
|
const withoutTrailingSemicolon = trimmed.replace(/;\s*$/, '').trim();
|
|
3419
|
-
const
|
|
3780
|
+
const readOnlyError = readOnlySqlValidationError(withoutTrailingSemicolon, 'Generated SQL preview');
|
|
3781
|
+
if (readOnlyError)
|
|
3782
|
+
throw new Error(readOnlyError);
|
|
3783
|
+
return `SELECT * FROM (\n${withoutTrailingSemicolon}\n) AS dql_agent_preview LIMIT 200`;
|
|
3784
|
+
}
|
|
3785
|
+
function readOnlySqlValidationError(sql, subject) {
|
|
3786
|
+
const scanSql = stripSqlStringsAndComments(sql).trim();
|
|
3420
3787
|
if (!/^(select|with)\b/i.test(scanSql)) {
|
|
3421
|
-
|
|
3788
|
+
return `${subject} only supports read-only SELECT or WITH queries.`;
|
|
3422
3789
|
}
|
|
3423
3790
|
if (scanSql.includes(';')) {
|
|
3424
|
-
|
|
3791
|
+
return `${subject} only supports one statement.`;
|
|
3425
3792
|
}
|
|
3426
3793
|
const forbiddenPattern = new RegExp(`\\b(${AGENT_PREVIEW_FORBIDDEN_SQL.join('|')})\\b`, 'i');
|
|
3427
3794
|
const forbidden = scanSql.match(forbiddenPattern)?.[1];
|
|
3428
3795
|
if (forbidden) {
|
|
3429
|
-
|
|
3796
|
+
return `${subject} rejected unsupported statement keyword: ${forbidden.toUpperCase()}.`;
|
|
3430
3797
|
}
|
|
3431
|
-
return
|
|
3798
|
+
return null;
|
|
3432
3799
|
}
|
|
3433
3800
|
function stripSqlStringsAndComments(sql) {
|
|
3434
3801
|
let output = '';
|
|
@@ -4211,6 +4578,16 @@ export function validateBlockStudioSource(source, semanticLayer) {
|
|
|
4211
4578
|
diagnostics.push(...resolvedCustomSql.diagnostics);
|
|
4212
4579
|
executableSql = resolvedCustomSql.sql;
|
|
4213
4580
|
}
|
|
4581
|
+
if (executableSql && semanticConfig.blockType !== 'semantic') {
|
|
4582
|
+
const readOnlyError = readOnlySqlValidationError(executableSql.trim().replace(/;\s*$/, '').trim(), 'Block SQL');
|
|
4583
|
+
if (readOnlyError) {
|
|
4584
|
+
diagnostics.push({
|
|
4585
|
+
severity: 'error',
|
|
4586
|
+
code: 'sql_read_only',
|
|
4587
|
+
message: readOnlyError,
|
|
4588
|
+
});
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4214
4591
|
const chartConfig = extractBlockStudioChartConfig(source);
|
|
4215
4592
|
if (!chartConfig) {
|
|
4216
4593
|
diagnostics.push({
|
|
@@ -4240,7 +4617,7 @@ export function validateBlockStudioSource(source, semanticLayer) {
|
|
|
4240
4617
|
executableSql,
|
|
4241
4618
|
};
|
|
4242
4619
|
}
|
|
4243
|
-
function saveBlockStudioArtifacts(projectRoot, options) {
|
|
4620
|
+
export function saveBlockStudioArtifacts(projectRoot, options) {
|
|
4244
4621
|
const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
|
|
4245
4622
|
const safeDomain = (options.domain ?? '')
|
|
4246
4623
|
.trim()
|
|
@@ -4392,12 +4769,6 @@ function buildBlockStudioCertificationChecklist(input) {
|
|
|
4392
4769
|
blockers.add(`${error.rule}: ${error.message}`);
|
|
4393
4770
|
for (const blocker of input.extraBlockers ?? [])
|
|
4394
4771
|
blockers.add(blocker);
|
|
4395
|
-
if (!parsed.domain.trim())
|
|
4396
|
-
blockers.add('Missing domain');
|
|
4397
|
-
if (!parsed.owner.trim())
|
|
4398
|
-
blockers.add('Missing owner');
|
|
4399
|
-
if (!parsed.description.trim())
|
|
4400
|
-
blockers.add('Missing description');
|
|
4401
4772
|
if (!input.previewSucceeded)
|
|
4402
4773
|
blockers.add('Block has not run successfully');
|
|
4403
4774
|
if (!input.testResults || input.testResults.failed > 0)
|
|
@@ -5092,7 +5463,7 @@ function buildProjectLineageGraphUncached(projectRoot, semanticLayer) {
|
|
|
5092
5463
|
// Fall back to a live build.
|
|
5093
5464
|
}
|
|
5094
5465
|
}
|
|
5095
|
-
const dbtManifestPath = resolveDbtManifestPath(projectRoot);
|
|
5466
|
+
const dbtManifestPath = resolveDbtManifestPath(projectRoot, {});
|
|
5096
5467
|
try {
|
|
5097
5468
|
const manifest = buildManifest({
|
|
5098
5469
|
projectRoot,
|
|
@@ -5149,9 +5520,14 @@ function buildProjectLineageGraphUncached(projectRoot, semanticLayer) {
|
|
|
5149
5520
|
return buildLineageGraph(blocks, metrics, dimensions);
|
|
5150
5521
|
}
|
|
5151
5522
|
}
|
|
5152
|
-
function resolveDbtManifestPath(projectRoot) {
|
|
5153
|
-
const
|
|
5154
|
-
|
|
5523
|
+
function resolveDbtManifestPath(projectRoot, projectConfig = {}) {
|
|
5524
|
+
const candidates = [];
|
|
5525
|
+
if (projectConfig.dbt?.projectDir || projectConfig.semanticLayer?.provider === 'dbt') {
|
|
5526
|
+
const dbtProjectPath = findDbtProjectPath(projectRoot, projectConfig);
|
|
5527
|
+
candidates.push(resolve(dbtProjectPath, projectConfig.dbt?.manifestPath ?? 'target/manifest.json'));
|
|
5528
|
+
}
|
|
5529
|
+
candidates.push(join(projectRoot, 'target', 'manifest.json'), join(resolve(projectRoot, '..'), 'target', 'manifest.json'), join(resolve(projectRoot, '../dbt'), 'target', 'manifest.json'), join(resolve(projectRoot, '../../dbt'), 'target', 'manifest.json'));
|
|
5530
|
+
return candidates.find((candidate, index, list) => list.indexOf(candidate) === index && existsSync(candidate));
|
|
5155
5531
|
}
|
|
5156
5532
|
export function discoverDbtProfileConnections(projectRoot, projectConfig) {
|
|
5157
5533
|
const dbtProjectPath = findDbtProjectPath(projectRoot, projectConfig);
|
|
@@ -6113,7 +6489,7 @@ function isAiPinRefreshDue(lastRefreshedAt) {
|
|
|
6113
6489
|
return true;
|
|
6114
6490
|
return Date.now() - last >= 24 * 60 * 60 * 1000;
|
|
6115
6491
|
}
|
|
6116
|
-
function buildAgentSchemaContext(question, rows) {
|
|
6492
|
+
export function buildAgentSchemaContext(question, rows) {
|
|
6117
6493
|
const byRelation = new Map();
|
|
6118
6494
|
for (const row of rows) {
|
|
6119
6495
|
if (!row || typeof row !== 'object')
|
|
@@ -6141,13 +6517,54 @@ function buildAgentSchemaContext(question, rows) {
|
|
|
6141
6517
|
byRelation.set(relation, current);
|
|
6142
6518
|
}
|
|
6143
6519
|
const tokens = agentSchemaTokens(question);
|
|
6520
|
+
const shouldProbeValues = extractAgentValueSearchTerms(question).length > 0;
|
|
6144
6521
|
return Array.from(byRelation.values())
|
|
6145
|
-
.map((table) => ({
|
|
6522
|
+
.map((table) => ({
|
|
6523
|
+
table,
|
|
6524
|
+
score: scoreAgentSchemaTable(table, tokens) + (shouldProbeValues ? scoreAgentValueProbeTable(table) : 0),
|
|
6525
|
+
}))
|
|
6146
6526
|
.filter((entry) => entry.score > 0)
|
|
6147
6527
|
.sort((a, b) => b.score - a.score || a.table.relation.localeCompare(b.table.relation))
|
|
6148
6528
|
.slice(0, 12)
|
|
6149
6529
|
.map((entry) => entry.table);
|
|
6150
6530
|
}
|
|
6531
|
+
async function enrichAgentSchemaContextWithValueMatches(question, schemaContext, executor, connection) {
|
|
6532
|
+
const searchTerms = extractAgentValueSearchTerms(question);
|
|
6533
|
+
if (schemaContext.length === 0 || searchTerms.length === 0)
|
|
6534
|
+
return schemaContext;
|
|
6535
|
+
const matches = new Map();
|
|
6536
|
+
for (const candidate of rankAgentValueProbeColumns(schemaContext).slice(0, 12)) {
|
|
6537
|
+
try {
|
|
6538
|
+
const result = await executor.executeQuery(buildAgentValueProbeSql(candidate.table, candidate.column.name, searchTerms, connection), [], runtimeVariables({}), connection);
|
|
6539
|
+
const values = uniqueStrings(result.rows.flatMap(valueProbeRowValues)).slice(0, 5);
|
|
6540
|
+
if (values.length === 0)
|
|
6541
|
+
continue;
|
|
6542
|
+
const tableMatches = matches.get(candidate.table.relation) ?? new Map();
|
|
6543
|
+
tableMatches.set(candidate.column.name, values);
|
|
6544
|
+
matches.set(candidate.table.relation, tableMatches);
|
|
6545
|
+
}
|
|
6546
|
+
catch {
|
|
6547
|
+
// Value probes are advisory. Unsupported casts, privileges, and large-table
|
|
6548
|
+
// failures should not block the metadata-backed answer path.
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6551
|
+
if (matches.size === 0)
|
|
6552
|
+
return schemaContext;
|
|
6553
|
+
return schemaContext.map((table) => {
|
|
6554
|
+
const tableMatches = matches.get(table.relation);
|
|
6555
|
+
if (!tableMatches)
|
|
6556
|
+
return table;
|
|
6557
|
+
return {
|
|
6558
|
+
...table,
|
|
6559
|
+
columns: table.columns.map((column) => {
|
|
6560
|
+
const sampleValues = tableMatches.get(column.name);
|
|
6561
|
+
return sampleValues?.length
|
|
6562
|
+
? { ...column, sampleValues: uniqueStrings([...(column.sampleValues ?? []), ...sampleValues]).slice(0, 5) }
|
|
6563
|
+
: column;
|
|
6564
|
+
}),
|
|
6565
|
+
};
|
|
6566
|
+
});
|
|
6567
|
+
}
|
|
6151
6568
|
function scoreAgentSchemaTable(table, tokens) {
|
|
6152
6569
|
let score = 0;
|
|
6153
6570
|
const relationTokens = agentSchemaTokens(`${table.schema ?? ''} ${table.name} ${table.relation}`);
|
|
@@ -6166,6 +6583,146 @@ function scoreAgentSchemaTable(table, tokens) {
|
|
|
6166
6583
|
score += 1;
|
|
6167
6584
|
return score;
|
|
6168
6585
|
}
|
|
6586
|
+
function scoreAgentValueProbeTable(table) {
|
|
6587
|
+
let score = 0;
|
|
6588
|
+
if (hasAgentSchemaToken(table.name, ['account', 'customer', 'member', 'order', 'product', 'sku', 'subscriber', 'user']))
|
|
6589
|
+
score += 5;
|
|
6590
|
+
for (const column of table.columns) {
|
|
6591
|
+
if (!isAgentValueProbeColumn(column))
|
|
6592
|
+
continue;
|
|
6593
|
+
score += 2;
|
|
6594
|
+
if (hasAgentSchemaToken(column.name, ['account', 'customer', 'email', 'full', 'member', 'name', 'product', 'sku', 'user']))
|
|
6595
|
+
score += 2;
|
|
6596
|
+
}
|
|
6597
|
+
return Math.min(score, 18);
|
|
6598
|
+
}
|
|
6599
|
+
function rankAgentValueProbeColumns(schemaContext) {
|
|
6600
|
+
const ranked = [];
|
|
6601
|
+
for (const table of schemaContext) {
|
|
6602
|
+
for (const column of table.columns) {
|
|
6603
|
+
if (!isAgentValueProbeColumn(column))
|
|
6604
|
+
continue;
|
|
6605
|
+
ranked.push({
|
|
6606
|
+
table,
|
|
6607
|
+
column,
|
|
6608
|
+
score: scoreAgentValueProbeColumn(table, column),
|
|
6609
|
+
});
|
|
6610
|
+
}
|
|
6611
|
+
}
|
|
6612
|
+
return ranked.sort((a, b) => b.score - a.score || a.table.relation.localeCompare(b.table.relation) || a.column.name.localeCompare(b.column.name));
|
|
6613
|
+
}
|
|
6614
|
+
function scoreAgentValueProbeColumn(table, column) {
|
|
6615
|
+
let score = 0;
|
|
6616
|
+
if (hasAgentSchemaToken(table.name, ['account', 'customer', 'member', 'product', 'sku', 'subscriber', 'user']))
|
|
6617
|
+
score += 4;
|
|
6618
|
+
if (hasAgentSchemaToken(column.name, ['full', 'name', 'email', 'account', 'customer', 'member', 'product', 'sku', 'subscriber', 'user']))
|
|
6619
|
+
score += 8;
|
|
6620
|
+
if (hasAgentSchemaToken(column.name, ['id', 'key', 'code', 'number', 'status', 'segment', 'region', 'category', 'type']))
|
|
6621
|
+
score += 3;
|
|
6622
|
+
return score;
|
|
6623
|
+
}
|
|
6624
|
+
function isAgentValueProbeColumn(column) {
|
|
6625
|
+
const name = column.name.toLowerCase();
|
|
6626
|
+
if (/\b(password|secret|token|credential|hash|salt)\b/.test(name))
|
|
6627
|
+
return false;
|
|
6628
|
+
if (!hasAgentSchemaToken(name, [
|
|
6629
|
+
'account',
|
|
6630
|
+
'category',
|
|
6631
|
+
'channel',
|
|
6632
|
+
'city',
|
|
6633
|
+
'code',
|
|
6634
|
+
'country',
|
|
6635
|
+
'customer',
|
|
6636
|
+
'email',
|
|
6637
|
+
'full',
|
|
6638
|
+
'id',
|
|
6639
|
+
'key',
|
|
6640
|
+
'member',
|
|
6641
|
+
'name',
|
|
6642
|
+
'number',
|
|
6643
|
+
'product',
|
|
6644
|
+
'region',
|
|
6645
|
+
'segment',
|
|
6646
|
+
'sku',
|
|
6647
|
+
'state',
|
|
6648
|
+
'status',
|
|
6649
|
+
'subscriber',
|
|
6650
|
+
'type',
|
|
6651
|
+
'user',
|
|
6652
|
+
])) {
|
|
6653
|
+
return false;
|
|
6654
|
+
}
|
|
6655
|
+
const type = column.type?.toLowerCase() ?? '';
|
|
6656
|
+
if (!type)
|
|
6657
|
+
return true;
|
|
6658
|
+
return /\b(char|character|clob|email|string|text|uuid|varchar)\b/.test(type);
|
|
6659
|
+
}
|
|
6660
|
+
function buildAgentValueProbeSql(table, column, searchTerms, connection) {
|
|
6661
|
+
const relation = quoteAgentRelation(table.relation, connection);
|
|
6662
|
+
const identifier = quoteAgentIdentifier(column, connection);
|
|
6663
|
+
const castValue = `LOWER(CAST(${identifier} AS ${agentTextCastType(connection.driver)}))`;
|
|
6664
|
+
const predicates = searchTerms
|
|
6665
|
+
.slice(0, 5)
|
|
6666
|
+
.map((term) => `${castValue} LIKE ${sqlStringLiteral(`%${escapeSqlLike(term.toLowerCase())}%`)} ESCAPE '\\\\'`)
|
|
6667
|
+
.join(' OR ');
|
|
6668
|
+
return [
|
|
6669
|
+
`SELECT DISTINCT CAST(${identifier} AS ${agentTextCastType(connection.driver)}) AS value`,
|
|
6670
|
+
`FROM ${relation}`,
|
|
6671
|
+
`WHERE ${identifier} IS NOT NULL AND (${predicates})`,
|
|
6672
|
+
'LIMIT 5',
|
|
6673
|
+
].join('\n');
|
|
6674
|
+
}
|
|
6675
|
+
function agentTextCastType(driver) {
|
|
6676
|
+
switch (driver) {
|
|
6677
|
+
case 'bigquery':
|
|
6678
|
+
return 'STRING';
|
|
6679
|
+
case 'clickhouse':
|
|
6680
|
+
return 'String';
|
|
6681
|
+
case 'fabric':
|
|
6682
|
+
case 'mssql':
|
|
6683
|
+
return 'NVARCHAR(MAX)';
|
|
6684
|
+
case 'mysql':
|
|
6685
|
+
return 'CHAR';
|
|
6686
|
+
case 'sqlite':
|
|
6687
|
+
return 'TEXT';
|
|
6688
|
+
default:
|
|
6689
|
+
return 'VARCHAR';
|
|
6690
|
+
}
|
|
6691
|
+
}
|
|
6692
|
+
function quoteAgentRelation(relation, connection) {
|
|
6693
|
+
return relation.split('.').map((part) => quoteAgentIdentifier(part, connection)).join('.');
|
|
6694
|
+
}
|
|
6695
|
+
function quoteAgentIdentifier(identifier, connection) {
|
|
6696
|
+
return getDialect(connection.driver).quoteIdentifier(identifier);
|
|
6697
|
+
}
|
|
6698
|
+
function sqlStringLiteral(value) {
|
|
6699
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
6700
|
+
}
|
|
6701
|
+
function escapeSqlLike(value) {
|
|
6702
|
+
return value.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
6703
|
+
}
|
|
6704
|
+
function valueProbeRowValues(row) {
|
|
6705
|
+
if (!row || typeof row !== 'object')
|
|
6706
|
+
return [];
|
|
6707
|
+
const record = row;
|
|
6708
|
+
return Object.values(record)
|
|
6709
|
+
.filter((value) => (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'))
|
|
6710
|
+
.map(String)
|
|
6711
|
+
.map((value) => value.trim())
|
|
6712
|
+
.filter(Boolean);
|
|
6713
|
+
}
|
|
6714
|
+
function uniqueStrings(values) {
|
|
6715
|
+
const seen = new Set();
|
|
6716
|
+
const output = [];
|
|
6717
|
+
for (const value of values) {
|
|
6718
|
+
const normalized = value.toLowerCase();
|
|
6719
|
+
if (seen.has(normalized))
|
|
6720
|
+
continue;
|
|
6721
|
+
seen.add(normalized);
|
|
6722
|
+
output.push(value);
|
|
6723
|
+
}
|
|
6724
|
+
return output;
|
|
6725
|
+
}
|
|
6169
6726
|
function agentSchemaTokens(value) {
|
|
6170
6727
|
const tokens = new Set();
|
|
6171
6728
|
for (const raw of value.toLowerCase().match(/[a-z0-9_]+/g) ?? []) {
|
|
@@ -6178,10 +6735,60 @@ function agentSchemaTokens(value) {
|
|
|
6178
6735
|
}
|
|
6179
6736
|
return tokens;
|
|
6180
6737
|
}
|
|
6738
|
+
function hasAgentSchemaToken(value, expected) {
|
|
6739
|
+
const tokens = agentSchemaTokens(value);
|
|
6740
|
+
return expected.some((token) => tokens.has(token));
|
|
6741
|
+
}
|
|
6742
|
+
export function extractAgentValueSearchTerms(question) {
|
|
6743
|
+
const terms = [];
|
|
6744
|
+
for (const match of question.matchAll(/["']([^"']{3,120})["']/g)) {
|
|
6745
|
+
terms.push(match[1]);
|
|
6746
|
+
}
|
|
6747
|
+
for (const match of question.matchAll(/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/g)) {
|
|
6748
|
+
terms.push(match[0]);
|
|
6749
|
+
}
|
|
6750
|
+
for (const match of question.matchAll(/\b[A-Z][a-z0-9]+(?:\s+[A-Z][a-z0-9]+){1,3}\b/g)) {
|
|
6751
|
+
terms.push(match[0]);
|
|
6752
|
+
}
|
|
6753
|
+
for (const match of question.matchAll(/\b(?:for|named|called|only|where|customer|user|account|product)\s+([A-Za-z0-9@._-]+(?:\s+[A-Za-z0-9@._-]+){0,3})/gi)) {
|
|
6754
|
+
terms.push(match[1]);
|
|
6755
|
+
}
|
|
6756
|
+
return uniqueStrings(terms
|
|
6757
|
+
.map(cleanAgentValueSearchTerm)
|
|
6758
|
+
.filter((term) => term.length >= 3 && !AGENT_VALUE_SEARCH_STOP_PHRASES.has(term.toLowerCase()))).slice(0, 6);
|
|
6759
|
+
}
|
|
6760
|
+
function cleanAgentValueSearchTerm(term) {
|
|
6761
|
+
return term
|
|
6762
|
+
.replace(/[?.,;:]+$/g, '')
|
|
6763
|
+
.replace(/\s+/g, ' ')
|
|
6764
|
+
.trim()
|
|
6765
|
+
.replace(/^(?:account|customer|member|named|called|product|sku|subscriber|user)\s+/i, '')
|
|
6766
|
+
.replace(/\s+\b(?:last|next|this)\b.*$/i, '')
|
|
6767
|
+
.replace(/\s+\b(?:last|this)\s+(?:day|week|month|quarter|year)\b.*$/i, '')
|
|
6768
|
+
.replace(/\s+\b(?:daily|weekly|monthly|quarterly|yearly)\b.*$/i, '')
|
|
6769
|
+
.trim();
|
|
6770
|
+
}
|
|
6181
6771
|
const AGENT_SCHEMA_STOPWORDS = new Set([
|
|
6182
6772
|
'all', 'and', 'are', 'can', 'data', 'for', 'from', 'have', 'how', 'many', 'me',
|
|
6183
6773
|
'show', 'the', 'this', 'who', 'with', 'value',
|
|
6184
6774
|
]);
|
|
6775
|
+
const AGENT_VALUE_SEARCH_STOP_PHRASES = new Set([
|
|
6776
|
+
'account',
|
|
6777
|
+
'customer',
|
|
6778
|
+
'last week',
|
|
6779
|
+
'this week',
|
|
6780
|
+
'last month',
|
|
6781
|
+
'this month',
|
|
6782
|
+
'last quarter',
|
|
6783
|
+
'this quarter',
|
|
6784
|
+
'last year',
|
|
6785
|
+
'this year',
|
|
6786
|
+
'member',
|
|
6787
|
+
'product',
|
|
6788
|
+
'sku',
|
|
6789
|
+
'subscriber',
|
|
6790
|
+
'user',
|
|
6791
|
+
]);
|
|
6185
6792
|
function normalizeAgentSchemaToken(token) {
|
|
6186
6793
|
if (token === 'orders')
|
|
6187
6794
|
return 'order';
|