@duckcodeailabs/dql-cli 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +13 -4
  2. package/dist/args.d.ts +1 -0
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +4 -0
  5. package/dist/args.js.map +1 -1
  6. package/dist/assets/dql-notebook/assets/index-CTmiMNUc.js +558 -0
  7. package/dist/assets/dql-notebook/index.html +1 -1
  8. package/dist/block-templates.d.ts +8 -0
  9. package/dist/block-templates.d.ts.map +1 -0
  10. package/dist/block-templates.js +60 -0
  11. package/dist/block-templates.js.map +1 -0
  12. package/dist/commands/build.test.js +1 -1
  13. package/dist/commands/build.test.js.map +1 -1
  14. package/dist/commands/doctor.d.ts.map +1 -1
  15. package/dist/commands/doctor.js +17 -1
  16. package/dist/commands/doctor.js.map +1 -1
  17. package/dist/commands/doctor.test.js +1 -1
  18. package/dist/commands/doctor.test.js.map +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +73 -5
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/init.test.js +82 -2
  23. package/dist/commands/init.test.js.map +1 -1
  24. package/dist/commands/new.test.js +7 -7
  25. package/dist/commands/new.test.js.map +1 -1
  26. package/dist/commands/notebook.d.ts.map +1 -1
  27. package/dist/commands/notebook.js +2 -2
  28. package/dist/commands/notebook.js.map +1 -1
  29. package/dist/commands/semantic.d.ts +2 -0
  30. package/dist/commands/semantic.d.ts.map +1 -1
  31. package/dist/commands/semantic.js +115 -1
  32. package/dist/commands/semantic.js.map +1 -1
  33. package/dist/index.js +18 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/local-runtime.d.ts +35 -0
  36. package/dist/local-runtime.d.ts.map +1 -1
  37. package/dist/local-runtime.js +1668 -45
  38. package/dist/local-runtime.js.map +1 -1
  39. package/dist/local-runtime.test.js +69 -1
  40. package/dist/local-runtime.test.js.map +1 -1
  41. package/dist/semantic-import.d.ts +127 -0
  42. package/dist/semantic-import.d.ts.map +1 -0
  43. package/dist/semantic-import.js +713 -0
  44. package/dist/semantic-import.js.map +1 -0
  45. package/dist/semantic-import.test.d.ts +2 -0
  46. package/dist/semantic-import.test.d.ts.map +1 -0
  47. package/dist/semantic-import.test.js +278 -0
  48. package/dist/semantic-import.test.js.map +1 -0
  49. package/package.json +9 -8
  50. package/dist/assets/dql-notebook/assets/index-Rushqlh8.js +0 -524
@@ -1,17 +1,23 @@
1
1
  import { createServer } from 'node:http';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs';
3
- import { dirname, extname, join, normalize, resolve } from 'node:path';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
3
+ import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
4
4
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, } from '@duckcodeailabs/dql-notebook';
5
5
  import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, } from '@duckcodeailabs/dql-core';
6
+ import { listBlockTemplates } from './block-templates.js';
7
+ import { buildSemanticObjectDetail, buildSemanticTree, computeSyncDiff, loadSemanticImportManifest, performSemanticImport, previewSemanticImport, syncSemanticImport, } from './semantic-import.js';
6
8
  export async function startLocalServer(opts) {
7
- const { rootDir, executor, connection, preferredPort, projectRoot = process.cwd() } = opts;
8
- const projectConfig = loadProjectConfig(projectRoot);
9
+ const { rootDir, executor, connection: rawConnection, preferredPort, projectRoot = process.cwd() } = opts;
10
+ let connection = normalizeProjectConnection(rawConnection, projectRoot);
11
+ let projectConfig = loadProjectConfig(projectRoot);
9
12
  // Load semantic layer via provider system (dql native, dbt, cubejs, etc.)
10
13
  let semanticLayer;
11
14
  let semanticLayerErrors = [];
12
15
  let semanticDetectedProvider;
13
16
  const semanticLayerDir = join(projectRoot, 'semantic-layer');
17
+ let semanticImportManifest = loadSemanticImportManifest(projectRoot);
18
+ const userPrefsPath = join(projectRoot, '.dql-user-prefs.json');
14
19
  const semanticConfig = projectConfig.semanticLayer;
20
+ let semanticLastSyncTime = null;
15
21
  {
16
22
  const executeQuery = semanticConfig?.provider === 'snowflake'
17
23
  ? async (sql) => { const r = await executor.executeQuery(sql, [], {}, connection); return { rows: r.rows }; }
@@ -20,10 +26,13 @@ export async function startLocalServer(opts) {
20
26
  semanticLayer = result.layer;
21
27
  semanticLayerErrors = result.errors;
22
28
  semanticDetectedProvider = result.detectedProvider;
29
+ semanticLastSyncTime = result.layer ? new Date().toISOString() : null;
30
+ semanticImportManifest = loadSemanticImportManifest(projectRoot);
23
31
  // Legacy fallback if provider system returned nothing and no errors
24
32
  if (!semanticLayer && semanticLayerErrors.length === 0 && existsSync(semanticLayerDir)) {
25
33
  try {
26
34
  semanticLayer = loadSemanticLayerFromDir(semanticLayerDir);
35
+ semanticLastSyncTime = new Date().toISOString();
27
36
  }
28
37
  catch { /* continue without */ }
29
38
  }
@@ -83,6 +92,8 @@ export async function startLocalServer(opts) {
83
92
  if (refreshed.layer) {
84
93
  semanticLayer = refreshed.layer;
85
94
  semanticLayerErrors = refreshed.errors;
95
+ semanticLastSyncTime = new Date().toISOString();
96
+ semanticImportManifest = loadSemanticImportManifest(projectRoot);
86
97
  }
87
98
  else if (refreshed.errors.length > 0) {
88
99
  semanticLayerErrors = refreshed.errors;
@@ -221,32 +232,14 @@ export async function startLocalServer(opts) {
221
232
  if (req.method === 'GET' && path === '/api/schema') {
222
233
  try {
223
234
  const dataFiles = scanDataFiles(projectRoot);
224
- // Also query database tables from the connected database
225
- let dbTables = [];
226
- try {
227
- const connector = await executor.getConnector(connection);
228
- if (typeof connector.listTables === 'function') {
229
- const tables = await connector.listTables();
230
- dbTables = tables.map((t) => {
231
- const qualifiedName = t.schema ? `${t.schema}.${t.name}` : t.name;
232
- return { name: qualifiedName, path: qualifiedName, columns: [], source: 'database' };
233
- });
234
- }
235
- else {
236
- // Fallback: query information_schema directly
237
- const result = await executor.executeQuery(`SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`, [], {}, connection);
238
- dbTables = result.rows.map((row) => {
239
- const schema = String(row['table_schema'] ?? '');
240
- const name = String(row['table_name'] ?? '');
241
- const qualifiedName = schema ? `${schema}.${name}` : name;
242
- return { name: qualifiedName, path: qualifiedName, columns: [], source: 'database' };
243
- });
244
- }
245
- }
246
- catch {
247
- // Non-fatal: schema discovery from DB may fail if not connected
248
- }
249
- // Merge: data files first, then db tables (dedup by name)
235
+ const { tables, columnsByPath } = await introspectSchema(executor, connection);
236
+ const dbTables = tables.map((t) => ({
237
+ name: t.path,
238
+ path: t.path,
239
+ columns: columnsByPath.get(t.path) ?? [],
240
+ source: 'database',
241
+ objectType: t.type,
242
+ }));
250
243
  const seen = new Set(dataFiles.map((f) => f.name));
251
244
  const merged = [
252
245
  ...dataFiles.map((f) => ({ ...f, source: 'file' })),
@@ -264,25 +257,273 @@ export async function startLocalServer(opts) {
264
257
  if (req.method === 'POST' && path === '/api/blocks') {
265
258
  try {
266
259
  const body = await readJSON(req);
267
- const { name } = body;
260
+ const { name, domain, content, description, tags, metricRefs, template, } = body;
268
261
  if (!name || typeof name !== 'string') {
269
262
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
270
263
  res.end(serializeJSON({ error: 'Missing block name' }));
271
264
  return;
272
265
  }
273
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
274
- const blocksDir = join(projectRoot, 'blocks');
275
- mkdirSync(blocksDir, { recursive: true });
276
- const blockPath = join(blocksDir, `${slug}.dql`);
277
- if (existsSync(blockPath)) {
266
+ const created = createBlockArtifacts(projectRoot, {
267
+ name,
268
+ domain,
269
+ content,
270
+ description,
271
+ tags,
272
+ metricRefs,
273
+ template,
274
+ });
275
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
276
+ res.end(serializeJSON(created));
277
+ }
278
+ catch (error) {
279
+ if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
278
280
  res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
279
281
  res.end(serializeJSON({ error: 'Block already exists' }));
280
282
  return;
281
283
  }
282
- const content = `-- ${name}\nSELECT 1;\n`;
283
- writeFileSync(blockPath, content, 'utf-8');
284
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
285
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
286
+ }
287
+ return;
288
+ }
289
+ if (req.method === 'POST' && path === '/api/blocks/save-from-cell') {
290
+ try {
291
+ const body = await readJSON(req);
292
+ const { name, domain, content, description, tags, metricRefs, template, } = body;
293
+ if (!name || typeof name !== 'string' || !content || typeof content !== 'string') {
294
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
295
+ res.end(serializeJSON({ error: 'name and content are required' }));
296
+ return;
297
+ }
298
+ const created = createBlockArtifacts(projectRoot, {
299
+ name,
300
+ domain,
301
+ content,
302
+ description,
303
+ tags,
304
+ metricRefs,
305
+ template,
306
+ });
284
307
  res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
285
- res.end(serializeJSON({ path: `blocks/${slug}.dql`, content }));
308
+ res.end(serializeJSON(created));
309
+ }
310
+ catch (error) {
311
+ if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
312
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
313
+ res.end(serializeJSON({ error: 'Block already exists' }));
314
+ return;
315
+ }
316
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
317
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
318
+ }
319
+ return;
320
+ }
321
+ if (req.method === 'GET' && path === '/api/blocks/templates') {
322
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
323
+ res.end(serializeJSON({ templates: listBlockTemplates() }));
324
+ return;
325
+ }
326
+ // ── Block library (list all blocks with metadata) ────────────────────
327
+ if (req.method === 'GET' && path === '/api/blocks/library') {
328
+ try {
329
+ const blocksDir = join(projectRoot, 'blocks');
330
+ const blocks = [];
331
+ if (existsSync(blocksDir)) {
332
+ const scanDir = (dir) => {
333
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
334
+ if (entry.isDirectory()) {
335
+ scanDir(join(dir, entry.name));
336
+ }
337
+ else if (entry.name.endsWith('.dql')) {
338
+ const filePath = join(dir, entry.name);
339
+ const relPath = relative(projectRoot, filePath);
340
+ try {
341
+ const source = readFileSync(filePath, 'utf-8');
342
+ const stat = statSync(filePath);
343
+ // Quick regex parse for key block fields
344
+ const nameMatch = /block\s+"([^"]+)"/.exec(source);
345
+ const domainMatch = /domain\s*=\s*"([^"]+)"/.exec(source);
346
+ const statusMatch = /status\s*=\s*"([^"]+)"/.exec(source);
347
+ const ownerMatch = /owner\s*=\s*"([^"]+)"/.exec(source);
348
+ const descMatch = /description\s*=\s*"([^"]+)"/.exec(source);
349
+ const tagsMatch = /tags\s*=\s*\[([^\]]*)\]/.exec(source);
350
+ const parsedTags = tagsMatch
351
+ ? tagsMatch[1].split(',').map((tag) => tag.trim().replace(/^"|"$/g, '')).filter(Boolean)
352
+ : [];
353
+ blocks.push({
354
+ name: nameMatch?.[1] ?? entry.name.replace('.dql', ''),
355
+ domain: domainMatch?.[1] ?? 'uncategorized',
356
+ status: statusMatch?.[1] ?? 'draft',
357
+ owner: ownerMatch?.[1] ?? null,
358
+ tags: parsedTags,
359
+ path: relPath,
360
+ lastModified: stat.mtime.toISOString(),
361
+ description: descMatch?.[1] ?? '',
362
+ });
363
+ }
364
+ catch { /* skip unreadable files */ }
365
+ }
366
+ }
367
+ };
368
+ scanDir(blocksDir);
369
+ }
370
+ blocks.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
371
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
372
+ res.end(serializeJSON({ blocks }));
373
+ }
374
+ catch (error) {
375
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
376
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
377
+ }
378
+ return;
379
+ }
380
+ // ── Block status update ──────────────────────────────────────────────
381
+ if (req.method === 'POST' && path === '/api/blocks/status') {
382
+ try {
383
+ const body = await readJSON(req);
384
+ const blockPath = body.path;
385
+ const newStatus = body.newStatus;
386
+ const validStatuses = ['draft', 'review', 'certified', 'deprecated'];
387
+ if (!validStatuses.includes(newStatus)) {
388
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
389
+ res.end(serializeJSON({ error: `Status must be one of: ${validStatuses.join(', ')}` }));
390
+ return;
391
+ }
392
+ const absPath = resolve(projectRoot, blockPath);
393
+ if (!existsSync(absPath)) {
394
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
395
+ res.end(serializeJSON({ error: 'Block file not found' }));
396
+ return;
397
+ }
398
+ let source = readFileSync(absPath, 'utf-8');
399
+ // Update or insert status field
400
+ if (/status\s*=\s*"[^"]*"/.test(source)) {
401
+ source = source.replace(/status\s*=\s*"[^"]*"/, `status = "${newStatus}"`);
402
+ }
403
+ else {
404
+ // Insert after first { in block declaration
405
+ source = source.replace(/block\s+"[^"]*"\s*\{/, (match) => `${match}\n status = "${newStatus}"`);
406
+ }
407
+ writeFileSync(absPath, source, 'utf-8');
408
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
409
+ res.end(serializeJSON({ ok: true, status: newStatus }));
410
+ }
411
+ catch (error) {
412
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
413
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
414
+ }
415
+ return;
416
+ }
417
+ // ── Block version history (git log) ──────────────────────────────────
418
+ if (req.method === 'GET' && path === '/api/blocks/history') {
419
+ try {
420
+ const blockPath = url.searchParams.get('path');
421
+ if (!blockPath) {
422
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
423
+ res.end(serializeJSON({ error: 'path parameter is required' }));
424
+ return;
425
+ }
426
+ const { execSync } = await import('node:child_process');
427
+ const gitLog = execSync(`git log --format="%H|||%ai|||%an|||%s" -20 -- "${blockPath}"`, { cwd: projectRoot, encoding: 'utf-8', timeout: 10000 }).trim();
428
+ const entries = gitLog
429
+ ? gitLog.split('\n').map((line) => {
430
+ const [hash, date, author, message] = line.split('|||');
431
+ return { hash, date, author, message };
432
+ })
433
+ : [];
434
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
435
+ res.end(serializeJSON({ entries }));
436
+ }
437
+ catch (error) {
438
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
439
+ res.end(serializeJSON({ entries: [] }));
440
+ }
441
+ return;
442
+ }
443
+ // ── Run block tests ────────────────────────────────────────────────
444
+ if (req.method === 'POST' && path === '/api/blocks/run-tests') {
445
+ try {
446
+ const body = await readJSON(req);
447
+ const source = body.source;
448
+ if (!source) {
449
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
450
+ res.end(serializeJSON({ error: 'source is required' }));
451
+ return;
452
+ }
453
+ // Parse the block to extract tests and query SQL
454
+ const parser = new Parser(source, '<run-tests>');
455
+ const ast = parser.parse();
456
+ const blockNode = ast.statements.find((n) => n.kind === 'BlockDecl');
457
+ if (!blockNode) {
458
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
459
+ res.end(serializeJSON({ error: 'No block declaration found in source' }));
460
+ return;
461
+ }
462
+ const testNodes = blockNode.tests ?? [];
463
+ if (testNodes.length === 0) {
464
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
465
+ res.end(serializeJSON({ assertions: [], passed: 0, failed: 0, duration: 0 }));
466
+ return;
467
+ }
468
+ // Get the block's base SQL
469
+ const baseSql = blockNode.query?.rawSQL?.trim() ?? '';
470
+ if (!baseSql) {
471
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
472
+ res.end(serializeJSON({ error: 'Block has no query SQL to test against' }));
473
+ return;
474
+ }
475
+ const resolvedSql = resolveProjectRelativeSqlPaths(baseSql, projectRoot, projectConfig.dataDir);
476
+ // Build and run assertions
477
+ const start = Date.now();
478
+ const results = [];
479
+ for (const test of testNodes) {
480
+ const field = test.field;
481
+ const op = test.operator;
482
+ const expected = test.expected;
483
+ // Extract the expected value from the AST node
484
+ const expectedValue = typeof expected === 'object' && expected !== null
485
+ ? (expected.value ?? String(expected))
486
+ : expected;
487
+ // Build a SQL query that computes the aggregate for this assertion
488
+ const testSql = `SELECT ${field} AS test_value FROM (${resolvedSql}) AS __test_block`;
489
+ try {
490
+ const result = await executor.executeQuery(testSql, [], {}, connection);
491
+ const actualRaw = result.rows?.[0];
492
+ const actual = actualRaw ? Object.values(actualRaw)[0] : undefined;
493
+ const actualNum = Number(actual);
494
+ const expectedNum = Number(expectedValue);
495
+ let passed = false;
496
+ switch (op) {
497
+ case '>':
498
+ passed = actualNum > expectedNum;
499
+ break;
500
+ case '<':
501
+ passed = actualNum < expectedNum;
502
+ break;
503
+ case '>=':
504
+ passed = actualNum >= expectedNum;
505
+ break;
506
+ case '<=':
507
+ passed = actualNum <= expectedNum;
508
+ break;
509
+ case '==':
510
+ passed = String(actual) === String(expectedValue);
511
+ break;
512
+ case '!=':
513
+ passed = String(actual) !== String(expectedValue);
514
+ break;
515
+ default: passed = false;
516
+ }
517
+ results.push({ field, operator: op, expected: String(expectedValue), passed, actual: String(actual ?? '') });
518
+ }
519
+ catch (err) {
520
+ results.push({ field, operator: op, expected: String(expectedValue), passed: false, actual: `Error: ${err instanceof Error ? err.message : String(err)}` });
521
+ }
522
+ }
523
+ const duration = Date.now() - start;
524
+ const passed = results.filter((r) => r.passed).length;
525
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
526
+ res.end(serializeJSON({ assertions: results, passed, failed: results.length - passed, duration }));
286
527
  }
287
528
  catch (error) {
288
529
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -290,6 +531,132 @@ export async function startLocalServer(opts) {
290
531
  }
291
532
  return;
292
533
  }
534
+ if (req.method === 'GET' && path === '/api/block-studio/catalog') {
535
+ try {
536
+ const cfg = loadProjectConfig(projectRoot);
537
+ const connections = cfg.connections ?? {};
538
+ if (Object.keys(connections).length === 0 && cfg.defaultConnection) {
539
+ connections.default = cfg.defaultConnection;
540
+ }
541
+ const defaultKey = cfg.defaultConnection ? 'default' : Object.keys(connections)[0] ?? 'default';
542
+ const userPrefs = readUserPrefs(userPrefsPath);
543
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
544
+ res.end(serializeJSON({
545
+ semanticTree: semanticLayer ? buildSemanticTree(semanticLayer, semanticImportManifest) : null,
546
+ databaseTree: await buildDatabaseSchemaTree(projectRoot, executor, connection),
547
+ connection: {
548
+ default: defaultKey,
549
+ current: defaultKey,
550
+ connections,
551
+ },
552
+ favorites: userPrefs.favorites,
553
+ recentlyUsed: userPrefs.recentlyUsed,
554
+ }));
555
+ }
556
+ catch (error) {
557
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
558
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
559
+ }
560
+ return;
561
+ }
562
+ if (req.method === 'GET' && path === '/api/block-studio/open') {
563
+ try {
564
+ const relativePath = url.searchParams.get('path');
565
+ if (!relativePath) {
566
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
567
+ res.end(serializeJSON({ error: 'Missing block path.' }));
568
+ return;
569
+ }
570
+ const payload = openBlockStudioDocument(projectRoot, relativePath, semanticLayer);
571
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
572
+ res.end(serializeJSON(payload));
573
+ }
574
+ catch (error) {
575
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
576
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
577
+ }
578
+ return;
579
+ }
580
+ if (req.method === 'POST' && path === '/api/block-studio/validate') {
581
+ try {
582
+ const body = await readJSON(req);
583
+ const source = typeof body.source === 'string' ? body.source : '';
584
+ const validation = validateBlockStudioSource(source, semanticLayer);
585
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
586
+ res.end(serializeJSON(validation));
587
+ }
588
+ catch (error) {
589
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
590
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
591
+ }
592
+ return;
593
+ }
594
+ if (req.method === 'POST' && path === '/api/block-studio/run') {
595
+ try {
596
+ const body = await readJSON(req);
597
+ const source = typeof body.source === 'string' ? body.source : '';
598
+ const validation = validateBlockStudioSource(source, semanticLayer);
599
+ if (!validation.executableSql) {
600
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
601
+ res.end(serializeJSON({ error: 'No executable SQL found in block source.' }));
602
+ return;
603
+ }
604
+ const sql = resolveProjectRelativeSqlPaths(validation.executableSql, projectRoot, projectConfig.dataDir);
605
+ const result = await executor.executeQuery(sql, [], {}, connection);
606
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
607
+ res.end(serializeJSON({
608
+ sql: validation.executableSql,
609
+ result: normalizeQueryResult(result),
610
+ chartConfig: validation.chartConfig ?? null,
611
+ }));
612
+ }
613
+ catch (error) {
614
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
615
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
616
+ }
617
+ return;
618
+ }
619
+ if (req.method === 'POST' && path === '/api/block-studio/save') {
620
+ try {
621
+ const body = await readJSON(req);
622
+ const source = typeof body.source === 'string' ? body.source : '';
623
+ const metadata = body.metadata && typeof body.metadata === 'object'
624
+ ? body.metadata
625
+ : {};
626
+ if (!source.trim()) {
627
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
628
+ res.end(serializeJSON({ error: 'Block source is required.' }));
629
+ return;
630
+ }
631
+ if (!metadata.name || typeof metadata.name !== 'string') {
632
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
633
+ res.end(serializeJSON({ error: 'Block name is required.' }));
634
+ return;
635
+ }
636
+ const savedPath = saveBlockStudioArtifacts(projectRoot, {
637
+ currentPath: typeof body.path === 'string' ? body.path : undefined,
638
+ source,
639
+ name: metadata.name,
640
+ domain: metadata.domain,
641
+ description: metadata.description,
642
+ owner: metadata.owner,
643
+ tags: Array.isArray(metadata.tags) ? metadata.tags.map(String) : [],
644
+ });
645
+ const payload = openBlockStudioDocument(projectRoot, savedPath, semanticLayer);
646
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
647
+ res.end(serializeJSON(payload));
648
+ }
649
+ catch (error) {
650
+ if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
651
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
652
+ res.end(serializeJSON({ error: 'Block already exists' }));
653
+ return;
654
+ }
655
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
656
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
657
+ }
658
+ return;
659
+ }
293
660
  if (req.method === 'GET' && path === '/api/connections') {
294
661
  const cfg = loadProjectConfig(projectRoot);
295
662
  const raw = cfg;
@@ -318,6 +685,35 @@ export async function startLocalServer(opts) {
318
685
  raw.connections = body.connections;
319
686
  }
320
687
  writeFileSync(configPath, JSON.stringify(raw, null, 2) + '\n', 'utf-8');
688
+ // Hot-swap: re-read the config and re-initialize the active connection
689
+ projectConfig = loadProjectConfig(projectRoot);
690
+ const newDefault = projectConfig.defaultConnection;
691
+ if (newDefault) {
692
+ connection = normalizeProjectConnection(newDefault, projectRoot);
693
+ // Auto-register data files if DuckDB/file driver
694
+ if (connection.driver === 'file' || connection.driver === 'duckdb') {
695
+ const dataDir = projectConfig.dataDir
696
+ ? resolve(projectRoot, projectConfig.dataDir)
697
+ : join(projectRoot, 'data');
698
+ if (existsSync(dataDir)) {
699
+ try {
700
+ const files = readdirSync(dataDir, { withFileTypes: true })
701
+ .filter((e) => e.isFile() && /\.(csv|parquet)$/i.test(e.name));
702
+ for (const file of files) {
703
+ const tableName = file.name.replace(/\.(csv|parquet)$/i, '');
704
+ const absPath = join(dataDir, file.name).replaceAll('\\', '/');
705
+ const reader = file.name.endsWith('.parquet') ? 'read_parquet' : 'read_csv_auto';
706
+ const ddl = `CREATE OR REPLACE VIEW "${tableName}" AS SELECT * FROM ${reader}('${absPath}')`;
707
+ try {
708
+ await executor.executeQuery(ddl, [], {}, connection);
709
+ }
710
+ catch { /* non-fatal */ }
711
+ }
712
+ }
713
+ catch { /* non-fatal */ }
714
+ }
715
+ }
716
+ }
321
717
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
322
718
  res.end(serializeJSON({ ok: true }));
323
719
  }
@@ -329,6 +725,7 @@ export async function startLocalServer(opts) {
329
725
  }
330
726
  // ── Semantic layer discovery API ─────────────────────────────────────────
331
727
  if (req.method === 'GET' && path === '/api/semantic-layer') {
728
+ const userPrefs = readUserPrefs(userPrefsPath);
332
729
  if (!semanticLayer) {
333
730
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
334
731
  res.end(serializeJSON({
@@ -338,6 +735,11 @@ export async function startLocalServer(opts) {
338
735
  metrics: [],
339
736
  dimensions: [],
340
737
  hierarchies: [],
738
+ domains: [],
739
+ tags: [],
740
+ favorites: userPrefs.favorites,
741
+ recentlyUsed: userPrefs.recentlyUsed,
742
+ lastSyncTime: semanticLastSyncTime,
341
743
  }));
342
744
  return;
343
745
  }
@@ -346,6 +748,7 @@ export async function startLocalServer(opts) {
346
748
  label: m.label,
347
749
  description: m.description,
348
750
  domain: m.domain,
751
+ sql: m.sql,
349
752
  type: m.type,
350
753
  table: m.table,
351
754
  tags: m.tags ?? [],
@@ -355,9 +758,12 @@ export async function startLocalServer(opts) {
355
758
  name: d.name,
356
759
  label: d.label,
357
760
  description: d.description,
761
+ domain: d.domain,
762
+ sql: d.sql,
358
763
  type: d.type,
359
764
  table: d.table,
360
765
  tags: d.tags ?? [],
766
+ owner: d.owner ?? null,
361
767
  }));
362
768
  const hierarchies = semanticLayer.listHierarchies().map((h) => ({
363
769
  name: h.name,
@@ -374,18 +780,370 @@ export async function startLocalServer(opts) {
374
780
  metrics,
375
781
  dimensions,
376
782
  hierarchies,
783
+ domains: semanticLayer.listDomains(),
784
+ tags: semanticLayer.listTags(),
785
+ favorites: userPrefs.favorites,
786
+ recentlyUsed: userPrefs.recentlyUsed,
787
+ lastSyncTime: semanticLastSyncTime,
377
788
  }));
378
789
  return;
379
790
  }
791
+ if (req.method === 'GET' && path === '/api/semantic-layer/tree') {
792
+ if (!semanticLayer) {
793
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
794
+ res.end(serializeJSON({
795
+ tree: {
796
+ id: 'provider:dql',
797
+ label: 'semantic layer',
798
+ kind: 'provider',
799
+ count: 0,
800
+ children: [],
801
+ },
802
+ }));
803
+ return;
804
+ }
805
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
806
+ res.end(serializeJSON({
807
+ tree: buildSemanticTree(semanticLayer, semanticImportManifest),
808
+ }));
809
+ return;
810
+ }
811
+ if (req.method === 'GET' && path.startsWith('/api/semantic-layer/object/')) {
812
+ if (!semanticLayer) {
813
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
814
+ res.end(serializeJSON({ error: 'No semantic layer configured.' }));
815
+ return;
816
+ }
817
+ const id = decodeURIComponent(path.slice('/api/semantic-layer/object/'.length));
818
+ const detail = buildSemanticObjectDetail(semanticLayer, semanticImportManifest, id);
819
+ if (!detail) {
820
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
821
+ res.end(serializeJSON({ error: `Unknown semantic object: ${id}` }));
822
+ return;
823
+ }
824
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
825
+ res.end(serializeJSON(detail));
826
+ return;
827
+ }
828
+ if (req.method === 'POST' && path === '/api/semantic-layer/import') {
829
+ try {
830
+ const body = await readJSON(req);
831
+ const provider = body.provider;
832
+ if (provider !== 'dbt' && provider !== 'cubejs' && provider !== 'snowflake') {
833
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
834
+ res.end(serializeJSON({ error: 'provider must be one of dbt, cubejs, snowflake' }));
835
+ return;
836
+ }
837
+ const sourceConfig = provider === 'snowflake'
838
+ ? {
839
+ provider,
840
+ projectPath: body.projectPath ?? projectConfig.semanticLayer?.projectPath,
841
+ connection: body.connection ?? projectConfig.semanticLayer?.connection,
842
+ }
843
+ : {
844
+ provider,
845
+ projectPath: typeof body.projectPath === 'string' ? body.projectPath : projectConfig.semanticLayer?.projectPath,
846
+ repoUrl: typeof body.repoUrl === 'string' ? body.repoUrl : projectConfig.semanticLayer?.repoUrl,
847
+ branch: typeof body.branch === 'string' ? body.branch : projectConfig.semanticLayer?.branch,
848
+ subPath: typeof body.subPath === 'string' ? body.subPath : projectConfig.semanticLayer?.subPath,
849
+ source: body.repoUrl || projectConfig.semanticLayer?.repoUrl
850
+ ? (body.source ?? projectConfig.semanticLayer?.source ?? 'github')
851
+ : 'local',
852
+ };
853
+ const executeQuery = provider === 'snowflake'
854
+ ? async (sql) => {
855
+ const result = await executor.executeQuery(sql, [], {}, connection);
856
+ return { rows: result.rows };
857
+ }
858
+ : undefined;
859
+ const importResult = await performSemanticImport({
860
+ targetProjectRoot: projectRoot,
861
+ provider,
862
+ sourceConfig,
863
+ executeQuery,
864
+ });
865
+ // Re-resolve using project's actual semantic config (not hardcoded 'dql')
866
+ const projSemConfig = loadProjectConfig(projectRoot)?.semanticLayer ?? { provider: 'dql', path: './semantic-layer' };
867
+ const refreshed = await resolveSemanticLayerAsync(projSemConfig, projectRoot);
868
+ semanticLayer = refreshed.layer;
869
+ semanticLayerErrors = refreshed.errors;
870
+ semanticDetectedProvider = refreshed.detectedProvider ?? 'dql';
871
+ semanticLastSyncTime = importResult.manifest.importedAt;
872
+ semanticImportManifest = importResult.manifest;
873
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
874
+ res.end(serializeJSON(importResult));
875
+ }
876
+ catch (error) {
877
+ const message = error instanceof Error ? error.message : String(error);
878
+ const hint = message.includes('conflict')
879
+ ? 'A file conflict was detected. Remove or rename the conflicting file and retry.'
880
+ : message.includes('dbt_project.yml')
881
+ ? 'Ensure your dbt project path contains a valid dbt_project.yml file.'
882
+ : message.includes('query executor')
883
+ ? 'A Snowflake connection is required. Configure one in the Connection panel first.'
884
+ : 'Check the provider path and ensure the source files are accessible.';
885
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
886
+ res.end(serializeJSON({ error: message, hint }));
887
+ }
888
+ return;
889
+ }
890
+ if (req.method === 'POST' && path === '/api/semantic-layer/sync') {
891
+ try {
892
+ const executeQuery = semanticImportManifest?.provider === 'snowflake'
893
+ ? async (sql) => {
894
+ const result = await executor.executeQuery(sql, [], {}, connection);
895
+ return { rows: result.rows };
896
+ }
897
+ : undefined;
898
+ const importResult = await syncSemanticImport({
899
+ targetProjectRoot: projectRoot,
900
+ executeQuery,
901
+ });
902
+ // Re-resolve using project's actual semantic config (not hardcoded 'dql')
903
+ const projSemConfig = loadProjectConfig(projectRoot)?.semanticLayer ?? { provider: 'dql', path: './semantic-layer' };
904
+ const refreshed = await resolveSemanticLayerAsync(projSemConfig, projectRoot);
905
+ semanticLayer = refreshed.layer;
906
+ semanticLayerErrors = refreshed.errors;
907
+ semanticDetectedProvider = refreshed.detectedProvider ?? 'dql';
908
+ semanticLastSyncTime = importResult.manifest.importedAt;
909
+ semanticImportManifest = importResult.manifest;
910
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
911
+ res.end(serializeJSON(importResult));
912
+ }
913
+ catch (error) {
914
+ const message = error instanceof Error ? error.message : String(error);
915
+ const hint = message.includes('No semantic import manifest')
916
+ ? 'No previous import found. Use the Setup Wizard to import a semantic layer first.'
917
+ : 'Check the source configuration and retry.';
918
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
919
+ res.end(serializeJSON({ error: message, hint }));
920
+ }
921
+ return;
922
+ }
923
+ // ── Semantic layer import preview (dry-run) ──────────────────────────
924
+ if (req.method === 'POST' && path === '/api/semantic-layer/import-preview') {
925
+ try {
926
+ const body = await readJSON(req);
927
+ const provider = body.provider;
928
+ if (provider !== 'dbt' && provider !== 'cubejs' && provider !== 'snowflake') {
929
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
930
+ res.end(serializeJSON({ error: 'provider must be one of dbt, cubejs, snowflake' }));
931
+ return;
932
+ }
933
+ const sourceConfig = provider === 'snowflake'
934
+ ? {
935
+ provider,
936
+ projectPath: body.projectPath ?? projectConfig.semanticLayer?.projectPath,
937
+ connection: body.connection ?? projectConfig.semanticLayer?.connection,
938
+ }
939
+ : {
940
+ provider,
941
+ projectPath: typeof body.projectPath === 'string' ? body.projectPath : projectConfig.semanticLayer?.projectPath,
942
+ repoUrl: typeof body.repoUrl === 'string' ? body.repoUrl : projectConfig.semanticLayer?.repoUrl,
943
+ branch: typeof body.branch === 'string' ? body.branch : projectConfig.semanticLayer?.branch,
944
+ subPath: typeof body.subPath === 'string' ? body.subPath : projectConfig.semanticLayer?.subPath,
945
+ source: body.repoUrl || projectConfig.semanticLayer?.repoUrl
946
+ ? (body.source ?? projectConfig.semanticLayer?.source ?? 'github')
947
+ : 'local',
948
+ };
949
+ const executeQuery = provider === 'snowflake'
950
+ ? async (sql) => {
951
+ const result = await executor.executeQuery(sql, [], {}, connection);
952
+ return { rows: result.rows };
953
+ }
954
+ : undefined;
955
+ const preview = await previewSemanticImport({
956
+ targetProjectRoot: projectRoot,
957
+ provider,
958
+ sourceConfig,
959
+ executeQuery,
960
+ });
961
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
962
+ res.end(serializeJSON(preview));
963
+ }
964
+ catch (error) {
965
+ const message = error instanceof Error ? error.message : String(error);
966
+ const hint = message.includes('dbt_project.yml')
967
+ ? 'Ensure your dbt project path contains a valid dbt_project.yml file.'
968
+ : message.includes('model/') || message.includes('schema/')
969
+ ? 'Ensure your Cube.js project has a model/ or schema/ directory.'
970
+ : message.includes('query executor')
971
+ ? 'A Snowflake connection is required. Configure one in the Connection panel first.'
972
+ : 'Check the provider path and ensure the source files are accessible.';
973
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
974
+ res.end(serializeJSON({ error: message, hint }));
975
+ }
976
+ return;
977
+ }
978
+ // ── Semantic layer sync diff preview ────────────────────────────────
979
+ if (req.method === 'POST' && path === '/api/semantic-layer/sync-preview') {
980
+ try {
981
+ const executeQuery = semanticImportManifest?.provider === 'snowflake'
982
+ ? async (sql) => {
983
+ const result = await executor.executeQuery(sql, [], {}, connection);
984
+ return { rows: result.rows };
985
+ }
986
+ : undefined;
987
+ const diff = await computeSyncDiff({
988
+ targetProjectRoot: projectRoot,
989
+ executeQuery,
990
+ });
991
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
992
+ res.end(serializeJSON(diff));
993
+ }
994
+ catch (error) {
995
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
996
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
997
+ }
998
+ return;
999
+ }
1000
+ if (req.method === 'GET' && path === '/api/semantic-layer/search') {
1001
+ if (!semanticLayer) {
1002
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1003
+ res.end(serializeJSON({ metrics: [], dimensions: [], hierarchies: [] }));
1004
+ return;
1005
+ }
1006
+ const q = url.searchParams.get('q') ?? '';
1007
+ const domain = url.searchParams.get('domain') ?? '';
1008
+ const tag = url.searchParams.get('tag') ?? '';
1009
+ const type = url.searchParams.get('type') ?? '';
1010
+ const results = semanticLayer.searchAdvanced(q, {
1011
+ domains: domain ? [domain] : undefined,
1012
+ tags: tag ? [tag] : undefined,
1013
+ types: type === 'metric' || type === 'dimension' || type === 'hierarchy' ? [type] : undefined,
1014
+ });
1015
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1016
+ res.end(serializeJSON({
1017
+ metrics: results.metrics.map((m) => ({
1018
+ name: m.name,
1019
+ label: m.label,
1020
+ description: m.description,
1021
+ domain: m.domain,
1022
+ sql: m.sql,
1023
+ type: m.type,
1024
+ table: m.table,
1025
+ tags: m.tags ?? [],
1026
+ owner: m.owner ?? null,
1027
+ })),
1028
+ dimensions: results.dimensions.map((d) => ({
1029
+ name: d.name,
1030
+ label: d.label,
1031
+ description: d.description,
1032
+ domain: d.domain,
1033
+ sql: d.sql,
1034
+ type: d.type,
1035
+ table: d.table,
1036
+ tags: d.tags ?? [],
1037
+ owner: d.owner ?? null,
1038
+ })),
1039
+ hierarchies: results.hierarchies.map((h) => ({
1040
+ name: h.name,
1041
+ label: h.label,
1042
+ description: h.description,
1043
+ domain: h.domain,
1044
+ levels: h.levels.map((l) => ({ name: l.name, label: l.label })),
1045
+ })),
1046
+ }));
1047
+ return;
1048
+ }
1049
+ if (req.method === 'GET' && path === '/api/semantic-layer/compatible-dims') {
1050
+ if (!semanticLayer) {
1051
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1052
+ res.end(serializeJSON({ dimensions: [] }));
1053
+ return;
1054
+ }
1055
+ const metrics = (url.searchParams.get('metrics') ?? '')
1056
+ .split(',')
1057
+ .map((value) => value.trim())
1058
+ .filter(Boolean);
1059
+ const dimensions = semanticLayer.listCompatibleDimensions(metrics).map((d) => ({
1060
+ name: d.name,
1061
+ label: d.label,
1062
+ description: d.description,
1063
+ domain: d.domain,
1064
+ sql: d.sql,
1065
+ type: d.type,
1066
+ table: d.table,
1067
+ tags: d.tags ?? [],
1068
+ owner: d.owner ?? null,
1069
+ }));
1070
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1071
+ res.end(serializeJSON({ dimensions }));
1072
+ return;
1073
+ }
1074
+ if (req.method === 'GET' && path === '/api/user-prefs/favorites') {
1075
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1076
+ res.end(serializeJSON({ favorites: readUserPrefs(userPrefsPath).favorites }));
1077
+ return;
1078
+ }
1079
+ if (req.method === 'POST' && path === '/api/user-prefs/favorites') {
1080
+ try {
1081
+ const body = await readJSON(req);
1082
+ const prefs = readUserPrefs(userPrefsPath);
1083
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
1084
+ if (name) {
1085
+ prefs.favorites = prefs.favorites.includes(name)
1086
+ ? prefs.favorites.filter((item) => item !== name)
1087
+ : [...prefs.favorites, name].sort((a, b) => a.localeCompare(b));
1088
+ writeUserPrefs(userPrefsPath, prefs);
1089
+ }
1090
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1091
+ res.end(serializeJSON({ favorites: prefs.favorites }));
1092
+ }
1093
+ catch (error) {
1094
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1095
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1096
+ }
1097
+ return;
1098
+ }
1099
+ if (req.method === 'GET' && path === '/api/user-prefs/recent') {
1100
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1101
+ res.end(serializeJSON({ recentlyUsed: readUserPrefs(userPrefsPath).recentlyUsed }));
1102
+ return;
1103
+ }
1104
+ if (req.method === 'POST' && path === '/api/user-prefs/recent') {
1105
+ try {
1106
+ const body = await readJSON(req);
1107
+ const prefs = readUserPrefs(userPrefsPath);
1108
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
1109
+ if (name) {
1110
+ prefs.recentlyUsed = [name, ...prefs.recentlyUsed.filter((item) => item !== name)].slice(0, 12);
1111
+ writeUserPrefs(userPrefsPath, prefs);
1112
+ }
1113
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1114
+ res.end(serializeJSON({ recentlyUsed: prefs.recentlyUsed }));
1115
+ }
1116
+ catch (error) {
1117
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1118
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1119
+ }
1120
+ return;
1121
+ }
380
1122
  // ── Semantic completions for SQL cells ─────────────────────────────────────
381
1123
  if (req.method === 'GET' && path === '/api/semantic-completions') {
382
1124
  const completions = [];
383
1125
  if (semanticLayer) {
384
1126
  for (const m of semanticLayer.listMetrics()) {
385
- completions.push({ type: 'metric', name: m.name, label: m.label, description: m.description ?? '', sql: m.sql });
1127
+ completions.push({
1128
+ type: 'metric',
1129
+ name: m.name,
1130
+ label: m.label,
1131
+ description: m.description ?? '',
1132
+ sql: m.sql,
1133
+ domain: m.domain,
1134
+ tags: m.tags ?? [],
1135
+ });
386
1136
  }
387
1137
  for (const d of semanticLayer.listDimensions()) {
388
- completions.push({ type: 'dimension', name: d.name, label: d.label, description: d.description ?? '', sql: d.sql });
1138
+ completions.push({
1139
+ type: 'dimension',
1140
+ name: d.name,
1141
+ label: d.label,
1142
+ description: d.description ?? '',
1143
+ sql: d.sql,
1144
+ domain: d.domain,
1145
+ tags: d.tags ?? [],
1146
+ });
389
1147
  }
390
1148
  }
391
1149
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -393,6 +1151,56 @@ export async function startLocalServer(opts) {
393
1151
  return;
394
1152
  }
395
1153
  // ── end dql-notebook API ──────────────────────────────────────────────────
1154
+ // GET /api/describe-table?table=schema.table — returns columns for a specific table
1155
+ if (req.method === 'GET' && path === '/api/describe-table') {
1156
+ try {
1157
+ const tablePath = url.searchParams.get('table') ?? '';
1158
+ const schemaName = url.searchParams.get('schema') ?? undefined;
1159
+ if (!tablePath) {
1160
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1161
+ res.end(serializeJSON({ error: 'Missing table parameter' }));
1162
+ return;
1163
+ }
1164
+ // Try connector.listColumns() first
1165
+ let columns = [];
1166
+ try {
1167
+ const connector = await executor.getConnector(connection);
1168
+ if (typeof connector.listColumns === 'function') {
1169
+ const rawCols = await connector.listColumns(schemaName, tablePath);
1170
+ columns = rawCols.map((c) => ({ name: c.name, type: c.dataType }));
1171
+ }
1172
+ }
1173
+ catch {
1174
+ // fallback below
1175
+ }
1176
+ // Fallback: DESCRIBE via SQL (works for DuckDB, PG)
1177
+ if (columns.length === 0) {
1178
+ try {
1179
+ const isFile = /\.(csv|parquet|json)$/i.test(tablePath) || tablePath.startsWith('data/');
1180
+ const safePath = tablePath.replace(/'/g, "''");
1181
+ const qualifiedIdentifier = tablePath.split('.').map((p) => `"${p.replace(/"/g, '""')}"`).join('.');
1182
+ const sql = isFile
1183
+ ? `DESCRIBE SELECT * FROM read_csv_auto('${safePath}') LIMIT 0`
1184
+ : `DESCRIBE ${qualifiedIdentifier}`;
1185
+ const result = await executor.executeQuery(sql, [], {}, connection);
1186
+ columns = result.rows.map((row) => ({
1187
+ name: String(row['column_name'] ?? row['Field'] ?? ''),
1188
+ type: String(row['column_type'] ?? row['Type'] ?? ''),
1189
+ }));
1190
+ }
1191
+ catch {
1192
+ // empty columns
1193
+ }
1194
+ }
1195
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1196
+ res.end(serializeJSON(columns));
1197
+ }
1198
+ catch (error) {
1199
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1200
+ res.end(serializeJSON({ error: String(error) }));
1201
+ }
1202
+ return;
1203
+ }
396
1204
  if (req.method === 'POST' && path === '/api/query') {
397
1205
  try {
398
1206
  const body = await readJSON(req);
@@ -487,6 +1295,119 @@ export async function startLocalServer(opts) {
487
1295
  }
488
1296
  return;
489
1297
  }
1298
+ if (req.method === 'POST' && path === '/api/semantic-builder/preview') {
1299
+ try {
1300
+ if (!semanticLayer) {
1301
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1302
+ res.end(serializeJSON({ error: 'No semantic layer configured.' }));
1303
+ return;
1304
+ }
1305
+ const body = await readJSON(req);
1306
+ const { metrics = [], dimensions = [], filters = [], limit, timeDimension, orderBy } = body;
1307
+ const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
1308
+ const driver = targetConnection.driver;
1309
+ let tableMapping;
1310
+ try {
1311
+ const tablesResult = await executor.executeQuery(`SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog')`, [], {}, targetConnection);
1312
+ const schemaQualified = new Map();
1313
+ for (const row of tablesResult.rows) {
1314
+ const schema = String(row['table_schema'] ?? '');
1315
+ const name = String(row['table_name'] ?? '');
1316
+ schemaQualified.set(name, schema ? `${schema}.${name}` : name);
1317
+ }
1318
+ tableMapping = {};
1319
+ for (const metric of semanticLayer.listMetrics()) {
1320
+ if (schemaQualified.has(metric.table))
1321
+ tableMapping[metric.table] = schemaQualified.get(metric.table);
1322
+ }
1323
+ for (const dimension of semanticLayer.listDimensions()) {
1324
+ if (schemaQualified.has(dimension.table))
1325
+ tableMapping[dimension.table] = schemaQualified.get(dimension.table);
1326
+ }
1327
+ if (Object.keys(tableMapping).length === 0)
1328
+ tableMapping = undefined;
1329
+ }
1330
+ catch {
1331
+ tableMapping = undefined;
1332
+ }
1333
+ const composed = semanticLayer.composeQuery({ metrics, dimensions, filters, limit, timeDimension, orderBy, driver, tableMapping });
1334
+ if (!composed) {
1335
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1336
+ res.end(serializeJSON({ error: 'Could not compose semantic block preview SQL.' }));
1337
+ return;
1338
+ }
1339
+ const prepared = prepareLocalExecution(composed.sql, targetConnection, projectRoot, projectConfig);
1340
+ const result = await executor.executeQuery(prepared.sql, [], {}, prepared.connection);
1341
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1342
+ res.end(serializeJSON({
1343
+ sql: composed.sql,
1344
+ joins: composed.joins,
1345
+ tables: composed.tables,
1346
+ result: normalizeQueryResult(result),
1347
+ }));
1348
+ }
1349
+ catch (error) {
1350
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1351
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1352
+ }
1353
+ return;
1354
+ }
1355
+ if (req.method === 'POST' && path === '/api/semantic-builder/save') {
1356
+ try {
1357
+ if (!semanticLayer) {
1358
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1359
+ res.end(serializeJSON({ error: 'No semantic layer configured.' }));
1360
+ return;
1361
+ }
1362
+ const body = await readJSON(req);
1363
+ const { name, domain, description, owner, tags, metrics = [], dimensions = [], timeDimension, filters = [], chart = 'table', blockType = 'semantic', } = body;
1364
+ if (!name || metrics.length === 0) {
1365
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1366
+ res.end(serializeJSON({ error: 'name and at least one metric are required.' }));
1367
+ return;
1368
+ }
1369
+ const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
1370
+ const composed = semanticLayer.composeQuery({
1371
+ metrics,
1372
+ dimensions,
1373
+ filters,
1374
+ timeDimension,
1375
+ driver: targetConnection.driver,
1376
+ });
1377
+ if (!composed) {
1378
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1379
+ res.end(serializeJSON({ error: 'Could not compose semantic block SQL.' }));
1380
+ return;
1381
+ }
1382
+ const created = createSemanticBuilderBlock(projectRoot, {
1383
+ name,
1384
+ domain,
1385
+ description,
1386
+ owner,
1387
+ tags,
1388
+ metrics,
1389
+ dimensions,
1390
+ timeDimension,
1391
+ chart,
1392
+ blockType,
1393
+ sql: composed.sql,
1394
+ tables: composed.tables,
1395
+ provider: semanticImportManifest?.provider ?? semanticDetectedProvider ?? 'dql',
1396
+ });
1397
+ res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
1398
+ res.end(serializeJSON(created));
1399
+ }
1400
+ catch (error) {
1401
+ if (error instanceof Error && error.message === 'BLOCK_EXISTS') {
1402
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
1403
+ res.end(serializeJSON({ error: 'Block already exists' }));
1404
+ return;
1405
+ }
1406
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1407
+ res.end(serializeJSON({ error: error instanceof Error ? error.message : String(error) }));
1408
+ }
1409
+ return;
1410
+ }
490
1411
  if (req.method === 'POST' && path === '/api/test-connection') {
491
1412
  try {
492
1413
  const body = await readJSON(req);
@@ -990,23 +1911,32 @@ function scanNotebookFiles(projectRoot) {
990
1911
  const dir = join(projectRoot, folder);
991
1912
  if (!existsSync(dir))
992
1913
  continue;
1914
+ collect(dir, folder, type);
1915
+ }
1916
+ return result;
1917
+ function collect(currentDir, relativeDir, type) {
993
1918
  try {
994
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1919
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
1920
+ const fullPath = join(currentDir, entry.name);
1921
+ const relativePath = `${relativeDir}/${entry.name}`;
1922
+ if (entry.isDirectory()) {
1923
+ collect(fullPath, relativePath, type);
1924
+ continue;
1925
+ }
995
1926
  if (!entry.isFile())
996
1927
  continue;
997
1928
  if (!entry.name.endsWith('.dql') && !entry.name.endsWith('.dqlnb'))
998
1929
  continue;
999
1930
  result.push({
1000
1931
  name: entry.name.replace(/\.(dql|dqlnb)$/, ''),
1001
- path: `${folder}/${entry.name}`,
1932
+ path: relativePath,
1002
1933
  type,
1003
- folder,
1934
+ folder: relativeDir.split('/')[0] ?? relativeDir,
1004
1935
  });
1005
1936
  }
1006
1937
  }
1007
1938
  catch { /* skip unreadable dirs */ }
1008
1939
  }
1009
- return result;
1010
1940
  }
1011
1941
  function scanDataFiles(projectRoot) {
1012
1942
  const dataDir = join(projectRoot, 'data');
@@ -1021,6 +1951,699 @@ function scanDataFiles(projectRoot) {
1021
1951
  return [];
1022
1952
  }
1023
1953
  }
1954
+ function readUserPrefs(userPrefsPath) {
1955
+ try {
1956
+ if (!existsSync(userPrefsPath)) {
1957
+ return { favorites: [], recentlyUsed: [] };
1958
+ }
1959
+ const raw = JSON.parse(readFileSync(userPrefsPath, 'utf-8'));
1960
+ return {
1961
+ favorites: Array.isArray(raw.favorites) ? raw.favorites.map(String) : [],
1962
+ recentlyUsed: Array.isArray(raw.recentlyUsed) ? raw.recentlyUsed.map(String) : [],
1963
+ };
1964
+ }
1965
+ catch {
1966
+ return { favorites: [], recentlyUsed: [] };
1967
+ }
1968
+ }
1969
+ function writeUserPrefs(userPrefsPath, prefs) {
1970
+ writeFileSync(userPrefsPath, JSON.stringify(prefs, null, 2) + '\n', 'utf-8');
1971
+ }
1972
+ async function introspectSchema(executor, connection) {
1973
+ let tables = [];
1974
+ let columnsByPath = new Map();
1975
+ // Tier 1: information_schema (PG, MySQL, Snowflake, MSSQL, DuckDB, Redshift, Fabric, Databricks)
1976
+ try {
1977
+ const catalogRows = await executor.executeQuery(`SELECT table_schema, table_name, table_type
1978
+ FROM information_schema.tables
1979
+ WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
1980
+ ORDER BY table_schema, table_name`, [], {}, connection);
1981
+ tables = catalogRows.rows.map((row) => {
1982
+ const schema = String(row['table_schema'] ?? row['TABLE_SCHEMA'] ?? 'default');
1983
+ const name = String(row['table_name'] ?? row['TABLE_NAME'] ?? '');
1984
+ const type = String(row['table_type'] ?? row['TABLE_TYPE'] ?? 'TABLE');
1985
+ const path = schema ? `${schema}.${name}` : name;
1986
+ return { schema, name, path, type };
1987
+ });
1988
+ const columnRows = await executor.executeQuery(`SELECT table_schema, table_name, column_name, data_type
1989
+ FROM information_schema.columns
1990
+ WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
1991
+ ORDER BY table_schema, table_name, ordinal_position`, [], {}, connection);
1992
+ columnsByPath = columnRows.rows.reduce((map, row) => {
1993
+ const schema = String(row['table_schema'] ?? row['TABLE_SCHEMA'] ?? 'default');
1994
+ const tableName = String(row['table_name'] ?? row['TABLE_NAME'] ?? '');
1995
+ const path = schema ? `${schema}.${tableName}` : tableName;
1996
+ const next = map.get(path) ?? [];
1997
+ next.push({
1998
+ name: String(row['column_name'] ?? row['COLUMN_NAME'] ?? ''),
1999
+ type: String(row['data_type'] ?? row['DATA_TYPE'] ?? ''),
2000
+ });
2001
+ map.set(path, next);
2002
+ return map;
2003
+ }, new Map());
2004
+ return { tables, columnsByPath };
2005
+ }
2006
+ catch {
2007
+ // Tier 1 failed — try connector methods
2008
+ }
2009
+ // Tier 2: connector.listTables() + connector.listColumns() (SQLite, BigQuery, Athena, ClickHouse, Trino)
2010
+ try {
2011
+ const connector = await executor.getConnector(connection);
2012
+ if (typeof connector.listTables === 'function') {
2013
+ const rawTables = await connector.listTables();
2014
+ tables = rawTables.map((t) => {
2015
+ const schema = t.schema || 'default';
2016
+ const path = t.schema ? `${t.schema}.${t.name}` : t.name;
2017
+ return { schema, name: t.name, path, type: t.type };
2018
+ });
2019
+ }
2020
+ if (typeof connector.listColumns === 'function') {
2021
+ const rawColumns = await connector.listColumns();
2022
+ columnsByPath = rawColumns.reduce((map, col) => {
2023
+ const schema = col.schema || 'default';
2024
+ const path = schema ? `${schema}.${col.table}` : col.table;
2025
+ const next = map.get(path) ?? [];
2026
+ next.push({ name: col.name, type: col.dataType });
2027
+ map.set(path, next);
2028
+ return map;
2029
+ }, new Map());
2030
+ }
2031
+ }
2032
+ catch {
2033
+ // Tier 3: tables only, no columns — already have what we have
2034
+ }
2035
+ return { tables, columnsByPath };
2036
+ }
2037
+ function buildDatabaseSchemaTree(projectRoot, executor, connection) {
2038
+ return (async () => {
2039
+ const dataFiles = scanDataFiles(projectRoot);
2040
+ const { tables: dbTables, columnsByPath: dbColumnsByPath } = await introspectSchema(executor, connection);
2041
+ const schemaMap = new Map();
2042
+ for (const table of dbTables) {
2043
+ const schemaName = table.schema || 'default';
2044
+ const existing = schemaMap.get(schemaName) ?? [];
2045
+ existing.push({ name: table.name, path: table.path, type: table.type });
2046
+ schemaMap.set(schemaName, existing);
2047
+ }
2048
+ const databaseNodes = Array.from(schemaMap.entries())
2049
+ .sort((a, b) => a[0].localeCompare(b[0]))
2050
+ .map(([schemaName, tables]) => ({
2051
+ id: `db-schema:${schemaName}`,
2052
+ label: schemaName,
2053
+ kind: 'schema',
2054
+ children: tables
2055
+ .sort((a, b) => a.name.localeCompare(b.name))
2056
+ .map((table) => ({
2057
+ id: `db-table:${table.path}`,
2058
+ label: table.name,
2059
+ kind: 'table',
2060
+ path: table.path,
2061
+ type: table.type,
2062
+ children: (dbColumnsByPath.get(table.path) ?? []).map((column) => ({
2063
+ id: `db-column:${table.path}:${column.name}`,
2064
+ label: column.name,
2065
+ kind: 'column',
2066
+ path: table.path,
2067
+ type: column.type,
2068
+ })),
2069
+ })),
2070
+ }));
2071
+ // Eagerly resolve file columns via DuckDB DESCRIBE
2072
+ if (dataFiles.length > 0) {
2073
+ const fileChildren = [];
2074
+ for (const file of dataFiles) {
2075
+ let columns = [];
2076
+ try {
2077
+ const ext = file.name.split('.').pop()?.toLowerCase();
2078
+ const readFn = ext === 'parquet' ? 'read_parquet' : ext === 'json' ? 'read_json_auto' : 'read_csv_auto';
2079
+ const descResult = await executor.executeQuery(`DESCRIBE SELECT * FROM ${readFn}('${file.path.replace(/'/g, "''")}') LIMIT 0`, [], {}, connection);
2080
+ columns = descResult.rows.map((row) => ({
2081
+ id: `db-column:${file.path}:${String(row['column_name'] ?? '')}`,
2082
+ label: String(row['column_name'] ?? ''),
2083
+ kind: 'column',
2084
+ path: file.path,
2085
+ type: String(row['column_type'] ?? ''),
2086
+ }));
2087
+ }
2088
+ catch {
2089
+ // file column discovery failed — empty children is fine
2090
+ }
2091
+ fileChildren.push({
2092
+ id: `db-table:${file.path}`,
2093
+ label: file.name,
2094
+ kind: 'table',
2095
+ path: file.path,
2096
+ type: 'FILE',
2097
+ children: columns,
2098
+ });
2099
+ }
2100
+ databaseNodes.unshift({
2101
+ id: 'db-schema:files',
2102
+ label: 'files',
2103
+ kind: 'schema',
2104
+ children: fileChildren,
2105
+ });
2106
+ }
2107
+ return databaseNodes;
2108
+ })();
2109
+ }
2110
+ function openBlockStudioDocument(projectRoot, relativePath, semanticLayer) {
2111
+ const normalizedPath = normalize(relativePath).replace(/^\/+/, '');
2112
+ if (!normalizedPath.startsWith('blocks/')) {
2113
+ throw new Error('Invalid block path');
2114
+ }
2115
+ const absPath = join(projectRoot, normalizedPath);
2116
+ if (!existsSync(absPath)) {
2117
+ throw new Error(`File not found: ${normalizedPath}`);
2118
+ }
2119
+ const source = readFileSync(absPath, 'utf-8');
2120
+ const companionPath = blockCompanionRelativePath(normalizedPath);
2121
+ const companion = companionPath ? readBlockCompanionFile(projectRoot, companionPath) : null;
2122
+ const parsedMetadata = parseBlockSourceMetadata(source);
2123
+ const fileName = normalizedPath.split('/').pop()?.replace(/\.dql$/, '') ?? 'block';
2124
+ const metadata = {
2125
+ name: parsedMetadata.name || companion?.name || fileName,
2126
+ path: normalizedPath,
2127
+ domain: parsedMetadata.domain || companion?.domain || normalizedPath.split('/').slice(1, -1).join('/') || 'uncategorized',
2128
+ description: parsedMetadata.description || companion?.description || '',
2129
+ owner: parsedMetadata.owner || companion?.owner || '',
2130
+ tags: parsedMetadata.tags.length > 0 ? parsedMetadata.tags : companion?.tags ?? [],
2131
+ reviewStatus: companion?.reviewStatus,
2132
+ };
2133
+ return {
2134
+ path: normalizedPath,
2135
+ source,
2136
+ metadata,
2137
+ companionPath: companionPath && existsSync(join(projectRoot, companionPath)) ? companionPath : null,
2138
+ validation: validateBlockStudioSource(source, semanticLayer),
2139
+ };
2140
+ }
2141
+ function validateBlockStudioSource(source, semanticLayer) {
2142
+ const diagnostics = [];
2143
+ try {
2144
+ const parser = new Parser(source, '<block-studio>');
2145
+ parser.parse();
2146
+ }
2147
+ catch (error) {
2148
+ diagnostics.push({
2149
+ severity: 'error',
2150
+ code: 'syntax',
2151
+ message: error instanceof Error ? error.message : String(error),
2152
+ });
2153
+ }
2154
+ const semanticRefs = extractBlockStudioSemanticReferences(source);
2155
+ if (semanticLayer) {
2156
+ const refValidation = semanticLayer.validateReferences([
2157
+ ...semanticRefs.metrics,
2158
+ ...semanticRefs.dimensions,
2159
+ ...semanticRefs.segments,
2160
+ ]);
2161
+ for (const unknown of refValidation.unknown) {
2162
+ diagnostics.push({
2163
+ severity: 'error',
2164
+ code: 'semantic_ref',
2165
+ message: `Unknown semantic reference: ${unknown}`,
2166
+ });
2167
+ }
2168
+ }
2169
+ const chartConfig = extractBlockStudioChartConfig(source);
2170
+ if (!chartConfig) {
2171
+ diagnostics.push({
2172
+ severity: 'warning',
2173
+ code: 'visualization_missing',
2174
+ message: 'Block has no visualization section yet.',
2175
+ });
2176
+ }
2177
+ const executableSql = extractBlockStudioSql(source);
2178
+ if (!executableSql) {
2179
+ diagnostics.push({
2180
+ severity: 'warning',
2181
+ code: 'sql_missing',
2182
+ message: 'No executable SQL found in the block source.',
2183
+ });
2184
+ }
2185
+ return {
2186
+ valid: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
2187
+ diagnostics,
2188
+ semanticRefs,
2189
+ chartConfig: chartConfig ?? undefined,
2190
+ executableSql,
2191
+ };
2192
+ }
2193
+ function saveBlockStudioArtifacts(projectRoot, options) {
2194
+ const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
2195
+ const safeDomain = (options.domain ?? '')
2196
+ .trim()
2197
+ .toLowerCase()
2198
+ .replace(/[^a-z0-9/_-]+/g, '-')
2199
+ .replace(/^\/+|\/+$/g, '') || 'uncategorized';
2200
+ const targetRelativePath = `blocks/${safeDomain}/${slug}.dql`;
2201
+ const targetPath = join(projectRoot, targetRelativePath);
2202
+ const previousPath = options.currentPath ? normalize(options.currentPath).replace(/^\/+/, '') : null;
2203
+ if (existsSync(targetPath) && previousPath !== targetRelativePath) {
2204
+ throw new Error('BLOCK_EXISTS');
2205
+ }
2206
+ mkdirSync(dirname(targetPath), { recursive: true });
2207
+ writeFileSync(targetPath, options.source.trimEnd() + '\n', 'utf-8');
2208
+ writeBlockCompanionFile(projectRoot, {
2209
+ slug,
2210
+ name: options.name,
2211
+ domain: safeDomain,
2212
+ description: options.description,
2213
+ owner: options.owner,
2214
+ tags: options.tags,
2215
+ provider: 'dql',
2216
+ content: options.source,
2217
+ });
2218
+ if (previousPath && previousPath !== targetRelativePath) {
2219
+ const previousAbsPath = join(projectRoot, previousPath);
2220
+ if (existsSync(previousAbsPath))
2221
+ rmSync(previousAbsPath, { force: true });
2222
+ const previousCompanion = blockCompanionRelativePath(previousPath);
2223
+ if (previousCompanion) {
2224
+ const previousCompanionPath = join(projectRoot, previousCompanion);
2225
+ if (existsSync(previousCompanionPath))
2226
+ rmSync(previousCompanionPath, { force: true });
2227
+ }
2228
+ }
2229
+ return targetRelativePath;
2230
+ }
2231
+ function blockCompanionRelativePath(blockPath) {
2232
+ const normalized = normalize(blockPath).replace(/^\/+/, '');
2233
+ if (!normalized.startsWith('blocks/'))
2234
+ return null;
2235
+ const withoutRoot = normalized.slice('blocks/'.length).replace(/\.dql$/, '.yaml');
2236
+ return join('semantic-layer', 'blocks', withoutRoot).replaceAll('\\', '/');
2237
+ }
2238
+ function readBlockCompanionFile(projectRoot, relativePath) {
2239
+ const absPath = join(projectRoot, relativePath);
2240
+ if (!existsSync(absPath))
2241
+ return null;
2242
+ try {
2243
+ const content = readFileSync(absPath, 'utf-8');
2244
+ const lines = content.split(/\r?\n/);
2245
+ const topLevel = {};
2246
+ const arrays = {};
2247
+ let currentArray = null;
2248
+ for (const rawLine of lines) {
2249
+ const line = rawLine.replace(/\t/g, ' ');
2250
+ if (!line.trim() || line.trimStart().startsWith('#'))
2251
+ continue;
2252
+ if (/^\S[^:]*:\s*$/.test(line)) {
2253
+ currentArray = line.trim().slice(0, -1);
2254
+ if (['tags', 'lineage', 'semanticMetrics', 'semanticDimensions'].includes(currentArray)) {
2255
+ arrays[currentArray] = [];
2256
+ }
2257
+ continue;
2258
+ }
2259
+ const itemMatch = line.match(/^\s*-\s*(.+)\s*$/);
2260
+ if (itemMatch && currentArray && arrays[currentArray]) {
2261
+ arrays[currentArray].push(parseYamlScalar(itemMatch[1]));
2262
+ continue;
2263
+ }
2264
+ const scalarMatch = line.match(/^([A-Za-z0-9_]+):\s*(.+)\s*$/);
2265
+ if (scalarMatch) {
2266
+ currentArray = null;
2267
+ topLevel[scalarMatch[1]] = parseYamlScalar(scalarMatch[2]);
2268
+ }
2269
+ }
2270
+ return {
2271
+ name: topLevel.name ?? '',
2272
+ block: topLevel.block ?? '',
2273
+ domain: topLevel.domain ?? '',
2274
+ description: topLevel.description ?? '',
2275
+ owner: topLevel.owner ?? '',
2276
+ tags: arrays.tags ?? [],
2277
+ reviewStatus: topLevel.reviewStatus ?? '',
2278
+ };
2279
+ }
2280
+ catch {
2281
+ return null;
2282
+ }
2283
+ }
2284
+ function parseBlockSourceMetadata(source) {
2285
+ const name = source.match(/^\s*block\s+"([^"]+)"/i)?.[1] ?? '';
2286
+ const extractString = (key) => source.match(new RegExp(`\\b${key}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? '';
2287
+ const tags = source.match(/\btags\s*=\s*\[([^\]]*)\]/i);
2288
+ return {
2289
+ name,
2290
+ domain: extractString('domain'),
2291
+ description: extractString('description'),
2292
+ owner: extractString('owner'),
2293
+ tags: tags ? (tags[1].match(/"([^"]*)"/g) ?? []).map((value) => value.slice(1, -1)) : [],
2294
+ };
2295
+ }
2296
+ function extractBlockStudioChartConfig(source) {
2297
+ const vizMatch = source.match(/visualization\s*\{([^}]+)\}/is);
2298
+ if (!vizMatch)
2299
+ return null;
2300
+ const body = vizMatch[1];
2301
+ const get = (key) => body.match(new RegExp(`\\b${key}\\s*=\\s*["']?([\\w-]+)["']?`, 'i'))?.[1];
2302
+ const chart = get('chart');
2303
+ if (!chart)
2304
+ return null;
2305
+ const title = body.match(/\btitle\s*=\s*"([^"]+)"/i)?.[1];
2306
+ return {
2307
+ chart,
2308
+ x: get('x'),
2309
+ y: get('y'),
2310
+ color: get('color'),
2311
+ title,
2312
+ };
2313
+ }
2314
+ function extractBlockStudioSql(source) {
2315
+ const tripleQuoteMatch = source.match(/query\s*=\s*"""([\s\S]*?)"""/i);
2316
+ if (tripleQuoteMatch)
2317
+ return tripleQuoteMatch[1].trim() || null;
2318
+ const bareTripleMatch = source.match(/"""([\s\S]*?)"""/);
2319
+ if (bareTripleMatch)
2320
+ return bareTripleMatch[1].trim() || null;
2321
+ if (/^\s*(dashboard|workbook)\s+"/i.test(source))
2322
+ return null;
2323
+ const sqlKeywordMatch = source.match(/\b(SELECT|WITH|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|SHOW|DESCRIBE|EXPLAIN)\b([\s\S]*)/i);
2324
+ if (!sqlKeywordMatch)
2325
+ return null;
2326
+ let raw = sqlKeywordMatch[0];
2327
+ const dqlSectionStart = raw.search(/\b(visualization|tests|block)\s*\{/i);
2328
+ if (dqlSectionStart > 0)
2329
+ raw = raw.slice(0, dqlSectionStart);
2330
+ return raw.trim() || null;
2331
+ }
2332
+ function extractBlockStudioSemanticReferences(source) {
2333
+ const metrics = new Set();
2334
+ const dimensions = new Set();
2335
+ const segments = new Set();
2336
+ const semanticRegex = /@(metric|dim)\(([^)]+)\)/gi;
2337
+ let match;
2338
+ while ((match = semanticRegex.exec(source))) {
2339
+ const name = match[2].trim();
2340
+ if (!name)
2341
+ continue;
2342
+ if (match[1].toLowerCase() === 'metric')
2343
+ metrics.add(name);
2344
+ else
2345
+ dimensions.add(name);
2346
+ }
2347
+ const segmentRegex = /\/\*\s*segment:([^*]+)\*\//gi;
2348
+ while ((match = segmentRegex.exec(source))) {
2349
+ const name = match[1].trim();
2350
+ if (name)
2351
+ segments.add(name);
2352
+ }
2353
+ return {
2354
+ metrics: Array.from(metrics),
2355
+ dimensions: Array.from(dimensions),
2356
+ segments: Array.from(segments),
2357
+ };
2358
+ }
2359
+ export function createBlockArtifacts(projectRoot, options) {
2360
+ const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
2361
+ const safeDomain = (options.domain ?? '')
2362
+ .trim()
2363
+ .toLowerCase()
2364
+ .replace(/[^a-z0-9/_-]+/g, '-')
2365
+ .replace(/^\/+|\/+$/g, '');
2366
+ const blocksDir = safeDomain ? join(projectRoot, 'blocks', safeDomain) : join(projectRoot, 'blocks');
2367
+ mkdirSync(blocksDir, { recursive: true });
2368
+ const blockPath = join(blocksDir, `${slug}.dql`);
2369
+ if (existsSync(blockPath)) {
2370
+ throw new Error('BLOCK_EXISTS');
2371
+ }
2372
+ const templateContent = options.template
2373
+ ? listBlockTemplates().find((template) => template.id === options.template)?.content
2374
+ : undefined;
2375
+ const fileContent = normalizeBlockStudioContent({
2376
+ name: options.name,
2377
+ domain: safeDomain || 'uncategorized',
2378
+ description: options.description,
2379
+ tags: options.tags,
2380
+ content: options.content?.trim() || templateContent,
2381
+ });
2382
+ writeFileSync(blockPath, fileContent, 'utf-8');
2383
+ const relativePath = safeDomain ? `blocks/${safeDomain}/${slug}.dql` : `blocks/${slug}.dql`;
2384
+ const companionPath = writeBlockCompanionFile(projectRoot, {
2385
+ slug,
2386
+ name: options.name,
2387
+ domain: safeDomain || 'uncategorized',
2388
+ description: options.description,
2389
+ tags: options.tags,
2390
+ provider: 'dql',
2391
+ content: fileContent,
2392
+ });
2393
+ return {
2394
+ path: relativePath,
2395
+ content: fileContent,
2396
+ companionPath,
2397
+ };
2398
+ }
2399
+ export function createSemanticBuilderBlock(projectRoot, options) {
2400
+ const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block';
2401
+ const safeDomain = (options.domain ?? '')
2402
+ .trim()
2403
+ .toLowerCase()
2404
+ .replace(/[^a-z0-9/_-]+/g, '-')
2405
+ .replace(/^\/+|\/+$/g, '') || 'uncategorized';
2406
+ const blocksDir = join(projectRoot, 'blocks', safeDomain);
2407
+ mkdirSync(blocksDir, { recursive: true });
2408
+ const blockPath = join(blocksDir, `${slug}.dql`);
2409
+ if (existsSync(blockPath)) {
2410
+ throw new Error('BLOCK_EXISTS');
2411
+ }
2412
+ const content = options.blockType === 'custom'
2413
+ ? buildCustomSemanticBlockContent(options)
2414
+ : buildSemanticBlockContent(options);
2415
+ writeFileSync(blockPath, content, 'utf-8');
2416
+ const companionPath = writeBlockCompanionFile(projectRoot, {
2417
+ slug,
2418
+ name: options.name,
2419
+ domain: safeDomain,
2420
+ description: options.description,
2421
+ owner: options.owner,
2422
+ tags: options.tags,
2423
+ provider: options.provider,
2424
+ content,
2425
+ lineage: options.tables,
2426
+ semanticMetrics: options.metrics,
2427
+ semanticDimensions: [
2428
+ ...options.dimensions,
2429
+ ...(options.timeDimension ? [options.timeDimension.name] : []),
2430
+ ],
2431
+ });
2432
+ return {
2433
+ path: `blocks/${safeDomain}/${slug}.dql`,
2434
+ content,
2435
+ companionPath,
2436
+ };
2437
+ }
2438
+ function buildSemanticBlockContent(options) {
2439
+ const lines = [
2440
+ `block "${options.name}" {`,
2441
+ ` domain = "${options.domain ?? 'uncategorized'}"`,
2442
+ ' type = "semantic"',
2443
+ ];
2444
+ if (options.description)
2445
+ lines.push(` description = "${escapeDqlString(options.description)}"`);
2446
+ if (options.owner)
2447
+ lines.push(` owner = "${escapeDqlString(options.owner)}"`);
2448
+ if (options.tags && options.tags.length > 0) {
2449
+ lines.push(` tags = [${options.tags.map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
2450
+ }
2451
+ if (options.metrics.length === 1) {
2452
+ lines.push(` metric = "${escapeDqlString(options.metrics[0])}"`);
2453
+ }
2454
+ else {
2455
+ lines.push(` metrics = [${options.metrics.map((metric) => `"${escapeDqlString(metric)}"`).join(', ')}]`);
2456
+ }
2457
+ if (options.dimensions.length > 0) {
2458
+ lines.push(` dimensions = [${options.dimensions.map((dimension) => `"${escapeDqlString(dimension)}"`).join(', ')}]`);
2459
+ }
2460
+ if (options.timeDimension) {
2461
+ lines.push(` time_dimension = "${escapeDqlString(options.timeDimension.name)}"`);
2462
+ lines.push(` granularity = "${escapeDqlString(options.timeDimension.granularity)}"`);
2463
+ }
2464
+ const visualization = buildVisualizationBlock(options.chart ?? 'table', options.dimensions, options.timeDimension, options.metrics);
2465
+ if (visualization) {
2466
+ lines.push('');
2467
+ lines.push(...visualization);
2468
+ }
2469
+ lines.push('}');
2470
+ return lines.join('\n') + '\n';
2471
+ }
2472
+ function buildCustomSemanticBlockContent(options) {
2473
+ const lines = [
2474
+ `block "${options.name}" {`,
2475
+ ` domain = "${options.domain ?? 'uncategorized'}"`,
2476
+ ' type = "custom"',
2477
+ ];
2478
+ if (options.description)
2479
+ lines.push(` description = "${escapeDqlString(options.description)}"`);
2480
+ if (options.owner)
2481
+ lines.push(` owner = "${escapeDqlString(options.owner)}"`);
2482
+ if (options.tags && options.tags.length > 0) {
2483
+ lines.push(` tags = [${options.tags.map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
2484
+ }
2485
+ lines.push('');
2486
+ lines.push(' query = """');
2487
+ lines.push(...indentBlock(options.sql.trim(), 8).split('\n'));
2488
+ lines.push(' """');
2489
+ const visualization = buildVisualizationBlock(options.chart ?? 'table', options.dimensions, options.timeDimension, options.metrics);
2490
+ if (visualization) {
2491
+ lines.push('');
2492
+ lines.push(...visualization);
2493
+ }
2494
+ lines.push('}');
2495
+ return lines.join('\n') + '\n';
2496
+ }
2497
+ function buildVisualizationBlock(chart, dimensions, timeDimension, metrics) {
2498
+ const x = timeDimension ? `${timeDimension.name}_${timeDimension.granularity}` : dimensions[0];
2499
+ const y = metrics[0];
2500
+ if (!x && chart !== 'kpi' && chart !== 'table')
2501
+ return null;
2502
+ if (chart === 'table') {
2503
+ return [' visualization {', ' chart = "table"', ' }'];
2504
+ }
2505
+ if (chart === 'kpi') {
2506
+ return [' visualization {', ' chart = "kpi"', ` y = ${y}`, ' }'];
2507
+ }
2508
+ return [
2509
+ ' visualization {',
2510
+ ` chart = "${chart}"`,
2511
+ ` x = ${x}`,
2512
+ ` y = ${y}`,
2513
+ ' }',
2514
+ ];
2515
+ }
2516
+ function writeBlockCompanionFile(projectRoot, options) {
2517
+ const extractedRefs = extractSemanticReferenceNames(options.content);
2518
+ const semanticMetrics = Array.from(new Set([...(options.semanticMetrics ?? []), ...extractedRefs.metrics]));
2519
+ const semanticDimensions = Array.from(new Set([...(options.semanticDimensions ?? []), ...extractedRefs.dimensions]));
2520
+ const companionDir = join(projectRoot, 'semantic-layer', 'blocks', options.domain);
2521
+ mkdirSync(companionDir, { recursive: true });
2522
+ const companionPath = join(companionDir, `${options.slug}.yaml`);
2523
+ const lines = [
2524
+ `name: ${options.slug}`,
2525
+ `block: ${options.slug}`,
2526
+ `domain: ${options.domain}`,
2527
+ `description: ${yamlScalar(options.description?.trim() || options.name)}`,
2528
+ ];
2529
+ if (options.owner)
2530
+ lines.push(`owner: ${yamlScalar(options.owner)}`);
2531
+ if (options.tags && options.tags.length > 0) {
2532
+ lines.push('tags:');
2533
+ for (const tag of options.tags)
2534
+ lines.push(` - ${yamlScalar(tag)}`);
2535
+ }
2536
+ if (options.provider) {
2537
+ lines.push('source:');
2538
+ lines.push(` provider: ${yamlScalar(options.provider)}`);
2539
+ lines.push(' objectType: block');
2540
+ lines.push(` objectId: ${yamlScalar(options.slug)}`);
2541
+ }
2542
+ if (semanticMetrics.length > 0) {
2543
+ lines.push('semanticMetrics:');
2544
+ for (const metric of semanticMetrics)
2545
+ lines.push(` - ${yamlScalar(metric)}`);
2546
+ }
2547
+ if (semanticDimensions.length > 0) {
2548
+ lines.push('semanticDimensions:');
2549
+ for (const dimension of semanticDimensions)
2550
+ lines.push(` - ${yamlScalar(dimension)}`);
2551
+ }
2552
+ const mappingEntries = [
2553
+ ...semanticMetrics.map((metric) => [metric, metric]),
2554
+ ...semanticDimensions.map((dimension) => [dimension, dimension]),
2555
+ ];
2556
+ if (mappingEntries.length > 0) {
2557
+ lines.push('semanticMappings:');
2558
+ for (const [key, value] of mappingEntries) {
2559
+ lines.push(` ${key}: ${yamlScalar(value)}`);
2560
+ }
2561
+ }
2562
+ if (options.lineage && options.lineage.length > 0) {
2563
+ lines.push('lineage:');
2564
+ for (const table of options.lineage)
2565
+ lines.push(` - ${yamlScalar(table)}`);
2566
+ }
2567
+ lines.push('reviewStatus: draft');
2568
+ writeFileSync(companionPath, lines.join('\n') + '\n', 'utf-8');
2569
+ return relative(projectRoot, companionPath).replaceAll('\\', '/');
2570
+ }
2571
+ function extractSemanticReferenceNames(content) {
2572
+ const metrics = new Set();
2573
+ const dimensions = new Set();
2574
+ const regex = /@(metric|dim)\(([^)]+)\)/gi;
2575
+ let match;
2576
+ while ((match = regex.exec(content))) {
2577
+ const name = match[2].trim();
2578
+ if (!name)
2579
+ continue;
2580
+ if (match[1].toLowerCase() === 'metric') {
2581
+ metrics.add(name);
2582
+ }
2583
+ else {
2584
+ dimensions.add(name);
2585
+ }
2586
+ }
2587
+ return {
2588
+ metrics: Array.from(metrics),
2589
+ dimensions: Array.from(dimensions),
2590
+ };
2591
+ }
2592
+ function escapeDqlString(value) {
2593
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
2594
+ }
2595
+ function indentBlock(value, spaces) {
2596
+ const prefix = ' '.repeat(spaces);
2597
+ return value.split('\n').map((line) => `${prefix}${line}`).join('\n');
2598
+ }
2599
+ function normalizeBlockStudioContent(options) {
2600
+ const content = options.content?.trim();
2601
+ if (content && /^\s*block\s+"/i.test(content)) {
2602
+ return `${content.trimEnd()}\n`;
2603
+ }
2604
+ return buildBlankBlockContent({
2605
+ name: options.name,
2606
+ domain: options.domain,
2607
+ description: options.description,
2608
+ tags: options.tags,
2609
+ sql: content || 'SELECT 1 AS value',
2610
+ });
2611
+ }
2612
+ function buildBlankBlockContent(options) {
2613
+ const lines = [
2614
+ `block "${escapeDqlString(options.name)}" {`,
2615
+ ` domain = "${escapeDqlString(options.domain)}"`,
2616
+ ' type = "custom"',
2617
+ ` description = "${escapeDqlString(options.description?.trim() || options.name)}"`,
2618
+ ' owner = ""',
2619
+ ];
2620
+ lines.push(` tags = [${(options.tags ?? []).map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
2621
+ lines.push('');
2622
+ lines.push(' query = """');
2623
+ lines.push(...indentBlock(options.sql.trim(), 8).split('\n'));
2624
+ lines.push(' """');
2625
+ lines.push('');
2626
+ lines.push(' visualization {');
2627
+ lines.push(' chart = "table"');
2628
+ lines.push(' }');
2629
+ lines.push('}');
2630
+ return lines.join('\n') + '\n';
2631
+ }
2632
+ function parseYamlScalar(value) {
2633
+ const trimmed = value.trim();
2634
+ if (!trimmed)
2635
+ return '';
2636
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
2637
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
2638
+ return trimmed.slice(1, -1);
2639
+ }
2640
+ return trimmed;
2641
+ }
2642
+ function yamlScalar(value) {
2643
+ if (/^[a-zA-Z0-9_.:/-]+$/.test(value))
2644
+ return value;
2645
+ return JSON.stringify(value);
2646
+ }
1024
2647
  function buildNotebookTemplate(title, template) {
1025
2648
  const id = () => Math.random().toString(36).slice(2, 10);
1026
2649
  let cells;