diff --git a/CHANGELOG.md b/CHANGELOG.md index 783b0527..ef69881f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ This project adheres to [Semantic Versioning](https://semver.org/). --- +## v1.0.3 — 2026-04-21 + +### v0.11 — Block-First Notebook (Tracks 1–6) + +Collapses three authoring paths (notebook SQL, notebook DQL, Block Studio) into one mental model: **every notebook cell is a draft block; blocks are live-referenced with `bound` / `forked` state; promotion is certification-gated**. + +### Added +- **Unified `@metric()` / `@dim()` resolver** — notebook SQL cells now resolve semantic refs the same way Block Studio does. `SELECT @metric(revenue) FROM @dim(date)` runs against the warehouse instead of throwing. +- **Block Picker as primary palette tile** — `Block` is the left-most tile in the Add-Cell palette; picking a block drops a **bound cell** (live reference, not `@include` SQL). +- **Semantic-aware cell pickers** — Chart / Pivot / SingleValue / Filter pickers read `QueryResult.semanticRefs` and show typed icons (`# metric`, `∴ dimension`, `abc column`); falls back to inference with a "no semantic binding" nag strip. +- **Save-as-Block governance gate** — `SaveAsBlockModal` runs `BUILTIN_RULES` inline; missing owner / domain / description blocks the save. Git metadata (commit SHA, repo, branch) auto-captured and written to the companion YAML. +- **Bound-cell state model** — `BlockBinding { path, commitSha?, version?, state, originalContent? }` on each cell. Green chrome for `bound`, yellow for `forked` after a local edit. Inline chip with path · 🔒 · Revert (forked only) · Unbind. +- **Bound cells in lineage** — bound cells flow into the lineage graph as `block: → dashboard:` edges. Draft SQL cells stay excluded (design preserved). + +### Changed +- Palette surface: dropped `Python` / `Map` / `Writeback` "coming soon" tiles and the legacy `DQL block` entry; single row, block-first ordering. +- `SingleValueCell` / `ChartCell` / `PivotCell` / `FilterCell` empty states rewritten to guide the user toward the upstream cell. +- Git metadata moved from `.dql` block body into companion YAML (DQL parser drops unknown tokens; body now only carries parser-known keys). + +### Fixed +- Notebook SQL cells containing `@metric()` / `@dim()` previously failed with a raw warehouse error. Resolver is now shared between the notebook path and the Block Studio path. +- `workspace:*` dependency resolution (retained from v0.8.2): release script rewrites to real `^x.y.z` before publish. + +--- + ## v0.8.7 — 2026-04-14 ### Added diff --git a/apps/cli/package.json b/apps/cli/package.json index 7baed277..3d60aebe 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-cli", - "version": "1.0.2", + "version": "1.0.3", "description": "Public CLI for parsing, formatting, testing, and certifying DQL blocks", "license": "Apache-2.0", "type": "module", diff --git a/apps/cli/src/local-runtime.ts b/apps/cli/src/local-runtime.ts index 2da637c6..05e1c699 100644 --- a/apps/cli/src/local-runtime.ts +++ b/apps/cli/src/local-runtime.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import { createServer } from 'node:http'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from 'node:fs'; import { dirname, extname, join, normalize, relative, resolve } from 'node:path'; @@ -411,6 +412,7 @@ export async function startLocalServer(opts: LocalServerOptions): Promise 0) { + res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(serializeJSON({ + error: `Block is missing required governance fields: ${missing.join(', ')}`, + missing, + })); + return; + } const created = createBlockArtifacts(projectRoot, { name, domain, + owner, content, description, tags, metricRefs, template, + gitMetadata: readGitMetadata(projectRoot), }); res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(serializeJSON(created)); @@ -1358,8 +1375,20 @@ export async function startLocalServer(opts: LocalServerOptions): Promise 0) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(serializeJSON({ + columns: [], + rows: [], + error: `Unknown semantic reference${semantic.unresolvedRefs.length > 1 ? 's' : ''}: ${semantic.unresolvedRefs.join(', ')}`, + code: 'semantic_ref', + unresolvedRefs: semantic.unresolvedRefs, + })); + return; + } const prepared = prepareLocalExecution( - typeof body.sql === 'string' ? body.sql : '', + semantic.sql, isConnectionConfig(body.connection) ? body.connection : connection, projectRoot, projectConfig, @@ -1370,7 +1399,7 @@ export async function startLocalServer(opts: LocalServerOptions): Promise[]; rowCount: number; executionTime: number; + semanticRefs?: { metrics: string[]; dimensions: string[] }; } { const rawCols: unknown[] = Array.isArray(result?.columns) ? result.columns : []; const columns = rawCols.map((c) => typeof c === 'string' ? c : typeof (c as any)?.name === 'string' ? (c as any).name : String(c) ); + const hasRefs = semanticRefs && (semanticRefs.metrics.length > 0 || semanticRefs.dimensions.length > 0); return { columns, rows: Array.isArray(result?.rows) ? result.rows : [], @@ -2026,6 +2060,7 @@ function normalizeQueryResult(result: any): { : typeof result?.executionTime === 'number' ? result.executionTime : 0, + ...(hasRefs ? { semanticRefs } : {}), }; } @@ -2122,6 +2157,35 @@ export function prepareLocalExecution( }; } +export interface PreparedSemanticSql { + sql: string; + semanticRefs: { metrics: string[]; dimensions: string[] }; + unresolvedRefs: string[]; +} + +/** + * Shared resolver for `@metric(name)` / `@dim(name)` refs in raw SQL. + * Used by notebook SQL execution and Block Studio validation so both paths + * behave identically. If the SQL has no refs, returns it unchanged. + */ +export function prepareSemanticSql( + sql: string, + semanticLayer: SemanticLayer | undefined, +): PreparedSemanticSql { + if (!hasSemanticRefs(sql)) { + return { sql, semanticRefs: { metrics: [], dimensions: [] }, unresolvedRefs: [] }; + } + const resolution = resolveSemanticRefs(sql, semanticLayer); + return { + sql: resolution.resolvedSql, + semanticRefs: { + metrics: resolution.resolvedMetrics, + dimensions: resolution.resolvedDimensions, + }, + unresolvedRefs: resolution.unresolvedRefs, + }; +} + export function normalizeProjectConnection(connection: ConnectionConfig, projectRoot: string): ConnectionConfig { const normalized: ConnectionConfig = { ...connection }; @@ -3057,16 +3121,39 @@ function canonicalizeSafe(source: string): string { } } +export interface BlockGitMetadata { + commitSha: string; + repo: string | null; + branch: string | null; +} + +export function readGitMetadata(projectRoot: string): BlockGitMetadata | null { + const run = (cmd: string): string => + execSync(cmd, { cwd: projectRoot, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); + try { + const commitSha = run('git rev-parse HEAD'); + let repo: string | null = null; + let branch: string | null = null; + try { repo = run('git config --get remote.origin.url') || null; } catch { /* no remote */ } + try { branch = run('git rev-parse --abbrev-ref HEAD') || null; } catch { /* detached */ } + return { commitSha, repo, branch }; + } catch { + return null; + } +} + export function createBlockArtifacts( projectRoot: string, options: { name: string; domain?: string; + owner?: string; content?: string; description?: string; tags?: string[]; metricRefs?: string[]; template?: string; + gitMetadata?: BlockGitMetadata | null; }, ): { path: string; content: string; companionPath: string } { const slug = options.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'block'; @@ -3085,24 +3172,28 @@ export function createBlockArtifacts( const templateContent = options.template ? listBlockTemplates().find((template) => template.id === options.template)?.content : undefined; + const relativePath = safeDomain ? `blocks/${safeDomain}/${slug}.dql` : `blocks/${slug}.dql`; const fileContent = canonicalizeSafe(normalizeBlockStudioContent({ name: options.name, domain: safeDomain || 'uncategorized', + owner: options.owner, description: options.description, tags: options.tags, content: options.content?.trim() || templateContent, })); writeFileSync(blockPath, fileContent, 'utf-8'); - const relativePath = safeDomain ? `blocks/${safeDomain}/${slug}.dql` : `blocks/${slug}.dql`; const companionPath = writeBlockCompanionFile(projectRoot, { slug, name: options.name, domain: safeDomain || 'uncategorized', + owner: options.owner, description: options.description, tags: options.tags, provider: 'dql', content: fileContent, + gitMetadata: options.gitMetadata, + gitPath: relativePath, }); return { path: relativePath, @@ -3288,6 +3379,8 @@ function writeBlockCompanionFile( lineage?: string[]; semanticMetrics?: string[]; semanticDimensions?: string[]; + gitMetadata?: BlockGitMetadata | null; + gitPath?: string; }, ): string { const extractedRefs = extractSemanticReferenceNames(options.content); @@ -3335,6 +3428,13 @@ function writeBlockCompanionFile( lines.push('lineage:'); for (const table of options.lineage) lines.push(` - ${yamlScalar(table)}`); } + if (options.gitMetadata || options.gitPath) { + lines.push('git:'); + if (options.gitMetadata?.commitSha) lines.push(` commitSha: ${yamlScalar(options.gitMetadata.commitSha)}`); + if (options.gitMetadata?.repo) lines.push(` repo: ${yamlScalar(options.gitMetadata.repo)}`); + if (options.gitMetadata?.branch) lines.push(` branch: ${yamlScalar(options.gitMetadata.branch)}`); + if (options.gitPath) lines.push(` path: ${yamlScalar(options.gitPath)}`); + } lines.push('reviewStatus: draft'); writeFileSync(companionPath, lines.join('\n') + '\n', 'utf-8'); return relative(projectRoot, companionPath).replaceAll('\\', '/'); @@ -3372,6 +3472,7 @@ function indentBlock(value: string, spaces: number): string { function normalizeBlockStudioContent(options: { name: string; domain: string; + owner?: string; description?: string; tags?: string[]; content?: string; @@ -3384,6 +3485,7 @@ function normalizeBlockStudioContent(options: { return buildBlankBlockContent({ name: options.name, domain: options.domain, + owner: options.owner, description: options.description, tags: options.tags, sql: content || 'SELECT 1 AS value', @@ -3393,6 +3495,7 @@ function normalizeBlockStudioContent(options: { function buildBlankBlockContent(options: { name: string; domain: string; + owner?: string; description?: string; tags?: string[]; sql: string; @@ -3402,7 +3505,7 @@ function buildBlankBlockContent(options: { ` domain = "${escapeDqlString(options.domain)}"`, ' type = "custom"', ` description = "${escapeDqlString(options.description?.trim() || options.name)}"`, - ' owner = ""', + ` owner = "${escapeDqlString(options.owner?.trim() ?? '')}"`, ]; lines.push(` tags = [${(options.tags ?? []).map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`); lines.push(''); diff --git a/apps/dql-notebook/package.json b/apps/dql-notebook/package.json index cf823c6e..7231541d 100644 --- a/apps/dql-notebook/package.json +++ b/apps/dql-notebook/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-notebook-app", - "version": "1.0.2", + "version": "1.0.3", "private": true, "type": "module", "scripts": { diff --git a/apps/dql-notebook/src/api/client.ts b/apps/dql-notebook/src/api/client.ts index 761c3252..0d772235 100644 --- a/apps/dql-notebook/src/api/client.ts +++ b/apps/dql-notebook/src/api/client.ts @@ -152,6 +152,7 @@ export const api = { notebookPath?: string | null; name: string; domain?: string; + owner?: string; content: string; description?: string; tags?: string[]; @@ -197,18 +198,25 @@ export const api = { body: JSON.stringify({ sql }), signal, }); - // Normalize: older server versions return columns as ColumnMeta[] ({name,type,driverType}). - // Always coerce to string[] so React never tries to render objects as children. + // Older server versions return columns as ColumnMeta[] ({name,type,driverType}); + // coerce to string[] so React never tries to render objects as children. const columns: string[] = Array.isArray(raw?.columns) ? raw.columns.map((c: unknown) => typeof c === 'string' ? c : typeof (c as any)?.name === 'string' ? (c as any).name : String(c) ) : []; + const semanticRefs = raw?.semanticRefs && typeof raw.semanticRefs === 'object' + ? { + metrics: Array.isArray(raw.semanticRefs.metrics) ? raw.semanticRefs.metrics.map(String) : [], + dimensions: Array.isArray(raw.semanticRefs.dimensions) ? raw.semanticRefs.dimensions.map(String) : [], + } + : undefined; return { columns, rows: Array.isArray(raw?.rows) ? raw.rows : [], rowCount: raw?.rowCount ?? raw?.rows?.length ?? 0, executionTime: raw?.executionTime ?? raw?.executionTimeMs ?? 0, + ...(semanticRefs ? { semanticRefs } : {}), }; }, diff --git a/apps/dql-notebook/src/components/blocks/BlockPicker.tsx b/apps/dql-notebook/src/components/blocks/BlockPicker.tsx new file mode 100644 index 00000000..2374a159 --- /dev/null +++ b/apps/dql-notebook/src/components/blocks/BlockPicker.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../../api/client'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { ThemeMode } from '../../store/types'; +import { STATUS_COLORS, type BlockEntry } from './block-types'; + +export type { BlockEntry } from './block-types'; + +interface BlockPickerProps { + themeMode: ThemeMode; + onPick: (block: BlockEntry) => void; + autoFocus?: boolean; + /** Compact popover layout (used by AddCellBar). Sidebar passes false for full card layout. */ + compact?: boolean; +} + +export function BlockPicker({ themeMode, onPick, autoFocus = true, compact = true }: BlockPickerProps) { + const t = themes[themeMode]; + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [domainFilter, setDomainFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + api.getBlockLibrary() + .then((result) => { if (!cancelled) setBlocks(result.blocks); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (autoFocus) { + const timer = setTimeout(() => inputRef.current?.focus(), 30); + return () => clearTimeout(timer); + } + }, [autoFocus]); + + const domains = useMemo(() => [...new Set(blocks.map((b) => b.domain))].sort(), [blocks]); + const statuses = useMemo(() => [...new Set(blocks.map((b) => b.status))].sort(), [blocks]); + + const filtered = useMemo(() => blocks.filter((b) => { + if (search) { + const needle = search.toLowerCase(); + if (!b.name.toLowerCase().includes(needle) && !b.description.toLowerCase().includes(needle)) return false; + } + if (domainFilter && b.domain !== domainFilter) return false; + if (statusFilter && b.status !== statusFilter) return false; + return true; + }), [blocks, search, domainFilter, statusFilter]); + + const selectStyle: React.CSSProperties = { + background: t.inputBg, + border: `1px solid ${t.inputBorder}`, + borderRadius: 4, + color: t.textPrimary, + fontSize: 11, + fontFamily: t.font, + padding: '4px 6px', + outline: 'none', + }; + + return ( +
+
+ setSearch(e.target.value)} + placeholder="Search blocks..." + style={{ ...selectStyle, flex: 1, padding: '6px 8px' }} + /> + + +
+
+ {loading ? ( + Loading blocks... + ) : filtered.length === 0 ? ( + {blocks.length === 0 ? 'No blocks yet. Save a cell as a block to get started.' : 'No blocks match your filters.'} + ) : ( + filtered.map((block) => ( + onPick(block)} + t={t} + compact={compact} + /> + )) + )} +
+
+ ); +} + +function EmptyNote({ children, t }: { children: React.ReactNode; t: Theme }) { + return ( +
+ {children} +
+ ); +} + +function BlockRow({ + block, + onClick, + t, + compact, +}: { + block: BlockEntry; + onClick: () => void; + t: Theme; + compact: boolean; +}) { + const [hovered, setHovered] = useState(false); + const statusColor = STATUS_COLORS[block.status] ?? t.textMuted; + return ( + + ); +} diff --git a/apps/dql-notebook/src/components/blocks/block-types.ts b/apps/dql-notebook/src/components/blocks/block-types.ts new file mode 100644 index 00000000..cb02380d --- /dev/null +++ b/apps/dql-notebook/src/components/blocks/block-types.ts @@ -0,0 +1,25 @@ +export type BlockStatus = + | 'draft' + | 'review' + | 'certified' + | 'deprecated' + | 'pending_recertification'; + +export interface BlockEntry { + name: string; + domain: string; + status: BlockStatus | string; + owner: string | null; + tags: string[]; + path: string; + lastModified: string; + description: string; +} + +export const STATUS_COLORS: Record = { + draft: '#8b949e', + review: '#d29922', + certified: '#3fb950', + deprecated: '#f85149', + pending_recertification: '#db6d28', +}; diff --git a/apps/dql-notebook/src/components/cells/Cell.tsx b/apps/dql-notebook/src/components/cells/Cell.tsx index 6943ba7e..5885ee38 100644 --- a/apps/dql-notebook/src/components/cells/Cell.tsx +++ b/apps/dql-notebook/src/components/cells/Cell.tsx @@ -20,8 +20,10 @@ import { ChartOutput, detectChartType, resolveChartType, renderChart, CHART_TYPE import type { ChartType } from '../output/ChartOutput'; import { ErrorOutput } from '../output/ErrorOutput'; import { CellLineage } from './CellLineage'; -import type { Cell } from '../../store/types'; +import type { Cell, BlockBinding } from '../../store/types'; import { format as formatSQL } from 'sql-formatter'; +import { api } from '../../api/client'; +import { extractSqlFromText } from '../../utils/block-studio'; interface CellProps { cell: Cell; @@ -100,7 +102,13 @@ const PLACEHOLDER_META: Partial> = { }, }; +const BOUND_ACCENT = '#56d364'; +const FORKED_ACCENT = '#e3b341'; + function getCellBorderColor(cell: Cell, t: Theme): string { + if (cell.blockBinding) { + return cell.blockBinding.state === 'forked' ? FORKED_ACCENT : BOUND_ACCENT; + } switch (cell.status) { case 'running': return t.cellBorderRunning; @@ -212,6 +220,94 @@ function setBlockTags(content: string, tags: string[]): string { return re.test(content) ? content.replace(re, `$1[${tagStr}]`) : content; } +function BlockBindingChip({ + binding, + t, + onRevert, + onUnbind, +}: { + binding: BlockBinding; + t: Theme; + onRevert: () => void | Promise; + onUnbind: () => void; +}) { + const isForked = binding.state === 'forked'; + const accent = isForked ? FORKED_ACCENT : BOUND_ACCENT; + const label = isForked ? 'FORKED' : 'BOUND'; + const helpText = isForked + ? 'Local edits have diverged from the block file — Revert to discard, or Save as Block to promote as a new version.' + : 'This cell mirrors the block file. Edit to fork locally.'; + return ( +
+ + {label} + + {binding.path} + {binding.version && · v{binding.version}} + {!isForked && ( + + 🔒 + + )} +
+ {isForked && ( + + )} + +
+ ); +} + +function binderBtnStyle(t: Theme, border: string): React.CSSProperties { + return { + background: 'transparent', + border: `1px solid ${border}`, + borderRadius: 4, + color: t.textSecondary, + fontSize: 10, + fontFamily: t.font, + fontWeight: 600, + letterSpacing: '0.04em', + padding: '1px 7px', + cursor: 'pointer', + }; +} + function BlockGovernanceBar({ content, onChange, @@ -367,10 +463,19 @@ export function CellComponent({ cell, index }: CellProps) { const handleContentChange = useCallback( (content: string) => { - dispatch({ type: 'UPDATE_CELL', id: cell.id, updates: { content } }); + const updates: Partial = { content }; + const binding = cell.blockBinding; + if (binding?.originalContent !== undefined) { + const diverged = content.trim() !== binding.originalContent.trim(); + const nextState = diverged ? 'forked' : 'bound'; + if (nextState !== binding.state) { + updates.blockBinding = { ...binding, state: nextState }; + } + } + dispatch({ type: 'UPDATE_CELL', id: cell.id, updates }); setIsDirty(content !== savedContentRef.current); }, - [cell.id, dispatch] + [cell.id, cell.blockBinding, dispatch] ); const handleReset = useCallback(() => { @@ -795,6 +900,35 @@ export function CellComponent({ cell, index }: CellProps) { /> )} + {cell.blockBinding && ( + { + const binding = cell.blockBinding; + if (!binding) return; + try { + const payload = await api.openBlockStudio(binding.path); + const sqlBody = extractSqlFromText(payload.source) ?? payload.source; + dispatch({ + type: 'UPDATE_CELL', + id: cell.id, + updates: { + content: sqlBody, + blockBinding: { ...binding, state: 'bound', originalContent: sqlBody }, + }, + }); + editorRef.current?.resetTo(sqlBody); + } catch (error) { + console.error('Failed to revert bound cell', error); + } + }} + onUnbind={() => { + dispatch({ type: 'UPDATE_CELL', id: cell.id, updates: { blockBinding: undefined } }); + }} + /> + )} + {/* Editor area */} {cell.type === 'markdown' ? ( void; +} + +/** + * Shared empty-state for viz/transform cells (Chart, Pivot, SingleValue, Filter). + * Teaches the "upstream dataframe" concept and lists nameable upstream cells as clickable chips. + */ +export function CellEmptyState({ + theme: t, + accentColor, + cellLabel, + cellName, + description, + upstreamOptions, + onPick, +}: CellEmptyStateProps) { + return ( +
+
+ + {cellLabel} + + {cellName && ( + {cellName} + )} +
+ +
{description}
+ + {upstreamOptions.length === 0 ? ( +
+
+ No dataframe available yet +
+
+ 1. Add a SQL or{' '} + Block cell above this one. +
+ 2. Run it (⌘↵) and give the cell a name. +
+ 3. Come back here — named upstream cells show up as chips you can pick. +
+
+ Tip: use @metric(...) and{' '} + @dim(...) in the upstream SQL and metrics / + dimensions appear with typed icons in the pickers here. +
+
+ ) : ( + <> +
+ Pick an upstream dataframe +
+
+ {upstreamOptions.map((c) => ( + + ))} +
+ + )} +
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/ChartCell.tsx b/apps/dql-notebook/src/components/cells/ChartCell.tsx index 5f682914..66b1826e 100644 --- a/apps/dql-notebook/src/components/cells/ChartCell.tsx +++ b/apps/dql-notebook/src/components/cells/ChartCell.tsx @@ -3,6 +3,9 @@ import { themes, type Theme } from '../../themes/notebook-theme'; import type { Cell, CellChartConfig, QueryResult, ThemeMode } from '../../store/types'; import { renderChart, CHART_TYPE_OPTIONS, type ChartType } from '../output/ChartOutput'; import { inferColumnKind, columnKindToChartRole, type ChartColumnRole } from '../../utils/column-kind'; +import { classifyColumns } from '../../utils/semantic-fields'; +import { NoSemanticBindingNote } from './SemanticFieldPicker'; +import { CellEmptyState } from './CellEmptyState'; interface ChartCellProps { cell: Cell; @@ -74,19 +77,27 @@ export function ChartCell({ cell, cells, index, themeMode, onUpdate }: ChartCell const upstreamOptions = useMemo(() => { return cells .slice(0, index) - .filter((c) => c.name && c.status === 'success' && c.result); + .filter((c) => c.name && c.result); }, [cells, index]); const result: QueryResult | undefined = upstream?.result; + const classified = useMemo(() => classifyColumns(result), [result]); + + // Semantic refs win: @metric → measure, @dim → dimension. Raw columns keep + // the inferred role so the chart builder still works pre-semantic-binding. const columnKinds = useMemo(() => { if (!result) return new Map(); const map = new Map(); + const metricSet = new Set(classified.metrics); + const dimSet = new Set(classified.dimensions); for (const col of result.columns) { - map.set(col, columnKindToChartRole(inferColumnKind(col, result.rows))); + if (metricSet.has(col)) map.set(col, 'measure'); + else if (dimSet.has(col)) map.set(col, 'dimension'); + else map.set(col, columnKindToChartRole(inferColumnKind(col, result.rows))); } return map; - }, [result]); + }, [result, classified]); const measures = useMemo(() => { if (!result) return [] as string[]; @@ -104,69 +115,15 @@ export function ChartCell({ cell, cells, index, themeMode, onUpdate }: ChartCell if (!cell.upstream || !result) { return ( -
-
- - Chart - - {cell.name && ( - {cell.name} - )} -
-
- Pick an upstream dataframe to chart. -
- {upstreamOptions.length === 0 ? ( -
- No successful upstream cells yet. Run a SQL/DQL cell above and name it, then come back. -
- ) : ( -
- {upstreamOptions.map((c) => ( - - ))} -
- )} -
+ onUpdate({ upstream: name })} + /> ); } @@ -265,6 +222,7 @@ export function ChartCell({ cell, cells, index, themeMode, onUpdate }: ChartCell overflow: 'auto', }} > + {!classified.hasSemanticBinding && } { return cells .slice(0, index) - .filter((c) => c.name && c.status === 'success' && c.result); + .filter((c) => c.name && c.result); }, [cells, index]); const result: QueryResult | undefined = upstream?.result; const columns = result?.columns ?? []; + const classified = useMemo(() => classifyColumns(result), [result]); const updateConfig = (next: FilterCellConfig) => { onUpdate({ filterConfig: next }); @@ -133,64 +137,15 @@ export function FilterCell({ cell, cells, index, themeMode, onUpdate }: FilterCe if (!upstream || !result) { return ( -
-
- - Filter - - {cell.name && ( - {cell.name} - )} -
-
Pick an upstream dataframe to filter.
- {upstreamOptions.length === 0 ? ( -
- No successful upstream cells yet. Run a SQL/DQL cell above and name it, then come back. -
- ) : ( -
- {upstreamOptions.map((c) => ( - - ))} -
- )} -
+ onUpdate({ upstream: name })} + /> ); } @@ -286,6 +241,7 @@ export function FilterCell({ cell, cells, index, themeMode, onUpdate }: FilterCe {/* Groups */}
+ {!classified.hasSemanticBinding && } {config.groups.map((group, gi) => (
o.value === rule.operation) ?? OPERATIONS[0]; return (
- + updateRule(group.id, rule.id, { column: name ?? '' })} + /> onUpdate(i, { aggregation: e.target.value as Aggregation })} - style={{ - background: theme.editorBg, - border: `1px solid ${theme.cellBorder}`, - borderRadius: 3, - color: theme.textSecondary, - fontSize: 10, - fontFamily: theme.fontMono, - padding: '1px 4px', - }} - > - {AGGREGATIONS.map((a) => )} - - - {v.column} - onRemove(i)} style={{ cursor: 'pointer', color: theme.textMuted, fontSize: 10 }}>✕ - -
- )) + values.map((v, i) => { + const kind = kindFor(v.column); + return ( +
+ + + + {fieldKindIcon(kind)} + + {v.column} + onRemove(i)} style={{ cursor: 'pointer', color: theme.textMuted, fontSize: 10 }}>✕ + +
+ ); + }) )} ); @@ -411,13 +416,13 @@ function ValueZone({ function ZoneBase({ label, theme, - availableColumns, + availableFields, onAdd, children, }: { label: string; theme: Theme; - availableColumns: string[]; + availableFields: { name: string; kind: FieldKind }[]; onAdd: (col: string) => void; children: React.ReactNode; }) { @@ -462,7 +467,7 @@ function ZoneBase({ > {children}
- {open && availableColumns.length > 0 && ( + {open && availableFields.length > 0 && (
- {availableColumns.map((col) => ( + {availableFields.map((field) => ( ))}
diff --git a/apps/dql-notebook/src/components/cells/SemanticFieldPicker.tsx b/apps/dql-notebook/src/components/cells/SemanticFieldPicker.tsx new file mode 100644 index 00000000..d5c7feca --- /dev/null +++ b/apps/dql-notebook/src/components/cells/SemanticFieldPicker.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { Theme } from '../../themes/notebook-theme'; +import type { ClassifiedField, FieldKind } from '../../utils/semantic-fields'; +import { fieldKindColor, fieldKindIcon, fieldKindLabel } from '../../utils/semantic-fields'; + +interface SemanticFieldPickerProps { + theme: Theme; + /** Current selection (field name). */ + value: string | undefined; + /** Ordered list of fields to display (already classified, metrics → dims → cols). */ + fields: ClassifiedField[]; + /** Only these kinds are selectable; the rest render as disabled rows. */ + allowKinds?: FieldKind[]; + /** Placeholder when no value is set. */ + placeholder?: string; + /** Minimum width of the button; the dropdown inherits it. */ + minWidth?: number; + onChange: (name: string | undefined) => void; +} + +/** + * Typed field picker: renders a button showing the current selection's kind + + * name; on click, opens a searchable list grouped metric → dimension → column. + * Rows whose kind is not in `allowKinds` render muted and non-selectable. + */ +export function SemanticFieldPicker({ + theme, + value, + fields, + allowKinds, + placeholder = 'Select field', + minWidth = 180, + onChange, +}: SemanticFieldPickerProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const ref = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + useEffect(() => { + if (open) { + const t = setTimeout(() => inputRef.current?.focus(), 20); + return () => clearTimeout(t); + } + setQuery(''); + }, [open]); + + const selected = useMemo(() => fields.find((f) => f.name === value), [fields, value]); + + const filtered = useMemo(() => { + if (!query) return fields; + const q = query.toLowerCase(); + return fields.filter((f) => f.name.toLowerCase().includes(q)); + }, [fields, query]); + + const isAllowed = (kind: FieldKind) => (allowKinds ? allowKinds.includes(kind) : true); + + return ( +
+ + {open && ( +
+ setQuery(e.target.value)} + placeholder="Search fields..." + style={{ + width: '100%', + padding: '4px 6px', + marginBottom: 4, + background: theme.editorBg, + border: `1px solid ${theme.cellBorder}`, + borderRadius: 3, + color: theme.textPrimary, + fontFamily: theme.font, + fontSize: 11, + outline: 'none', + }} + /> + {value && ( + + )} + {filtered.length === 0 && ( +
+ No matches +
+ )} + {filtered.map((field) => { + const allowed = isAllowed(field.kind); + return ( + + ); + })} +
+ )} +
+ ); +} + +/** Muted strip shown when upstream has no semantic binding (no @metric/@dim). */ +export function NoSemanticBindingNote({ theme }: { theme: Theme }) { + return ( +
+ No semantic binding — metrics and dimensions unavailable. Use @metric(...) or @dim(...) upstream to populate typed pickers. +
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/SingleValueCell.tsx b/apps/dql-notebook/src/components/cells/SingleValueCell.tsx index 28cb628e..190341db 100644 --- a/apps/dql-notebook/src/components/cells/SingleValueCell.tsx +++ b/apps/dql-notebook/src/components/cells/SingleValueCell.tsx @@ -2,6 +2,9 @@ import React, { useMemo } from 'react'; import { themes, type Theme } from '../../themes/notebook-theme'; import type { Cell, QueryResult, SingleValueCellConfig, ThemeMode } from '../../store/types'; import { aggregate } from '../../utils/aggregate'; +import { classifyColumns } from '../../utils/semantic-fields'; +import { SemanticFieldPicker, NoSemanticBindingNote } from './SemanticFieldPicker'; +import { CellEmptyState } from './CellEmptyState'; interface SingleValueCellProps { cell: Cell; @@ -72,11 +75,11 @@ export function SingleValueCell({ cell, cells, index, themeMode, onUpdate }: Sin }, [cell.upstream, config.upstream, cells]); const upstreamOptions = useMemo(() => { - return cells.slice(0, index).filter((c) => c.name && c.status === 'success' && c.result); + return cells.slice(0, index).filter((c) => c.name && c.result); }, [cells, index]); const result: QueryResult | undefined = upstream?.result; - const columns = result?.columns ?? []; + const classified = useMemo(() => classifyColumns(result), [result]); const value = useMemo( () => (result ? computeAggregate(result, config.metric, aggregation) : null), @@ -89,64 +92,15 @@ export function SingleValueCell({ cell, cells, index, themeMode, onUpdate }: Sin if (!upstream || !result) { return ( -
-
- - Single value - - {cell.name && ( - {cell.name} - )} -
-
Pick an upstream dataframe to compute a single value.
- {upstreamOptions.length === 0 ? ( -
- No successful upstream cells yet. -
- ) : ( -
- {upstreamOptions.map((c) => ( - - ))} -
- )} -
+ onUpdate({ upstream: name })} + /> ); } @@ -231,15 +185,16 @@ export function SingleValueCell({ cell, cells, index, themeMode, onUpdate }: Sin {aggregation !== 'count' && ( - - + + updateConfig({ metric: name })} + /> + {!classified.hasSemanticBinding && } )} diff --git a/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx b/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx index 215a95ac..6b3cc4bf 100644 --- a/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx +++ b/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx @@ -15,6 +15,10 @@ interface SaveAsBlockModalProps { initialTags?: string[]; } +function RequiredMark() { + return *; +} + function slugify(value: string): string { return value .trim() @@ -48,6 +52,7 @@ export function SaveAsBlockModal({ const [name, setName] = useState(initialName || cell.name || 'new_block'); const [domain, setDomain] = useState(''); + const [owner, setOwner] = useState(''); const [description, setDescription] = useState(initialDescription ?? state.notebookMetadata.description ?? ''); const [tags, setTags] = useState((initialTags ?? state.notebookMetadata.categories ?? []).join(', ')); const [content, setContent] = useState(initialContent ?? cell.content); @@ -55,6 +60,17 @@ export function SaveAsBlockModal({ const [error, setError] = useState(null); const semanticRefs = useMemo(() => extractSemanticRefs(content), [content]); + const tagList = useMemo(() => tags.split(',').map((s) => s.trim()).filter(Boolean), [tags]); + + const ruleResults: { id: string; label: string; severity: 'error' | 'warning'; passed: boolean }[] = [ + { id: 'has-name', label: 'Block has name', severity: 'error', passed: !!name.trim() }, + { id: 'has-description', label: 'Block has description', severity: 'error', passed: !!description.trim() }, + { id: 'has-owner', label: 'Block has owner', severity: 'error', passed: !!owner.trim() }, + { id: 'has-domain', label: 'Block has domain', severity: 'error', passed: !!domain.trim() }, + { id: 'has-tags', label: 'Has at least one tag', severity: 'warning', passed: tagList.length > 0 }, + ]; + + const hasErrors = ruleResults.some((r) => r.severity === 'error' && !r.passed); useEffect(() => { nameRef.current?.focus(); @@ -72,8 +88,8 @@ export function SaveAsBlockModal({ const blockPath = `${domain.trim() ? `blocks/${slugify(domain)}/` : 'blocks/'}${slugify(name) || 'new-block'}.dql`; const handleSave = async () => { - if (!name.trim()) { - setError('Block name is required.'); + if (hasErrors) { + setError('Fix the required governance fields before saving.'); return; } if (!content.trim()) { @@ -89,9 +105,10 @@ export function SaveAsBlockModal({ notebookPath: state.activeFile?.path ?? null, name: name.trim(), domain: domain.trim() || undefined, + owner: owner.trim() || undefined, content, description: description.trim() || undefined, - tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean), + tags: tagList, metricRefs: semanticRefs, }); const file = { @@ -177,11 +194,15 @@ export function SaveAsBlockModal({
- + setName(e.target.value)} style={inputStyle} />
- + setDomain(e.target.value)} @@ -197,8 +218,15 @@ export function SaveAsBlockModal({
- - setDescription(e.target.value)} style={inputStyle} /> + + setOwner(e.target.value)} + style={inputStyle} + placeholder="data-platform@company.com" + />
@@ -206,6 +234,56 @@ export function SaveAsBlockModal({
+
+ + setDescription(e.target.value)} style={inputStyle} /> +
+ +
+
+ Governance checks +
+
+ {ruleResults.map((rule) => { + const color = rule.passed ? '#3fb950' : rule.severity === 'error' ? '#ff7b72' : '#e3b341'; + const glyph = rule.passed ? '✓' : rule.severity === 'error' ? '✕' : '!'; + return ( + + {glyph} + {rule.label} + + ); + })} +
+
+
-
- )} - - {blockSearchOpen && ( -
- setBlockQuery(e.target.value)} - placeholder="Search blocks..." - style={{ - background: t.inputBg, - border: `1px solid ${t.inputBorder}`, - borderRadius: 4, - color: t.textPrimary, - fontSize: 11, - fontFamily: t.font, - padding: '6px 10px', - outline: 'none', - }} + void insertBoundBlockCell(block)} /> -
- {filteredBlocks.length === 0 ? ( -
- {blockFiles.length === 0 ? 'No blocks yet' : 'No matches'} -
- ) : ( - filteredBlocks.map((file) => ( - insertBlockRef(file)} t={t} /> - )) - )} -
)} +
)}
@@ -281,43 +224,38 @@ function PaletteTile({ entry, onClick, t, + active = false, }: { entry: PaletteEntry; onClick: () => void; t: Theme; + active?: boolean; }) { const [hovered, setHovered] = useState(false); - const disabled = !entry.available; + const highlighted = active || hovered; return ( - ); -} - -function MoreButton({ - open, - onClick, - t, -}: { - open: boolean; - onClick: () => void; - t: Theme; -}) { - const [hovered, setHovered] = useState(false); - return ( - ); } -function BlockSearchItem({ - file, - onClick, - t, -}: { - file: { name: string; path: string }; - onClick: () => void; - t: Theme; -}) { - const [hovered, setHovered] = useState(false); - return ( - - ); -} diff --git a/apps/dql-notebook/src/components/sidebar/BlockLibraryPanel.tsx b/apps/dql-notebook/src/components/sidebar/BlockLibraryPanel.tsx index c375f372..e899e09d 100644 --- a/apps/dql-notebook/src/components/sidebar/BlockLibraryPanel.tsx +++ b/apps/dql-notebook/src/components/sidebar/BlockLibraryPanel.tsx @@ -2,25 +2,7 @@ import React, { useEffect, useState } from 'react'; import { api } from '../../api/client'; import { useNotebook } from '../../store/NotebookStore'; import { themes } from '../../themes/notebook-theme'; - -interface BlockEntry { - name: string; - domain: string; - status: string; - owner: string | null; - tags: string[]; - path: string; - lastModified: string; - description: string; -} - -const STATUS_COLORS: Record = { - draft: '#8b949e', - review: '#d29922', - certified: '#3fb950', - deprecated: '#f85149', - pending_recertification: '#db6d28', -}; +import { STATUS_COLORS, type BlockEntry } from '../blocks/block-types'; export function BlockLibraryPanel() { const { state, dispatch } = useNotebook(); diff --git a/apps/dql-notebook/src/store/types.ts b/apps/dql-notebook/src/store/types.ts index 7f39301b..5afdece9 100644 --- a/apps/dql-notebook/src/store/types.ts +++ b/apps/dql-notebook/src/store/types.ts @@ -104,6 +104,10 @@ export interface QueryResult { rows: Record[]; executionTime?: number; rowCount?: number; + semanticRefs?: { + metrics: string[]; + dimensions: string[]; + }; } export interface RunSnapshotCell { @@ -122,6 +126,22 @@ export interface RunSnapshot { cells: RunSnapshotCell[]; } +/** + * A bound cell is a live reference to a `.dql` block file. + * - `bound`: content matches the block file + * - `forked`: user edited locally, diverged from file + * + * `originalContent` is the canonical cell body derived from the block on bind/revert; + * divergence is detected by comparing `cell.content` to this. + */ +export interface BlockBinding { + path: string; + commitSha?: string; + version?: string; + state: 'bound' | 'forked'; + originalContent?: string; +} + export interface Cell { id: string; type: CellType; @@ -139,6 +159,7 @@ export interface Cell { singleValueConfig?: SingleValueCellConfig; // Single-value cell tableConfig?: TableCellConfig; // Table cell upstream?: string; // Dataframe handle this cell consumes + blockBinding?: BlockBinding; // Present when cell references a .dql block file } export interface NotebookFile { diff --git a/apps/dql-notebook/src/utils/parse-workbook.ts b/apps/dql-notebook/src/utils/parse-workbook.ts index fab11d0e..205498d2 100644 --- a/apps/dql-notebook/src/utils/parse-workbook.ts +++ b/apps/dql-notebook/src/utils/parse-workbook.ts @@ -7,6 +7,7 @@ import type { PivotCellConfig, SingleValueCellConfig, TableCellConfig, + BlockBinding, } from '../store/types'; import { makeCellId } from '../store/NotebookStore'; @@ -122,6 +123,7 @@ export interface DqlNotebookFile { singleValueConfig?: SingleValueCellConfig; tableConfig?: TableCellConfig; upstream?: string; + blockBinding?: BlockBinding; }>; } @@ -143,6 +145,7 @@ export function parseDqlNotebook(content: string): ParsedWorkbook { ...(c.singleValueConfig ? { singleValueConfig: c.singleValueConfig } : {}), ...(c.tableConfig ? { tableConfig: c.tableConfig } : {}), ...(c.upstream ? { upstream: c.upstream } : {}), + ...(c.blockBinding ? { blockBinding: c.blockBinding } : {}), })); const { title: _metaTitle, ...restMeta } = data.metadata ?? {}; return { title, cells, metadata: restMeta }; @@ -187,6 +190,7 @@ export function serializeDqlNotebook(title: string, cells: Cell[], existingMetad ...(c.singleValueConfig ? { singleValueConfig: c.singleValueConfig } : {}), ...(c.tableConfig ? { tableConfig: c.tableConfig } : {}), ...(c.upstream ? { upstream: c.upstream } : {}), + ...(c.blockBinding ? { blockBinding: c.blockBinding } : {}), })), }; return JSON.stringify(data, null, 2); diff --git a/apps/dql-notebook/src/utils/semantic-fields.ts b/apps/dql-notebook/src/utils/semantic-fields.ts new file mode 100644 index 00000000..3741e887 --- /dev/null +++ b/apps/dql-notebook/src/utils/semantic-fields.ts @@ -0,0 +1,83 @@ +import type { QueryResult } from '../store/types'; +import { inferColumnKind, columnKindToChartRole, type ChartColumnRole } from './column-kind'; + +export type FieldKind = 'metric' | 'dimension' | 'column'; + +export interface ClassifiedField { + name: string; + kind: FieldKind; + /** Present only for plain (non-semantic) columns; drives chart-builder roles and icons. */ + chartRole?: ChartColumnRole; +} + +export interface ClassifiedColumns { + metrics: string[]; + dimensions: string[]; + /** Raw columns that are not a semantic metric or dimension. */ + columns: string[]; + /** Every field in display order (metrics → dims → plain cols) with its kind. */ + fields: ClassifiedField[]; + /** True when the upstream result had no semantic refs at all. */ + hasSemanticBinding: boolean; +} + +/** + * Partition a result's columns into semantic metrics, semantic dimensions, and + * plain columns. Metrics and dimensions come from the server-resolved + * `QueryResult.semanticRefs`; anything not in either list is a raw column. + */ +export function classifyColumns(result: QueryResult | undefined): ClassifiedColumns { + if (!result) { + return { metrics: [], dimensions: [], columns: [], fields: [], hasSemanticBinding: false }; + } + + const refMetrics = new Set(result.semanticRefs?.metrics ?? []); + const refDims = new Set(result.semanticRefs?.dimensions ?? []); + const hasSemanticBinding = refMetrics.size > 0 || refDims.size > 0; + + const metrics: string[] = []; + const dimensions: string[] = []; + const columns: string[] = []; + const fields: ClassifiedField[] = []; + + for (const col of result.columns) { + if (refMetrics.has(col)) { + metrics.push(col); + fields.push({ name: col, kind: 'metric' }); + } else if (refDims.has(col)) { + dimensions.push(col); + fields.push({ name: col, kind: 'dimension' }); + } + } + + for (const col of result.columns) { + if (refMetrics.has(col) || refDims.has(col)) continue; + columns.push(col); + fields.push({ + name: col, + kind: 'column', + chartRole: columnKindToChartRole(inferColumnKind(col, result.rows)), + }); + } + + return { metrics, dimensions, columns, fields, hasSemanticBinding }; +} + +/** Icon + color pair for a field kind; used in typed pickers and chips. */ +export function fieldKindIcon(kind: FieldKind): string { + if (kind === 'metric') return '#'; + if (kind === 'dimension') return '∴'; + return 'A'; +} + +export function fieldKindColor(kind: FieldKind, accent: string): string { + if (kind === 'metric') return accent; + if (kind === 'dimension') return '#e3b341'; + return '#56d364'; +} + +export function fieldKindLabel(kind: FieldKind): string { + if (kind === 'metric') return 'metric'; + if (kind === 'dimension') return 'dimension'; + return 'column'; +} diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index cae5450e..81adaf8d 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "dql-language-support", "displayName": "DQL Language Support", "description": "Syntax highlighting, snippets, formatting, and language server support for DQL.", - "version": "1.0.2", + "version": "1.0.3", "publisher": "dql", "license": "Apache-2.0", "repository": { diff --git a/packages/create-dql-app/package.json b/packages/create-dql-app/package.json index ab182ea1..e9b27a37 100644 --- a/packages/create-dql-app/package.json +++ b/packages/create-dql-app/package.json @@ -1,6 +1,6 @@ { "name": "create-dql-app", - "version": "1.0.2", + "version": "1.0.3", "description": "Scaffold a new DQL project. Run with: npx create-dql-app ", "license": "MIT", "author": "DuckCode AI Labs", diff --git a/packages/dql-charts/package.json b/packages/dql-charts/package.json index c12a7be9..1d69e23d 100644 --- a/packages/dql-charts/package.json +++ b/packages/dql-charts/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-charts", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL chart library: visx-powered React SVG components for reusable analytics blocks", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-compiler/package.json b/packages/dql-compiler/package.json index d28c7520..afd5a2c4 100644 --- a/packages/dql-compiler/package.json +++ b/packages/dql-compiler/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-compiler", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL compiler: IR lowering, Vega-Lite code generation, HTML/CSS emitting", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-connectors/package.json b/packages/dql-connectors/package.json index 20eef622..850e6b69 100644 --- a/packages/dql-connectors/package.json +++ b/packages/dql-connectors/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-connectors", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL database connectors for local files, SQL warehouses, and lakehouse engines", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-core/package.json b/packages/dql-core/package.json index f6ee7297..f4f2a129 100644 --- a/packages/dql-core/package.json +++ b/packages/dql-core/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-core", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL language core: lexer, parser, AST, semantic analysis", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-core/src/lineage/builder.ts b/packages/dql-core/src/lineage/builder.ts index 6279a083..84256e6e 100644 --- a/packages/dql-core/src/lineage/builder.ts +++ b/packages/dql-core/src/lineage/builder.ts @@ -334,10 +334,11 @@ export function buildLineageGraph( }); } // NOTE: We intentionally do NOT create table/dbt_model → dashboard edges - // from raw notebook SQL cells. The correct flow is always: + // from raw draft SQL cells. The correct flow is always: // dbt_source → dbt_model → block → dashboard - // Raw SQL in notebook cells that aren't block declarations are exploratory - // queries — they don't represent a formal lineage path. + // Draft cells (unbound, no inline block declaration) are exploratory queries + // and don't represent a formal lineage path. Bound cells (Track 5) DO appear + // — their block binding flows in via `dashboard.blocks` upstream. } // 5. Add domain nodes and connect diff --git a/packages/dql-core/src/manifest/builder.ts b/packages/dql-core/src/manifest/builder.ts index 36590c6b..7918b1ff 100644 --- a/packages/dql-core/src/manifest/builder.ts +++ b/packages/dql-core/src/manifest/builder.ts @@ -436,6 +436,7 @@ function scanNotebooks( refDependencies: parseResult.refs, blockName, chartType: chartCell?.config?.chart, + bindingPath: typeof cell.blockBinding?.path === 'string' ? cell.blockBinding.path : undefined, }); } @@ -924,12 +925,14 @@ function buildManifestLineage( // Used to resolve "which blocks feed into a notebook" when a notebook // references a table directly rather than via ref(). const tableToBlocks = new Map(); + const pathToBlockName = new Map(); for (const block of Object.values(blocks)) { for (const table of block.tableDependencies) { const key = table.toLowerCase(); if (!tableToBlocks.has(key)) tableToBlocks.set(key, []); tableToBlocks.get(key)!.push(block.name); } + if (block.filePath) pathToBlockName.set(block.filePath, block.name); } const dashboards: LineageDashboardInput[] = Object.values(notebooks ?? {}).map((notebook) => { @@ -939,12 +942,17 @@ function buildManifestLineage( .filter((name): name is string => Boolean(name)); const inlineBlockSet = new Set(inlineBlockNames); - // Blocks explicitly ref()-ed from notebook SQL cells + // Blocks explicitly ref()-ed from notebook SQL cells, plus bound cells + // (Track 5): a bound cell points at a `.dql` block file by path. const refDeps = new Set(); for (const cell of notebook.cells) { for (const ref of cell.refDependencies ?? []) { if (!inlineBlockSet.has(ref) && blocks[ref]) refDeps.add(ref); } + if (cell.bindingPath) { + const bound = pathToBlockName.get(cell.bindingPath); + if (bound && !inlineBlockSet.has(bound)) refDeps.add(bound); + } } // Blocks whose output tables are directly queried by this notebook's SQL cells. diff --git a/packages/dql-core/src/manifest/types.ts b/packages/dql-core/src/manifest/types.ts index 57cf70ab..3997612b 100644 --- a/packages/dql-core/src/manifest/types.ts +++ b/packages/dql-core/src/manifest/types.ts @@ -115,6 +115,8 @@ export interface ManifestNotebookCell { blockName?: string; /** Chart config (if a chart cell references this cell) */ chartType?: string; + /** Path of a `.dql` block file this cell is bound to (Track 5 block-first cells) */ + bindingPath?: string; } // ---- Semantic Layer ---- diff --git a/packages/dql-governance/package.json b/packages/dql-governance/package.json index 99906de4..8429a07a 100644 --- a/packages/dql-governance/package.json +++ b/packages/dql-governance/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-governance", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL Governance: block testing, certification, cost estimation, and access policies", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-lsp/package.json b/packages/dql-lsp/package.json index fe97dcae..5975a892 100644 --- a/packages/dql-lsp/package.json +++ b/packages/dql-lsp/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-lsp", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL Language Server Protocol implementation", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-notebook/package.json b/packages/dql-notebook/package.json index 5d6833e5..3707e934 100644 --- a/packages/dql-notebook/package.json +++ b/packages/dql-notebook/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-notebook", - "version": "1.0.2", + "version": "1.0.3", "description": "Notebook document model and execution helpers for DQL", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-notebook/src/semantic-refs.ts b/packages/dql-notebook/src/semantic-refs.ts index c682bfcc..11f415cb 100644 --- a/packages/dql-notebook/src/semantic-refs.ts +++ b/packages/dql-notebook/src/semantic-refs.ts @@ -30,7 +30,9 @@ export interface SemanticRefResolution { * Check if a SQL string contains any semantic references. */ export function hasSemanticRefs(sql: string): boolean { - return SEMANTIC_REF_PATTERN.test(sql); + // New regex each call — the module-level /g pattern carries lastIndex state + // across calls and would return false on every second call for the same SQL. + return /@(metric|dim|dimension)\(\s*[a-zA-Z_][a-zA-Z0-9_.]*\s*\)/.test(sql); } /** diff --git a/packages/dql-openlineage/package.json b/packages/dql-openlineage/package.json index 80b49032..457d2876 100644 --- a/packages/dql-openlineage/package.json +++ b/packages/dql-openlineage/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-openlineage", - "version": "1.0.2", + "version": "1.0.3", "description": "OpenLineage event emitter for DQL block and notebook runs.", "license": "MIT", "type": "module", diff --git a/packages/dql-plugin-api/package.json b/packages/dql-plugin-api/package.json index a5385043..e268b439 100644 --- a/packages/dql-plugin-api/package.json +++ b/packages/dql-plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-plugin-api", - "version": "1.0.2", + "version": "1.0.3", "description": "Stable plugin contracts for DQL — connectors, chart renderers, governance rule packs. Frozen at v1.0.", "license": "MIT", "type": "module", diff --git a/packages/dql-project/package.json b/packages/dql-project/package.json index f774ace2..412b7f75 100644 --- a/packages/dql-project/package.json +++ b/packages/dql-project/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-project", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL project and block registry primitives: block metadata, versions, sync, and storage adapters", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-runtime/package.json b/packages/dql-runtime/package.json index 2828b886..7f420742 100644 --- a/packages/dql-runtime/package.json +++ b/packages/dql-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-runtime", - "version": "1.0.2", + "version": "1.0.3", "description": "DQL browser runtime: data fetching, Vega rendering, hot-reload client", "license": "Apache-2.0", "type": "module", diff --git a/packages/dql-telemetry/package.json b/packages/dql-telemetry/package.json index c863b104..4d5ff3ed 100644 --- a/packages/dql-telemetry/package.json +++ b/packages/dql-telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-telemetry", - "version": "1.0.2", + "version": "1.0.3", "description": "Opt-in, privacy-first usage telemetry for DQL.", "license": "MIT", "type": "module", diff --git a/packages/dql-ui/package.json b/packages/dql-ui/package.json index c2a30565..ecf6b93d 100644 --- a/packages/dql-ui/package.json +++ b/packages/dql-ui/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-ui", - "version": "1.0.2", + "version": "1.0.3", "description": "Design tokens, theme provider, and headless primitives for the DQL notebook + dashboard shell", "license": "Apache-2.0", "type": "module",