pf_app/ui/src/duckServerHandler.js
Paul Trowbridge 1ed8595577 SPIKE: server-side DuckDB virtual server behind ?engine=duck
Working measurement spike for Option C: aggregation runs in a server-side
DuckDB instead of shipping every row to the browser. Opt-in via
?engine=duck; the default client-side WASM path is untouched.

- routes/perspective.js: POST /perspective/init materializes a version's
  forecast table into a persistent in-process DuckDB (ATTACH postgres,
  so 535k rows land in ~1.5s without streaming through Node); POST
  /perspective/sql executes one Perspective-generated statement, with
  Arrow IPC output for viewport reads.
- ui/src/duckServerHandler.js: VirtualServerHandler that delegates SQL
  generation to Perspective's own GenericSQLVirtualServerModel and POSTs
  the result to the server. Adapted from @perspective-dev/client's
  virtual_servers/duckdb.ts — same SQL model, different transport.
- Forecast.jsx: DUCK_MODE branch replaces the heavy /data fetch with
  /perspective/init, opens the hosted table over a message port, and
  shows a latency overlay. applyDepth routes through viewer.restore()
  since the SQL server cannot handle view.set_depth().

Preserved for a future faster-load effort. Findings and the reason this
is not the shipped path (no interactive tree expand/collapse) are in
pf_perspective_options.md on master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:48:06 -04:00

155 lines
6.5 KiB
JavaScript

// SPIKE (Option C): a Perspective VirtualServerHandler that runs in the browser
// but executes its generated SQL on a SERVER-SIDE DuckDB (routes/perspective.js).
//
// Perspective's GenericSQLVirtualServerModel (WASM) turns each view interaction
// into a SQL string; we POST that SQL to /api/perspective/sql and feed the
// result back. Metadata queries come back as JSON; viewGetData comes back as
// Arrow IPC and is handed straight to the data slice. Only generated SQL and
// the aggregated viewport cross the wire.
//
// Adapted from @perspective-dev/client/src/ts/virtual_servers/duckdb.ts
// (which targets in-browser duckdb-wasm). Same SQL model, different transport.
const NUMBER_AGGS = [
'sum', 'count', 'any_value', 'arbitrary', 'array_agg', 'avg', 'bit_and',
'bit_or', 'bit_xor', 'bitstring_agg', 'bool_and', 'bool_or', 'countif',
'favg', 'fsum', 'geomean', 'kahan_sum', 'last', 'max', 'min', 'product',
'string_agg', 'sumkahan',
]
const STRING_AGGS = [
'count', 'any_value', 'arbitrary', 'first', 'countif', 'last', 'string_agg',
]
const FILTER_OPS = [
'==', '!=', 'LIKE', 'IS DISTINCT FROM', 'IS NOT DISTINCT FROM',
'>=', '<=', '>', '<',
]
function duckdbTypeToPsp(name) {
name = String(name).toLowerCase()
if (name === 'varchar' || name === 'utf8') return 'string'
if (name === 'double' || name === 'bigint' || name === 'hugeint' ||
name === 'float64' || name.startsWith('decimal')) return 'float'
if (name.startsWith('int')) return 'integer'
if (name.startsWith('date')) return 'date'
if (name.startsWith('bool')) return 'boolean'
if (name.startsWith('timestamp')) return 'datetime'
if (name.startsWith('json') || name.startsWith('struct')) return 'string'
console.warn(`[duck-server] unknown type '${name}'`)
return 'string'
}
// Build a VirtualServerHandler bound to the given perspective module (for the
// SQL generator) and server SQL endpoint. onTiming(ms, bytes) is called for
// every viewGetData round trip so the UI can show interaction latency.
export function makeDuckServerHandler(mod, { sqlUrl = '/api/perspective/sql', onTiming } = {}) {
let sqlBuilder = null
// The SQL generator must come from an INITIALIZED perspective wasm module.
// The <perspective-viewer> element's static __wasm_module__ is initialized
// once the element is registered; the bare client default export is not, so
// `new perspective.GenericSQLVirtualServerModel()` throws on its wasm glue.
// Mirror @perspective-dev's reference DuckDBHandler and resolve from the viewer.
function wasmModule() {
const viewerClass = typeof customElements !== 'undefined'
&& customElements.get('perspective-viewer')
if (viewerClass && viewerClass.__wasm_module__) return viewerClass.__wasm_module__
return mod
}
const builder = () => (sqlBuilder ||= new (wasmModule().GenericSQLVirtualServerModel)())
async function postSql(sql, wantArrow) {
const r = await fetch(sqlUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql, arrow: !!wantArrow }),
})
if (!r.ok) {
let msg = r.statusText
try { msg = (await r.json()).error } catch {}
throw new Error(`[duck-server] ${msg}`)
}
return wantArrow ? new Uint8Array(await r.arrayBuffer()) : r.json()
}
return {
getFeatures() {
return {
group_by: true,
split_by: true,
sort: true,
expressions: true,
group_rollup_mode: ['rollup', 'flat', 'total'],
filter_ops: {
integer: FILTER_OPS, float: FILTER_OPS, string: FILTER_OPS,
boolean: FILTER_OPS, date: FILTER_OPS, datetime: FILTER_OPS,
},
aggregates: {
integer: NUMBER_AGGS, float: NUMBER_AGGS, string: STRING_AGGS,
boolean: STRING_AGGS, date: STRING_AGGS, datetime: STRING_AGGS,
},
}
},
async getHostedTables() {
const rows = await postSql(builder().getHostedTables())
return rows.map(r => `${r.database || 'memory'}.${r.name}`)
},
async tableSchema(tableId) {
const rows = await postSql(builder().tableSchema(tableId))
const schema = {}
for (const r of rows) {
if (!String(r.column_name).startsWith('__')) {
schema[r.column_name] = duckdbTypeToPsp(r.column_type)
}
}
return schema
},
async tableSize(tableId) {
const rows = await postSql(builder().tableSize(tableId))
return Number(rows[0]['count_star()'])
},
async tableMakeView(tableId, viewId, config) {
await postSql(builder().tableMakeView(tableId, viewId, config))
},
async tableValidateExpression(tableId, expression) {
const rows = await postSql(builder().tableValidateExpression(tableId, expression))
return duckdbTypeToPsp(rows[0].column_type)
},
async viewColumnSize(viewId, config) {
const rows = await postSql(builder().viewColumnSize(viewId))
const count = Number(Object.values(rows[0])[0])
const gs = config?.group_by?.length || 0
const isFlat = config?.group_rollup_mode === 'flat'
return count - (gs === 0 ? 0 : isFlat ? gs : gs + 1)
},
async viewSize(viewId) {
const rows = await postSql(builder().viewSize(viewId))
return Number(Object.values(rows[0])[0])
},
async viewDelete(viewId) {
await postSql(builder().viewDelete(viewId))
},
async viewGetMinMax(viewId, columnName, config) {
const rows = await postSql(builder().viewGetMinMax(viewId, columnName, config))
let [min, max] = Object.values(rows[0])
if (typeof min === 'bigint') min = Number(min)
if (typeof max === 'bigint') max = Number(max)
return { min: min ?? null, max: max ?? null }
},
async viewGetData(viewId, config, schema, viewport, dataSlice) {
const t0 = performance.now()
const ipc = await postSql(builder().viewGetData(viewId, config, viewport, schema), true)
onTiming?.(performance.now() - t0, ipc.length)
dataSlice.fromArrowIpc(ipc)
},
}
}