diff --git a/apps/web/package.json b/apps/web/package.json
index 01b04ad..77ccf9a 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -33,7 +33,9 @@
"@tailwindcss/vite": "^4.3.0",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.6",
+ "date-fns": "^4.4.0",
"express": "^5.2.1",
+ "lodash-es": "^4.18.1",
"lucide-react": "^1.16.0",
"monaco-editor": "0.55.1",
"mssql": "^12.6.0",
@@ -52,6 +54,7 @@
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
+ "@types/lodash-es": "^4.17.12",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
diff --git a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx
index 81a65b8..6d30716 100644
--- a/apps/web/src/frontend/components/sql-editor/DataGrid.tsx
+++ b/apps/web/src/frontend/components/sql-editor/DataGrid.tsx
@@ -478,7 +478,7 @@ export const DataGrid: React.FC<{
{
@@ -677,7 +677,7 @@ export const DataGrid: React.FC<{
)}
-
+
{result.rowCount} row{result.rowCount === 1 ? '' : 's'}
{pageSize ? ` / page of ${pageSize}` : ''}
@@ -686,7 +686,7 @@ export const DataGrid: React.FC<{
{result.durationMs} ms
{result.truncated && (
-
+
truncated — use Next page or raise Rows/page
)}
@@ -697,7 +697,7 @@ export const DataGrid: React.FC<{
data-testid="sql-page-prev"
disabled={!hasPrevPage || pageLoading}
onClick={onPrevPage}
- className="px-1.5 py-0.5 rounded border border-slate-300 text-slate-600 font-semibold hover:bg-slate-100 disabled:opacity-40"
+ className="px-1.5 py-0.5 rounded border border-slate-600 text-slate-300 font-semibold hover:bg-slate-800 disabled:opacity-40"
>
Prev
@@ -706,7 +706,7 @@ export const DataGrid: React.FC<{
data-testid="sql-page-next"
disabled={!hasNextPage || pageLoading}
onClick={onNextPage}
- className="px-1.5 py-0.5 rounded border border-slate-300 text-slate-600 font-semibold hover:bg-slate-100 disabled:opacity-40"
+ className="px-1.5 py-0.5 rounded border border-slate-600 text-slate-300 font-semibold hover:bg-slate-800 disabled:opacity-40"
>
{pageLoading ? '…' : 'Next'}
diff --git a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
index ad45169..f4b4370 100644
--- a/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
+++ b/apps/web/src/frontend/components/sql-editor/ResultsPanel.tsx
@@ -4,6 +4,7 @@ import type { CredentialRun } from '../../store/useSqlEditorStore';
import type { ResultsLayout } from '../../store/sqlEditorTabLogic';
import { DataGrid, PANE_DEFAULT_H_PX, PANE_DEFAULT_PX, PANE_MIN_H_PX, PANE_MIN_PX } from './DataGrid';
import type { SqlStatementResult } from '../../api/sqlApi';
+import { detectCodeCell } from '../../lib/codeCellRunner';
interface Props {
runs: CredentialRun[];
@@ -45,6 +46,10 @@ function bindAxisDrag(cursor: string, onMove: (ev: MouseEvent) => void): void {
}
const statementLabel = (sql: string, index: number): string => {
+ const cell = detectCodeCell(sql);
+ if (cell) {
+ return `Query ${index + 1} · ${cell.kind === 'ts' ? 'TypeScript' : 'JavaScript'}`;
+ }
const compact = sql.replace(/\s+/g, ' ').trim();
return `Query ${index + 1} · ${compact.length > 48 ? compact.slice(0, 48) + '…' : compact}`;
};
diff --git a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx
index f6e3e71..a273b19 100644
--- a/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx
+++ b/apps/web/src/frontend/components/sql-editor/StatementStrip.tsx
@@ -183,7 +183,8 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle,
const status = checkStatement(stmt);
const ok = status.level === 'ok';
const isChecked = checked.includes(i);
- const verb = statementVerb(stmt.text);
+ const codeKind = stmt.kind === 'js' || stmt.kind === 'ts' ? stmt.kind : null;
+ const verb = codeKind ? null : statementVerb(stmt.text);
const dmlBadge =
safeMode && verb && isMutatingDmlStatement(stmt.text) ? DML_BADGE[verb] : null;
const noWhere = dmlBadge ? dmlLacksWhere(stmt.text) : false;
@@ -226,6 +227,14 @@ export const StatementStrip: React.FC = ({ statements, checked, onToggle,
>
{ok ? '✓' : '⚠'}
+ {codeKind && (
+
+ {codeKind === 'ts' ? 'TS' : 'JS'}
+
+ )}
{dmlBadge && (
+import {
+ executeCodeCellSync,
+ type CodeCellLast,
+ type CodeCellResult,
+ type CodeCellVars,
+} from './codeCellExec';
+
+export type CodeCellWorkerRequest = {
+ id: number;
+ body: string;
+ last: CodeCellLast;
+ vars: CodeCellVars;
+ maxRows: number;
+};
+
+export type CodeCellWorkerResponse = { id: number } & CodeCellResult;
+
+const ctx: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope;
+
+ctx.onmessage = (ev: MessageEvent
) => {
+ const msg = ev.data;
+ const result = executeCodeCellSync({
+ body: msg.body,
+ last: msg.last,
+ vars: msg.vars,
+ maxRows: msg.maxRows,
+ });
+ ctx.postMessage({ id: msg.id, ...result } satisfies CodeCellWorkerResponse);
+};
diff --git a/apps/web/src/frontend/lib/codeCellExec.test.ts b/apps/web/src/frontend/lib/codeCellExec.test.ts
new file mode 100644
index 0000000..3e9ab63
--- /dev/null
+++ b/apps/web/src/frontend/lib/codeCellExec.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from 'vitest';
+import {
+ executeCodeCellSync,
+ normalizeCodeCellReturn,
+ sanitizeVarsForCodeCell,
+ codeCellHasReturn,
+} from './codeCellExec';
+import { prepareCodeCellSource } from './codeCellRunner';
+
+describe('sanitizeVarsForCodeCell', () => {
+ it('omits secret variables', () => {
+ const vars = sanitizeVarsForCodeCell([
+ { name: 'token', kind: 'scalar', value: 'secret', secret: true },
+ { name: 'n', kind: 'scalar', value: 3 },
+ { name: 'ids', kind: 'list', values: [1, 2] },
+ {
+ name: 't',
+ kind: 'table',
+ columns: ['a'],
+ rows: [[1]],
+ },
+ ]);
+ expect(vars.token).toBeUndefined();
+ expect(vars.n).toEqual({ kind: 'scalar', value: 3 });
+ expect(vars.ids).toEqual({ kind: 'list', values: [1, 2] });
+ expect(vars.t).toEqual({ kind: 'table', columns: ['a'], rows: [[1]] });
+ });
+});
+
+describe('normalizeCodeCellReturn', () => {
+ it('accepts columns/rows and caps maxRows', () => {
+ const r = normalizeCodeCellReturn(
+ { columns: ['a'], rows: [[1], [2], [3]] },
+ 2
+ );
+ expect(r).toMatchObject({ ok: true, rowCount: 2, truncated: true });
+ if (r.ok) expect(r.rows).toEqual([[1], [2]]);
+ });
+
+ it('accepts array of objects', () => {
+ const r = normalizeCodeCellReturn([{ id: 1, name: 'a' }, { id: 2, name: 'b' }], 50);
+ expect(r).toMatchObject({ ok: true, columns: ['id', 'name'], rowCount: 2 });
+ if (r.ok) expect(r.rows).toEqual([
+ [1, 'a'],
+ [2, 'b'],
+ ]);
+ });
+
+ it('rejects unsupported returns', () => {
+ expect(normalizeCodeCellReturn(42, 10).ok).toBe(false);
+ });
+});
+
+describe('executeCodeCellSync', () => {
+ it('maps last.rows into a new grid', () => {
+ const r = executeCodeCellSync({
+ body: `return {
+ columns: ['id', 'n'],
+ rows: last.rows.map((r) => [r[0], Number(r[0]) * 2]),
+ };`,
+ last: { columns: ['id'], rows: [[1], [2]], rowCount: 2 },
+ vars: {},
+ maxRows: 100,
+ });
+ expect(r).toMatchObject({ ok: true, rowCount: 2 });
+ if (r.ok) expect(r.rows).toEqual([
+ [1, 2],
+ [2, 4],
+ ]);
+ });
+
+ it('allows local variables and for…of loops', () => {
+ const r = executeCodeCellSync({
+ body: `
+ const out = [];
+ const factor = 2;
+ for (const row of last.rows) {
+ const id = Number(row[0]);
+ out.push({ id, n: id * factor });
+ }
+ return out;
+ `,
+ last: { columns: ['id'], rows: [[3], [5]], rowCount: 2 },
+ vars: {},
+ maxRows: 100,
+ });
+ expect(r).toMatchObject({ ok: true, rowCount: 2, columns: ['id', 'n'] });
+ if (r.ok) expect(r.rows).toEqual([
+ [3, 6],
+ [5, 10],
+ ]);
+ });
+
+ it('rejects cells without a return statement', () => {
+ const r = executeCodeCellSync({
+ body: `const rows = last.rows.map((r) => ({ id: r[0] }));`,
+ last: { columns: ['id'], rows: [[1]], rowCount: 1 },
+ vars: {},
+ maxRows: 10,
+ });
+ expect(r).toMatchObject({ ok: false });
+ if (!r.ok) expect(r.error).toMatch(/return statement/i);
+ });
+
+ it('ignores the word return inside strings when checking', () => {
+ expect(codeCellHasReturn(`const s = "return nothing";`)).toBe(false);
+ expect(codeCellHasReturn(`const s = "return nothing";\nreturn [];`)).toBe(true);
+ });
+
+ it('reads vars and surfaces throws', () => {
+ const ok = executeCodeCellSync({
+ body: `return { columns: ['v'], rows: [[vars.n.value]] };`,
+ last: null,
+ vars: { n: { kind: 'scalar', value: 9 } },
+ maxRows: 10,
+ });
+ expect(ok).toMatchObject({ ok: true });
+ if (ok.ok) expect(ok.rows).toEqual([[9]]);
+
+ const bad = executeCodeCellSync({
+ body: `throw new Error('boom'); return null;`,
+ last: null,
+ vars: {},
+ maxRows: 10,
+ });
+ expect(bad).toMatchObject({ ok: false });
+ if (!bad.ok) expect(bad.error).toMatch(/boom/);
+ });
+});
+
+
+describe('prepareCodeCellSource', () => {
+ it('strips fence markers and @set lines', () => {
+ const prepared = prepareCodeCellSource(`-- @js
+-- @set out = table
+return last;
+-- @end`);
+ expect(prepared).toMatchObject({
+ kind: 'js',
+ body: 'return last;',
+ });
+ if (!('error' in prepared)) {
+ expect(prepared.directives).toEqual([{ mode: 'table', name: 'out' }]);
+ }
+ });
+
+ it('accepts leading @set above the fence', () => {
+ const prepared = prepareCodeCellSource(`-- @set src = table
+-- @js
+return last;
+-- @end`);
+ expect(prepared).toMatchObject({ kind: 'js', body: 'return last;' });
+ if (!('error' in prepared)) {
+ expect(prepared.directives).toEqual([{ mode: 'table', name: 'src' }]);
+ }
+ });
+});
diff --git a/apps/web/src/frontend/lib/codeCellExec.ts b/apps/web/src/frontend/lib/codeCellExec.ts
new file mode 100644
index 0000000..2a898bc
--- /dev/null
+++ b/apps/web/src/frontend/lib/codeCellExec.ts
@@ -0,0 +1,222 @@
+/**
+ * Pure helpers for SQL Editor code cells (`-- @js` / `-- @ts`).
+ * The Worker and unit tests both call `executeCodeCellSync`.
+ *
+ * Cells may use local `let`/`const`/`var`, functions, loops, and allowlisted
+ * `import`s (`lodash` / `lodash-es` / `date-fns`). Each cell is isolated
+ * (no shared helpers across cells). Must **return** a grid value.
+ */
+
+import { codeCellHasReturn } from './sql-splitter';
+import { prepareCodeCellImports } from './codeCellPackages';
+
+export { codeCellHasReturn } from './sql-splitter';
+
+export type CodeCellLast = {
+ columns: string[];
+ rows: unknown[][];
+ rowCount: number;
+} | null;
+
+/** Structured-cloneable variable bag passed into cells (secrets omitted). */
+export type CodeCellVars = Record<
+ string,
+ | { kind: 'scalar'; value: unknown }
+ | { kind: 'list'; values: unknown[] }
+ | { kind: 'table'; columns: string[]; rows: unknown[][] }
+>;
+
+export type CodeCellOk = {
+ ok: true;
+ columns: string[];
+ rows: unknown[][];
+ rowCount: number;
+ truncated: boolean;
+};
+
+export type CodeCellErr = { ok: false; error: string };
+
+export type CodeCellResult = CodeCellOk | CodeCellErr;
+
+type VarLike = {
+ name: string;
+ kind: 'scalar' | 'list' | 'table';
+ secret?: boolean;
+ value?: unknown;
+ values?: unknown[];
+ columns?: string[];
+ rows?: unknown[][];
+};
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+/** Drop secret variables; shape the rest for the cell scope. */
+export function sanitizeVarsForCodeCell(variables: VarLike[]): CodeCellVars {
+ const out: CodeCellVars = {};
+ for (const v of variables) {
+ if (v.secret) continue;
+ if (v.kind === 'scalar') {
+ out[v.name] = { kind: 'scalar', value: v.value };
+ } else if (v.kind === 'list') {
+ out[v.name] = { kind: 'list', values: [...(v.values ?? [])] };
+ } else {
+ out[v.name] = {
+ kind: 'table',
+ columns: [...(v.columns ?? [])],
+ rows: (v.rows ?? []).map((r) => [...r]),
+ };
+ }
+ }
+ return out;
+}
+
+function normalizeObjectRows(
+ value: Record[],
+ maxRows: number
+): CodeCellOk {
+ const colSet = new Set();
+ for (const row of value.slice(0, 50)) {
+ for (const k of Object.keys(row)) colSet.add(k);
+ }
+ const columns = [...colSet];
+ const truncated = value.length > maxRows;
+ const kept = truncated ? value.slice(0, maxRows) : value;
+ const rows = kept.map((r) => columns.map((c) => r[c]));
+ return { ok: true, columns, rows, rowCount: rows.length, truncated };
+}
+
+function normalizeColumnsRows(
+ columnsRaw: unknown[],
+ rowsRaw: unknown[],
+ maxRows: number
+): CodeCellOk {
+ const columns = columnsRaw.map((c) => String(c));
+ const truncated = rowsRaw.length > maxRows;
+ const kept = truncated ? rowsRaw.slice(0, maxRows) : rowsRaw;
+ const rows: unknown[][] = kept.map((r) => {
+ if (Array.isArray(r)) return columns.map((_, i) => r[i]);
+ if (isPlainObject(r)) return columns.map((c) => r[c]);
+ return columns.map(() => undefined);
+ });
+ return { ok: true, columns, rows, rowCount: rows.length, truncated };
+}
+
+/**
+ * Normalize a cell return value into columns/rows.
+ * Accepts `{ columns, rows }` or an array of plain objects.
+ */
+export function normalizeCodeCellReturn(
+ value: unknown,
+ maxRows: number
+): CodeCellOk | CodeCellErr {
+ if (value === null || value === undefined) {
+ return {
+ ok: false,
+ error:
+ 'Code cell must return a value — use return { columns, rows } or return [...objects]',
+ };
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return { ok: true, columns: [], rows: [], rowCount: 0, truncated: false };
+ }
+ if (value.every(isPlainObject)) {
+ return normalizeObjectRows(value, maxRows);
+ }
+ return {
+ ok: false,
+ error: 'Array return must be an array of plain objects (or return { columns, rows })',
+ };
+ }
+
+ if (isPlainObject(value)) {
+ const { columns, rows } = value as { columns?: unknown; rows?: unknown };
+ if (!Array.isArray(columns) || !Array.isArray(rows)) {
+ return {
+ ok: false,
+ error: 'Return { columns: string[], rows: unknown[][] } or an array of objects',
+ };
+ }
+ return normalizeColumnsRows(columns, rows, maxRows);
+ }
+
+ return { ok: false, error: `Unsupported return type: ${typeof value}` };
+}
+
+const SANDBOX_PREAMBLE = `
+"use strict";
+var fetch = undefined;
+var XMLHttpRequest = undefined;
+var WebSocket = undefined;
+var indexedDB = undefined;
+var localStorage = undefined;
+var sessionStorage = undefined;
+var importScripts = undefined;
+var self = undefined;
+var window = undefined;
+var document = undefined;
+var globalThis = undefined;
+`;
+
+/**
+ * Run JS cell body with `last`, `vars`, and allowlisted import bindings in scope.
+ * Callers must transpile TypeScript to JS before invoking.
+ */
+export function executeCodeCellSync(args: {
+ body: string;
+ last: CodeCellLast;
+ vars: CodeCellVars;
+ maxRows: number;
+}): CodeCellResult {
+ const rawBody = args.body.trim();
+ if (!rawBody) {
+ return { ok: false, error: 'Code cell is empty' };
+ }
+
+ const prepared = prepareCodeCellImports(rawBody);
+ if (!prepared.ok) {
+ return { ok: false, error: prepared.error };
+ }
+
+ const body = prepared.body.trim();
+ if (!body) {
+ return { ok: false, error: 'Code cell is empty after imports' };
+ }
+ if (!codeCellHasReturn(body)) {
+ return {
+ ok: false,
+ error:
+ 'Code cell must include a return statement — e.g. return { columns, rows } or return rows.map(...)',
+ };
+ }
+
+ const bindingNames = Object.keys(prepared.bindings);
+ for (const name of bindingNames) {
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
+ return { ok: false, error: `Invalid import binding name: ${name}` };
+ }
+ }
+ const bindingValues = bindingNames.map((n) => prepared.bindings[n]);
+
+ try {
+ // Sandboxed Function: last/vars + allowlisted import bindings only.
+ const fn = new Function('last', 'vars', ...bindingNames, `${SANDBOX_PREAMBLE}${body}`);
+ const raw = fn(args.last, args.vars, ...bindingValues);
+ if (raw != null && typeof (raw as Promise).then === 'function') {
+ return {
+ ok: false,
+ error: 'Async code cells are not supported yet (do not return a Promise)',
+ };
+ }
+ return normalizeCodeCellReturn(raw, args.maxRows);
+ } catch (error: unknown) {
+ return { ok: false, error: errorMessage(error) };
+ }
+}
diff --git a/apps/web/src/frontend/lib/codeCellPackages.test.ts b/apps/web/src/frontend/lib/codeCellPackages.test.ts
new file mode 100644
index 0000000..07483be
--- /dev/null
+++ b/apps/web/src/frontend/lib/codeCellPackages.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+import { parseCodeCellImports } from './codeCellPackages';
+import { executeCodeCellSync } from './codeCellExec';
+
+describe('parseCodeCellImports', () => {
+ it('accepts allowlisted default / named / namespace imports', () => {
+ const parsed = parseCodeCellImports(`import _ from 'lodash';
+import { format } from 'date-fns';
+import * as df from 'date-fns';
+
+return [];
+`);
+ expect(parsed.ok).toBe(true);
+ if (!parsed.ok) return;
+ expect(parsed.specs).toHaveLength(3);
+ expect(parsed.bodyWithoutImports).toBe('return [];');
+ });
+
+ it('rejects unknown packages', () => {
+ const parsed = parseCodeCellImports(`import fs from 'fs';\nreturn [];`);
+ expect(parsed.ok).toBe(false);
+ if (!parsed.ok) expect(parsed.error).toMatch(/allowlisted/i);
+ });
+
+ it('rejects mid-body imports', () => {
+ const parsed = parseCodeCellImports(`const x = 1;\nimport _ from 'lodash';\nreturn [];`);
+ expect(parsed.ok).toBe(false);
+ if (!parsed.ok) expect(parsed.error).toMatch(/top of the code cell/i);
+ });
+});
+
+describe('executeCodeCellSync with imports + functions', () => {
+ it('runs lodash + local function', () => {
+ const body = `import _ from 'lodash';
+function doubleRow(r) {
+ return { id: r[0], n: Number(r[0]) * 2 };
+}
+return _.map(last.rows, doubleRow);
+`;
+ const r = executeCodeCellSync({
+ body,
+ last: { columns: ['id'], rows: [[4], [6]], rowCount: 2 },
+ vars: {},
+ maxRows: 100,
+ });
+ expect(r).toMatchObject({ ok: true, rowCount: 2 });
+ if (r.ok) {
+ expect(r.columns).toEqual(['id', 'n']);
+ expect(r.rows).toEqual([
+ [4, 8],
+ [6, 12],
+ ]);
+ }
+ });
+
+ it('runs date-fns named import', () => {
+ const r = executeCodeCellSync({
+ body: `import { format } from 'date-fns';
+return [{ stamp: format(new Date(Date.UTC(2020, 0, 2)), 'yyyy-MM-dd', { useAdditionalDayOfYearTokens: false }) }];
+`,
+ last: null,
+ vars: {},
+ maxRows: 10,
+ });
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ expect(String(r.rows[0]?.[0])).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ }
+ });
+});
diff --git a/apps/web/src/frontend/lib/codeCellPackages.ts b/apps/web/src/frontend/lib/codeCellPackages.ts
new file mode 100644
index 0000000..d2ddd6f
--- /dev/null
+++ b/apps/web/src/frontend/lib/codeCellPackages.ts
@@ -0,0 +1,205 @@
+/**
+ * Allowlisted packages for SQL Editor JS/TS code cells.
+ * Only these names may appear in `import … from '…'`.
+ * Cells stay isolated — imports are resolved per cell (no shared function registry).
+ */
+
+import * as lodash from 'lodash-es';
+import * as dateFns from 'date-fns';
+
+export const CODE_CELL_ALLOWED_PACKAGES = ['lodash', 'lodash-es', 'date-fns'] as const;
+export type CodeCellAllowedPackage = (typeof CODE_CELL_ALLOWED_PACKAGES)[number];
+
+const ALLOWED = new Set(CODE_CELL_ALLOWED_PACKAGES);
+
+/** Bundled module namespaces keyed by import specifier. */
+export const CODE_CELL_PACKAGE_MODULES: Record = {
+ lodash: lodash as object,
+ 'lodash-es': lodash as object,
+ 'date-fns': dateFns as object,
+};
+
+type NamedBinding = { imported: string; local: string };
+
+type ImportSpec =
+ | { kind: 'default'; local: string; pkg: string }
+ | { kind: 'namespace'; local: string; pkg: string }
+ | { kind: 'named'; names: NamedBinding[]; pkg: string };
+
+const DEFAULT_IMPORT_RE =
+ /^import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/;
+const NAMESPACE_IMPORT_RE =
+ /^import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/;
+const NAMED_IMPORT_RE =
+ /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?\s*$/;
+const MIXED_IMPORT_RE =
+ /^import\s+[A-Za-z_$][\w$]*\s*,\s*\{/;
+
+function parseNamedList(inner: string): NamedBinding[] | { error: string } {
+ const names: NamedBinding[] = [];
+ for (const part of inner.split(',')) {
+ const bit = part.trim();
+ if (!bit) continue;
+ const asMatch = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(bit);
+ if (asMatch) {
+ names.push({ imported: asMatch[1]!, local: asMatch[2]! });
+ continue;
+ }
+ if (/^[A-Za-z_$][\w$]*$/.test(bit)) {
+ names.push({ imported: bit, local: bit });
+ continue;
+ }
+ return { error: `Invalid named import: ${bit}` };
+ }
+ return names;
+}
+
+function parseImportLine(trimmed: string): ImportSpec | { error: string } {
+ if (MIXED_IMPORT_RE.test(trimmed)) {
+ return {
+ error: 'Mixed default+named imports are not supported — use separate import lines',
+ };
+ }
+
+ const ns = NAMESPACE_IMPORT_RE.exec(trimmed);
+ if (ns) return { kind: 'namespace', local: ns[1]!, pkg: ns[2]! };
+
+ const named = NAMED_IMPORT_RE.exec(trimmed);
+ if (named) {
+ const names = parseNamedList(named[1]!);
+ if ('error' in names) return names;
+ return { kind: 'named', names, pkg: named[2]! };
+ }
+
+ const def = DEFAULT_IMPORT_RE.exec(trimmed);
+ if (def) return { kind: 'default', local: def[1]!, pkg: def[2]! };
+
+ return { error: `Unsupported import syntax: ${trimmed.slice(0, 80)}` };
+}
+
+/**
+ * Parse leading `import` lines, validate against the allowlist, and return the
+ * remaining body. Imports must be at the top (blank lines / // comments ok).
+ */
+export function parseCodeCellImports(body: string):
+ | { ok: true; specs: ImportSpec[]; bodyWithoutImports: string }
+ | { ok: false; error: string } {
+ const lines = body.split(/\r?\n/);
+ const specs: ImportSpec[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const trimmed = lines[i]!.trim();
+ if (trimmed === '' || trimmed.startsWith('//')) {
+ i++;
+ continue;
+ }
+ if (!trimmed.startsWith('import')) break;
+
+ const parsed = parseImportLine(trimmed);
+ if ('error' in parsed) return { ok: false, error: parsed.error };
+ if (!ALLOWED.has(parsed.pkg)) {
+ return {
+ ok: false,
+ error: `Package "${parsed.pkg}" is not allowlisted. Allowed: ${[...ALLOWED].join(', ')}`,
+ };
+ }
+ specs.push(parsed);
+ i++;
+ }
+
+ // Reject mid-body imports (after first non-import code line).
+ for (let j = i; j < lines.length; j++) {
+ const t = lines[j]!.trim();
+ if (t.startsWith('import ') || t.startsWith('import{')) {
+ return {
+ ok: false,
+ error: 'import statements must appear at the top of the code cell',
+ };
+ }
+ }
+
+ return {
+ ok: true,
+ specs,
+ bodyWithoutImports: lines.slice(i).join('\n').trim(),
+ };
+}
+
+function moduleDefault(mod: object): unknown {
+ if (mod && typeof mod === 'object' && 'default' in mod) {
+ const def = (mod as { default: unknown }).default;
+ if (def != null) return def;
+ }
+ return mod;
+}
+
+function setBinding(
+ bindings: Record,
+ local: string,
+ value: unknown
+): { error: string } | null {
+ if (bindings[local] !== undefined) {
+ return { error: `Duplicate binding "${local}"` };
+ }
+ bindings[local] = value;
+ return null;
+}
+
+/**
+ * Build identifier → value bindings for `new Function` parameters.
+ */
+export function resolveCodeCellImportBindings(
+ specs: ImportSpec[],
+ modules: Record = CODE_CELL_PACKAGE_MODULES
+): { ok: true; bindings: Record } | { ok: false; error: string } {
+ const bindings: Record = {};
+
+ for (const spec of specs) {
+ const mod = modules[spec.pkg];
+ if (!mod) {
+ return { ok: false, error: `Package "${spec.pkg}" is not loaded` };
+ }
+
+ if (spec.kind === 'default') {
+ const err = setBinding(bindings, spec.local, moduleDefault(mod));
+ if (err) return { ok: false, error: err.error };
+ continue;
+ }
+
+ if (spec.kind === 'namespace') {
+ const err = setBinding(bindings, spec.local, mod);
+ if (err) return { ok: false, error: err.error };
+ continue;
+ }
+
+ const rec = mod as Record;
+ for (const { imported, local } of spec.names) {
+ if (!(imported in rec)) {
+ return {
+ ok: false,
+ error: `"${imported}" is not exported by "${spec.pkg}"`,
+ };
+ }
+ const err = setBinding(bindings, local, rec[imported]);
+ if (err) return { ok: false, error: err.error };
+ }
+ }
+
+ return { ok: true, bindings };
+}
+
+/** Parse + resolve imports in one step. */
+export function prepareCodeCellImports(body: string):
+ | { ok: true; body: string; bindings: Record }
+ | { ok: false; error: string } {
+ const parsed = parseCodeCellImports(body);
+ if (!parsed.ok) return parsed;
+ const resolved = resolveCodeCellImportBindings(parsed.specs);
+ if (!resolved.ok) return resolved;
+ return {
+ ok: true,
+ body: parsed.bodyWithoutImports,
+ bindings: resolved.bindings,
+ };
+}
diff --git a/apps/web/src/frontend/lib/codeCellRunner.ts b/apps/web/src/frontend/lib/codeCellRunner.ts
new file mode 100644
index 0000000..68f4dfe
--- /dev/null
+++ b/apps/web/src/frontend/lib/codeCellRunner.ts
@@ -0,0 +1,213 @@
+/**
+ * Run a SQL Editor JS/TS code cell. Prefers a Web Worker; falls back to sync
+ * execution when Worker is unavailable (unit tests / Node).
+ */
+
+import type { SqlStatementResult } from '../api/sqlApi';
+import type { SetDirective, SqlVariable } from './sql-variables';
+import { parseSetDirectives } from './sql-variables';
+import { parseCodeCell } from './sql-splitter';
+import {
+ executeCodeCellSync,
+ sanitizeVarsForCodeCell,
+ type CodeCellLast,
+ type CodeCellResult,
+ type CodeCellVars,
+} from './codeCellExec';
+
+const DEFAULT_TIMEOUT_MS = 5_000;
+
+let nextId = 1;
+let workerCtorPromise: Promise<(new () => Worker) | null> | null = null;
+
+export type RunCodeCellArgs = {
+ /** Full statement text including `-- @js` / `-- @end` (and optional `@set` lines). */
+ statement: string;
+ last: CodeCellLast;
+ variables: SqlVariable[];
+ maxRows: number;
+ timeoutMs?: number;
+};
+
+/**
+ * Detect a fenced JS/TS cell, allowing leading `-- @set` above `-- @js`/`-- @ts`.
+ */
+export function detectCodeCell(
+ statement: string
+): { kind: 'js' | 'ts'; body: string; closed: boolean } | null {
+ const afterSets = parseSetDirectives(statement).sql;
+ return parseCodeCell(afterSets) ?? parseCodeCell(statement);
+}
+
+/** Prepare body + kind from a fenced statement (strips @set + fence markers). */
+export function prepareCodeCellSource(statement: string):
+ | { kind: 'js' | 'ts'; body: string; directives: SetDirective[] }
+ | { error: string } {
+ // Leading `-- @set` may sit above `-- @js` (reattachSetComments).
+ const leading = parseSetDirectives(statement);
+ const cell = parseCodeCell(leading.sql);
+ if (!cell) return { error: 'Not a code cell' };
+ const inner = parseSetDirectives(cell.body);
+ return {
+ kind: cell.kind,
+ body: inner.sql,
+ directives: [...leading.directives, ...inner.directives],
+ };
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+async function transpileTs(body: string): Promise {
+ const ts = await import('typescript');
+ const out = ts.transpileModule(body, {
+ compilerOptions: {
+ target: ts.ScriptTarget.ES2020,
+ module: ts.ModuleKind.None,
+ strict: false,
+ },
+ reportDiagnostics: true,
+ });
+ const errs = (out.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error);
+ if (errs.length > 0) {
+ const msg = errs
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
+ .join('; ');
+ throw new Error(msg || 'TypeScript transpile failed');
+ }
+ return out.outputText;
+}
+
+function toStatementResult(result: CodeCellResult, started: number): SqlStatementResult {
+ const durationMs = Date.now() - started;
+ if (!result.ok) {
+ return { ok: false, error: result.error, durationMs };
+ }
+ return {
+ ok: true,
+ columns: result.columns,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ truncated: result.truncated,
+ hasNext: false,
+ durationMs,
+ };
+}
+
+async function loadWorkerCtor(): Promise<(new () => Worker) | null> {
+ if (typeof Worker === 'undefined') return null;
+ if (!workerCtorPromise) {
+ workerCtorPromise = import('./codeCell.worker.ts?worker')
+ .then((mod) => (mod as { default?: new () => Worker }).default ?? null)
+ .catch(() => null);
+ }
+ return workerCtorPromise;
+}
+
+function runInWorker(args: {
+ WorkerCtor: new () => Worker;
+ body: string;
+ last: CodeCellLast;
+ vars: CodeCellVars;
+ maxRows: number;
+ timeoutMs: number;
+ started: number;
+}): Promise {
+ const { WorkerCtor, body, last, vars, maxRows, timeoutMs, started } = args;
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const id = nextId++;
+ let worker: Worker;
+ try {
+ worker = new WorkerCtor();
+ } catch {
+ resolve(toStatementResult(executeCodeCellSync({ body, last, vars, maxRows }), started));
+ return;
+ }
+
+ const finish = (result: SqlStatementResult) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ try {
+ worker.terminate();
+ } catch {
+ /* ignore */
+ }
+ resolve(result);
+ };
+
+ const timer = setTimeout(() => {
+ finish({
+ ok: false,
+ error: `Code cell timed out after ${timeoutMs}ms`,
+ durationMs: Date.now() - started,
+ });
+ }, timeoutMs);
+
+ worker.onmessage = (ev: MessageEvent) => {
+ const data = ev.data as { id: number } & CodeCellResult;
+ if (data.id !== id) return;
+ finish(toStatementResult(data, started));
+ };
+ worker.onerror = (err) => {
+ finish({
+ ok: false,
+ error: err.message || 'Code cell worker error',
+ durationMs: Date.now() - started,
+ });
+ };
+
+ worker.postMessage({ id, body, last, vars, maxRows });
+ });
+}
+
+/**
+ * Execute a fenced JS/TS cell. Returns the same shape as SQL statement results.
+ * Also returns parsed `@set` directives so the store can apply them.
+ */
+export async function runCodeCell(
+ args: RunCodeCellArgs
+): Promise<{ result: SqlStatementResult; directives: SetDirective[] }> {
+ const started = Date.now();
+ const prepared = prepareCodeCellSource(args.statement);
+ if ('error' in prepared) {
+ return {
+ result: { ok: false, error: prepared.error, durationMs: Date.now() - started },
+ directives: [],
+ };
+ }
+
+ let body = prepared.body;
+ try {
+ if (prepared.kind === 'ts') {
+ body = await transpileTs(body);
+ }
+ } catch (error: unknown) {
+ return {
+ result: {
+ ok: false,
+ error: `TypeScript: ${errorMessage(error)}`,
+ durationMs: Date.now() - started,
+ },
+ directives: prepared.directives,
+ };
+ }
+
+ const vars = sanitizeVarsForCodeCell(args.variables);
+ const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const execArgs = { body, last: args.last, vars, maxRows: args.maxRows };
+
+ const WorkerCtor = await loadWorkerCtor();
+ if (!WorkerCtor) {
+ return {
+ result: toStatementResult(executeCodeCellSync(execArgs), started),
+ directives: prepared.directives,
+ };
+ }
+
+ const result = await runInWorker({ WorkerCtor, ...execArgs, timeoutMs, started });
+ return { result, directives: prepared.directives };
+}
diff --git a/apps/web/src/frontend/lib/sql-splitter.ts b/apps/web/src/frontend/lib/sql-splitter.ts
index f9e430b..8484039 100644
--- a/apps/web/src/frontend/lib/sql-splitter.ts
+++ b/apps/web/src/frontend/lib/sql-splitter.ts
@@ -1,5 +1,15 @@
-// Re-export from core browser entry — single source of truth for statement
-// splitting and the editor's per-statement heuristics (same code the backend
-// uses to validate /sql/execute requests).
-export { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, statementVerb, isMutatingDmlStatement, dmlLacksWhere } from '@foxschema/core';
-export type { SplitStatement, StatementStatus } from '@foxschema/core';
+export {
+ splitSqlStatements,
+ checkStatement,
+ isWriteStatement,
+ firstKeyword,
+ extractTableAliases,
+ statementVerb,
+ isMutatingDmlStatement,
+ dmlLacksWhere,
+ parseCodeCell,
+ findCodeFences,
+ stripCodeFenceMarkers,
+ codeCellHasReturn,
+} from '@foxschema/core';
+export type { SplitStatement, StatementStatus, StatementKind } from '@foxschema/core';
diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts
index 571178e..1fa9a78 100644
--- a/apps/web/src/frontend/store/useSqlEditorStore.ts
+++ b/apps/web/src/frontend/store/useSqlEditorStore.ts
@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware';
import { executeSql, type SqlStatementResult } from '../api/sqlApi';
import { loadSchema } from '../api/schemaApi';
import { isMutatingDmlStatement, isWriteStatement } from '../lib/sql-splitter';
+import type { CodeCellLast } from '../lib/codeCellExec';
+import { detectCodeCell, runCodeCell } from '../lib/codeCellRunner';
import {
applySetDirectives,
exportVariables,
@@ -13,6 +15,7 @@ import {
prepareStatement,
resolveVariablesForConnection,
stripSecretsForPersist,
+ type SetDirective,
type SqlVariable,
type SqlVariableExport,
type SqlVariableKind,
@@ -698,8 +701,81 @@ export const useSqlEditorStore = create()(
const ranDisplay: string[] = [];
let aborted: string | null = null;
+ const lastGridFrom = (prev: SqlStatementResult[]): CodeCellLast => {
+ const prior = prev[prev.length - 1];
+ return prior?.ok
+ ? { columns: prior.columns, rows: prior.rows, rowCount: prior.rowCount }
+ : null;
+ };
+
+ const setPageSql = (connectionId: string, index: number, sql: string) => {
+ const sqlList = pageSqlByConn.get(connectionId) ?? [];
+ while (sqlList.length < index) sqlList.push('');
+ sqlList[index] = sql;
+ pageSqlByConn.set(connectionId, sqlList);
+ };
+
+ const applySetsFromFirstOk = (directives: SetDirective[], index: number) => {
+ if (directives.length === 0) return;
+ for (const c of connections) {
+ const res = resultsByConn.get(c.id)?.[index];
+ if (!res?.ok) continue;
+ const sets = applySetDirectives(directives, res);
+ if (sets.ok) {
+ for (const u of sets.updates) get().upsertVariable(u);
+ } else {
+ appendWarning(sets.error);
+ }
+ break;
+ }
+ };
+
for (let si = 0; si < rawStatements.length; si++) {
const raw = rawStatements[si]!;
+
+ if (detectCodeCell(raw)) {
+ ranDisplay.push(raw);
+ setRanStatements([...ranDisplay]);
+
+ let codeDirectives: SetDirective[] = [];
+ const isLastStmt = si === rawStatements.length - 1;
+
+ await Promise.allSettled(
+ connections.map(async (c) => {
+ const prev = resultsByConn.get(c.id) ?? [];
+ try {
+ const { result, directives } = await runCodeCell({
+ statement: raw,
+ last: lastGridFrom(prev),
+ variables: resolveVariablesForConnection(get().variables, c.id),
+ maxRows,
+ });
+ codeDirectives = directives;
+ prev.push(result);
+ resultsByConn.set(c.id, prev);
+ setPageSql(c.id, si, ''); // code cells are not re-pageable
+ patchRun(c.id, {
+ status: isLastStmt ? 'done' : 'running',
+ results: [...prev],
+ error: result.ok ? undefined : result.error,
+ });
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : String(error);
+ prev.push({ ok: false, error: msg, durationMs: 0 });
+ resultsByConn.set(c.id, prev);
+ patchRun(c.id, {
+ status: 'error',
+ error: msg,
+ results: [...prev],
+ });
+ }
+ })
+ );
+
+ applySetsFromFirstOk(codeDirectives, si);
+ continue;
+ }
+
const { directives } = parseSetDirectives(raw);
type Prep =
@@ -774,10 +850,7 @@ export const useSqlEditorStore = create()(
};
prev.push(one);
resultsByConn.set(c.id, prev);
- const sqlList = pageSqlByConn.get(c.id) ?? [];
- while (sqlList.length < si) sqlList.push('');
- sqlList[si] = prep.sql;
- pageSqlByConn.set(c.id, sqlList);
+ setPageSql(c.id, si, prep.sql);
const last = si === rawStatements.length - 1;
patchRun(c.id, {
status: last ? 'done' : 'running',
@@ -799,22 +872,7 @@ export const useSqlEditorStore = create()(
// @set from the first successful credential result (connection list order).
// Writes the global base value (not a per-connection override).
- if (directives.length > 0) {
- for (const c of connections) {
- const res = resultsByConn.get(c.id)?.[si];
- if (res && res.ok) {
- const sets = applySetDirectives(directives, res);
- if (sets.ok) {
- for (const u of sets.updates) {
- get().upsertVariable(u);
- }
- } else {
- appendWarning(sets.error);
- }
- break;
- }
- }
- }
+ applySetsFromFirstOk(directives, si);
}
// Mark any still-running connections as done.
@@ -840,10 +898,11 @@ export const useSqlEditorStore = create()(
results.forEach((res, si) => {
const key = pageMetaKey(c.id, si);
pageCache[pageCacheKey(c.id, si, 0)] = res;
+ const pageable = Boolean(pageSqlByConnection[c.id]?.[si]);
pageMeta[key] = {
pageIndex: 0,
pageSize: maxRows,
- hasNext: Boolean(res.ok && (res.hasNext || res.truncated)),
+ hasNext: pageable && Boolean(res.ok && (res.hasNext || res.truncated)),
};
});
}
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index 0016c23..f2932bc 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -1,7 +1,7 @@
@import "tailwindcss";
:root {
- /* User-tunable accent (Appearance settings). Default: cyan → indigo. */
+ /* User-tunable accent (Appearance settings). Default: cyan ? indigo. */
--accent: #06b6d4;
--accent-strong: #4f46e5;
}
@@ -58,7 +58,7 @@
/* Colored palette families used in the UI. Defined explicitly so the
light-mode inversion (e.g. --color-cyan-50 -> var(--color-cyan-950))
- resolves — Tailwind tree-shakes unused shades. */
+ resolves ? Tailwind tree-shakes unused shades. */
:root {
--color-orange-50: oklch(98% 0.016 73.684);
--color-orange-100: oklch(95.4% 0.038 75.164);
@@ -214,10 +214,10 @@
.accent-grad:hover { filter: brightness(1.07); }
/* Fixed, non-inverting near-black for text/icons that sit ON the bright accent
- gradient — so it stays dark (readable) in both light and dark themes. */
+ gradient ? so it stays dark (readable) in both light and dark themes. */
.on-accent-fg { color: #020617; }
-/* In light mode the accent (tuned for dark backgrounds) is too pale as text —
+/* In light mode the accent (tuned for dark backgrounds) is too pale as text ?
darken + saturate it so labels stay legible on near-white surfaces. */
[data-theme="light"] .accent-text { filter: brightness(0.62) saturate(1.3); }
@@ -313,7 +313,7 @@
animation: indeterminateProgress 1.2s ease-in-out infinite;
}
-/* Search keyword highlight — used both by the object browser / Schema Blueprint
+/* Search keyword highlight ? used both by the object browser / Schema Blueprint
() and inside Monaco. Accent-tinted so it stands out in light & dark. */
.fox-search-hl {
background-color: color-mix(in oklab, var(--accent) 40%, transparent);
@@ -335,11 +335,11 @@ mark.fox-search-hl {
font-weight: 700;
}
.fox-stmt-glyph-ok::after {
- content: '✓';
+ content: '?';
color: #34d399; /* emerald-400 */
}
.fox-stmt-glyph-warn::after {
- content: '⚠';
+ content: '?';
color: #fbbf24; /* amber-400 */
}
@@ -348,3 +348,54 @@ mark.fox-search-hl {
color: #34d399; /* emerald-400 */
font-weight: 600;
}
+
+/*
+ * Result DataGrid is a light "paper" surface. Soft-dark remaps --color-slate-50/100/200
+ * toward gray, which made tables muddy with dark cell ink. Pin literal colors here.
+ */
+.fox-sql-grid {
+ --fox-grid-bg: #f8fafc;
+ --fox-grid-bg-header: #e2e8f0;
+ --fox-grid-bg-hover: #f1f5f9;
+ --fox-grid-border: #cbd5e1;
+ --fox-grid-border-soft: #e2e8f0;
+ --fox-grid-ink: #1e293b;
+ --fox-grid-muted: #64748b;
+ background-color: var(--fox-grid-bg) !important;
+ border-color: var(--fox-grid-border) !important;
+ color: var(--fox-grid-ink);
+}
+.fox-sql-grid thead tr,
+.fox-sql-grid thead th {
+ background-color: var(--fox-grid-bg-header) !important;
+ border-color: var(--fox-grid-border) !important;
+ color: var(--fox-grid-ink) !important;
+}
+.fox-sql-grid thead th .text-slate-800 {
+ color: inherit;
+}
+.fox-sql-grid thead th .text-slate-500,
+.fox-sql-grid thead th .text-slate-400 {
+ color: var(--fox-grid-muted) !important;
+}
+.fox-sql-grid tbody {
+ background-color: var(--fox-grid-bg) !important;
+}
+.fox-sql-grid tbody tr {
+ border-color: var(--fox-grid-border-soft) !important;
+}
+.fox-sql-grid tbody tr:hover {
+ background-color: var(--fox-grid-bg-hover) !important;
+}
+.fox-sql-grid tbody td {
+ border-color: var(--fox-grid-border-soft) !important;
+}
+.fox-sql-grid tbody td.bg-slate-50,
+.fox-sql-grid tbody td[data-testid='sql-row-num'] {
+ background-color: var(--fox-grid-bg) !important;
+ color: var(--fox-grid-muted) !important;
+ border-color: var(--fox-grid-border-soft) !important;
+}
+.fox-sql-grid tbody tr:hover td[data-testid='sql-row-num'] {
+ background-color: var(--fox-grid-bg-hover) !important;
+}
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 21ebd68..df061cb 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -179,6 +179,29 @@ Tips:
200). Use **Next** / **Prev** on a result grid to page through more rows;
visited pages stay cached in memory so going back does not re-query the server.
Sibling result grids from the same Run sync vertical scroll by row index.
+- **Code cells (JS / TS)** — mix SQL with local transforms in the same buffer. Fence
+ a cell with `-- @js` / `-- @ts` … `-- @end` (inner semicolons are fine). You can use
+ local `let`/`const`/`var`, **functions**, and loops (`for`, `while`, `for…of`).
+ Allowlisted **imports** (bundled, no CDN): `lodash`, `lodash-es`, `date-fns` —
+ put `import` lines at the top of the cell. Cells are **isolated** (imports/functions
+ do not carry to the next cell; use `last` / `vars` to pass data). The cell receives
+ `last` (previous statement’s grid) and `vars` (non-secret Variables). **You must
+ `return`** either `{ columns, rows }` or an array of plain objects. Python is not
+ available yet.
+
+ ```sql
+ SELECT id, email FROM user;
+
+ -- @js
+ import _ from 'lodash';
+
+ function doubleRow(r) {
+ return { id: r[0], name: r[1], n: Number(r[0]) * 2 };
+ }
+ return _.map(last.rows, doubleRow);
+ -- @end
+ ```
+
- **Safe mode** — when on, write/DDL statements need an extra confirmation before run.
Writes and DDL are allowed when you confirm them. Some dialects (e.g. SQLite /
diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts
index fef1e60..9d7ab14 100644
--- a/packages/core/src/browser.ts
+++ b/packages/core/src/browser.ts
@@ -23,8 +23,21 @@ export type { ValidationIssue, ValidationSeverity, ValidationCode } from './modu
export { CROSS_DIALECT_READINESS } from './modules/cross-dialect-readiness';
export type { ObjectTypeReadiness, ReadinessLevel } from './modules/cross-dialect-readiness';
export { buildBrowseResult } from './modules/browse';
-export { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, statementVerb, isMutatingDmlStatement, dmlLacksWhere } from './modules/sql-splitter';
-export type { SplitStatement, StatementStatus } from './modules/sql-splitter';
+export {
+ splitSqlStatements,
+ checkStatement,
+ isWriteStatement,
+ firstKeyword,
+ extractTableAliases,
+ statementVerb,
+ isMutatingDmlStatement,
+ dmlLacksWhere,
+ parseCodeCell,
+ findCodeFences,
+ stripCodeFenceMarkers,
+ codeCellHasReturn,
+} from './modules/sql-splitter';
+export type { SplitStatement, StatementStatus, StatementKind } from './modules/sql-splitter';
export type { SqlDialect, CanonicalType, CanonicalBase, RenderedType } from './modules/sql-dialect.interface';
export { resolveDialect, DIALECT_MAP } from './modules/dialect-registry';
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 433552b..321fc64 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -26,8 +26,21 @@ export type { ValidationIssue, ValidationSeverity, ValidationCode } from './modu
export { CROSS_DIALECT_READINESS } from './modules/cross-dialect-readiness';
export type { ObjectTypeReadiness, ReadinessLevel } from './modules/cross-dialect-readiness';
export { buildBrowseResult } from './modules/browse';
-export { splitSqlStatements, checkStatement, isWriteStatement, firstKeyword, extractTableAliases, statementVerb, isMutatingDmlStatement, dmlLacksWhere } from './modules/sql-splitter';
-export type { SplitStatement, StatementStatus } from './modules/sql-splitter';
+export {
+ splitSqlStatements,
+ checkStatement,
+ isWriteStatement,
+ firstKeyword,
+ extractTableAliases,
+ statementVerb,
+ isMutatingDmlStatement,
+ dmlLacksWhere,
+ parseCodeCell,
+ findCodeFences,
+ stripCodeFenceMarkers,
+ codeCellHasReturn,
+} from './modules/sql-splitter';
+export type { SplitStatement, StatementStatus, StatementKind } from './modules/sql-splitter';
export type { SqlDialect, CanonicalType, CanonicalBase, RenderedType } from './modules/sql-dialect.interface';
export { resolveDialect, DIALECT_MAP } from './modules/dialect-registry';
diff --git a/packages/core/src/modules/sql-splitter.test.ts b/packages/core/src/modules/sql-splitter.test.ts
index 95cfbf1..9103e53 100644
--- a/packages/core/src/modules/sql-splitter.test.ts
+++ b/packages/core/src/modules/sql-splitter.test.ts
@@ -60,6 +60,71 @@ describe('splitSqlStatements', () => {
expect(stmts).toHaveLength(1);
expect(stmts[0].text).toBe("SELECT 'a\\';b' FROM t;");
});
+
+ it('keeps JS/TS fences as one statement (inner semicolons ok)', () => {
+ const sql = `SELECT 1 AS id;
+-- @js
+const x = 1; const y = 2;
+return { columns: ['n'], rows: [[x + y]] };
+-- @end
+SELECT 2;`;
+ const stmts = splitSqlStatements(sql);
+ expect(stmts).toHaveLength(3);
+ expect(stmts[0]).toMatchObject({ kind: 'sql', text: 'SELECT 1 AS id;' });
+ expect(stmts[1]?.kind).toBe('js');
+ expect(stmts[1]?.terminated).toBe(true);
+ expect(stmts[1]?.text).toContain('const x = 1;');
+ expect(stmts[1]?.text).toContain('-- @end');
+ expect(stmts[2]).toMatchObject({ kind: 'sql', text: 'SELECT 2;' });
+ });
+
+ it('supports -- @ts / -- @typescript fences', () => {
+ const stmts = splitSqlStatements(`-- @ts
+const n: number = 1;
+return { columns: ['n'], rows: [[n]] };
+-- @end`);
+ expect(stmts).toHaveLength(1);
+ expect(stmts[0]?.kind).toBe('ts');
+ });
+
+ it('warns when -- @end is missing', () => {
+ const stmts = splitSqlStatements(`-- @js
+return last;`);
+ expect(stmts).toHaveLength(1);
+ expect(stmts[0]?.terminated).toBe(false);
+ expect(checkStatement(stmts[0]!).level).toBe('warn');
+ expect(checkStatement(stmts[0]!).reasons.join()).toMatch(/@end/);
+ });
+
+ it('warns when a closed code cell has no return', () => {
+ const stmts = splitSqlStatements(`-- @js
+const x = 1;
+-- @end`);
+ expect(checkStatement(stmts[0]!).level).toBe('warn');
+ expect(checkStatement(stmts[0]!).reasons.join()).toMatch(/return/i);
+ });
+
+ it('ok for a closed code cell with return + locals/loop', () => {
+ const stmts = splitSqlStatements(`-- @js
+const out = [];
+for (const r of last.rows) out.push(r);
+return out;
+-- @end`);
+ expect(checkStatement(stmts[0]!)).toEqual({ level: 'ok', reasons: [] });
+ });
+
+ it('preserves @set comments before a code fence via offsets', () => {
+ const sql = `SELECT 1;
+-- @set src = table
+-- @js
+return last;
+-- @end`;
+ const stmts = splitSqlStatements(sql);
+ expect(stmts).toHaveLength(2);
+ expect(stmts[1]?.kind).toBe('js');
+ // Gap comments live before the fence start; reattachSetComments restores them.
+ expect(sql.slice(stmts[0]!.end, stmts[1]!.start)).toContain('-- @set src = table');
+ });
});
describe('checkStatement', () => {
diff --git a/packages/core/src/modules/sql-splitter.ts b/packages/core/src/modules/sql-splitter.ts
index 25e06a5..8e8ef00 100644
--- a/packages/core/src/modules/sql-splitter.ts
+++ b/packages/core/src/modules/sql-splitter.ts
@@ -11,8 +11,13 @@
* Known v1 limitation: procedural bodies (CREATE PROCEDURE … BEGIN … END)
* with internal semicolons split at each `;`; routine DDL belongs in the
* migration flow, not the editor.
+ *
+ * Code cells: `-- @js` / `-- @ts` … `-- @end` fences are opaque (inner `;`
+ * do not split) and emit a single statement with kind `'js'` | `'ts'`.
*/
+export type StatementKind = 'sql' | 'js' | 'ts';
+
export interface SplitStatement {
/** Statement text from its first non-whitespace character through its terminator. */
text: string;
@@ -23,8 +28,10 @@ export interface SplitStatement {
startLine: number;
/** 1-based line of the statement's last character. */
endLine: number;
- /** True when the statement ended with a `;`. */
+ /** True when the statement ended with a `;` (SQL) or `-- @end` (code cell). */
terminated: boolean;
+ /** `'sql'` (default), or a fenced code cell. */
+ kind?: StatementKind;
}
export interface StatementStatus {
@@ -52,8 +59,177 @@ const WRITE_KEYWORDS = new Set([
// eslint-disable-next-line security/detect-unsafe-regex -- false positive: anchored at ^; optional bounded identifier
const DOLLAR_TAG_RE = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/;
-/** Split a SQL buffer into `;`-terminated statements, skipping comment-only segments. */
-export function splitSqlStatements(sql: string): SplitStatement[] {
+const FENCE_START_RE = /^[ \t]*--[ \t]*@(js|javascript|ts|typescript)[ \t]*$/i;
+const FENCE_END_RE = /^[ \t]*--[ \t]*@end[ \t]*$/i;
+
+function kindFromFenceTag(tag: string): 'js' | 'ts' {
+ const t = tag.toLowerCase();
+ return t === 'ts' || t === 'typescript' ? 'ts' : 'js';
+}
+
+/** 1-based line number of the character at `index` (0 = start of file). */
+function lineAtOffset(sql: string, index: number): number {
+ let line = 1;
+ const end = Math.min(Math.max(0, index), sql.length);
+ for (let i = 0; i < end; i++) {
+ if (sql[i] === '\n') line++;
+ }
+ return line;
+}
+
+/** Next line bounds starting at `from` (`end` exclusive of newline; `next` after it). */
+function lineBounds(sql: string, from: number): { start: number; end: number; next: number } {
+ const len = sql.length;
+ const end = sql.indexOf('\n', from);
+ if (end < 0) return { start: from, end: len, next: len };
+ return { start: from, end, next: end + 1 };
+}
+
+interface CodeFenceRange {
+ kind: 'js' | 'ts';
+ start: number;
+ end: number;
+ closed: boolean;
+}
+
+/**
+ * Line-oriented scan for `-- @js` / `-- @ts` … `-- @end` fences.
+ * Fences are only recognized when the whole line matches (so SQL string
+ * literals containing the text are unlikely to false-positive).
+ */
+export function findCodeFences(sql: string): CodeFenceRange[] {
+ const fences: CodeFenceRange[] = [];
+ let i = 0;
+ const len = sql.length;
+ while (i < len) {
+ const { start: lineStart, end: lineEnd, next } = lineBounds(sql, i);
+ const startMatch = FENCE_START_RE.exec(sql.slice(lineStart, lineEnd));
+ if (!startMatch) {
+ i = next;
+ continue;
+ }
+
+ const kind = kindFromFenceTag(startMatch[1]!);
+ let j = next;
+ let closed = false;
+ let fenceEnd = len;
+ while (j < len) {
+ const inner = lineBounds(sql, j);
+ const line = sql.slice(inner.start, inner.end);
+ if (FENCE_END_RE.test(line)) {
+ closed = true;
+ fenceEnd = inner.next;
+ break;
+ }
+ if (FENCE_START_RE.test(line)) {
+ // Nested start without @end — close previous at this line (unterminated).
+ fenceEnd = inner.start;
+ break;
+ }
+ j = inner.next;
+ }
+ fences.push({ kind, start: lineStart, end: fenceEnd, closed });
+ i = fenceEnd;
+ }
+ return fences;
+}
+
+/**
+ * True when `text` is (or starts as) a fenced JS/TS code cell.
+ * Returns kind + body (fence markers stripped; leading `-- @set` may still
+ * remain in body — callers typically run parseSetDirectives first).
+ */
+export function parseCodeCell(
+ statement: string
+): { kind: 'js' | 'ts'; body: string; closed: boolean } | null {
+ const trimmed = statement.replace(/^\s+/, '');
+ const nl = trimmed.search(/\r?\n/);
+ const firstLine = nl < 0 ? trimmed : trimmed.slice(0, nl);
+ const startMatch = FENCE_START_RE.exec(firstLine);
+ if (!startMatch) return null;
+
+ const kind = kindFromFenceTag(startMatch[1]!);
+ if (nl < 0) return { kind, body: '', closed: false };
+
+ const lines = trimmed.slice(nl).replace(/^\r?\n/, '').split(/\r?\n/);
+ let closed = false;
+ if (lines.length > 0 && FENCE_END_RE.test(lines[lines.length - 1]!)) {
+ closed = true;
+ lines.pop();
+ }
+ return { kind, body: lines.join('\n'), closed };
+}
+
+/** Strip fence open/close lines from a full cell statement (after @set parse). */
+export function stripCodeFenceMarkers(
+ statement: string
+): { kind: 'js' | 'ts'; body: string } | null {
+ const parsed = parseCodeCell(statement);
+ return parsed ? { kind: parsed.kind, body: parsed.body } : null;
+}
+
+/** Strip JS line/block comments and quoted strings for keyword scans. */
+function stripJsStringsAndComments(src: string): string {
+ let out = '';
+ let i = 0;
+ const n = src.length;
+ while (i < n) {
+ const ch = src[i]!;
+ const next = src[i + 1] ?? '';
+ if (ch === '/' && next === '/') {
+ i += 2;
+ while (i < n && src[i] !== '\n') i++;
+ continue;
+ }
+ if (ch === '/' && next === '*') {
+ i += 2;
+ while (i < n - 1 && !(src[i] === '*' && src[i + 1] === '/')) i++;
+ i = Math.min(n, i + 2);
+ continue;
+ }
+ if (ch === "'" || ch === '"' || ch === '`') {
+ const q = ch;
+ out += ' ';
+ i++;
+ while (i < n) {
+ if (src[i] === '\\') {
+ i += 2;
+ continue;
+ }
+ if (src[i] === q) {
+ i++;
+ break;
+ }
+ if (q === '`' && src[i] === '$' && src[i + 1] === '{') {
+ i += 2;
+ let depth = 1;
+ while (i < n && depth > 0) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') depth--;
+ i++;
+ }
+ continue;
+ }
+ i++;
+ }
+ continue;
+ }
+ out += ch;
+ i++;
+ }
+ return out;
+}
+
+/**
+ * True when a JS/TS cell body contains a `return` statement (not inside a
+ * string/comment). Code cells must return a grid value.
+ */
+export function codeCellHasReturn(body: string): boolean {
+ return /\breturn\b/.test(stripJsStringsAndComments(body));
+}
+
+/** Split a SQL-only buffer into `;`-terminated statements (no code fences). */
+function splitSqlOnly(sql: string): SplitStatement[] {
const out: SplitStatement[] = [];
const len = sql.length;
@@ -96,6 +272,7 @@ export function splitSqlStatements(sql: string): SplitStatement[] {
startLine: codeStartLine,
endLine,
terminated,
+ kind: 'sql',
});
reset();
};
@@ -177,6 +354,53 @@ export function splitSqlStatements(sql: string): SplitStatement[] {
return out;
}
+function remapSqlGap(
+ gap: string,
+ baseOffset: number,
+ baseLine: number
+): SplitStatement[] {
+ return splitSqlOnly(gap).map((s) => ({
+ ...s,
+ kind: 'sql' as const,
+ start: s.start + baseOffset,
+ end: s.end + baseOffset,
+ startLine: baseLine + s.startLine - 1,
+ endLine: baseLine + s.endLine - 1,
+ }));
+}
+
+/** Split a SQL buffer into `;`-terminated statements, skipping comment-only segments. */
+export function splitSqlStatements(sql: string): SplitStatement[] {
+ const fences = findCodeFences(sql);
+ if (fences.length === 0) return splitSqlOnly(sql);
+
+ const out: SplitStatement[] = [];
+ let cursor = 0;
+ for (const f of fences) {
+ if (f.start > cursor) {
+ const gap = sql.slice(cursor, f.start);
+ out.push(...remapSqlGap(gap, cursor, lineAtOffset(sql, cursor)));
+ }
+ // Trim trailing whitespace from fence end for display, keep closed flag.
+ let realEnd = f.end;
+ while (realEnd > f.start && /\s/.test(sql[realEnd - 1]!)) realEnd--;
+ out.push({
+ text: sql.slice(f.start, realEnd),
+ start: f.start,
+ end: realEnd,
+ startLine: lineAtOffset(sql, f.start),
+ endLine: lineAtOffset(sql, Math.max(f.start, realEnd - 1)),
+ terminated: f.closed,
+ kind: f.kind,
+ });
+ cursor = f.end;
+ }
+ if (cursor < sql.length) {
+ out.push(...remapSqlGap(sql.slice(cursor), cursor, lineAtOffset(sql, cursor)));
+ }
+ return out;
+}
+
/** First code word of a statement (lowercased), skipping leading comments/whitespace/parens. */
export function firstKeyword(text: string): string | null {
let mode: Mode = 'code';
@@ -209,7 +433,24 @@ export function firstKeyword(text: string): string | null {
* complete" — balanced quoting/parens, known leading keyword, terminated with
* a semicolon — NOT that the statement will execute.
*/
-export function checkStatement(stmt: Pick): StatementStatus {
+export function checkStatement(
+ stmt: Pick & { kind?: StatementKind }
+): StatementStatus {
+ const cell = parseCodeCell(stmt.text);
+ const kind = stmt.kind ?? cell?.kind ?? 'sql';
+ if (kind === 'js' || kind === 'ts') {
+ const reasons: string[] = [];
+ if (!stmt.terminated) {
+ reasons.push('Missing -- @end for code cell');
+ }
+ // Strip leading `-- @set` lines inside the fence before the return check.
+ const body = (cell?.body ?? '').replace(/^(?:--\s*@set[^\n]*\n)+/i, '');
+ if (body.trim() && !codeCellHasReturn(body)) {
+ reasons.push('Code cell must include a return statement');
+ }
+ return { level: reasons.length ? 'warn' : 'ok', reasons };
+ }
+
const reasons: string[] = [];
const text = stmt.text;
@@ -276,6 +517,7 @@ export function checkStatement(stmt: Pick
* mutating verb — EXPLAIN does not apply the change.
*/
export function isWriteStatement(text: string): boolean {
+ if (parseCodeCell(text)) return false;
const kw = firstKeyword(text);
if (!kw) return false;
if (WRITE_KEYWORDS.has(kw)) return true;
@@ -289,6 +531,8 @@ export function isWriteStatement(text: string): boolean {
/** Leading verb after skipping WITH CTEs (lowercased), or null. */
export function statementVerb(text: string): string | null {
+ const cell = parseCodeCell(text);
+ if (cell) return cell.kind;
const kw = firstKeyword(text);
if (!kw) return null;
if (kw === 'explain') return 'explain';