Skip to content

Grid column rules and .plumesql.js extensions ​

This document explains, from nothing, how PlumeSQL lets you reshape a result grid: change how a column reads, colour it, right align it, or add a whole new column computed from the others. It is written for a reader who has never seen the feature. For the exact, normative format (the types and every rule of interpretation) see docs/GRIDJSSPEC.md; for the engine internals see the source in web/src/lib/gridscript.ts and web/src/lib/gridfile.ts.

The idea in one paragraph ​

A query result is a grid of text. A column rule changes what a column shows without touching the data underneath: a boolean can read Yes in green, a numeric column can read 1,234.50 right aligned, a created_at column can drop its time. A computed column is a new column whose value is a small formula over the row. Rules live in reusable .plumesql.js files whose location and name decide how widely they apply, from one query to everywhere. The formula is plain JavaScript.

One idea: everything is a .plumesql.js file ​

There is one mechanism, not two. Every rule lives in a .plumesql.js file: a small JavaScript file that exports an array of rules, a real file you can keep, edit, share and version. There are no session-only rules.

The editor gives real, type-aware help while you write one: completion on the value and the standard library, hovers, signature help and go to definition (Monaco's JavaScript language service, loaded the first time you open an extension). To light up the extension's OWN vocabulary too, so ctx., row.<column> and the rule shape complete, annotate the export with its type:

js
/** @type {PlumeSQLExtension} */
export default [
  { match: { type: 'boolean' }, fn: (value, row, prevRow, ctx) => (value === 't' ? 'Yes' : 'No') }
]

The starter extensions carry that annotation as their example.

Add // @ts-check at the top of the file to turn the type help into error squiggles: a real mistake (a typo on a typed value, a wrong argument count, an add/match key that is not a rule key) is then underlined as you write it, instead of only failing when the extension runs. It is opt-in per file, so an extension without the line is never surprised by red; the starters carry it. Imports against the stores ($global/, $scripts/, ./) never squiggle as unresolved, since they are wired at runtime, not by the editor.

Everything to do with grid rules is inline in every result's right-click menu:

  • For a column: right-click a column header or a cell and pick Format <column> or Add Computed Column, both offering only what fits the column's type. Pick a template (or Custom…) and PlumeSQL opens an unsaved.plumesql.js that applies to these results at once, before you save anything. Tune it, then save it to keep it. The column's menu also traces a rule already shaping it: Edit opens its file (or brings its unsaved draft forward), and Delete removes a single-rule file.
  • For a whole type: Format all columns of a type sits beside Add Computed Column and needs no column selected, so it is there in the dots menu and over an empty or computed header too; it lists the type-general formats per column type present (every boolean, every timestamp, and so on).
  • The Extensions node gathers everything about the files themselves under one entry: Applied to this result first (the extensions actually shaping it, each naming the columns it touches, opening its file or bringing an unsaved draft forward in one click), then the other saved extensions in scope to open, then New extension… (a blank file or one of the worked examples). Greyed section headers keep the three apart.

A .plumesql.js, saved or not, is an extension by its name; where it APPLIES is decided by its @for / @query markers and its location (global or the workspace). With no scope marker it applies nowhere until attached. See "Extension files" below.

More than the grid: result views ​

A .plumesql.js file can also declare views: a tab a matching result grows, beside Results and Messages, that renders a chart, a report, or any custom surface in a sandboxed iframe. Views sit in the same file as rules (export default { rules, views }) and are scoped the same way, by @for / @query. A view may pull a charting library from a CDN by naming its URL under scripts, loaded only when the host is on the extensions.remoteScriptHosts allowlist (empty by default). They are a newer, still-settling feature with a document of their own, docs/RESULTVIEWS.md; the rest of THIS document is about grid rules.

The formula ​

Every rule carries a function, fn, called once per row with a fixed signature:

js
fn: (value, row, prevRow, ctx) => ...
  • value is this column's cell, for a formatter. For a computed column it is undefined (the column has no source cell).
  • row is the row addressable by column name (row.amount, row.created_at) or by position (row[0]), for when two columns share a name. It has a null prototype, so a column named constructor is safe.
  • prevRow is the row above, or null at the top. It includes the computed columns' values, so a computed column can look back at itself.
  • ctx is the row and column context:
    • ctx.i is this row's index in the whole result (counting from 0), the absolute index, not the visible window.
    • ctx.count is the result's total row count.
    • ctx.column is { name, type, index } of the column the rule runs for.
    • ctx.prev is this column's OWN previous returned value. It is what makes a running total possible.
    • ctx.log(...) writes a line to PlumeSQL's Log, reading like console.log.

Logging from a formula ​

A formula's console.log / console.info / console.debug / console.warn / console.error (and console.dir, console.trace, console.group, console.assert, and console.table, which renders as a TABLE (Markdown, drawn by the Log's viewer, Copy as Markdown under it), so console.table(row) prints the row's columns and their values) are captured and shown in PlumeSQL's Log (the bottom panel), so you can debug an extension without opening the browser devtools. ctx.log(...) is the same channel, written deliberately. It behaves like the browser console: each line leads with its real level (log / info / debug / warn / error), names the column it came from, and consecutive identical lines fold into one with a ×N count. A console.error is a red error row, like any other error, but it never takes the Log over (it is the extension's debug output, not an app failure). A formula runs once per row, so a per-row log can be a lot: the output is capped per compute (a safety valve against a formula that logs in a loop), and a note says when it was. The capture is only for the on-screen compute; a copy runs quietly.

prevRow and ctx.prev look back over the rows computed together. On a small result that is the whole thing, so a running total is correct end to end. On a large result the grid computes a block at a time, so they reset at each block boundary; for a true cumulative over an arbitrarily large result, use SQL (a window function). The grid cannot hold every row of a large result at once.

What a formula returns ​

  • A plain value (a string, a number, anything): the cell shows it.
  • An object to decorate the cell, a descriptor: { value, class, style, align, title, html }. Only these fields are used, and only strings are honoured for class, style, title and html, so a formula can never smuggle a live object into the page. An object that sets none of these is treated as a plain value.
    • class is one of PlumeSQL's formatter classes (they read correctly in both the light and the dark theme): fmt-ok (green), fmt-bad (red), fmt-warn (amber), fmt-muted (grey), fmt-strong (bold). A class name of your own is applied to the cell too, but an extension cannot add the CSS to style it, so it does nothing unless it happens to match a class the app already defines; for your own look use style.
    • style is inline CSS for anything the classes do not cover.
    • align is left, right or center, the tidy way to right align a number without hand writing the CSS.
    • title is the cell's hover text.
    • html renders as markup (a string, not an element) instead of text; you escape any data in it. Use it deliberately.
    • Any field the format does not know is ignored, so a file written for a later version still runs here.

The value you return is also what a copy or an export writes, so a grid you copy carries the values you see.

If a formula fails ​

A formula is your own code; a broken one never breaks the grid. If it throws (or does not compile), a computed column shows ERR in the cell and a formatter falls back to the raw value. Either way it is logged once, in the Log panel, not once per row.

A rule's fn in a .plumesql.js file runs as the live function, so it can call helper functions and read constants or lookup tables you declare at the top of the same file. Share what several rules need instead of repeating it:

js
// @for workspace

const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const grade = (n) => (n >= 90 ? 'A' : n >= 80 ? 'B' : n >= 70 ? 'C' : 'F')

export default [
  { match: { name: 'amount' }, fn: (v) => money.format(Number(v)) },
  { add: 'grade', fn: (v, row) => grade(Number(row.score)) }
]

Keep a module-level binding a constant (a formatter, a frozen lookup): PlumeSQL may compute rows in separate passes, so a counter kept at module scope is not reliable. For a value that accumulates down the rows (a running total), use prev / prevRow, not a module variable.

Importing helpers from another file ​

When several extensions share the same formatting, put it in its own .js file and import it. PlumeSQL resolves an import to a file in any of the script stores, so one library serves every extension:

js
// scripts/lib/format.js  (a plain helper file, no @for)
export const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
export const yesNo = (v) => (v === 't' ? 'Yes' : 'No')
js
// @for workspace
import { money, yesNo } from '$scripts/lib/format.js'

export default [
  { match: { name: 'amount' }, fn: (v) => money.format(Number(v)) },
  { match: { type: 'boolean' }, fn: (v) => yesNo(v) }
]

Two specifier forms resolve:

  • $<store>/<path> reads from a store's root, with the same store words the @for marker uses put behind a $ (as SvelteKit's $lib): $global/ the shared scripts store, $scripts/ the workspace scripts store, $workspace/ the workspace files. So import { fmt } from '$global/lib/money.js' reaches a shared helper every workspace can use.
  • ./x.js, ../lib/x.js read from the importing file's own store, relative to its folder.

A bare name (import x from 'lodash'), a URL, or a path that climbs above a store root does not resolve: a grid helper lives in a store, not a package tree. The helper file needs no markers: a plain .js is never an extension on its own (only a .plumesql.js is, by its name), so a helper is only ever pulled in by an import. Edit a helper and save it, and every extension that imports it recomputes.

Extension files (.plumesql.js) ​

A .plumesql.js file exports an array of rules (as many as you like, formatters and computed columns mixed):

js
export default [
  { match: { type: 'boolean' }, fn: (v) => (v === 't' ? 'Yes' : 'No') },
  { match: { name: 'amount' }, fn: (v) => `$${Number(v).toFixed(2)}` },
  { match: { name: /_at$/ }, fn: (v) => (v ? String(v).slice(0, 10) : v) },
  { add: 'row #', at: 'start', fn: (value, row, prevRow, ctx) => ctx.i + 1 }
]

For a one-rule file you can drop the array and export the single rule on its own, and the { version?, rules } object form is accepted too:

js
export default { add: 'row text', fn: (value, row) => Object.values(row).join(', ') }

Each rule is one of two shapes:

  • A format rule, { match, fn }, reformats the columns its match selects. A match can combine (all must hold):
    • name as a string, an exact column name;
    • name as a RegExp, tested against the name (/_at$/, /(amount|price)$/, /.*/ for every column);
    • type, a PostgreSQL type as the header shows it (boolean, numeric, ...);
    • index, a 0 based position, or 'first' or 'last'.
  • A computed column, { add, fn }, adds a new column whose header is add. An optional at says where it goes ('end' the default, 'start', { before: match }, { after: match }).
  • A companion column, { match, add, fn } (both match and add), adds a computed column beside each column the match selects instead of formatting it in place. One rule fans out to one new column per match, so "a … ago after every timestamptz" is a single rule. The fn's value is the matched column's cell (as for a formatter); add is a header template where {col} becomes the matched column's name ({col} ago → created_at ago); at: 'after' (the default) or at: 'before' chooses the side.

Any rule may also carry a when(columns) data guard: a self contained predicate over the result's columns ({ name, type } each). The rule applies only where it returns true and does nothing elsewhere, so it is the way to add a name column only when both first_name and last_name are present (a match selects columns that exist; it cannot gate on which columns the result has). Like a view's when, it is compiled on its own, so keep it self contained.

When more than one format rule could match the same column, the most specific wins: an exact name, then an index, then a name RegExp, then a type; a match with more fields beats one with fewer, and an explicit priority overrides all of that. Exact ties keep the rule listed later. A computed column never competes; it is added on its own.

at places the computed column: 'end' (the default) appends it, 'start' puts it first, and { before: match } / { after: match } set it beside the first column the match selects. Several computed columns keep their listed order within the same placement; a before / after that matches no visible column (or a frozen one, since a computed column never freezes) falls back to the end.

Where extensions live, and their scope ​

A grid extension is found in the same three places, and the same way, as a query or command extension (.plumesql.sql, .plumesql.run, docs/ACTIONS.md): the opened workspace itself, the workspace's own Scripts store and the shared Scripts store.

The .plumesql.js name is the file's identity. Its name is a claim no other tool makes, so PlumeSQL loads it, shows it with the feather (every extension's mark, in violet for a grid one) and runs it, whether or not it carries any marker. (The older .plume.js spelling is the same extension under the product's previous name; the older still .grid.js spelling is generic JavaScript a project might hold for its own reasons, so a .grid.js counts as an extension only when it also carries a // @for marker, the way a query extension is known by its annotations. This is the one transitional case where a marker still decides identity.) The workspace walk skips node_modules, vendor and hidden directories. A team keeps its extensions in source control simply by committing the .plumesql.js files into the repo, beside its .plumesql.sql files.

Identity is not scope. Being recognized does not mean applying anywhere: a .plumesql.js with no scope marker (no @for and no @query) is a known extension that applies to no result until it is scoped. Scope is opt in, so an extension formats exactly where its markers say and nowhere else. Any grid offers a per grid apply extension gesture that writes the attachment for you: one time (nothing saved), always for this query (appends a @query line to the extension), always for this directory (appends a @for glob to the extension), or always for this file (writes a -- @extension line into the SCRIPT, see below).

Two directions of attachment. The @for / @query markers are the EXTENSION's own word on where it applies, and they are the only way to express breadth or a pattern (@for global, @for reports/*.sql, @query users). The other direction is the CONSUMER's: a .sql script or query action opts an extension in with a -- @extension <store path> line in its own head:

sql
-- @extension $global/format/money.plumesql.js
-- @extension $scripts/charts/bars.plumesql.js
select amount, month from sales

Every result the file runs then carries those extensions, whatever their own @for / @query say (an UNSCOPED extension, applying nowhere on its own, is the natural fit). Several extensions are several -- @extension lines, or one comma separated; typing -- @extension completes from the loaded extensions. Because the binding lives with the file, a general purpose extension shared between many scripts stays free of the paths that use it. See docs/ACTIONSPEC.md 8.29.

js
// @for workspace

A // @for marker scopes the file to a result's SOURCE. From widest to narrowest:

  • // @for global: applies in every workspace, to every result. Keep the file in the shared Scripts store so it is loaded everywhere.
  • // @for workspace: applies to every result in this workspace. Keep the file in the workspace, its own files (in the repo, in git) or its Scripts store. A workspace rule wins a formatter tie over a global one.
  • // @for <script>: binds the file to one script, matched by name (// @for report.sql applies to results run from report.sql, in any folder). To tell apart a script of the same name in different stores, qualify it with the store word, which mirrors the breadth words: // @for global:report.sql (the shared Scripts store), // @for workspace:report.sql (the workspace files) or // @for scripts:report.sql (the workspace's own Scripts store). Several extensions can target the same script, each in its own file.
  • A GLOB in the script name matches a pattern, not one name: * and ? within a segment, ** across folders. A pattern with no / matches the file's own name at any depth (// @for report* covers report.sql and reports.sql, // @for *.sql every SQL script); one with a / matches the store-relative path (// @for scripts:reports/*.sql, // @for **/summary*). Store-qualify a glob the same way as a name.
  • // @for <store>: (a store word with an empty name): a store wildcard, every result from that store whatever the script name: // @for scripts: (the workspace Scripts store), // @for workspace: (the workspace files), // @for global: (the shared Scripts store). A bare // @for scripts means the same as // @for scripts: (the Scripts store), not a script named scripts; global and workspace are the exception, since bare they are the breadth words above and their stores take the colon.
  • Several targets are a union (the file applies if any matches), whether comma-separated on one line or written as several // @for lines. This is how you cover both LOCAL stores while leaving the shared one out: // @for scripts:, workspace: (every result from the workspace Scripts store or the workspace files). A breadth word among the targets still widens to every result. The per grid apply extension gesture appends another // @for line to attach an extension to a new file or directory, so a union grows over time.
  • // @query <query>: an optional marker that binds the file to a single query, written out in full so you can read it. It may be the ONLY marker (scope by query alone) or pair with @for (a result must then match both). Like @for, it may repeat (a union of queries or tables). PlumeSQL writes it; matching ignores whitespace, comments and a trailing semicolon, so reformatting or a changed comment still matches, but editing the query itself makes a stale marker stop matching.
  • // @query <table> (a single identifier, bare or schema.table): binds to that TABLE instead of one query text, so every form of the table's result matches, by the result's own source relation, not the SQL: select * from users, select * from users where id = 1, a browse of the table, an FK step onto it. It works wherever PlumeSQL identified a single source relation (the same case that makes a result editable), and does NOT need the DDL snapshot loaded (a single word is never a real query). The snapshot is consulted only to reject a name it positively knows is not a relation, which then matches as an exact query.

Hovering a @for value names the exact stores and their physical directories on disk, then, live, WHICH open results the scope matches right now (by name, honouring the file's @query); a @query value hover shows the normalized query and the same live list. So a hover answers concretely what the extension will format, not only the rule.

global and workspace are reserved words, not script names (target a script actually named that store qualified, // @for scripts:workspace). The two markers combine, so @for and @query together mean "this query, in this script". PlumeSQL writes the marker when you create a rule from a column; you can also add or edit it by hand, and the editor completes and explains its values.

A description and a colour, like any file ​

An extension carries the same presentation pair an action or a plain script does, in its head comments, and PlumeSQL treats it the same way across the UI:

js
// @for global
// @description Booleans as Yes / No, coloured
// @color teal
  • // @description <text> shows beside the file in the Explorer, so a folder of extensions reads as what each one does, not just its file name.
  • // @color <name> gives the file its identity colour (its tree row and its tab mark), from the same ten-name palette actions use (blue, teal, amber, ...). The editor completes both, and the colour names after @color.

These are optional and describe the FILE; they never change what the rules do.

Creating and saving an extension ​

Right-click a column and pick a Format or Add Computed Column template (or Custom…), inline in the grid's menu. The Format templates follow the column's type, so you are offered only what fits: Yes / No and TRUE / FALSE on a boolean, a thousands separator and currency on a number, UPPERCASE / lowercase on text, date-only and Time ago on a timestamp, and As a tag (an HTML example, with the value escaped) or Dim NULL on any. Applying a format to a whole type is its own entry beside Add Computed Column, Format all columns of a type: it does not depend on the clicked column, so it appears even away from one, with a submenu per column type present in the result (every boolean, every timestamp without time zone, and so on). Add Computed Column places the new column where you choose: After or Before this column, At start, or At end (Running total is offered only on a numeric column). Away from a column (the dots menu, the empty header) there is no single column to format, so Format <column> drops away, leaving Format all columns of a type and Add Computed (At start / At end). PlumeSQL opens the rule as an unsaved .plumesql.js tab and applies it to these results immediately, as a draft, scoped to the current script (a // @for marker) or, for an ad hoc query, to that query (a // @query marker). A template opens quietly in the background so the result stays in view and the formatting just appears; Custom opens the tab focused so you can start writing. There is no dialog and nothing to run again.

Editing the draft tab re-applies it live. When you are happy, save it with the usual Save: it asks where among the stores (global to apply everywhere, the workspace otherwise) and a name, exactly the way the object tree's "Script" saves a new script. Change the @for / @query markers to widen or narrow the scope. A closed-without-saving draft simply disappears.

The results menu's Extensions picker lists every extension in scope (open one to edit it) and offers New extension..., a picker that starts from a blank file or one of the worked examples.

Every extension PlumeSQL generates carries a typed default export (/** @type {PlumeSQLExtension} */), and a blank one shows the rule and view shapes as commented examples. The editor's own type-aware help (the language service and the PlumeSQLExtension type: the rule shapes, the fn arguments, and the full list of what a formula may return, the descriptor's fields and its class values) is the reference you keep next to your code; the precise version lives in docs/GRIDJSSPEC.md.

Starter extensions ​

New extension offers a blank file, nineteen column examples and twenty-one result views, each a small, complete file you edit and save:

  • Booleans as Yes / No, coloured.
  • Status column coloured by its value (active green, pending amber, failed red), matched by the column name.
  • Money columns (names like amount, price, total) as a currency.
  • Numbers grouped with thousands separators, right aligned.
  • Human byte sizes (size, bytes) as KB / MB / GB.
  • Timestamps as dates (names ending _at).
  • Time ago (names ending _at) as relative time (3 hours ago), the exact value on hover. A timestamptz keeps its zone (PostgreSQL's hours-only offset +02 is padded to +02:00 so Date accepts it); a naive timestamp reads as the viewer's local zone.
  • Mark NULLs in every column with a dimmed symbol.
  • Row numbers (a computed #).
  • Running total (a computed column that accumulates a numeric column).
  • Combine columns (a computed column joining two others).
  • Trend vs previous row (every number column shows an up / down arrow and a green / red tint against the row above; sort the query so the rows read in the trend's order).
  • Percentages with a bar (*_pct, percent, ratio, rate, share; a fraction between 0 and 1 reads as a percent).
  • Milliseconds as durations (*_ms, *_time, duration, elapsed, latency: 1.2 s, 4m 12s, 2h 05m; the number on hover).
  • JSON on one line, the pretty print on hover.
  • Short UUIDs (the first block, the whole id on hover).
  • Arrays as a list (PostgreSQL's {a,b,"c d"} parsed to a, b, c d, the count on hover).
  • Negative numbers in red, every number right aligned.
  • Timestamps in local time (a timestamptz in the viewer's zone and locale, the server's text on hover).

The result view starters draw the rows instead of listing them. Hand written, nothing to fetch: an interactive bar chart (a header button copies the values, clicking a bar selects its row), a Scatter GL (WebGL: a million points on the GPU, pan and zoom, hover the nearest row and click to select it), a Pivot table (rows × columns × a folded value, totals down and across, Copy as TSV) and a Column profile (every column's nulls, distinct count, range, mean and distribution in one pass). From a CDN: a Chart.js chart with a type picker (bar / line / pie / doughnut / radar / polar), an ECharts chart (line / area / bar / scatter / pie with a range slider and wheel zoom, a million-point line thinned on the fly), a μPlot line chart (a million points in milliseconds, drag to zoom, a live legend), a Vega-Lite chart that GROUPS and AGGREGATES for you (mean of Y per X, count, ...), a SandDance explorer (Microsoft's 2D / 3D viewer: scatter, bars, stacks, treemap, density, with animated transitions), and the Observable Plot family, twelve single-view extensions sharing one shared.js helper module: a multi-series chart with a hover read-out and start / end value badges, focus + context (brush the overview, the detail zooms), density (hexbin or contours), regression, streamgraph, beeswarm, calendar heatmap, ridgeline, horizon, box plot, heatmap and ranked bars. Every one is written for a LOT of rows: the loaders page the whole result in through ctx.rows, and the SVG charts thin, sample or fold a million points to what the pixels can show before drawing. The library charts pull from a CDN, so their host must be on extensions.remoteScriptHosts (a blocked one offers a one-click "Allow host" in the Log); the Chart.js and Plot starters import their library as an ES module, while the heavier ECharts, μPlot, Vega-Lite and SandDance load their runtime the classic-script way (scripts: [...], <script> tags, lazily on first open) because their ES-module graphs are enormous or they ship as one bundle. SandDance and Scatter GL draw with WebGL, so they want a GPU-capable browser (the desktop app or a normal tab).

Every starter leads with // @ts-check and a /** @type {PlumeSQLExtension} */, and each is written to pass the type check, so a generated extension gives type help and a clean Problems tab from the start (a test loads and runs every one). Delete the // @ts-check line if you would rather edit without it. Starters ship UNSCOPED: a new extension carries no // @for / // @query marker and so applies NOWHERE until you attach it (the grid's Extensions menu, or a marker you add) — where it runs is your choice, not the template's.

The .plumesql.js editor ​

A .plumesql.js file opens in a JavaScript editor, and that editor is the reference now: the generated files carry no spec header (and no session runs on them, so there is no run bar or connection picker, only a Format button).

  • Syntax highlighting and the violet grid icon on the tab, so it reads like code.
  • Completion that fits the cursor: a whole-rule snippet at the array level, the rule keys (match, add, at, priority, fn) inside a rule, and inside a fn the formula arguments, a return descriptor snippet whose class and align values are a dropdown, and, when the file was authored from a result, that result's columns as row.<col>. In a // comment it suggests the @for / @query scope markers.
  • The descriptor completes when you write it by hand too: inside a returned { … } object it offers the keys (value, class, style, align, title, html), after class: the exact class palette (fmt-ok green, fmt-bad red, fmt-warn amber, fmt-muted grey, fmt-strong bold), and after align: the values (left / right / center) — so there is no guessing which classes exist. The palette is the same five the engine honours; it never offers one that does nothing.
  • Plain JavaScript completes too, so a formula reads like code: the globals a formula uses (console, Math, JSON, Object, Number, String, Array, Date, Intl, parseInt, …), their members after a dot (console.log, Math.round, ctx.log), and, after any other ., the common String / Array / Number methods (toUpperCase, slice, replace, split, map, filter, join, toFixed, …), plus the keywords and a few control-flow snippets. This curated list is the INSTANT surface (it needs no worker, so it is there on the first keystroke); the lazily loaded TypeScript language service layers real, type-aware completion on top, so once the worker is up a typed value completes with its own methods.
  • Parameter hints for the formula itself: while the cursor sits in a fn: ( … ) parameter list, a hint shows all four parameters a formula may take, in order (value, row, prevRow, ctx), with the current one highlighted and its meaning, so which are available (and what each is) is never a guess. It fires only for the fn's own parentheses, not a nested arrow's.
  • The @for / @query markers are highlighted the way a query extension's annotations are (the marker in the annotation colour, its value beside it), so a scoped file reads its scope at a glance.
  • Hover explains the whole vocabulary: the rule keys, the fn arguments and its ctx, the formatter classes, the scope markers, and the JavaScript globals, members and methods above (console.log, Math.round, .slice, a keyword). Hovering a @for or @query value also answers concretely who it applies to: the stores and their directories, then which open results the scope matches right now, by name.
  • Go to a result it shapes (the editor's right-click, for a saved extension): the open result tabs this extension applies to, each a click away, even ones that are not the active tab. The mirror of a grid's menu opening its applied extension.
  • Format (the toolbar button, or ⇧⌥F): a safe reindent that changes only whitespace, never content. Generated files are already formatted this way.
  • An inline check as you type: a file that will not load, or whose default export is not an array of rules, underlines the error right in the editor (the check runs in the grid's own Worker, so a runaway file cannot hang the tab), instead of only surfacing in the Log when you save. A rule that loads but does NOTHING is underlined as a warning too: a formatter whose match names no column (needs name, type or index, so the common match: { match: {…} } typo is caught), a rule with neither match nor add, or one missing its fn.

Colour, the curated completion, hover, a reindent and the compile check are all instant, needing no worker; the TypeScript language service (jsservice.ts) then layers type-aware completion, hover, signature help, navigation and, under // @ts-check, diagnostics on top, loaded lazily on the first extension editor.

  • The diagnostics go to the Problems tab too, the SQL parser's twin for JavaScript: a syntax error (always) and a type error (under // @ts-check) each become a Problems row and feed the status bar's problems item, not only a squiggle. A .plumesql.js never runs, so the pane it never mounts on its own opens from the status-bar item or Toggle Problems and carries only the Problems tab; a row's menu is Go to Problem and Copy Message. The same holds for a plain .js file open in the editor.
  • The lightbulb offers TypeScript's own quick fixes (Ctrl+. / ⌘.), no AI: a spelling correction, an added await, a JSDoc @type, and the rest of the compiler's code fixes, plus "Ignore this error" and "Disable checking for this file". Not every diagnostic has a targeted fix; an operator type mismatch, for one, reads "No quick fixes available" and is for a human, or an annotation, to resolve.

Where the errors go ​

Consistent with the rest of PlumeSQL, every RUNTIME failure has one home: the Log panel. A file that fails to load, a rule with no formula, a formula that throws: each is one Log entry, and the grid degrades gracefully (ERR or the raw value) rather than breaking.

Editor-time mistakes are the parser's business, not the Log's: a syntax or type error the editor finds while you type is a squiggle and a Problems row (see the editor section above), exactly as a SQL script's parser problems are. The split is the same one SQL keeps: what the checker finds before anything runs is a Problem, what fails at run is a Log entry.

PlumeSQL is a hercstack product.