Skip to content
Open
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
86 changes: 85 additions & 1 deletion apps/web/src/backend/api/code-cell-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,90 @@ describe('code cell SQL bridge', () => {
enforceBeamSqlOnCap: true,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(new RegExp(`at most ${MAX_SQL} sql\\.on`, 'i'));
if (!result.ok) {
expect(result.error).toMatch(new RegExp(`at most ${MAX_SQL} SQL bridge calls`, 'i'));
}
}, 60_000);

it('counts plain sql`` toward the Beam cap (not only sql.on)', async () => {
const calls: string[] = [];
const runQuery = async (text: string) => {
calls.push(text);
return [{ n: 1 }];
};
// Mix: MAX_SQL - 1 via plain sql, then one sql.on succeeds, then one more fails.
const body =
`for (let i = 0; i < ${MAX_SQL - 1}; i++) { await sql\`SELECT 1\`; }\n` +
`await sql.on('source')\`SELECT 2\`;\n` +
`try { await sql\`SELECT 3\`; return [{ ok: true }]; }\n` +
`catch (e) { return [{ ok: false, msg: String(e.message) }]; }`;
const result = await runCell(body, {
dialect: 'sqlite',
allowWrites: false,
runQuery,
beamDialects: { source: 'sqlite' },
defaultBeamAlias: 'source',
enforceBeamSqlOnCap: true,
});
if (!result.ok) throw new Error(result.error);
expect(result.rows[0]![0]).toBe(false);
expect(String(result.rows[0]![1])).toMatch(/at most .* SQL bridge calls/i);
// Cap rejects before the last runQuery — MAX_SQL successful bridge calls.
expect(calls.length).toBe(MAX_SQL);
}, 60_000);

it('enforces the Beam cap under Promise.all concurrency', async () => {
let inFlight = 0;
let maxInFlight = 0;
const runQuery = async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
return [{ n: 1 }];
};
const body =
`const jobs = [];\n` +
`for (let i = 0; i < ${MAX_SQL + 5}; i++) {\n` +
` jobs.push(sql.on('source')\`SELECT \${i} AS n\`);\n` +
`}\n` +
`const settled = await Promise.allSettled(jobs);\n` +
`const rejected = settled.filter((s) => s.status === 'rejected').length;\n` +
`const fulfilled = settled.filter((s) => s.status === 'fulfilled').length;\n` +
`return [{ fulfilled, rejected }];`;
const result = await runCell(
body,
{
dialect: 'sqlite',
allowWrites: false,
runQuery,
beamDialects: { source: 'sqlite' },
defaultBeamAlias: 'source',
enforceBeamSqlOnCap: true,
},
60_000
);
if (!result.ok) throw new Error(result.error);
expect(result.rows[0]![0]).toBe(MAX_SQL);
expect(result.rows[0]![1]).toBe(5);
// Serialized Beam bridge: at most one query in flight at a time.
expect(maxInFlight).toBe(1);
}, 90_000);

it('allows exactly MAX_SQL sequential Beam bridge calls', async () => {
const runQuery = async () => [{ n: 1 }];
const body =
`for (let i = 0; i < ${MAX_SQL}; i++) { await sql.on('source')\`SELECT 1\`; }\n` +
`return [{ ok: true, n: ${MAX_SQL} }];`;
const result = await runCell(body, {
dialect: 'sqlite',
allowWrites: false,
runQuery,
beamDialects: { source: 'sqlite' },
defaultBeamAlias: 'source',
enforceBeamSqlOnCap: true,
});
if (!result.ok) throw new Error(result.error);
expect(result.rows).toEqual([[true, MAX_SQL]]);
}, 60_000);
});
44 changes: 31 additions & 13 deletions apps/web/src/backend/api/code-cell-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type CellQueryRunner = (
alias?: string
) => Promise<Record<string, unknown>[]>;
import { clampMaxRows } from './sql-execute';
import { MAX_SQL } from '../../shared/server-beam';
import { createBeamSqlCap } from '../../shared/server-beam';

export const MAX_CODE_CELL_LENGTH = 100_000;
export const DEFAULT_CODE_CELL_TIMEOUT_MS = 10_000;
Expand Down Expand Up @@ -135,7 +135,7 @@ function runInWorkerThread(args: {
beamDialects?: Record<string, string>;
/** Server Beam: default alias for plain `sql`…``. */
defaultBeamAlias?: string;
/** When true, enforce max `sql.on()` calls per Execute. */
/** When true, enforce max SQL bridge calls per Execute (all `sql` / `sql.on`). */
enforceBeamSqlOnCap?: boolean;
}): Promise<CodeCellResult> {
return new Promise((resolve) => {
Expand All @@ -144,6 +144,9 @@ function runInWorkerThread(args: {
let timer: ReturnType<typeof setTimeout> | undefined;
/** Bridged queries in flight; the cell clock is paused while > 0. */
let inFlight = 0;
const beamSqlCap = args.enforceBeamSqlOnCap ? createBeamSqlCap() : null;
/** Serialize Beam bridge work so cap + query stay ordered under Promise.all. */
let beamBridgeTail: Promise<void> = Promise.resolve();

const startTimer = () => {
timer = setTimeout(() => {
Expand Down Expand Up @@ -215,8 +218,6 @@ function runInWorkerThread(args: {

startTimer();

let sqlOnCount = 0;

const answerQuery = async (req: CellQueryRequest) => {
pauseClock();
const reply = (res: CellQueryResponse) => {
Expand All @@ -226,18 +227,35 @@ function runInWorkerThread(args: {
/* worker already gone */
}
};
try {
if (!args.runQuery) throw new Error('This cell has no connection — select a credential first');
if (args.enforceBeamSqlOnCap && req.viaOn) {
sqlOnCount += 1;
if (sqlOnCount > MAX_SQL) {
throw new Error(
`Server Beam allows at most ${MAX_SQL} sql.on() calls per editor Execute`
);
}

const run = async () => {
if (!args.runQuery) {
throw new Error('This cell has no connection — select a credential first');
}
// Every bridged call counts (plain `sql` and `sql.on`) when Beam is on.
beamSqlCap?.take();
const rows = await args.runQuery(req.text, req.params, req.alias);
reply({ type: 'cell-query-result', id: req.id, ok: true, rows, rowCount: rows.length });
};

try {
if (beamSqlCap) {
// Queue Beam bridge calls so concurrent cell Promise.all cannot
// interleave take()+query in surprising ways.
const prev = beamBridgeTail;
let release!: () => void;
beamBridgeTail = new Promise<void>((r) => {
release = r;
});
try {
await prev;
await run();
} finally {
release();
}
} else {
await run();
}
} catch (error: unknown) {
reply({ type: 'cell-query-result', id: req.id, ok: false, error: errorMessage(error) });
} finally {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/frontend/lib/sqlEditorSamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ return [
title: '★ Sample · Server Beam copy rows source → target',
sql: `-- Server Beam — WRITES on target. Check TWO Destinations (source, then target).
-- Turn Safe mode OFF. Creates fox_beam_demo on both sides, copies reshaped rows.
-- Caps: 2 servers, up to 20 sql.on() calls per Execute.
-- Caps: 2 servers, up to 20 SQL bridge calls (sql / sql.on) per Execute.

-- @node
await sql.on('source')\`DROP TABLE IF EXISTS fox_beam_demo\`;
Expand Down Expand Up @@ -570,7 +570,7 @@ return await sql.on('target')\`SELECT id, email, domain FROM fox_beam_demo ORDER
title: '★ Sample · Server Beam chunked pull → push',
sql: `-- Server Beam — WRITES. Two Destinations (source, then target). Safe mode OFF.
-- Pulls from source in chunks and inserts into target (async / await).
-- Stays within the sql.on() cap per Execute (2 setup + 2×(pull+push) + 1 verify).
-- Stays within the SQL bridge cap per Execute (2 setup + 2×(pull+push) + 1 verify).

-- @node
await sql.on('source')\`DROP TABLE IF EXISTS fox_beam_bulk\`;
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/shared/server-beam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
MAX_SERVERS,
MAX_SQL,
beamAliasesForCount,
createBeamSqlCap,
normalizeBeamAlias,
parseBeamEndpoints,
usesServerBeam,
Expand All @@ -14,6 +15,28 @@ describe('server-beam', () => {
expect(MAX_SQL).toBe(20);
});

it('createBeamSqlCap counts every take and rejects past max', () => {
const cap = createBeamSqlCap(3);
cap.take();
cap.take();
cap.take();
expect(cap.count).toBe(3);
expect(() => cap.take()).toThrow(/at most 3 SQL bridge calls/i);
expect(cap.count).toBe(4);
});

it('createBeamSqlCap take() stays correct under interleaved microtasks', async () => {
const cap = createBeamSqlCap(5);
const tasks = Array.from({ length: 8 }, async () => {
await Promise.resolve();
cap.take();
});
const results = await Promise.allSettled(tasks);
const rejected = results.filter((r) => r.status === 'rejected');
expect(rejected.length).toBe(3);
expect(cap.count).toBe(8);
});

it('detects sql.on usage', () => {
expect(usesServerBeam('await sql.on("source")`SELECT 1`')).toBe(true);
expect(usesServerBeam('await sql.on(\'target\')`SELECT 1`')).toBe(true);
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/shared/server-beam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ export type BeamEndpointRef = {
password?: string;
};

/**
* Per-Execute counter for Server Beam SQL bridge calls.
* Counts every bridged query (`sql`…`` and `sql.on`…``), not only `.on()`.
* `take()` is synchronous so concurrent `answerQuery` handlers (after an
* `await`) cannot slip past {@link MAX_SQL} on a single-threaded event loop.
*/
export function createBeamSqlCap(max = MAX_SQL): {
take: () => void;
readonly count: number;
} {
let count = 0;
return {
take() {
count += 1;
if (count > max) {
throw new Error(
`Server Beam allows at most ${max} SQL bridge calls per editor Execute`
);
}
},
get count() {
return count;
},
};
}

const ALIAS_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;

export function normalizeBeamAlias(raw: unknown): string | null {
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/server-beam.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ credentials are the Beam endpoints; clients choose source and target servers
| Rule | Value |
|------|--------|
| Beam servers selectable per editor Execute | **up to 2** (source + target) |
| `sql.on()` calls per editor Execute | **up to 20** (`MAX_SQL`) |
| SQL bridge calls per editor Execute | **up to 20** (`MAX_SQL`) — every `sql` / `sql.on` |
| Async / Promises in Node cells | **required** (already supported by code-cell runtime) |
| Password / decrypt | Server-only via existing `resolveRef` / connection store |

Expand Down
Loading