From b19e9f814b55228f212a636bd916cace7554034b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:02:24 +0000 Subject: [PATCH 1/6] docs: plan Link Servers (sql.on, caps, no commercial notes) Cross-DB data move via Node cells: up to 2 link servers and 10 sql.on() calls per editor Execute. Explicitly omit commercial/edition metering. Co-authored-by: huy.phan9 --- docs/plans/link-servers.md | 82 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/plans/link-servers.md diff --git a/docs/plans/link-servers.md b/docs/plans/link-servers.md new file mode 100644 index 0000000..e08ede2 --- /dev/null +++ b/docs/plans/link-servers.md @@ -0,0 +1,82 @@ +# Link Servers — cross-database data move via JS + SQL + +## Goal + +Evolve saved credentials into **Link Servers** so an editor script can move data +between databases using mixed JavaScript/TypeScript and SQL (`sql.on(alias)`), +with async / Promises. Clients choose source and target servers (and tables); +the editor runtime queries only through the server-side bridge (passwords never +reach the browser sandbox). + +## Out of scope (v1) + +- Billing, entitlements, edition gates, or “commercial” product wording anywhere + in code, UI copy, or docs for this feature +- Background job service (progress UI beyond the editor run is later) +- Distributed transactions across servers + +## Locked decisions + +| Rule | Value | +|------|--------| +| Link servers selectable per editor Execute | **up to 2** (source + target) | +| `sql.on()` calls per editor Execute | **up to 10** | +| Async / Promises in Node cells | **required** (already supported by code-cell runtime) | +| Password / decrypt | Server-only via existing `resolveRef` / connection store | + +## Concepts + +- **Link Server** — a saved connection used with an **alias** in editor scripts + (same encrypted store as today’s credentials; metadata adds alias + role for + the run: source | target). +- **`sql.on(alias)`** — Node/`@nodets` cell bridge that runs SQL on the link + bound to `alias` for this Execute (same trust model as today’s single-ref + `sql\`...\``: worker posts query to parent; parent applies Safe mode + RBAC). +- **Editor Execute** — one Run from the SQL Editor (may include multiple + statements / one code cell). Caps above apply per Execute. + +## Editor contract (sketch) + +```js +-- @node +const rows = await sql.on('oltp').` + SELECT id, amount FROM orders WHERE id > ${cursor} LIMIT 500 +`; +await sql.on('warehouse').` + INSERT INTO orders_copy (id, amount) ${sql.values(rows)} +`; +return rows; +-- @end +``` + +- Aliases must map 1:1 to the at most **2** Link Servers chosen for the run. +- A third distinct alias / server → hard error. +- An 11th `sql.on()` in the same Execute → hard error. +- Browser-only `@js` / `@ts` cells do **not** get `sql.on` (no DB bridge). + +## Backend sketch + +1. Extend run payload for Node cells: `{ links: [{ alias, connectionId }], … }` + (still one worker; parent `makeCellQueryRunner` resolves alias → `ConnectionRef`). +2. Count `sql.on` invocations in the parent bridge; reject over 10. +3. Reject if resolved distinct `connectionId`s > 2. +4. Keep existing row caps / write permission checks per statement. + +## UI sketch + +- Credentials manager gains Link Server framing (alias optional; default = name). +- SQL Editor run bar: pick **source** + **target** (max 2), not an unbounded + Destinations fan-out for this mode. +- Samples: “copy rows oltp → warehouse” using `sql.on`. + +## Phases + +1. **Bridge** — multi-link `sql.on(alias)` + Enforce 2 servers / 10 calls. +2. **UI** — source/target picker + alias binding + sample script. +3. **Hardening** — chunk helpers, clearer errors, tests for caps and RBAC. + +## Non-goals for naming in repo + +Do not introduce strings or flags such as `commercial`, `enterprise`, +`entitlement`, `tokenQuota`, or paid-tier checks for Link Servers in this work. +Safety caps (2 servers / 10 `sql.on`) are product limits only. From c0648ffc686fbc1158c59d85998ea579dcdf0342 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:07:22 +0000 Subject: [PATCH 2/6] docs: rename feature to Server Beam Replace Link Servers plan with docs/plans/server-beam.md; same caps (2 endpoints, 10 sql.on per Execute), no paid-tier metering notes. Co-authored-by: huy.phan9 --- .../plans/{link-servers.md => server-beam.md} | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) rename docs/plans/{link-servers.md => server-beam.md} (52%) diff --git a/docs/plans/link-servers.md b/docs/plans/server-beam.md similarity index 52% rename from docs/plans/link-servers.md rename to docs/plans/server-beam.md index e08ede2..31dd327 100644 --- a/docs/plans/link-servers.md +++ b/docs/plans/server-beam.md @@ -1,16 +1,16 @@ -# Link Servers — cross-database data move via JS + SQL +# Server Beam — cross-database data move via JS + SQL ## Goal -Evolve saved credentials into **Link Servers** so an editor script can move data -between databases using mixed JavaScript/TypeScript and SQL (`sql.on(alias)`), -with async / Promises. Clients choose source and target servers (and tables); -the editor runtime queries only through the server-side bridge (passwords never -reach the browser sandbox). +**Server Beam** lets an editor script move data between databases using mixed +JavaScript/TypeScript and SQL (`sql.on(alias)`), with async / Promises. Saved +credentials are the Beam endpoints; clients choose source and target servers +(and tables). The editor runtime queries only through the server-side bridge +(passwords never reach the browser sandbox). ## Out of scope (v1) -- Billing, entitlements, edition gates, or “commercial” product wording anywhere +- Billing, entitlements, edition gates, or paid-tier product wording anywhere in code, UI copy, or docs for this feature - Background job service (progress UI beyond the editor run is later) - Distributed transactions across servers @@ -19,17 +19,19 @@ reach the browser sandbox). | Rule | Value | |------|--------| -| Link servers selectable per editor Execute | **up to 2** (source + target) | +| Beam servers selectable per editor Execute | **up to 2** (source + target) | | `sql.on()` calls per editor Execute | **up to 10** | | Async / Promises in Node cells | **required** (already supported by code-cell runtime) | | Password / decrypt | Server-only via existing `resolveRef` / connection store | ## Concepts -- **Link Server** — a saved connection used with an **alias** in editor scripts - (same encrypted store as today’s credentials; metadata adds alias + role for - the run: source | target). -- **`sql.on(alias)`** — Node/`@nodets` cell bridge that runs SQL on the link +- **Server Beam** — the feature: run a script that reads/writes across up to two + saved connections in one editor Execute. +- **Beam endpoint** — a saved connection used with an **alias** in editor scripts + (same encrypted store as today’s credentials; run metadata adds alias + role: + source | target). +- **`sql.on(alias)`** — Node/`@nodets` cell bridge that runs SQL on the endpoint bound to `alias` for this Execute (same trust model as today’s single-ref `sql\`...\``: worker posts query to parent; parent applies Safe mode + RBAC). - **Editor Execute** — one Run from the SQL Editor (may include multiple @@ -49,14 +51,15 @@ return rows; -- @end ``` -- Aliases must map 1:1 to the at most **2** Link Servers chosen for the run. +- Aliases must map 1:1 to the at most **2** Beam endpoints chosen for the run. - A third distinct alias / server → hard error. - An 11th `sql.on()` in the same Execute → hard error. - Browser-only `@js` / `@ts` cells do **not** get `sql.on` (no DB bridge). ## Backend sketch -1. Extend run payload for Node cells: `{ links: [{ alias, connectionId }], … }` +1. Extend run payload for Node cells: + `{ beam: [{ alias, connectionId }], … }` (still one worker; parent `makeCellQueryRunner` resolves alias → `ConnectionRef`). 2. Count `sql.on` invocations in the parent bridge; reject over 10. 3. Reject if resolved distinct `connectionId`s > 2. @@ -64,19 +67,19 @@ return rows; ## UI sketch -- Credentials manager gains Link Server framing (alias optional; default = name). -- SQL Editor run bar: pick **source** + **target** (max 2), not an unbounded - Destinations fan-out for this mode. -- Samples: “copy rows oltp → warehouse” using `sql.on`. +- Credentials stay the store; Server Beam mode binds aliases for the run. +- SQL Editor run bar: pick **source** + **target** (max 2) when Beaming. +- Samples: “Server Beam: copy rows oltp → warehouse” using `sql.on`. ## Phases -1. **Bridge** — multi-link `sql.on(alias)` + Enforce 2 servers / 10 calls. +1. **Bridge** — multi-endpoint `sql.on(alias)` + enforce 2 servers / 10 calls. 2. **UI** — source/target picker + alias binding + sample script. 3. **Hardening** — chunk helpers, clearer errors, tests for caps and RBAC. -## Non-goals for naming in repo +## Naming in repo -Do not introduce strings or flags such as `commercial`, `enterprise`, -`entitlement`, `tokenQuota`, or paid-tier checks for Link Servers in this work. -Safety caps (2 servers / 10 `sql.on`) are product limits only. +- Product / UI / docs: **Server Beam** +- Code identifiers (suggested): `serverBeam`, `beam`, `sql.on` +- Do not introduce paid-tier / entitlement flags for this feature in v1. + Safety caps (2 servers / 10 `sql.on`) are product limits only. From 33d9f7b8decc59f32613070e4cb20a4bf84fdf65 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:12:57 +0000 Subject: [PATCH 3/6] feat: Server Beam sql.on() across two Destinations Node cells can call sql.on('source'|'target') with up to 2 checked Destinations (order = source, then target) and at most 10 sql.on() calls per Execute. Adds ping / copy / chunked samples and bridge tests. Co-authored-by: huy.phan9 --- apps/web/src/backend/api/code-cell-bridge.ts | 27 +++-- .../src/backend/api/code-cell-execute.test.ts | 46 +++++++ apps/web/src/backend/api/code-cell-execute.ts | 36 +++++- apps/web/src/backend/api/code-cell-query.ts | 21 ++++ apps/web/src/backend/api/code-cell-thread.ts | 103 +++++++++++++--- apps/web/src/backend/api/routes.ts | 54 +++++++-- apps/web/src/frontend/api/sqlApi.ts | 16 ++- apps/web/src/frontend/lib/codeCellRunner.ts | 11 +- apps/web/src/frontend/lib/sqlEditorSamples.ts | 112 ++++++++++++++++++ .../src/frontend/store/useSqlEditorStore.ts | 60 +++++++++- apps/web/src/shared/server-beam.test.ts | 43 +++++++ apps/web/src/shared/server-beam.ts | 81 +++++++++++++ docs/plans/server-beam.md | 15 ++- 13 files changed, 581 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/shared/server-beam.test.ts create mode 100644 apps/web/src/shared/server-beam.ts diff --git a/apps/web/src/backend/api/code-cell-bridge.ts b/apps/web/src/backend/api/code-cell-bridge.ts index e8daf78..e7ef684 100644 --- a/apps/web/src/backend/api/code-cell-bridge.ts +++ b/apps/web/src/backend/api/code-cell-bridge.ts @@ -6,12 +6,16 @@ * connection, the write policy, and the row cap; the worker only sees rows. */ -/** Worker → parent: run this statement on the cell's connection. */ +/** Worker → parent: run this statement on a connection (optional Server Beam alias). */ export interface CellQueryRequest { type: 'cell-query'; id: number; text: string; params: unknown[]; + /** Server Beam endpoint alias (`sql.on('source')`). Omit = default connection. */ + alias?: string; + /** True when the call used `sql.on(...)` (counts toward the per-Execute cap). */ + viaOn?: boolean; } /** Parent → worker: the outcome of one `cell-query`. */ @@ -28,13 +32,20 @@ export interface CellDoneMessage { 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' - ); + if ( + 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' + ) { + return false; + } + const alias = (msg as { alias?: unknown }).alias; + if (alias !== undefined && typeof alias !== 'string') return false; + const viaOn = (msg as { viaOn?: unknown }).viaOn; + if (viaOn !== undefined && typeof viaOn !== 'boolean') return false; + return true; } export function isCellDoneMessage(msg: unknown): msg is CellDoneMessage { diff --git a/apps/web/src/backend/api/code-cell-execute.test.ts b/apps/web/src/backend/api/code-cell-execute.test.ts index 19c11af..3b8da17 100644 --- a/apps/web/src/backend/api/code-cell-execute.test.ts +++ b/apps/web/src/backend/api/code-cell-execute.test.ts @@ -240,4 +240,50 @@ describe('code cell SQL bridge', () => { ); expect(result.ok).toBe(true); }, 45_000); + + it('routes sql.on(alias) to the matching Server Beam endpoint', async () => { + const calls: { text: string; alias?: string }[] = []; + const runQuery = async (text: string, _params: unknown[], alias?: string) => { + calls.push({ text, alias }); + return [{ hop: alias ?? 'none' }]; + }; + const result = await runCell( + `const a = await sql.on('source')\`SELECT \${1} AS n\`;` + + `const b = await sql.on('target')\`SELECT \${2} AS n\`;` + + `return [{ a: a[0].hop, b: b[0].hop }];`, + { + dialect: 'sqlite', + allowWrites: false, + runQuery, + beamDialects: { source: 'sqlite', target: 'postgres' }, + defaultBeamAlias: 'source', + enforceBeamSqlOnCap: true, + } + ); + if (!result.ok) throw new Error(result.error); + expect(calls.map((c) => c.alias)).toEqual(['source', 'target']); + expect(calls[0]!.text).toBe('SELECT ? AS n'); + expect(calls[1]!.text).toBe('SELECT $1 AS n'); + expect(result.rows).toEqual([['source', 'target']]); + }, 30_000); + + it('rejects an 11th sql.on() under the Server Beam cap', async () => { + const runQuery = async () => [{ n: 1 }]; + const body = + 'const out = [];\n' + + 'for (let i = 0; i < 11; i++) {\n' + + " out.push(await sql.on('source')`SELECT ${'i'} AS n`);\n" + + '}\n' + + 'return out;'; + const result = await runCell(body, { + dialect: 'sqlite', + allowWrites: false, + runQuery, + beamDialects: { source: 'sqlite' }, + defaultBeamAlias: 'source', + enforceBeamSqlOnCap: true, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/at most 10 sql\.on/i); + }, 30_000); }); diff --git a/apps/web/src/backend/api/code-cell-execute.ts b/apps/web/src/backend/api/code-cell-execute.ts index 55a374a..61c2d0e 100644 --- a/apps/web/src/backend/api/code-cell-execute.ts +++ b/apps/web/src/backend/api/code-cell-execute.ts @@ -18,9 +18,11 @@ import { /** Runs one bridged statement for a cell and returns rows as objects. */ export type CellQueryRunner = ( text: string, - params: unknown[] + params: unknown[], + alias?: string ) => Promise[]>; import { clampMaxRows } from './sql-execute'; +import { MAX_BEAM_SQL_ON } from '../../shared/server-beam'; export const MAX_CODE_CELL_LENGTH = 100_000; export const DEFAULT_CODE_CELL_TIMEOUT_MS = 10_000; @@ -129,6 +131,12 @@ function runInWorkerThread(args: { allowWrites?: boolean; /** Runs one bridged `sql` statement. Absent = the cell has no connection. */ runQuery?: CellQueryRunner; + /** Server Beam: alias → dialect for the worker renderer. */ + beamDialects?: Record; + /** Server Beam: default alias for plain `sql`…``. */ + defaultBeamAlias?: string; + /** When true, enforce max `sql.on()` calls per Execute. */ + enforceBeamSqlOnCap?: boolean; }): Promise { return new Promise((resolve) => { let settled = false; @@ -195,6 +203,8 @@ function runInWorkerThread(args: { maxRows: args.maxRows, dialect: args.dialect, allowWrites: args.allowWrites, + beamDialects: args.beamDialects, + defaultBeamAlias: args.defaultBeamAlias, }, execArgv, }); @@ -205,6 +215,8 @@ function runInWorkerThread(args: { startTimer(); + let sqlOnCount = 0; + const answerQuery = async (req: CellQueryRequest) => { pauseClock(); const reply = (res: CellQueryResponse) => { @@ -216,7 +228,15 @@ function runInWorkerThread(args: { }; 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); + if (args.enforceBeamSqlOnCap && req.viaOn) { + sqlOnCount += 1; + if (sqlOnCount > MAX_BEAM_SQL_ON) { + throw new Error( + `Server Beam allows at most ${MAX_BEAM_SQL_ON} sql.on() calls per editor Execute` + ); + } + } + 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 }); } catch (error: unknown) { reply({ type: 'cell-query-result', id: req.id, ok: false, error: errorMessage(error) }); @@ -252,7 +272,14 @@ function runInWorkerThread(args: { */ export async function runCodeCellOnServer( validated: ValidatedCodeCell, - options?: { dialect?: string; allowWrites?: boolean; runQuery?: CellQueryRunner } + options?: { + dialect?: string; + allowWrites?: boolean; + runQuery?: CellQueryRunner; + beamDialects?: Record; + defaultBeamAlias?: string; + enforceBeamSqlOnCap?: boolean; + } ): Promise { const started = Date.now(); let body = validated.body; @@ -277,6 +304,9 @@ export async function runCodeCellOnServer( dialect: options?.dialect, allowWrites: options?.allowWrites, runQuery: options?.runQuery, + beamDialects: options?.beamDialects, + defaultBeamAlias: options?.defaultBeamAlias, + enforceBeamSqlOnCap: options?.enforceBeamSqlOnCap, }); return { ...result, durationMs: Date.now() - started }; } diff --git a/apps/web/src/backend/api/code-cell-query.ts b/apps/web/src/backend/api/code-cell-query.ts index badee1c..51ab9bd 100644 --- a/apps/web/src/backend/api/code-cell-query.ts +++ b/apps/web/src/backend/api/code-cell-query.ts @@ -73,3 +73,24 @@ export function makeCellQueryRunner( return rows.length > MAX_CELL_QUERY_ROWS ? rows.slice(0, MAX_CELL_QUERY_ROWS) : rows; }; } + +/** + * Server Beam router: pick a per-alias runner. Unknown aliases fail closed. + */ +export function makeBeamCellQueryRunner( + byAlias: Map, + defaultAlias?: string +): CellQueryRunner { + return async (text, params, alias) => { + const key = alias ?? defaultAlias; + if (!key) { + throw new Error('Server Beam query missing alias — use sql.on("source") or sql.on("target")'); + } + const runner = byAlias.get(key); + if (!runner) { + const known = [...byAlias.keys()].join(', ') || '(none)'; + throw new Error(`Unknown Server Beam alias "${key}". Known: ${known}`); + } + return runner(text, params, key); + }; +} diff --git a/apps/web/src/backend/api/code-cell-thread.ts b/apps/web/src/backend/api/code-cell-thread.ts index baff987..ae79176 100644 --- a/apps/web/src/backend/api/code-cell-thread.ts +++ b/apps/web/src/backend/api/code-cell-thread.ts @@ -23,8 +23,12 @@ type Payload = { last: CodeCellLast; vars: CodeCellVars; maxRows: number; - /** Dialect of the cell's connection — decides placeholder + quoting style. */ + /** Dialect of the default (non-beam) connection. */ dialect?: string; + /** Alias → dialect for Server Beam (`sql.on`). */ + beamDialects?: Record; + /** Default alias when plain `sql`…`` is used under Server Beam. */ + defaultBeamAlias?: string; /** When false, the parent rejects write/DDL statements from `sql`. */ allowWrites?: boolean; }; @@ -45,38 +49,103 @@ parentPort?.on('message', (msg: CellQueryResponse) => { else waiter.reject(new Error(msg.error)); }); +type SqlBinding = ReturnType; + /** * `sql` inside a cell. Renders the tagged template to `{ text, params }` for * the connection's dialect, then asks the parent to run it — the worker never * touches a driver itself. + * + * Server Beam: `sql.on('source')`…`` / `sql.on('target')`…`` select an endpoint. */ -function makeSqlBinding(dialect: string) { - const run = (query: SqlQuery): Promise[]> => { +function makeSqlBinding(opts: { + dialect?: string; + beamDialects?: Record; + defaultBeamAlias?: string; +}): SqlBinding { + const beamDialects = opts.beamDialects ?? {}; + const hasBeam = Object.keys(beamDialects).length > 0; + + const run = ( + query: SqlQuery, + alias: string | undefined, + viaOn: boolean + ): Promise[]> => { if (!isSqlQuery(query)) { return Promise.reject( new Error('sql`…` must be used as a tagged template: sql`SELECT 1`, not sql("SELECT 1")') ); } if (!parentPort) return Promise.reject(new Error('No SQL bridge available in this context')); + + let dialect = opts.dialect ?? 'postgres'; + let resolvedAlias = alias; + if (hasBeam) { + const key = alias ?? opts.defaultBeamAlias; + if (!key || !beamDialects[key]) { + const known = Object.keys(beamDialects).join(', ') || '(none)'; + return Promise.reject( + new Error( + alias + ? `Unknown Server Beam alias "${alias}". Known: ${known}` + : `Server Beam needs sql.on('alias') — known aliases: ${known}` + ) + ); + } + resolvedAlias = key; + dialect = beamDialects[key]!; + } else if (alias) { + return Promise.reject( + new Error( + 'sql.on() needs Server Beam endpoints — check two Destinations (source, then target) and re-run' + ) + ); + } + const { text, params } = renderSqlQuery(query, dialect); const id = nextQueryId++; return new Promise[]>((resolve, reject) => { pending.set(id, { resolve, reject }); - parentPort!.postMessage({ type: 'cell-query', id, text, params }); + parentPort!.postMessage({ + type: 'cell-query', + id, + text, + params, + alias: resolvedAlias, + viaOn, + }); }); }; - // Callable as a tag, and carrying the fragment helpers (sql.values, sql.id, …). + const attachHelpers = ( + tag: (strings: TemplateStringsArray, ...values: unknown[]) => Promise[]> + ) => + Object.assign(tag, { + raw: sqlTag.raw, + id: sqlTag.id, + values: sqlTag.values, + list: sqlTag.list, + run: (query: SqlQuery) => run(query, undefined, false), + on(alias: string) { + if (typeof alias !== 'string' || !alias.trim()) { + throw new Error(`sql.on(alias) needs a non-empty alias string`); + } + const a = alias.trim(); + const bound = (strings: TemplateStringsArray, ...values: unknown[]) => + run(sqlTag(strings, ...values), a, true); + return Object.assign(bound, { + raw: sqlTag.raw, + id: sqlTag.id, + values: sqlTag.values, + list: sqlTag.list, + run: (query: SqlQuery) => run(query, a, true), + }); + }, + }); + const tag = (strings: TemplateStringsArray, ...values: unknown[]) => - run(sqlTag(strings, ...values)); - return Object.assign(tag, { - raw: sqlTag.raw, - id: sqlTag.id, - values: sqlTag.values, - list: sqlTag.list, - /** Escape hatch for callers holding an already-built query. */ - run, - }); + run(sqlTag(strings, ...values), undefined, false); + return attachHelpers(tag); } async function main() { @@ -86,7 +155,11 @@ async function main() { last: data.last, vars: data.vars, maxRows: data.maxRows, - sql: makeSqlBinding(data.dialect ?? 'postgres'), + sql: makeSqlBinding({ + dialect: data.dialect, + beamDialects: data.beamDialects, + defaultBeamAlias: data.defaultBeamAlias, + }), }); parentPort?.postMessage({ type: 'cell-done', result }); } diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index c7f70e7..d8d0136 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -43,7 +43,8 @@ import { type CodeCellRequestBody, type CellQueryRunner, } from './code-cell-execute'; -import { makeCellQueryRunner } from './code-cell-query'; +import { makeBeamCellQueryRunner, makeCellQueryRunner } from './code-cell-query'; +import { parseBeamEndpoints } from '../../shared/server-beam'; import { getMetadataDbConfig, SUPPORTED_ENGINES, type DbEngine } from '../database/config'; import { createMetadataStore } from '../database/stores/registry'; import { keySchemeInfo } from '../cores/crypto'; @@ -584,7 +585,8 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt // runs allowlisted JS/TS with fetch in a worker_threads sandbox. const codeCellLimiter = rateLimit({ windowMs: 60 * 1000, max: 30 }); router.post('/sql/code-cell', codeCellLimiter, async (req: Request, res: Response) => { - const body = req.body as CodeCellRequestBody & ConnectionRef & { allowWrites?: boolean }; + const body = req.body as CodeCellRequestBody & + ConnectionRef & { allowWrites?: boolean; beam?: unknown }; const authed = req as AuthedRequest; if (denyUnless(authed, res, 'editor.advanced')) return; // A cell builds its SQL at runtime, so "may write" means it could do either @@ -595,28 +597,58 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt res.status(400).json({ error: validated.error }); return; } + const beamParsed = parseBeamEndpoints(body.beam); + if (!beamParsed.ok) { + res.status(400).json({ error: beamParsed.error }); + return; + } try { - // A cell only gets a `sql` bridge when it was run against a credential. - // Without one it still executes — it just cannot reach a database. + // A cell only gets a `sql` bridge when it was run against a credential + // (or Server Beam endpoints). Without one it still executes — it just + // cannot reach a database. let dialect: string | undefined; let runQuery: CellQueryRunner | undefined; - if (body.connectionId || (body.dialect && body.option)) { + let beamDialects: Record | undefined; + let defaultBeamAlias: string | undefined; + let enforceBeamSqlOnCap = false; + const granted = authed.permissions ?? new Set(); + const policy = { + allowWrites: body.allowWrites === true, + can: (permission: Permission) => + authed.appRole === 'admin' || permissionSatisfied(granted, permission), + }; + + if (beamParsed.value.length > 0) { + const userId = (req as AuthedRequest).userId; + const byAlias = new Map(); + beamDialects = {}; + for (const ep of beamParsed.value) { + const resolved = await resolveRef(userId, { + connectionId: ep.connectionId, + password: ep.password, + }); + byAlias.set(ep.alias, makeCellQueryRunner(resolved, policy)); + beamDialects[ep.alias] = resolved.dialect; + } + defaultBeamAlias = beamParsed.value[0]!.alias; + dialect = beamDialects[defaultBeamAlias]; + runQuery = makeBeamCellQueryRunner(byAlias, defaultBeamAlias); + enforceBeamSqlOnCap = true; + } else if (body.connectionId || (body.dialect && body.option)) { const resolved = await resolveRef((req as AuthedRequest).userId, body); dialect = resolved.dialect; // Per-statement permission check: a cell's SQL is unknown until it // runs, so `allowWrites` alone must not be a blanket pass — GRANT still // needs `editor.grant`, admin still bypasses as everywhere else. - const granted = authed.permissions ?? new Set(); - runQuery = makeCellQueryRunner(resolved, { - allowWrites: body.allowWrites === true, - can: (permission) => - authed.appRole === 'admin' || permissionSatisfied(granted, permission), - }); + runQuery = makeCellQueryRunner(resolved, policy); } const result = await runCodeCellOnServer(validated.value, { dialect, allowWrites: body.allowWrites === true, runQuery, + beamDialects, + defaultBeamAlias, + enforceBeamSqlOnCap, }); res.json(result); } catch (error: unknown) { diff --git a/apps/web/src/frontend/api/sqlApi.ts b/apps/web/src/frontend/api/sqlApi.ts index 945980a..128b8f6 100644 --- a/apps/web/src/frontend/api/sqlApi.ts +++ b/apps/web/src/frontend/api/sqlApi.ts @@ -39,6 +39,13 @@ export async function executeSql( return { results: data.results }; } +/** Server Beam endpoint on the wire (alias → saved connection). */ +export type BeamEndpointPayload = { + alias: string; + connectionId: string; + password?: string; +}; + /** Body for POST /sql/code-cell. Wire `kind` is language only (`js`|`ts`); Node vs browser is implied by the route. */ export type ServerCodeCellPayload = { body: string; @@ -52,6 +59,11 @@ export type ServerCodeCellPayload = { * no database access (it still executes; `sql` just reports no connection). */ ref?: ConnectionRef; + /** + * Server Beam: up to two `{ alias, connectionId }` endpoints for `sql.on`. + * When set, the cell runs once across those servers (not per-Destination fan-out). + */ + beam?: BeamEndpointPayload[]; /** Mirrors Safe mode — the server rejects write/DDL from `sql` when false. */ allowWrites?: boolean; }; @@ -118,13 +130,13 @@ export function parseSqlStatementResult( export async function runCodeCellOnServer( payload: ServerCodeCellPayload ): Promise { - const { ref, ...rest } = payload; + const { ref, beam, ...rest } = payload; const res = await fetch(`${getApiBase()}/sql/code-cell`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', // The ref is flattened onto the request body (same shape /sql/execute takes). - body: JSON.stringify({ ...ref, ...rest }), + body: JSON.stringify({ ...ref, ...rest, ...(beam?.length ? { beam } : {}) }), }); const data = await parseJsonResponse(res); const parsed = parseSqlStatementResult(data); diff --git a/apps/web/src/frontend/lib/codeCellRunner.ts b/apps/web/src/frontend/lib/codeCellRunner.ts index d2a42e7..b7bfcf7 100644 --- a/apps/web/src/frontend/lib/codeCellRunner.ts +++ b/apps/web/src/frontend/lib/codeCellRunner.ts @@ -5,8 +5,14 @@ * (`-- @node` / `-- @nodets`) POST to the FoxSchema server. */ -import { runCodeCellOnServer, type SqlStatementResult } from '../api/sqlApi'; +import { + runCodeCellOnServer, + type BeamEndpointPayload, + type SqlStatementResult, +} from '../api/sqlApi'; import type { ConnectionRef } from '../api/schemaApi'; +import { usesServerBeam } from '../../shared/server-beam'; +export { usesServerBeam }; import type { SetDirective, SqlVariable } from './sql-variables'; import { parseSetDirectives } from './sql-variables'; import { @@ -43,6 +49,8 @@ export type RunCodeCellArgs = { * (`-- @js` / `-- @ts`) have no bridge and ignore this. */ ref?: ConnectionRef; + /** Server Beam endpoints for `sql.on('source'|'target')`. */ + beam?: BeamEndpointPayload[]; /** Mirrors Safe mode; the server rejects writes from `sql` when false. */ allowWrites?: boolean; }; @@ -217,6 +225,7 @@ export async function runCodeCell( maxRows: args.maxRows, timeoutMs, ref: args.ref, + beam: args.beam, allowWrites: args.allowWrites, }); return { result, directives: prepared.directives }; diff --git a/apps/web/src/frontend/lib/sqlEditorSamples.ts b/apps/web/src/frontend/lib/sqlEditorSamples.ts index ef59d7d..fe85002 100644 --- a/apps/web/src/frontend/lib/sqlEditorSamples.ts +++ b/apps/web/src/frontend/lib/sqlEditorSamples.ts @@ -500,6 +500,118 @@ await sql\`INSERT INTO \${sql.id('fox_demo_accounts_v2')} \${sql.values(rows)}\` return await sql\`SELECT id, email, domain FROM fox_demo_accounts_v2 ORDER BY id\`; -- @end +`, + }, + { + id: 'sample-server-beam-ping', + title: '★ Sample · Server Beam ping (source + target)', + sql: `-- Server Beam: check TWO Destinations (order = source, then target). +-- Read-only — Safe mode friendly. Each sql.on() hits a different server. + +-- @node +const fromSource = await sql.on('source')\`SELECT 1 AS n, 'source' AS hop\`; +const fromTarget = await sql.on('target')\`SELECT 1 AS n, 'target' AS hop\`; + +return [ + { hop: 'source', n: Number(fromSource[0]?.n ?? fromSource[0]?.N ?? 0) }, + { hop: 'target', n: Number(fromTarget[0]?.n ?? fromTarget[0]?.N ?? 0) }, +]; +-- @end +`, + }, + { + id: 'sample-server-beam-copy-rows', + 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 10 sql.on() calls per Execute. + +-- @node +await sql.on('source')\`DROP TABLE IF EXISTS fox_beam_demo\`; +await sql.on('source')\` + CREATE TABLE fox_beam_demo ( + id INTEGER, + email VARCHAR(200) + ) +\`; +await sql.on('source')\` + INSERT INTO \${sql.id('fox_beam_demo')} \${sql.values([ + { id: 1, email: "o'brien@source.example" }, + { id: 2, email: 'ada@source.example' }, + ])} +\`; + +const src = await sql.on('source')\`SELECT id, email FROM fox_beam_demo ORDER BY id\`; +const rows = src.map((r) => { + const email = String(r.email ?? r.EMAIL ?? '').toLowerCase(); + return { + id: Number(r.id ?? r.ID), + email, + domain: email.split('@')[1] ?? '', + }; +}); + +await sql.on('target')\`DROP TABLE IF EXISTS fox_beam_demo\`; +await sql.on('target')\` + CREATE TABLE fox_beam_demo ( + id INTEGER, + email VARCHAR(200), + domain VARCHAR(200) + ) +\`; +await sql.on('target')\`INSERT INTO \${sql.id('fox_beam_demo')} \${sql.values(rows)}\`; + +return await sql.on('target')\`SELECT id, email, domain FROM fox_beam_demo ORDER BY id\`; +-- @end +`, + }, + { + id: 'sample-server-beam-chunked', + 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 10 sql.on() cap per Execute (2 setup + 2×(pull+push) + 1 verify). + +-- @node +await sql.on('source')\`DROP TABLE IF EXISTS fox_beam_bulk\`; +await sql.on('source')\`CREATE TABLE fox_beam_bulk (id INTEGER, name VARCHAR(100))\`; +const seed = Array.from({ length: 40 }, (_, i) => ({ + id: i + 1, + name: 'row ' + (i + 1), +})); +await sql.on('source')\`INSERT INTO \${sql.id('fox_beam_bulk')} \${sql.values(seed)}\`; + +await sql.on('target')\`DROP TABLE IF EXISTS fox_beam_bulk\`; +await sql.on('target')\`CREATE TABLE fox_beam_bulk (id INTEGER, name VARCHAR(100))\`; + +const CHUNK = 20; +let copied = 0; +for (let start = 1; start <= 40; start += CHUNK) { + const end = start + CHUNK - 1; + const batch = await sql.on('source')\` + SELECT id, name FROM fox_beam_bulk + WHERE id BETWEEN \${start} AND \${end} + ORDER BY id + \`; + const rows = batch.map((r) => ({ + id: Number(r.id ?? r.ID), + name: String(r.name ?? r.NAME ?? ''), + })); + if (rows.length) { + await sql.on('target')\`INSERT INTO \${sql.id('fox_beam_bulk')} \${sql.values(rows)}\`; + copied += rows.length; + } +} + +const sample = await sql.on('target')\` + SELECT id, name FROM fox_beam_bulk WHERE id IN \${[1, 20, 40]} ORDER BY id +\`; +return sample.map((r) => ({ + id: Number(r.id ?? r.ID), + name: String(r.name ?? r.NAME ?? ''), + copied, +})); +-- @end `, }, { diff --git a/apps/web/src/frontend/store/useSqlEditorStore.ts b/apps/web/src/frontend/store/useSqlEditorStore.ts index 5f3a495..439f3c2 100644 --- a/apps/web/src/frontend/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/store/useSqlEditorStore.ts @@ -13,7 +13,8 @@ import { resolveAppSecrets } from '../api/appSecretsApi'; import { loadSchema } from '../api/schemaApi'; import { isMutatingDmlStatement, isWriteStatement, splitSqlStatements } from '../lib/sql-splitter'; import type { CodeCellLast } from '../lib/codeCellExec'; -import { detectCodeCell, runCodeCell } from '../lib/codeCellRunner'; +import { detectCodeCell, runCodeCell, usesServerBeam } from '../lib/codeCellRunner'; +import { beamAliasesForCount } from '../../shared/server-beam'; import { buildSampleBookmarks } from '../lib/sqlEditorSamples'; import { buildForeignKeyDrilldown, @@ -1082,6 +1083,63 @@ export const useSqlEditorStore = create()( let codeDirectives: SetDirective[] = []; const isLastStmt = si === rawStatements.length - 1; + // Server Beam (`sql.on`) runs once across up to two Destinations + // (order = source, then target) instead of fan-out per credential. + if (usesServerBeam(raw)) { + const beamConns = connections.slice(0, 2); + const aliases = beamAliasesForCount(beamConns.length); + const beam = beamConns.map((c, i) => ({ + alias: aliases[i]!, + connectionId: c.id, + password: sessionPasswords[c.id] || undefined, + })); + const primary = beamConns[0]; + if (!primary) { + appendWarning( + 'Server Beam needs at least one Destination checked (source). ' + + 'For cross-server copy, check two — first is source, second is target.' + ); + } else { + const prev = resultsByConn.get(primary.id) ?? []; + try { + const { result, directives } = await runCodeCell({ + statement: raw, + last: lastGridFrom(prev), + variables: resolveVariablesForConnection(runVariables, primary.id), + maxRows, + ref: { + connectionId: primary.id, + password: sessionPasswords[primary.id] || undefined, + }, + beam, + allowWrites: !safeMode, + }); + codeDirectives = directives; + for (const c of beamConns) { + const list = resultsByConn.get(c.id) ?? []; + list.push(result); + resultsByConn.set(c.id, list); + setPageSql(c.id, si, ''); + patchRun(c.id, { + status: isLastStmt ? 'done' : 'running', + results: [...list], + error: result.ok ? undefined : result.error, + }); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + for (const c of beamConns) { + const list = resultsByConn.get(c.id) ?? []; + list.push({ ok: false, error: msg, durationMs: 0 }); + resultsByConn.set(c.id, list); + patchRun(c.id, { status: 'error', error: msg, results: [...list] }); + } + } + } + applySetsFromFirstOk(codeDirectives, si); + continue; + } + await Promise.allSettled( connections.map(async (c) => { const prev = resultsByConn.get(c.id) ?? []; diff --git a/apps/web/src/shared/server-beam.test.ts b/apps/web/src/shared/server-beam.test.ts new file mode 100644 index 0000000..dbcdf52 --- /dev/null +++ b/apps/web/src/shared/server-beam.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_BEAM_SERVERS, + MAX_BEAM_SQL_ON, + beamAliasesForCount, + normalizeBeamAlias, + parseBeamEndpoints, + usesServerBeam, +} from './server-beam'; + +describe('server-beam', () => { + it('exposes product caps', () => { + expect(MAX_BEAM_SERVERS).toBe(2); + expect(MAX_BEAM_SQL_ON).toBe(10); + }); + + 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); + expect(usesServerBeam('await sql`SELECT 1`')).toBe(false); + }); + + it('parses beam endpoints and rejects a third server', () => { + const ok = parseBeamEndpoints([ + { alias: 'source', connectionId: 'a' }, + { alias: 'target', connectionId: 'b' }, + ]); + expect(ok.ok).toBe(true); + const bad = parseBeamEndpoints([ + { alias: 'a', connectionId: '1' }, + { alias: 'b', connectionId: '2' }, + { alias: 'c', connectionId: '3' }, + ]); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.error).toMatch(/at most 2/i); + }); + + it('normalizes aliases', () => { + expect(normalizeBeamAlias(' source ')).toBe('source'); + expect(normalizeBeamAlias('1bad')).toBe(null); + expect(beamAliasesForCount(2)).toEqual(['source', 'target']); + }); +}); diff --git a/apps/web/src/shared/server-beam.ts b/apps/web/src/shared/server-beam.ts new file mode 100644 index 0000000..f6cbec4 --- /dev/null +++ b/apps/web/src/shared/server-beam.ts @@ -0,0 +1,81 @@ +/** + * Server Beam — cross-database `sql.on(alias)` limits and helpers. + * + * Product caps (not billing): up to 2 beam endpoints and 10 `sql.on()` calls + * per editor Execute. + */ + +export const MAX_BEAM_SERVERS = 2; +export const MAX_BEAM_SQL_ON = 10; + +/** Wire shape: alias → saved connection (password optional, session-only). */ +export type BeamEndpointRef = { + alias: string; + connectionId: string; + password?: string; +}; + +const ALIAS_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/; + +export function normalizeBeamAlias(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const a = raw.trim(); + if (!ALIAS_RE.test(a)) return null; + return a; +} + +/** True when a Node cell body uses Server Beam (`sql.on(...)`). */ +export function usesServerBeam(body: string): boolean { + return /\bsql\s*\.\s*on\s*\(/.test(body); +} + +/** + * Validate/normalize a `beam` array from the client. + * At most {@link MAX_BEAM_SERVERS} distinct aliases; aliases must be identifiers. + */ +export function parseBeamEndpoints( + raw: unknown +): { ok: true; value: BeamEndpointRef[] } | { ok: false; error: string } { + if (raw == null) return { ok: true, value: [] }; + if (!Array.isArray(raw)) { + return { ok: false, error: 'beam must be an array of { alias, connectionId }' }; + } + if (raw.length > MAX_BEAM_SERVERS) { + return { + ok: false, + error: `Server Beam allows at most ${MAX_BEAM_SERVERS} servers per editor Execute`, + }; + } + const out: BeamEndpointRef[] = []; + const seen = new Set(); + for (const item of raw) { + if (!item || typeof item !== 'object') { + return { ok: false, error: 'beam entries must be objects' }; + } + const o = item as Record; + const alias = normalizeBeamAlias(o.alias); + if (!alias) { + return { + ok: false, + error: 'beam alias must be an identifier (e.g. source, target)', + }; + } + if (seen.has(alias)) { + return { ok: false, error: `Duplicate Server Beam alias "${alias}"` }; + } + if (typeof o.connectionId !== 'string' || !o.connectionId.trim()) { + return { ok: false, error: `beam alias "${alias}" needs a connectionId` }; + } + seen.add(alias); + const password = typeof o.password === 'string' && o.password ? o.password : undefined; + out.push({ alias, connectionId: o.connectionId.trim(), password }); + } + return { ok: true, value: out }; +} + +/** Default aliases when the UI maps checked Destinations in order. */ +export function beamAliasesForCount(n: number): string[] { + if (n <= 0) return []; + if (n === 1) return ['source']; + return ['source', 'target']; +} diff --git a/docs/plans/server-beam.md b/docs/plans/server-beam.md index 31dd327..974ac8a 100644 --- a/docs/plans/server-beam.md +++ b/docs/plans/server-beam.md @@ -73,9 +73,18 @@ return rows; ## Phases -1. **Bridge** — multi-endpoint `sql.on(alias)` + enforce 2 servers / 10 calls. -2. **UI** — source/target picker + alias binding + sample script. -3. **Hardening** — chunk helpers, clearer errors, tests for caps and RBAC. +1. **Bridge** — multi-endpoint `sql.on(alias)` + enforce 2 servers / 10 calls. *(done)* +2. **UI** — Destinations order = source then target when the cell uses `sql.on`; + samples under Bookmarks → Add samples. *(v1 playable)* +3. **Hardening** — dedicated source/target picker, chunk helpers, more tests. + +## How to try (v1) + +1. Save **two** credentials; in SQL Editor check them as Destinations + (**first = source**, **second = target**). +2. Bookmarks → **Add samples** → open + `★ Sample · Server Beam ping (source + target)` or the copy/chunked samples. +3. Run. Node cells with `sql.on` execute **once** across both servers (no fan-out). ## Naming in repo From aec1ab9bcdc547734a32536ae50dba61f8a624c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:17:27 +0000 Subject: [PATCH 4/6] fix(sql-editor): normalize object and scalar code-cell returns Single objects and primitive/Date values become a one-row grid instead of failing normalization (which left the result grid unusable). Explicit { columns, rows } shape is unchanged. Co-authored-by: huy.phan9 --- .../core/src/modules/code-cell-exec.test.ts | 82 +++++++++++++++++++ packages/core/src/modules/code-cell-exec.ts | 70 +++++++++++++--- 2 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/modules/code-cell-exec.test.ts diff --git a/packages/core/src/modules/code-cell-exec.test.ts b/packages/core/src/modules/code-cell-exec.test.ts new file mode 100644 index 0000000..3b1e67b --- /dev/null +++ b/packages/core/src/modules/code-cell-exec.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeCodeCellReturn } from './code-cell-exec'; + +describe('normalizeCodeCellReturn', () => { + it('keeps explicit { columns, rows } grids', () => { + const r = normalizeCodeCellReturn( + { columns: ['id', 'name'], rows: [[1, 'a'], [2, 'b']] }, + 100 + ); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.columns).toEqual(['id', 'name']); + expect(r.rows).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + } + }); + + it('treats a single plain object as one row (not a broken grid)', () => { + const r = normalizeCodeCellReturn({ id: 7, email: 'a@b.c' }, 100); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.columns).toEqual(['id', 'email']); + expect(r.rows).toEqual([[7, 'a@b.c']]); + expect(r.rowCount).toBe(1); + } + }); + + it('does not mis-classify data objects that mention columns/rows keys', () => { + const r = normalizeCodeCellReturn({ columns: 3, rows: 5, label: 'x' }, 100); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.columns).toEqual(['columns', 'rows', 'label']); + expect(r.rows).toEqual([[3, 5, 'x']]); + } + }); + + it('wraps scalar returns under a value column', () => { + expect(normalizeCodeCellReturn(42, 100)).toEqual({ + ok: true, + columns: ['value'], + rows: [[42]], + rowCount: 1, + truncated: false, + }); + expect(normalizeCodeCellReturn('hi', 100)).toMatchObject({ + ok: true, + columns: ['value'], + rows: [['hi']], + }); + expect(normalizeCodeCellReturn(true, 100)).toMatchObject({ + rows: [[true]], + }); + expect(normalizeCodeCellReturn(10n, 100)).toMatchObject({ + rows: [['10']], + }); + }); + + it('wraps Date scalars as ISO strings', () => { + const d = new Date('2026-08-02T00:00:00.000Z'); + const r = normalizeCodeCellReturn(d, 100); + expect(r.ok).toBe(true); + if (r.ok) expect(r.rows).toEqual([['2026-08-02T00:00:00.000Z']]); + }); + + it('accepts arrays of scalars and mixed object/scalar rows', () => { + const scalars = normalizeCodeCellReturn([1, 2, null], 100); + expect(scalars.ok).toBe(true); + if (scalars.ok) { + expect(scalars.columns).toEqual(['value']); + expect(scalars.rows).toEqual([[1], [2], [null]]); + } + + const mixed = normalizeCodeCellReturn([{ id: 1 }, 9], 100); + expect(mixed.ok).toBe(true); + if (mixed.ok) { + expect(mixed.columns.sort()).toEqual(['id', 'value'].sort()); + expect(mixed.rowCount).toBe(2); + } + }); +}); diff --git a/packages/core/src/modules/code-cell-exec.ts b/packages/core/src/modules/code-cell-exec.ts index 6c96dbe..14dacfd 100644 --- a/packages/core/src/modules/code-cell-exec.ts +++ b/packages/core/src/modules/code-cell-exec.ts @@ -255,38 +255,88 @@ function normalizeColumnsRows( return { ok: true, columns, rows, rowCount: rows.length, truncated }; } +function isGridScalar(value: unknown): boolean { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ); +} + +function scalarCell(value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + if (value instanceof Date) return value.toISOString(); + return value; +} + +function normalizeScalarGrid(value: unknown): CodeCellOk { + return { + ok: true, + columns: ['value'], + rows: [[scalarCell(value)]], + rowCount: 1, + truncated: false, + }; +} + /** * Normalize a cell return value into columns/rows. - * Accepts `{ columns, rows }` or an array of plain objects. + * Accepts: + * - `{ columns, rows }` explicit grid + * - array of plain objects (or mix of objects + scalars) + * - a single plain object (one row) + * - a scalar / Date (one cell under `value`) */ 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]', + error: + 'Code cell must return a value — e.g. return [{ … }], return { … }, return 1, or return { columns, rows }', }; } + if (isGridScalar(value) || value instanceof Date) { + return normalizeScalarGrid(value); + } + 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); + if (value.every((v) => isPlainObject(v) && !(v instanceof Date))) { + return normalizeObjectRows(value, maxRows); + } + // Scalars / nulls / Dates — and mixed with objects — become grid rows. + if ( + value.every( + (v) => v === null || isPlainObject(v) || isGridScalar(v) || v instanceof Date + ) + ) { + const asObjects = value.map((v) => + isPlainObject(v) && !(v instanceof Date) + ? v + : ({ value: scalarCell(v) } as Record) + ); + return normalizeObjectRows(asObjects, maxRows); + } return { ok: false, - error: 'Array return must be an array of plain objects (or return { columns, rows })', + error: + 'Array return must be objects and/or scalars (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', - }; + // Only treat as an explicit grid when both fields are arrays. A data row + // like `{ id: 1, email: 'a' }` must not be rejected (and must not be + // mis-read when it happens to have non-array `columns`/`rows` keys). + if (Array.isArray(columns) && Array.isArray(rows)) { + return normalizeColumnsRows(columns, rows, maxRows); } - return normalizeColumnsRows(columns, rows, maxRows); + return normalizeObjectRows([value], maxRows); } return { ok: false, error: `Unsupported return type: ${typeof value}` }; From ef203ca8889b3a613741aeaa93c39199b82a637b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:20:08 +0000 Subject: [PATCH 5/6] refactor: rename Server Beam caps to MAX_SERVERS / MAX_SQL Co-authored-by: huy.phan9 --- apps/web/src/backend/api/code-cell-execute.ts | 6 +++--- apps/web/src/shared/server-beam.test.ts | 8 ++++---- apps/web/src/shared/server-beam.ts | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/src/backend/api/code-cell-execute.ts b/apps/web/src/backend/api/code-cell-execute.ts index 61c2d0e..d356ab0 100644 --- a/apps/web/src/backend/api/code-cell-execute.ts +++ b/apps/web/src/backend/api/code-cell-execute.ts @@ -22,7 +22,7 @@ export type CellQueryRunner = ( alias?: string ) => Promise[]>; import { clampMaxRows } from './sql-execute'; -import { MAX_BEAM_SQL_ON } from '../../shared/server-beam'; +import { MAX_SQL } from '../../shared/server-beam'; export const MAX_CODE_CELL_LENGTH = 100_000; export const DEFAULT_CODE_CELL_TIMEOUT_MS = 10_000; @@ -230,9 +230,9 @@ function runInWorkerThread(args: { 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_BEAM_SQL_ON) { + if (sqlOnCount > MAX_SQL) { throw new Error( - `Server Beam allows at most ${MAX_BEAM_SQL_ON} sql.on() calls per editor Execute` + `Server Beam allows at most ${MAX_SQL} sql.on() calls per editor Execute` ); } } diff --git a/apps/web/src/shared/server-beam.test.ts b/apps/web/src/shared/server-beam.test.ts index dbcdf52..7a1a1c3 100644 --- a/apps/web/src/shared/server-beam.test.ts +++ b/apps/web/src/shared/server-beam.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - MAX_BEAM_SERVERS, - MAX_BEAM_SQL_ON, + MAX_SERVERS, + MAX_SQL, beamAliasesForCount, normalizeBeamAlias, parseBeamEndpoints, @@ -10,8 +10,8 @@ import { describe('server-beam', () => { it('exposes product caps', () => { - expect(MAX_BEAM_SERVERS).toBe(2); - expect(MAX_BEAM_SQL_ON).toBe(10); + expect(MAX_SERVERS).toBe(2); + expect(MAX_SQL).toBe(10); }); it('detects sql.on usage', () => { diff --git a/apps/web/src/shared/server-beam.ts b/apps/web/src/shared/server-beam.ts index f6cbec4..5bf571b 100644 --- a/apps/web/src/shared/server-beam.ts +++ b/apps/web/src/shared/server-beam.ts @@ -5,8 +5,8 @@ * per editor Execute. */ -export const MAX_BEAM_SERVERS = 2; -export const MAX_BEAM_SQL_ON = 10; +export const MAX_SERVERS = 2; +export const MAX_SQL = 10; /** Wire shape: alias → saved connection (password optional, session-only). */ export type BeamEndpointRef = { @@ -31,7 +31,7 @@ export function usesServerBeam(body: string): boolean { /** * Validate/normalize a `beam` array from the client. - * At most {@link MAX_BEAM_SERVERS} distinct aliases; aliases must be identifiers. + * At most {@link MAX_SERVERS} distinct aliases; aliases must be identifiers. */ export function parseBeamEndpoints( raw: unknown @@ -40,10 +40,10 @@ export function parseBeamEndpoints( if (!Array.isArray(raw)) { return { ok: false, error: 'beam must be an array of { alias, connectionId }' }; } - if (raw.length > MAX_BEAM_SERVERS) { + if (raw.length > MAX_SERVERS) { return { ok: false, - error: `Server Beam allows at most ${MAX_BEAM_SERVERS} servers per editor Execute`, + error: `Server Beam allows at most ${MAX_SERVERS} servers per editor Execute`, }; } const out: BeamEndpointRef[] = []; From 49bd07bca305db0a62ef11d2a651952dfb26aa6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:25:21 +0000 Subject: [PATCH 6/6] chore: raise Server Beam MAX_SQL to 20 Update cap constant, error copy, tests, plan, and sample comments. Co-authored-by: huy.phan9 --- apps/web/src/backend/api/code-cell-execute.test.ts | 9 +++++---- apps/web/src/frontend/lib/sqlEditorSamples.ts | 4 ++-- apps/web/src/shared/server-beam.test.ts | 2 +- apps/web/src/shared/server-beam.ts | 10 ++++------ docs/plans/server-beam.md | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/web/src/backend/api/code-cell-execute.test.ts b/apps/web/src/backend/api/code-cell-execute.test.ts index 3b8da17..a989fdf 100644 --- a/apps/web/src/backend/api/code-cell-execute.test.ts +++ b/apps/web/src/backend/api/code-cell-execute.test.ts @@ -5,6 +5,7 @@ import { runCodeCellOnServer, validateCodeCellRequest, } from './code-cell-execute'; +import { MAX_SQL } from '../../shared/server-beam'; /** Value planted in APP_ENCRYPTION_KEY to prove an escaped cell cannot read it. */ const SENTINEL_SECRET = 'sentinel-must-not-leak'; @@ -267,11 +268,11 @@ describe('code cell SQL bridge', () => { expect(result.rows).toEqual([['source', 'target']]); }, 30_000); - it('rejects an 11th sql.on() under the Server Beam cap', async () => { + it('rejects sql.on() calls beyond the Server Beam cap', async () => { const runQuery = async () => [{ n: 1 }]; const body = 'const out = [];\n' + - 'for (let i = 0; i < 11; i++) {\n' + + `for (let i = 0; i < ${MAX_SQL + 1}; i++) {\n` + " out.push(await sql.on('source')`SELECT ${'i'} AS n`);\n" + '}\n' + 'return out;'; @@ -284,6 +285,6 @@ describe('code cell SQL bridge', () => { enforceBeamSqlOnCap: true, }); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toMatch(/at most 10 sql\.on/i); - }, 30_000); + if (!result.ok) expect(result.error).toMatch(new RegExp(`at most ${MAX_SQL} sql\\.on`, 'i')); + }, 60_000); }); diff --git a/apps/web/src/frontend/lib/sqlEditorSamples.ts b/apps/web/src/frontend/lib/sqlEditorSamples.ts index fe85002..228e92f 100644 --- a/apps/web/src/frontend/lib/sqlEditorSamples.ts +++ b/apps/web/src/frontend/lib/sqlEditorSamples.ts @@ -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 10 sql.on() calls per Execute. +-- Caps: 2 servers, up to 20 sql.on() calls per Execute. -- @node await sql.on('source')\`DROP TABLE IF EXISTS fox_beam_demo\`; @@ -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 10 sql.on() cap per Execute (2 setup + 2×(pull+push) + 1 verify). +-- Stays within the sql.on() cap per Execute (2 setup + 2×(pull+push) + 1 verify). -- @node await sql.on('source')\`DROP TABLE IF EXISTS fox_beam_bulk\`; diff --git a/apps/web/src/shared/server-beam.test.ts b/apps/web/src/shared/server-beam.test.ts index 7a1a1c3..ef8f776 100644 --- a/apps/web/src/shared/server-beam.test.ts +++ b/apps/web/src/shared/server-beam.test.ts @@ -11,7 +11,7 @@ import { describe('server-beam', () => { it('exposes product caps', () => { expect(MAX_SERVERS).toBe(2); - expect(MAX_SQL).toBe(10); + expect(MAX_SQL).toBe(20); }); it('detects sql.on usage', () => { diff --git a/apps/web/src/shared/server-beam.ts b/apps/web/src/shared/server-beam.ts index 5bf571b..0a5b11a 100644 --- a/apps/web/src/shared/server-beam.ts +++ b/apps/web/src/shared/server-beam.ts @@ -1,12 +1,10 @@ /** - * Server Beam — cross-database `sql.on(alias)` limits and helpers. - * - * Product caps (not billing): up to 2 beam endpoints and 10 `sql.on()` calls - * per editor Execute. + * Server Beam — cross-database `sql.on(alias)` helpers. + */ export const MAX_SERVERS = 2; -export const MAX_SQL = 10; +export const MAX_SQL = 20; /** Wire shape: alias → saved connection (password optional, session-only). */ export type BeamEndpointRef = { @@ -43,7 +41,7 @@ export function parseBeamEndpoints( if (raw.length > MAX_SERVERS) { return { ok: false, - error: `Server Beam allows at most ${MAX_SERVERS} servers per editor Execute`, + error: `Server Beam cant handle more than ${MAX_SERVERS} aliases`, }; } const out: BeamEndpointRef[] = []; diff --git a/docs/plans/server-beam.md b/docs/plans/server-beam.md index 974ac8a..a26c24e 100644 --- a/docs/plans/server-beam.md +++ b/docs/plans/server-beam.md @@ -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 10** | +| `sql.on()` calls per editor Execute | **up to 20** (`MAX_SQL`) | | Async / Promises in Node cells | **required** (already supported by code-cell runtime) | | Password / decrypt | Server-only via existing `resolveRef` / connection store |