Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,9 @@ apps/cli/npm-pack/
!apps/cli/README.md
!docs/homebrew.md


# FoxFlow's local SQLite DB (sibling project) — never belongs in this repo.
# Its credentials/variables tables can hold real secrets.
foxflow.sqlite
foxflow.sqlite-shm
foxflow.sqlite-wal
47 changes: 47 additions & 0 deletions apps/web/src/backend/api/code-cell-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Message protocol between a Node code-cell worker and the API process.
*
* The worker is deliberately isolated — no DB handle, no `process.env` — so a
* cell that wants to run SQL asks the parent to do it. The parent owns the
* connection, the write policy, and the row cap; the worker only sees rows.
*/

/** Worker → parent: run this statement on the cell's connection. */
export interface CellQueryRequest {
type: 'cell-query';
id: number;
text: string;
params: unknown[];
}

/** Parent → worker: the outcome of one `cell-query`. */
export type CellQueryResponse =
| { type: 'cell-query-result'; id: number; ok: true; rows: Record<string, unknown>[]; rowCount: number }
| { type: 'cell-query-result'; id: number; ok: false; error: string };

/** Worker → parent: the cell finished (existing single-result message). */
export interface CellDoneMessage {
type: 'cell-done';
result: unknown;
}

export type WorkerToParent = CellQueryRequest | CellDoneMessage;

export function isCellQueryRequest(msg: unknown): msg is CellQueryRequest {
return (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'cell-query' &&
typeof (msg as { id?: unknown }).id === 'number' &&
typeof (msg as { text?: unknown }).text === 'string'
);
}

export function isCellDoneMessage(msg: unknown): msg is CellDoneMessage {
return (
typeof msg === 'object' && msg !== null && (msg as { type?: unknown }).type === 'cell-done'
);
}

/** Rows a bridged query may hand back to a cell before it is truncated. */
export const MAX_CELL_QUERY_ROWS = 10_000;
91 changes: 91 additions & 0 deletions apps/web/src/backend/api/code-cell-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,94 @@ describe('runCodeCellOnServer (worker_threads sandbox)', () => {
if (!result.ok) expect(result.error).toMatch(/timed out/i);
}, 15_000);
});

describe('code cell SQL bridge', () => {
/** Capture what the parent was asked to run, and reply with canned rows. */
function stubRunner(rows: Record<string, unknown>[] = []) {
const calls: { text: string; params: unknown[] }[] = [];
const runQuery = async (text: string, params: unknown[]) => {
calls.push({ text, params });
return rows;
};
return { calls, runQuery };
}

async function runCell(
body: string,
opts: Parameters<typeof runCodeCellOnServer>[1],
timeoutMs = 20_000
) {
const v = validateCodeCellRequest({
body,
kind: 'js',
last: null,
vars: {},
maxRows: 50,
timeoutMs,
});
if (!v.ok) throw new Error(v.error);
return runCodeCellOnServer(v.value, opts);
}

it('binds interpolations instead of inlining them', async () => {
const { calls, runQuery } = stubRunner([{ who: "O'Brien" }]);
const result = await runCell(
'return await sql`SELECT * FROM t WHERE name = ${"O\'Brien"} AND id = ${7}`;',
{ dialect: 'postgres', allowWrites: false, runQuery }
);
if (!result.ok) throw new Error(result.error);
expect(calls).toHaveLength(1);
expect(calls[0]!.text).toBe('SELECT * FROM t WHERE name = $1 AND id = $2');
expect(calls[0]!.params).toEqual(["O'Brien", 7]);
expect(result.rows).toEqual([["O'Brien"]]);
}, 30_000);

it('builds a parameterized bulk INSERT from JS objects', async () => {
const { calls, runQuery } = stubRunner();
const result = await runCell(
"const v=[{id:1,email:\"a'b\"},{id:2,email:'c'}];" +
'await sql`INSERT INTO ${sql.id("accounts")} ${sql.values(v)}`;' +
'return [{ done: true }];',
{ dialect: 'sqlite', allowWrites: true, runQuery }
);
if (!result.ok) throw new Error(result.error);
expect(calls[0]!.text).toBe('INSERT INTO "accounts" ("id", "email") VALUES (?, ?), (?, ?)');
expect(calls[0]!.params).toEqual([1, "a'b", 2, 'c']);
}, 30_000);

it('surfaces a query error to the cell as a catchable rejection', async () => {
const runQuery = async () => {
throw new Error('no such table: ghost');
};
const result = await runCell(
'try { await sql`SELECT 1 FROM ghost`; return [{ caught: false }]; }' +
' catch (e) { return [{ caught: true, msg: String(e.message) }]; }',
{ dialect: 'sqlite', allowWrites: false, runQuery }
);
if (!result.ok) throw new Error(result.error);
expect(result.rows[0]![0]).toBe(true);
expect(String(result.rows[0]![1])).toContain('no such table: ghost');
}, 30_000);

it('reports a missing connection instead of hanging', async () => {
const result = await runCell('return await sql`SELECT 1`;', { dialect: 'sqlite' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/no connection/i);
}, 30_000);

it('does not count database time against the cell timeout', async () => {
// 3 × 2s of query time (6s) under a 4s cell budget. If the clock ran during
// bridged queries this would time out; the budget is still generous enough
// to absorb worker cold-start when the whole suite runs in parallel.
const runQuery = async () => {
await new Promise((r) => setTimeout(r, 2000));
return [{ n: 1 }];
};
const result = await runCell(
'for (let i=0;i<3;i++) { await sql`SELECT 1`; } return [{ ok: true }];',
{ dialect: 'sqlite', allowWrites: false, runQuery },
4000
);
expect(result.ok).toBe(true);
}, 45_000);
});
91 changes: 83 additions & 8 deletions apps/web/src/backend/api/code-cell-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ import { fileURLToPath } from 'node:url';
import type { BrowserCodeCellKind } from '@foxschema/core';
import { isCodeCellLast, isCodeCellVars } from '@foxschema/core';
import type { CodeCellLast, CodeCellResult, CodeCellVars } from './code-cell-node-exec';
import {
isCellDoneMessage,
isCellQueryRequest,
type CellQueryRequest,
type CellQueryResponse,
} from './code-cell-bridge';

/** Runs one bridged statement for a cell and returns rows as objects. */
export type CellQueryRunner = (
text: string,
params: unknown[]
) => Promise<Record<string, unknown>[]>;
import { clampMaxRows } from './sql-execute';

export const MAX_CODE_CELL_LENGTH = 100_000;
Expand Down Expand Up @@ -113,11 +125,42 @@ function runInWorkerThread(args: {
vars: CodeCellVars;
maxRows: number;
timeoutMs: number;
dialect?: string;
allowWrites?: boolean;
/** Runs one bridged `sql` statement. Absent = the cell has no connection. */
runQuery?: CellQueryRunner;
}): Promise<CodeCellResult> {
return new Promise((resolve) => {
let settled = false;
let worker: Worker | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
/** Bridged queries in flight; the cell clock is paused while > 0. */
let inFlight = 0;

const startTimer = () => {
timer = setTimeout(() => {
settle({ ok: false, error: `Code cell timed out after ${args.timeoutMs}ms` });
}, args.timeoutMs);
};

// The timeout budgets the *cell's own* work. A bridged query is the
// database's time, not the cell's, and can legitimately outlast it — so
// stop the clock while one is outstanding. Without this, any cell doing
// real migration work is killed mid-statement. The budget restarts fresh
// after each query rather than resuming its remainder: total DB time is
// deliberately unbounded, while a runaway loop between queries is still
// caught within one full budget.
const pauseClock = () => {
inFlight += 1;
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
const resumeClock = () => {
inFlight = Math.max(0, inFlight - 1);
if (inFlight === 0 && !settled && timer === undefined) startTimer();
};

const settle = (result: CodeCellResult) => {
if (settled) return;
Expand Down Expand Up @@ -150,6 +193,8 @@ function runInWorkerThread(args: {
last: args.last,
vars: args.vars,
maxRows: args.maxRows,
dialect: args.dialect,
allowWrites: args.allowWrites,
},
execArgv,
});
Expand All @@ -158,14 +203,40 @@ function runInWorkerThread(args: {
return;
}

timer = setTimeout(() => {
settle({
ok: false,
error: `Code cell timed out after ${args.timeoutMs}ms`,
});
}, args.timeoutMs);
startTimer();

const answerQuery = async (req: CellQueryRequest) => {
pauseClock();
const reply = (res: CellQueryResponse) => {
try {
worker?.postMessage(res);
} catch {
/* worker already gone */
}
};
try {
if (!args.runQuery) throw new Error('This cell has no connection — select a credential first');
const rows = await args.runQuery(req.text, req.params);
reply({ type: 'cell-query-result', id: req.id, ok: true, rows, rowCount: rows.length });
} catch (error: unknown) {
reply({ type: 'cell-query-result', id: req.id, ok: false, error: errorMessage(error) });
} finally {
resumeClock();
}
};

worker.on('message', (msg: CodeCellResult) => settle(msg));
worker.on('message', (msg: unknown) => {
if (isCellQueryRequest(msg)) {
void answerQuery(msg);
return;
}
if (isCellDoneMessage(msg)) {
settle(msg.result as CodeCellResult);
return;
}
// Older shape (bare result) — keep accepting it.
settle(msg as CodeCellResult);
});
worker.on('error', (error) => {
if (!settled) failed(errorMessage(error));
});
Expand All @@ -180,7 +251,8 @@ function runInWorkerThread(args: {
* Transpile (if needed) and execute a code cell on Node (worker_threads + timeout).
*/
export async function runCodeCellOnServer(
validated: ValidatedCodeCell
validated: ValidatedCodeCell,
options?: { dialect?: string; allowWrites?: boolean; runQuery?: CellQueryRunner }
): Promise<CodeCellResult & { durationMs: number }> {
const started = Date.now();
let body = validated.body;
Expand All @@ -202,6 +274,9 @@ export async function runCodeCellOnServer(
vars: validated.vars,
maxRows: validated.maxRows,
timeoutMs: validated.timeoutMs,
dialect: options?.dialect,
allowWrites: options?.allowWrites,
runQuery: options?.runQuery,
});
return { ...result, durationMs: Date.now() - started };
}
10 changes: 9 additions & 1 deletion apps/web/src/backend/api/code-cell-node-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export function executeCodeCellNode(args: {
last: CodeCellLast;
vars: CodeCellVars;
maxRows: number;
/** Query bridge exposed to the cell as `sql`; omitted when unavailable. */
sql?: unknown;
}): Promise<CodeCellResult> {
return runCodeCellBody({ ...args, preamble: SANDBOX_PREAMBLE, modules: MODULES });
const { sql, ...rest } = args;
return runCodeCellBody({
...rest,
preamble: SANDBOX_PREAMBLE,
modules: MODULES,
extraBindings: sql ? { sql } : undefined,
});
}
45 changes: 45 additions & 0 deletions apps/web/src/backend/api/code-cell-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Executes the statements a `-- @node` cell sends over the bridge.
*
* This is the trust boundary for `sql` inside a cell: the write policy and the
* row cap live here, in the API process, not in the worker that runs user code.
*/

import { ConnectionFactory, isWriteStatement, type ConnectionOptions } from '@foxschema/core';
import { MAX_CELL_QUERY_ROWS } from './code-cell-bridge';
import type { CellQueryRunner } from './code-cell-execute';

/**
* Build the bridge runner for one resolved connection.
*
* `allowWrites` mirrors the editor's Safe mode. A cell cannot be statically
* analyzed for what SQL it will build at runtime, so the check has to happen
* here, per statement, at the moment it is submitted.
*/
export function makeCellQueryRunner(
resolved: { dialect: string; option: ConnectionOptions },
allowWrites: boolean
): CellQueryRunner {
return async (text: string, params: unknown[]) => {
const sql = text.trim();
if (!sql) throw new Error('Empty statement');

if (!allowWrites && isWriteStatement(sql)) {
throw new Error(
'Safe mode is on — this cell tried to run a write/DDL statement. ' +
'Turn Safe mode off (or confirm the run) to allow writes from code cells.'
);
}

const rows = await ConnectionFactory.executeQuery<Record<string, unknown>>(
resolved.dialect,
resolved.option,
sql,
params
);
if (!Array.isArray(rows)) return [];
// Cap what crosses back into the worker: a cell asking for a 10M-row table
// would otherwise serialize the whole thing through structured clone.
return rows.length > MAX_CELL_QUERY_ROWS ? rows.slice(0, MAX_CELL_QUERY_ROWS) : rows;
};
}
Loading
Loading