Result Views (design proposal)
A proposal to extend grid extensions so a .grid.js file can add a whole new tab to a query's result area (beside Results, Messages and Problems) and render anything it likes into it: a chart, a visualization, a report, a custom readout. The extension supplies a render(root, data, ctx) callback that runs in a sandboxed iframe with the result's data, so it has the DOM it needs without any of the access it should not have.
This is a design and a phased plan. Part of it now exists; the rest is planned.
Implementation status
Working today:
A
.plumesql.jsfile declaresviews: [{ tab, when?, render }]beside its rules; they parse into PlanViews (moduleViewList / parseModuleViews / toViews), scope tagged by the file's@for/@querymarkers.app.viewsFor(st, ...)selects the views a result should show (scope plus the self-containedwhen(columns)guard).Results grows a tab per matching view and renders it in the sandboxed, opaque-origin iframe (
ResultView.svelte): the iframe imports the view's module from a blob, runs itsrender(root, data, ctx)over a fetched row window, and pipesctx.logand errors back to the Log. Theme aware; repaints on a theme change.renderis driven by a ResizeObserver on the iframe's viewport (the html element, which never scrolls; the body scrolls a view that outgrows the pane, and its scrollbar coming and going is not a resize) (coalesced per frame), so a view that sizes itself to the element draws once the pane has laid out and again on every resize, rather than once at a size that may still be zero or stale (which left an SVG's viewBox mismatched and the iframe background showing through). A view is mounted the first time its tab is opened and KEPT in the DOM after (only the active one shows; the rest are hidden), so switching back to a chart is instant, with no iframe rebuild, row re-fetch or re-render; a new run rebuilds them for fresh data. Until a view's first render postsready, a theme-coloured loading overlay (a soft shimmer) covers the iframe, so the first paint reads as loading, not as a white flash.ctxis{ theme, inputs, log, copy, selectRow, rows }(see "The render API").A view has its own header, the grid header's twin: the command and the same rows / execution-time readout on the left, and two actions on the right, Open source (opens the
.plumesql.jsfile behind the view) and Open in tab (a live result only; promotes the view into a result document tab of its own, carrying its rows and its one-time apply so the document opens on the chart). A chart has none of the grid's row actions, so the toolbar carries only these, plus the view menu behind the dots button at the header's end, every pane's shape. A right-click inside the view opens the same menu (the iframe swallows the engine's frame menu and reports the spot to the host), unless the view declarescontextMenu: falsebecause it drives the mouse itself (SandDance rotates its 3D view with a right drag); the dots button is then the one road. The menu: the view's header buttons in the same words, Configure View and Reload View, Copy as Image (the iframe rasterizes its largest SVG or canvas to a PNG on the theme surface and hands the bytes up; the host writes the clipboard through the one clipboard road; every WebGL context a view creates keeps its drawing buffer unless the view asks otherwise by name, so a deck.gl or SandDance frame reads back too) and Copy as SVG, Open Source, Open in Tab, and the Extensions settings.The results pane of a tab whose pane shows a chart is KEPT ALIVE while another tab shows (App keeps one Results instance per such tab, script tab or result document alike, plus the active tab's, each in a slot of its own hidden while inactive; a moved iframe would reload, so the slots never move, only hide): switching to Settings or another script and back finds the chart as it was, camera and selection kept, nothing drawn again. A background tab whose pane shows the bare grid is let go, a grid remounts in a blink. Which view a tab shows is the tab's own (
tabs[].resultsView, in the session), and only a fresh run of that tab returns it to the grid.A pane resize (the Log panel opening, a splitter, the window) is answered in this order: the
extensions.viewResizesetting'skeepleaves the drawing at the size it was made and the body scrolls, whatever the view could do; withredraw(the default) the view's ownresize(root, size, ctx)refits it when it declares one (the SandDance starter re-projects its MorphCharts scene in place, so 300k cubes follow the pane in a frame; a throw there falls back to a redraw and says so in the Log), else render runs again. The choice is one checkable row in every view's menu (Keep Size on Resize), written to the workspace scope. A redraw waits for the size to settle (150 ms), never runs per pixel of a splitter drag.A query (or a file) can name the view a run OPENS on:
-- @view <extension>applies the extension as-- @extensiondoes and makes its tab the one showing after Run;onlyhides the grid tab for that result;-- @inputs x=ts, series=a,banswers the view's inputs in the file, so it draws with no dialog (the values are that query's defaults, a dialog pick still wins). Both sit and reach as-- @extensiondoes (docs/ACTIONS.md, ACTIONSPEC 8.30 / 8.31)..plumesql.jsis recognized as the extension file by its NAME alone (with.plume.jsread as the same extension for good,.grid.jstoo but only with a@formarker). Its@for/@querymarkers only SCOPE it: a.plumesql.jswith no scope marker is recognized but applies nowhere, and a file may carry SEVERAL@forand@querylines, each a union (a result matches if any line does).The per grid "apply extension" gesture attaches an unapplied extension (an unscoped one, or one scoped elsewhere) to a result three ways: apply here once (no marker written, a transient in-memory application cleared on the next run), always for this query (appends a
@querymarker, the source relation when the result has one), or always for this file or its folder (appends a@formarker). The markers accrete on the union the loaders read.A view may load a classic-script (UMD / IIFE / global) library from a CDN by naming its URL under
scripts: a browser build that defines a global (window.Chart, ...), not an ES module. This is the escape hatch for a library with no ESM build, or whose ESM build you would rather not pull; an ES module dependency is a normalimportinstead (next bullet), never listed here. The host must be on theextensions.remoteScriptHostsallowlist (a comma separated global setting, empty by default so no host is allowed); PlumeSQL fetches the permitted scripts on the main thread (cached in memory and by the browser's disk cache), and injects them into the iframe as blob<script>tags beforerenderruns, so the iframe's strict CSP is never relaxed. A host the list does not name is refused and logged with a one-click "Allow this host" answer (see "From a CDN" below).A view can
importother modules: a helper from a store ($global/,$scripts/,$workspace/,./) and an ES module from an allowlisted CDN. The host builds the view's module GRAPH once, on the main thread (state.resolveModuleGraph): local specifiers are read from disk, allowlisted CDN specifiers are fetched (transitively), and a blocked host is refused there, before any view exists. ResultView receives a finished graph and only turns it into blob URLs, handing the iframe the sources with each specifier rewritten to a placeholder it swaps for its own blob URL (the iframe cannot use a parent-origin blob, so it builds the blob graph itself). Because the graph is resolved on the main thread, neither the parse nor the compute Worker (which have no CSP) ever fetches a module, and the iframe'sconnect-src 'none'CSP never relaxes. A CDN host must be onextensions.remoteScriptHosts, the same allowlistscriptsuses; a host it does not name leaves the extension PENDING (no rules, no views) with one "Allow host" line in the Log, and the extension loads on its own once the host is allowed. The Chart.js starter (chartjs.plumesql.js)imports Chart.js this way (chart.js/autofrom a CDN).
Built since: richer inputs (number, text and multi-column alongside column / choice), the host bridge ctx.copy, ctx.selectRow and ctx.rows(offset, limit) (paging the full result beyond the render window), and declared header actions (buttons that run against the view's live ctx). See "Inputs", "The render API" and "Actions" below.
Not yet built (the rest of this document):
ctx.refresh(),ctx.onThemeChange.- Inputs bound to a SQL run parameter (a re-run on change), and richer input kinds (toggle, color, date, multi-column, options-from-data).
- The data-driven per-statement tab strip (a view currently attaches to the first statement with columns).
- The hybrid
+affordance on the result's tab strip (the apply gesture lives in the grid's Extensions menu today, not yet as a+beside the view tabs). - The declarative chart tier, and deferred connection access.
The idea
Today a .grid.js extension shapes the GRID: it formats columns, adds computed columns, and (new) adds a companion column beside a match. Every rule's fn runs in a Web Worker, so it never touches the DOM and a runaway one is killed.
A Result View turns "a result is a grid" into "a result is any view of the data". The same file, scoped the same way (@for / @query), can also declare views. When you run a query whose result matches, the view's tab appears; selecting it renders the extension's own content from the result's rows.
Viability in one paragraph
There is one hard problem and it has a standard answer. Grid rules run in a Worker precisely so they cannot reach the DOM; a chart needs the DOM, so a view callback cannot run there. The answer is a sandboxed iframe (sandbox="allow-scripts", without allow-same-origin, allow-top-navigation or allow-popups) carrying its own strict CSP, into which PlumeSQL posts the result data. The view's render module and its imports load as the same blob URL module graph the compute already builds, targeting the iframe instead of the Worker. PlumeSQL sets no CSP today and a Worker fn can already fetch() freely, so a locked down iframe is MORE contained than the status quo, not less, and it reinforces the no navigation rule. The scope markers, the module graph, the result data model and the session already exist; the iframe host is the only genuinely new machinery.
The execution model
Two execution targets from one file, each taking the parts it needs, the same way the file already loads into two Workers (the parse Worker and the compute Worker):
- Rules keep running in the compute Worker. No DOM, killable, timeout bounded. Unchanged.
- Views run in a sandboxed iframe on the page. Full DOM, isolated from the app, no network. New.
The view spec
A view is recognized by carrying a render function (as a rule is recognized by fn). It lives beside rules in the same default export. Annotating that export /** @type {PlumeSQLExtension} */ types the whole thing, so render's root, data and ctx (and the inputs shapes) complete as you write it, and adding // @ts-check at the top turns that into error squiggles for real mistakes (see docs/GRIDRULES.md "everything is a .plumesql.js file"):
js
// @for global
// @query orders // reuse the existing scope markers
import { barChart } from '$scripts/lib/charts.js'
export default {
rules: [ /* format / add / companion, still run in the Worker */ ],
views: [
{
tab: 'Revenue', // the tab's label
when: (columns) => columns.some(c => c.type === 'numeric'), // optional guard
scripts: ['https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js'], // optional CDN libraries
inputs: [ // optional: what the view asks the user
{ key: 'kind', kind: 'choice', label: 'Chart type', options: ['bar','line'], default: 'bar' },
{ key: 'x', kind: 'column', label: 'Category (X)' }, // any column, required
{ key: 'y', kind: 'column', label: 'Value (Y)', types: ['numeric'] } // numeric, required
],
render: (root, data, ctx) => { // runs in the iframe
barChart(root, data.rows, { x: ctx.inputs.x, y: ctx.inputs.y, theme: ctx.theme })
}
}
]
}The file's @for / @query markers decide WHICH results expose the tab, exactly as they decide which results a rule applies to. when(columns) is an optional second guard on the column shape (only offer the tab when the columns fit). scripts is an optional list of CDN library URLs the view needs, loaded only when their host is on the extensions.remoteScriptHosts allowlist (see "From a CDN" below). inputs is an optional list of things the view ASKS the user for rather than guessing (see "Inputs" next).
Inputs: the view asks, it does not guess
A chart over an arbitrary result must otherwise GUESS which column is the label and which is the value, and a result with several numeric columns makes the guess wrong. inputs removes the guess: the view declares what it needs, PlumeSQL asks the user (the same picker RUN actions use for @file), and the chosen values reach render as ctx.inputs[key].
Each input is { key, kind, label, options?, types?, default?, description? }:
kind: 'column'is a pick over the result's columns, narrowed totypes(the familiesnumeric,text,temporal,boolean,json,any; absent means any). The value is the chosen column's NAME.kind: 'choice'is a pick over theoptionsconstants (bar / line / area). The value is the chosen constant.kind: 'number'is a numeric field;kind: 'text'a free-text field. Both reach render as a STRING inctx.inputs[key](parse a number yourself), like every input.kind: 'multi-column'picks SEVERAL columns at once, for comparison series (several lines, several datasets). The value is a comma-separated list of column names; split it and look each up. It pre-fills with every column matchingtypes, so a chart draws them all and the user edits the list to add or remove a series. The Chart.js and Observable Plot starters use it.defaultprefills the input and makes it OPTIONAL; an input with no default is REQUIRED, and opening the view tab prompts for it (the@filerhythm) before it can draw. The header's sliders button reopens the form to change any input.ask, on the VIEW rather than an input, says when the form opens:'first'(the default) on the first draw of a result, so the user confirms guessed columns;'missing'only while a required input has no answer, so a view whose defaults are exact (a fixed catalog shape, the Activity chart) draws at once;'never'never (render sees what it has);'always'on every fresh result. A script's-- @ask/-- @noasklines win over it: the file that calls decides over the file that is called. A file of several views sets the word once with a// @ask <word>marker at its top (beside// @descriptionand// @for); a view's ownaskwins over the marker.descriptionis one short sentence on what the input does, for an input whose label cannot make it plain ("Splits the chart into one panel per distinct value"). The form shows it on the label, the editor on the key in a-- @inputsline (hover and completion). Leave it out when the label says it all; the starters do.
render reads the picks off ctx.inputs (a column name or a constant, per key) and should handle an unanswered required input gracefully (draw a "pick a column" note; the form was cancelled). The picks are remembered per QUERY (the extension, the view and a hash of the normalized statement) while the app runs, so switching tabs and re-running the query keep them; a restart does not, and an edited query is a new query with no picks (picks never travel between queries on their own). What should outlive the app goes into the script (below). Only a CHANGE is a pick: a field left as the form showed it stores nothing. A picked column the result lacks falls back to the default. The form's one checkbox, Save in the script, writes the picks INTO the script as a -- @inputs line above the query; the file is then the picks' home for that query (an edit of the SQL keeps them), the session's picks for it are dropped, and the view draws from the line without the form (docs/ACTIONS.md, "Opening on a view"). A view promoted to a tab of its own has no script, so there the checkbox is Don't ask again, the same decision kept in the session.
Stopping a view
A view that holds memory or the GPU (a big SandDance scene, a chart paging the whole result) can be torn down without closing the tab: the console's extensions.running[i].kill() or extensions.<name>.kill() (docs/CONSOLE.md) removes its iframe and everything in it, the pane shows a calm "The view was stopped" face, and Reload, a click on its tab or Run in its dialog mounts a fresh one. A render that gives no answer for 90 seconds writes a Log entry that carries the same stop as a Stop the view button (a LogFix, stop-view, resolved against the views alive now, so a stale row shows nothing). What blocks the thread outright (a synchronous loop in render) is out of reach of anything on the page: the iframe shares the page's thread.
The render API
render(root, data, ctx), called inside the iframe when the tab is first shown and again when the inputs change:
root: the tab's content element, inside the iframe body. The view owns it.data:{ columns: {name, type}[], rows: unknown[][] }, a bounded window of the result (see "Data" below;ctx.rowspages past it).ctx:theme:'dark' | 'light', andonThemeChange(cb)so a view repaints when the app theme toggles. Both themes are a hard rule.inputs: the resolved values of the view's declaredinputs, keyed by theirkey(a picked column's name, or a chosen constant);{}when the view declares none (see "Inputs" above).log(...args): a line to PlumeSQL's Log, like the formulactx.log.copy(text): copy text to the clipboard through the host (the sandboxed iframe cannot reach it) — a summary, a computed value, the data behind a chart.selectRow(index): select and reveal the row at that 0-based index in the result grid. An active view REPLACES the grid, so this switches back to it and lands the selection: a click on a bar takes the user to its row. A no-op in a view opened in a tab of its own (no grid beside it).rows(offset, limit): Promise<rows>: fetch a further window of the result, beyond the bounded one indata.rows(extensions.viewRowCap), for a chart or report that needs a lot of points. The host pages the result store for you (one server fetch returns a bounded block;ctx.rowsgathers page by page up tolimit), so a single call can return far more than the render window. A big pull takes a moment, so render the window meanwhile and update when it resolves. The Chart.js and Observable Plot starters use it when the row limit exceeds the window.refresh(): ask the host to re-run the view against the current result.
Actions: buttons in the view's header
A view may declare actions, buttons PlumeSQL draws in the view's header (beside the sliders, reload and source buttons). Each is { label, icon?, run(ctx) }; the run executes IN the iframe against the view's live ctx, so it can ctx.copy, ctx.selectRow, or read the view's own state (the last draw, a chart instance). The host learns only the labels and icons (to draw the buttons); the functions never leave the sandbox. A click posts the action's index in, the view runs it, and any error lands in the Log like a render error.
js
actions: [{ label: 'Copy values', run: (ctx) => ctx.copy(values.join('\n')) }]A render error is posted out to the host and lands in the Log (the one home for errors); the tab shows the calm "the view failed; the error is in the Log" state, like a failed action, never inline error text.
The render model: a batch, not a per row callback
A view renders in ONE call over a batch (data.rows as an array), not per row the way a grid rule's fn(value, row, prevRow, ctx) is called. That per row model is the GRID's, and it stays there: the grid is virtualized and cell oriented, so formatting a cell or a running total column one row at a time is exactly right. A view is not a grid; "when a row is created" is a virtualization detail of the grid tab. A view is a holistic surface (a chart, a report) and it wants the whole set at once.
Why batch is the right model for a view:
- Charting libraries take an array. Drawing a bar chart needs every bar to size the axis, a line needs every point, a report aggregates every row. A per row callback would force the author to accumulate state and rebuild the array by hand.
- Batch subsumes per row. A row oriented view (a card list, a timeline) just loops:
for (const row of data.rows) root.append(card(row)). The author owns the loop. There is no view a per row callback enables that a batch does not. - It fits the iframe and performs. One data payload in, one render, and the library runs its own efficient internal updates. Per row is either chatty across the postMessage boundary or a batch with extra steps and O(n) DOM churn.
Streaming and very large results are handled by PAGING the batch, not by a per row callback: data.rows is the initial window and ctx.rows(offset, limit) pulls more, and a push form (ctx.onRows(block => ...)) can be added later for progressive draw. This stays batch, just chunked. A per row view mode is therefore NOT offered: it would be a second code path and an authoring decision for no capability gain.
"Access to all data" means the WINDOW (capped, in the order of the compute cap, 20k rows), not literally every row of a large result. For more, page with ctx.rows() or aggregate in SQL.
Loading a library
PlumeSQL ships no charting library of its own (the minimal dependency rule stands: pgx, Svelte, Monaco). You bring your own. There are two axes, and PlumeSQL supports every combination; pick by where the library lives and what format it ships.
Whichever you use, the iframe itself makes zero network calls: PlumeSQL fetches every module on the main thread (a store file through the file API, a CDN URL with fetch) and hands the iframe the sources, which it blobs and imports. The sandbox stays at connect-src 'none'.
Axis 1: where the library lives
- A store (offline, recommended for a team). Drop the file in a script store, commit it with the extension, and reference it with a store alias:
$global/,$scripts/,$workspace/, or a relative./. No allowlist, no internet, works behind a proxy and in the packaged desktop app, versioned in git. - A CDN (convenient, needs internet). Reference an absolute
https://URL. The host must be on theextensions.remoteScriptHostsallowlist (a comma separated setting, empty by default so nothing is fetched, set per workspace or globally). A blocked host is not a dead end: PlumeSQL logs it with a one-click "Allow<host>" that adds it to the setting, and the extension then loads and the view draws on its own (animportthat was blocked left the extension pending; allowing the host re-resolves it). CDN sub-imports resolve transitively (each host allowlisted).
Axis 2: what format the library ships
- ES module (
import, cleanest).import Chart from 'url'(default) orimport { a, b } from 'url'(named). The library must be a browser-loadable ESM: a single file, or one whose imports are themselves URLs (a CDN's bundled build, e.g. jsdelivr's/+esmor esm.sh, not a raw package ESM that imports a bare'@scope/pkg'a browser cannot resolve). The editor also learns the library's TYPES: it fetches the library's.d.tsin the background and maps the import to it, so the imported value completes and hovers with its real shape (and, under// @ts-check, a misuse squiggles) instead of beingany. A library that ships no types staysany; nothing else changes. - UMD / global (
scripts, most robust for older libraries). Declarescripts: ['https://.../lib.umd.min.js']on the view. PlumeSQL injects it as a classic<script>beforerender, so it runs in the context UMD expects and sets its global (Chart), whichrenderreads offglobalThis. This is the sturdiest path for a UMD/global-only library. A UMD file can also be pulled through the module graph with a side-effectimport 'url'then aglobalThisread, but a library that assumes the classic script context is safer viascripts.
So: prefer import for an ESM build (store or CDN); use scripts for a UMD / global-only library. The Chart.js starter shows the CDN import path; a store import and the scripts path (ECharts, μPlot, Vega-Lite, SandDance) are the other worked shapes.
The CDN tradeoff is stated at the setting: it needs internet, it breaks offline, and allowing a host slightly widens the data egress surface (a GET to an allowed host can carry data in its URL, visible in that host's logs). For a database client the safe default is off; a store library avoids all of it.
Data: the row window
A result can be millions of rows (spooled server side, fetched in 500 row blocks). A view cannot receive all of it, so:
- The initial
data.rowsis a bounded window, capped in the order of the existing compute cap (20k rows), read through the same seam ResultGrid uses (therowSourceprop for documents and actions, elseapi.resultRows(sessionId, resultId, offset, limit, filter)). ctx.rows(offset, limit)fetches further blocks on demand through that same seam (a postMessage round trip to the host and back).- The guidance, documented like the running total note, is: for a result larger than the window, aggregate in SQL. A chart over 20k points is already a lot.
Lifecycle
- The tab appears when a result matches the view's scope and
when(columns)passes. - Rendered lazily on first selection (do not build a chart nobody looks at).
- Re-rendered on a result identity change (a re-run), on a theme toggle, and on
ctx.refresh(). Not per grid scroll: the whole result is the input. The iframe stays alive across those redraws (no flash, no reload), but the view's MODULE is imported again for every one, so a module-level variable starts empty each time; state that must outlive a redraw (a rolling series sampled per@refreshtick, the Activity chart starter) lives on the iframe'sglobalThis. - Disposed when the result closes or the tab is torn down.
- A view that never signals
readywithin a long grace (90 s, a big view pages a million rows first) shows the calm failed state and the Log says so once; areadythat arrives after all clears it. Even a spinning one cannot touch the app.
Security model
The iframe is the containment boundary:
sandbox="allow-scripts"only. Noallow-same-origin(opaque origin, no access to app storage or cookies), noallow-top-navigation(cannot navigate the app, reinforcing the no navigation rule), noallow-popups.- Content CSP
default-src 'none'; script-src blob: 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'. No CDN host is ever added to it: a library from an allowlisted host is fetched by the HOST (through the offline cache) and injected as a blob script.unsafe-inlineandunsafe-evalare contained inside an opaque origin iframe that can reach nothing else (Vega and its dependants compile expressions withnew Function). - The data goes in by postMessage, never by network, so
connect-src 'none'holds inside the bootstrap document. The one way out a sandbox cannot forbid is a view NAVIGATING its own frame (alocation.hrefto a blob it built or to an https URL): the CSP does not travel to the new document. The host watches the frame'sloadevent, and a second load (the first is the bootstrap) cuts the view off for good: no init, no rows, no snapshot, a Log line saying why; and a message from any origin but the opaque one ("null") is dropped. What the view was already handed (its row window, the rows it paged before) is what it could carry; nothing more follows. - Errors and log lines come back by postMessage and go through the Log (log lines capped per render, like a rule's console output).
This is the same trust author as today (your own workspace's JavaScript), in a stricter sandbox than the Worker it augments.
UI integration
The results tab strip is currently hardcoded: three conditional .rtab blocks in web/src/components/Results.svelte (around lines 1988 to 2043), a Tab.resultsTab?: 'results' | 'messages' | 'problems' union in web/src/lib/state.svelte.ts (around line 700), and a hardcoded body dispatch (around 2583 to 2609). Views are dynamic (zero or more per result, named by the extension), so the enabling change is to make the strip iterate a small tab model:
- A computed
resultTabslist = the built in tabs that apply, plus the view tabs fromapp.viewsFor(st). pickResultsTab(id: string)and aneffTabchosen from that list, keeping the current priority default.- The body renders a built in snippet or a
ResultViewcomponent for a view tab. Tab.resultsTabbecomes a string id, persisted in the session as today, and falls back to Results when the id no longer resolves (the extension was removed), the same re-resolve at render discipline the Log's fixes use.
app.viewsFor(st) mirrors the existing rulesFor(st) (Results.svelte around line 739): it reuses forScriptMatches and queryMarkerInScope (state.svelte.ts around line 5322) to select the views whose scope matches the statement, then filters by when(columns).
What this reuses (already built)
- Scope markers and their matchers (
@for/@query,forScriptMatches,queryMarkerInScope). - The cross file module graph (
resolveModuleGraph,buildBlobGraph), now aimed at an iframe as well as the Workers. - The result data model and row windowing (
StatementResult, the 500 row blocks,rowSource/api.resultRows). - The session model for the active results tab.
- The
.jsfile admission in the script stores, the import resolver, and the editor's JS support (completion, F12), all just added.
Phased plan
Phase 0, spike the iframe (de-risk the one new primitive). A ResultView component hosting a sandboxed iframe with the strict CSP, a postMessage protocol (init, data, ready, log, error, rows request), the blob module graph built inside the iframe, rendering a hardcoded SVG bar chart from a real result's rows, honoring theme, tearing down cleanly. Prove it in the browser and the desktop webview.
Phase 1, the view spec and the tab. Parse views in gridfile.ts. Add app.viewsFor(st) reusing the scope seam plus when. Make the results strip data driven and render ResultView for the active view tab, fed columns, an initial row window, and the ctx config. Errors to the Log, calm failed state, session persistence with fallback.
Phase 2, data, refresh, robustness. ctx.rows(offset, limit) through the existing fetch seam. Re-render on re-run, theme toggle, and refresh(). The row cap and the "aggregate in SQL" docs. The drop on timeout guard. Lazy render on select, dispose on close.
Phase 3, editor support, starters, docs. ctx / data / root completion and hover, a view snippet (F12 into an imported chart helper already works). Starter views: a no dependency SVG bar chart, a chart using an imported library, a plain HTML report. Sections in GRIDJSSPEC.md and GRIDRULES.md, a CHANGELOG line.
Phase 4, optional declarative tier. A small chart spec PlumeSQL renders natively (no code), beside the imperative render, for the common "just a bar chart" case.
CDN setting. Threaded through Phases 1 and 2: views.remoteScriptHosts (empty by default), the resolver pass through for allowed https:// hosts, and the added script-src hosts on the iframe CSP. Off unless the user opts in.
Resolved decisions
- File kind: rename to
.plumesql.js. The one PlumeSQL extension file (rules and views, and whatever comes next), replacing.grid.js, which had grown inaccurate. Plain.jsstays the helper / library kind. The internal language id keeps its historical name (plumesql-gridjs); only the user facing extension and labels change. The stores already admit.js, so no server change. - View input: the raw query result. Columns and rows as returned (real values), not the grid shaped result. The view derives what it needs.
- Render model: a single batch, not a per row callback (see "The render model"). Per row stays the grid rule model; large data pages via
ctx.rows(). - Tab presence: hybrid. A view whose scope AND
when(columns)match shows its tab automatically; on a result with no matching view but available ones, a+offers them, and picking one BINDS it here by writing a scope marker into its file (a@query <table>where the result has a source relation, else the exact query), so it is automatic next run. Two consequences:@querybecomes a UNION (a file may carry several, matching if any), the same shape as the@forcomma union; and a purely breadth scoped view (@for global, no query scope) is+only, never auto everywhere, because a chart tab on every result is noise (rules keep their global means everywhere meaning; a view earns its tab). - Connection access: deferred. v1 visualizes the result you ran, with no database access. A later phase adds
ctx.query(sql), host mediated (posted to the parent, never a socket in the iframe), READ ONLY by default, on a safe non transaction borrowing session; a query capable view is then the higher (SQL action) trust tier. The no network sandbox already contains exfiltration, read only removes mutation, and the postMessage protocol is built so adding it is incremental. A self querying dashboard may even be a sibling concept, opened from a schema rather than triggered by a result. - CDN loading: a setting, off by default (
views.remoteScriptHostsempty); disk loading is the primary path.
Defaults taken (overridable): several matching views each add a tab in file / path order; ctx.theme is 'dark' | 'light' plus a few of PlumeSQL's palette tokens; the spec object grows a views key (a lone view object also works); render draws into root and returns nothing; the initial window is about 20k rows.
Still open: whether the declarative chart tier (Phase 4) ships at all.
Non-goals and risks
- PlumeSQL ships no charting library; the user brings one. Not a regression, a stance.
- The iframe is not as cleanly killable as a Worker (no
terminate()); mitigated by containment and the drop on timeout guard. - Very large results are the extension's job to aggregate in SQL; the view gets a bounded window.
- A code split library build will not load; single file builds only.