Skip to content

Actions in PlumeSQL ​

MAINTENANCE RULE: the ANNOTATION FORMAT and its action vocabulary (both kinds of file, the annotation grammar, every annotation with its dependencies, $column, every {placeholder}, the execution requirements) is specified in docs/ACTIONSPEC.md, written to stand alone so it can be published and implemented independently. That document is the authority on the format; this one documents how PLUMESQL surrounds it (the Explorer, the file and its view, the settings, the editor help) and never redefines it. The registries stay DATA (ANNOTATIONS in web/src/lib/actions.ts, PLACEHOLDERS in web/src/lib/placeholders.ts) and a test holds the spec's registry table to them (web/test/actions.test.ts): adding an annotation means a row in the registry and its subsection in the spec, in the same change.

What an action is ​

A saved question, kept as a FILE, whose answer comes back live. In PlumeSQL such a file is an EXTENSION, one of the three kinds (the grid extension, name.plumesql.js, is the third, docs/GRIDRULES.md), and the two here are told apart by the file's name and by nothing else:

  • a query extension (name.plumesql.sql), asked of a database, answered by a grid;
  • a command extension (name.plumesql.run), which runs a program on this machine, answered by the terminal that watched it.

"Action" is the name of what such a file DECLARES, the annotation vocabulary of docs/ACTIONSPEC.md (its run, its inputs, its buttons), and the word this document keeps for it; in the app's own words the file is an extension. The older spellings, name.plume.sql and name.plume.run (the product's previous name) and name.action.sql and name.action.run before them, are recognized for good (a file in a repository is never renamed); PlumeSQL's own stores were moved to the new names once per spelling, by the rename-extensions and rename-extensions-plumesql bootstrap steps (docs/GLOBALDIR.md).

The double extension is what makes a file an action, wherever it lives: in the workspace (versioned with the project, plain SQL to every other tool), or in either script store, Scripts (the installation's, there whatever directory PlumeSQL opened) and Workspace Scripts (this project's, kept in the global plumesql dir so the opened directory is never written to). There is no store of its own and no pane of its own: an action is a file the Explorer manages, and everything the Explorer does with a file it does with an action, new, edit, rename, delete, cut, copy, paste, find, pins, Frequent, picking several rows at once and dragging them into a folder.

An action's IDENTITY is its file, spoken as a virtual path naming the root (~file:workspace/tools/rows.plumesql.sql, ~file:globalscripts/Extensions/Activity/current-activity.plumesql.sql), so the same name in two roots is two actions with two views and two results. Everything that keys on an action keys on that string; only the file IO resolves it back to a root and a path.

A row wears the extension's mark before its name is read: the feather every PlumeSQL extension wears, in the flavor's hue (the accent for a query extension, green for a command extension, violet for a grid extension), since which one a row is decides what its doors do; a row's own @color overrides the hue, and the name's double extension and the tooltip say the flavor in words. The tab strip keeps a lettermark (EXT, RUN, GRID), where three letters read better than a small icon. The bolt is the Run Extension button's, a verb, not the file's. It carries its @description under it when the Explorer shows descriptions (files.treeDescriptions), and the Explorer's own order and metrics apply to it as to any file.

The row's menu LEADS with the action's own two doors, the same for either kind: Run Extension (a query's answer in a tab of its own, a command's program in its terminal) and View Source (the file's own tab). A click on the row opens the FILE, as a click on any file does, unless the file says @face result (docs below, "What a click opens").

An action is also somewhere to GO: the Go to Tab or File picker lists the ones asked for before in a section of their own, ranked by frecency like the database objects, and picking one opens its view.

The file is a COMMAND with a header above it, then one more command per button, each under a header of its own:

sql
-- @description What every backend on this database is doing
-- @refresh 3s
select ... ;

-- @button cancel query
-- @confirm Cancel the running query on backend $pid?
select pg_catalog.pg_cancel_backend($pid);

The shape is chosen for the EDITOR. A button's command is real SQL in the buffer rather than a string inside a comment, so it colours, completes and folds like the SQL it is, and it may run to several lines. Nothing here is quoted: every annotation takes the rest of its line, which removes the whole class of escaping rules a quoted argument needs, and with it the mistakes they cause.

The first command is the action's own, and it is the only one that runs when the action runs. Every command past it is a button and needs @button <label> above it; one without is reported. Commands are counted with the same scanner the editor and the run guard use, so a semicolon inside a string, a comment or a dollar quoted body is not a boundary.

The file format, in one paragraph ​

The format itself lives in docs/ACTIONSPEC.md: the concept (an annotation OPENS its line and applies to the code below it, the comment around it is only the carrier that keeps it out of execution's way), the grammar (case does not matter, the value is the rest of the line, nothing is quoted), the full registry with every annotation's syntax, scope, kind and default, the dependency rules between them (@on and @at exist only under @button/@open and exclude each other, @confirm is the one annotation legal in both headers, and the rest of section 9), the $column and {placeholder} binding rules, and what executing a command action requires. This document assumes all of it and only adds what is PlumeSQL's own.

What is PlumeSQL's own about READING the format: everything the parser diagnoses goes to the Log with the line it is on (one mistake, one message, re-reported only when it changes), and the same problems mark the line in the editor as it is written. An annotation reads as structure, not as comment: the name and its value each take a color of their own and drop the comment's italic, in both kinds of file, placeholders in a command's values keeping their own color on top.

A button on a column of its own, or on one of the result's ​

By default a button gets a COLUMN of its own, one per place, and @at says where that column goes. @on <column> says the other thing: that an existing column IS the button, its cells clickable and still carrying their values.

sql
-- @description What every backend on this database is doing
select pid, usename, state, query from pg_catalog.pg_stat_activity;

-- @button cancel
-- @on pid
-- @confirm Cancel the running query on backend $pid?
select pg_catalog.pg_cancel_backend($pid);

Reading the value and acting on it happen in one place, so the value the button works with is not written twice: without this, the column has to be hidden and a labelled button given the same value as a parameter.

  • A column carries one button. One click cannot run two commands, so a second @on for the same column is reported and that button is dropped.
  • @at says nothing there. Such a button has its place already, and writing both is reported. The column it names wins.
  • Only the value is the target. The rest of the cell selects and drags like every other cell, so a column you can act on is still one you can read and copy.
  • The keyboard reaches it: the grid's own arrows walk into the cell, Enter presses it.
  • A NULL cell is not a button: there is no value to act on, so it keeps reading NULL and does nothing.
  • A column the action does not show (never selected, or taken away by @hide) has nowhere to be clicked, so that button falls back to a column of its own and the Log says so once.

@button runs a command and reports what the server said. @open asks a QUESTION with that row's values and its answer replaces the grid, one step deeper into the same tab.

sql
-- @description Databases on this server
select datname, pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(datname)) as size
from pg_catalog.pg_database where not datistemplate;

-- @open backends
-- @on datname
select pid, usename, state, query
from pg_catalog.pg_stat_activity where datname = $datname;

-- @button cancel
-- @on pid
-- @confirm Cancel the running query on backend $pid?
select pg_catalog.pg_cancel_backend($pid);

An action stops being one question and becomes a small report to walk through: databases, then what runs on one of them, then an end to what runs. Nothing about a navigation is a second grammar: @on, @at and @confirm mean there what they mean on a @button (a @confirm on a navigation is for the expensive question, not the destructive one), the values travel bound the same way, and the answer goes through the same drain, spool and paging every other result does.

Which buttons a given answer carries ​

A button reads its row through the columns it NAMES, so it belongs wherever those columns are. In the file above, cancel never appears on the list of databases and appears by itself the moment a navigation lands on something with a pid. A button that names no column at all does not depend on the row, so it applies everywhere.

That is also why the Log stops checking: in a file with no @open, a button whose columns the action does not select cannot run at all and is reported. Once a file navigates, the same absence is the file working as intended, and there is nothing to report.

Back, Forward, and the trail ​

The header grows ‹ › and a trail the moment the tab has been somewhere, and every step says which ROW it came from: table-sizes › indexes data_points, where the file called the step "indexes" and data_points is the value that was clicked (the @on column's, or the first one the command binds, cut to a crumb's worth with the whole of it in the tooltip). Without it the same word names every visit, and a trail that cannot tell two of them apart is decoration. Every crumb is a way back, Back and Forward are one step of it, and taking a different turn drops whatever was ahead, the way every history behaves. The combos are PlumeSQL's own, since VS Code's Back is browser-reserved on the web: ⌥⌘- and ⌥⇧⌘- on macOS, Alt+- and Alt+Shift+- elsewhere (the Keyboard settings say why), deliberately not Alt+←, which belongs to the browser.

Going back ASKS AGAIN. An action dials, asks and closes, so there is no held answer to return to, and one from ten minutes ago is not what belongs behind a Back button in a database client. Run again and the @refresh timer follow the same rule: they re-ask the step on SCREEN rather than dragging the tab home. What is lost by this is the scroll position.

The trail lives in memory, not in the session: a reopened workspace starts at the action's own question, because that question is the tab's identity and yesterday's answer is not.

Told apart at a glance ​

A navigation carries a chevron, on its cell and on its button. An act does not. One click ends a stranger's transaction and the other one just looks around, so they must never read the same.

Values from rows and from PlumeSQL ​

$column (a clicked row's value, bound, never pasted) and {placeholder} (what PlumeSQL knows before the run: the connection's parameters and the clock) are specified in ACTIONSPEC.md sections 10 and 11, the secret's single channel included: {password} may be spent on a command action's @env line and nowhere else, refused by both parsers and again by the server where it cannot be talked around (internal/server/commandaction.go). What is PlumeSQL's own here is where the expansion happens: an @env value is expanded by the SERVER, in the process that starts the program, so the page never holds the secret and what comes back is the command line with the secret absent by construction. A @confirm's placeholders fill in on the PAGE, before anything runs, from the values it already holds and shows, so the question can name the exact file it is about to write; the password is not among them and never appears.

Asking for a value: the input form ​

A query action, or one of its buttons, may ASK for what it cannot know in advance, with the same three lines a command action has always had (@var, @file, @dir, ACTIONSPEC.md 8.7 to 8.9). Everything a command asks for opens in ONE form, the row form's shape: a field per input with its prompt as the label, Cmd+Enter runs, Escape runs nothing. One shortcut: a command whose inputs are files and directories alone, each one to be picked (no default to accept, nothing to type), opens the pickers directly, in written order, with no form in between; a dismissed picker runs nothing. Any typed value, default or @pick brings the form, whose Browse buttons open the same pickers. A form dismissed on the tab's first showing leaves the tab saying what the action asks for ("This action asks for "Who?" before it runs") with a Run button that opens the form again; the tab does not reopen it by itself when shown again.

A field's control is not written in the file: before the form opens, PlumeSQL asks the server what the statement's parameters ARE (Parse and a parameter description, nothing runs) and gives each field the row form's own control for that type, the one component both forms render a field with: a segment for a boolean, a decimal keypad for a number, a format hint for a date, the picker for an enum the dictionary knows, one element per line for an array, and the NULL toggle beside every field. A type it cannot learn is text, and the value is still bound and typed by the server. A field sends exactly what it holds, an empty one the empty string; NULL is the toggle and nothing else, never inferred from an empty box (an integer left empty is refused by the server, in so many words, rather than silently read as NULL).

The command may spell an input the way a plain query carries a parameter, too: $1, $seconds, :who, :'who' and the identifier :"tbl" (inlined and quoted by the server) all name inputs, declared by a @var line when one names them, asked by their spelling when none does; only {name} reaches the registry ({db}, {schema}), so $db is an input called db. A button's $pid stays the clicked row's column, as ever. The run.parameters setting that governs a script's spellings governs these too.

And the other way round: a PLAIN SCRIPT's run parameters take the same three lines. -- @var seconds "Running longer than (seconds)", "30" above a query gives the form's field for $seconds (or {seconds}, :seconds) its label and its default, -- @var $1 "How many", "10" the positional one, -- @file and -- @dir make a parameter a picked path with a Browse button, -- @pick who select usename from pg_user lists a parameter's values, and -- @var tag=v1 declares a value that never asks. In the header the line describes every query's parameter of that name, above a query that query's alone (the query's line winning), exactly as -- @inputs reaches; an -- @inputs value answers a declared parameter and wins over its default, and the tab's remembered answer wins over both. A parameter no line describes is asked by its spelling, as before.

A field with no default in the file is REQUIRED: Run waits while it is empty, and the field says "required" beside its name, in red once Run was tried. A written default, "Prompt", "" included, makes the empty answer valid (ACTIONSPEC.md 8.7). So @var who "Who?" must be answered, @var note "Reason", "" may be left blank, and a @file or @dir with no default path must be picked.

An action that takes a parameter:

sql
-- @description Backends whose statement has run longer than you say
-- @var seconds "Running longer than (seconds)", "30"
-- @refresh 5s
select pid, usename as "user", pg_catalog.now() - query_start as elapsed, query
from pg_catalog.pg_stat_activity
where state <> 'idle'
  and query_start < pg_catalog.now() - pg_catalog.make_interval(secs => {seconds});

Run Extension opens the form with 30 in the field; the timer then asks every five seconds with the same answer (a timer never opens a form), Back along the trail runs on it too, and Refresh, a gesture, asks again with the last answer in the field.

A button that edits the clicked row through a form:

sql
-- @description Films and their rates
select film_id, title, rental_rate, rental_duration from public.film order by film_id;

-- @button set rate
-- @var rate "New rental rate", "$rental_rate"
-- @var days "Rental duration (days)", "$rental_duration"
-- @confirm "Change the rate?", "Set $title to {rate} for {days} days?"
update public.film set rental_rate = {rate}, rental_duration = {days}
where film_id = $film_id;

The default's $rental_rate is the clicked row's value, so the form opens prefilled; the confirmation names what was typed; the update runs with every value bound; the grid refreshes. The starter long-running (Scripts, Actions/Activity) is the shipped example of the first shape.

A field whose values come from a query, @pick (ACTIONSPEC.md 8.26):

sql
-- @var role Role
-- @pick role select rolname as role, rolcanlogin as login from pg_catalog.pg_roles order by 1
select m.rolname from pg_catalog.pg_auth_members am
     join pg_catalog.pg_roles r on r.oid = am.roleid
     join pg_catalog.pg_roles m on m.oid = am.member
where r.rolname = {role};

The field grows the chip the row editor's foreign key field wears ("pick from a query") and a button beside it (Alt+↓ in the field), and the pick IS the row editor's picker: the form folds away with everything typed so far, the query's rows open as a result document in pick mode (the same grid every result gets: every column, sorting, the row count, paging past any size), with the bar "Pick <column> for <field> in the <action> form: select a row", Select puts the value column of the selected row into the field (the column named like the input, else the first; Enter on the row does the same) and the form comes back as it was, Return to Form brings it back untouched, and so does closing the picker's tab. The field stays typeable throughout: the picker is help, the server judges the value. The query runs on the action's connection when the picker opens, never when the form does, its values bound (the clicked row's $column above a button, the registry's), and a failing one goes to the Log with the field left as it was. It never reads the form's other fields, which are not answered yet. The starter role-privileges carries two, on its grant forms.

Inlining a value: utility statements (@inline) ​

PostgreSQL accepts no bound parameters in utility statements (ALTER SYSTEM, VACUUM, REINDEX, GRANT and their kin), and this format never pastes a value into SQL, so for a long time an action simply could not run one with a value in it. @inline <name> literal|identifier (ACTIONSPEC.md 8.27) is the door: the named $column or {placeholder} is expanded into the statement's text, but the quoting is PostgreSQL's own (quote_nullable for a value, quote_ident for a name), asked of the very server the statement runs on, in one round trip right before it. Nothing is ever concatenated out of data; a hostile value comes back inert. Everything not named stays bound, which remains the default and the preferred road.

sql
-- @button set value
-- @var new_value "New value", ""
-- @inline name identifier
-- @inline new_value literal
alter system set $name = {new_value};

The starter server-settings is the shipped example: browse every parameter, open its card on the name, Set value (the form, then ALTER SYSTEM validating the value in the server's own words), Reset, and Reload to apply what a reload can.

A command action: one saved command ​

The file is a header of # comment lines and then ONE command:

# @description Back up this database, custom format
# @env PGPASSWORD={password}
# @alert-on-success Wrote {db}-{timestamp}.dump
pg_dump --host={host} --port={port} --username={user} --no-password \
        --format=custom --dbname={db} --file={db}-{timestamp}.dump

The comment marker is # and not --, because this is not SQL and must not pretend to be: every shell, every .env and every Dockerfile a user has read says # here. An annotation is read only when it OPENS its line, the same rule the other kind follows.

A long command breaks across lines with a trailing backslash. There is exactly one command per file; a second is reported rather than half run.

How the command is executed (directly, never through a shell, the line split into argv the way a shell splits words and nothing more) is the specification's section 12, requirements and all. PlumeSQL's own pieces of it: a PostgreSQL tool is resolved to an installation that suits the server per the spec's SHOULD (internal/db/toolpick.go, and the refusal naming the version to install goes to the Log), and a command that needs a connection's values with no connection chosen refuses in the Log rather than running with an empty {db}.

@file and @dir are how a command asks for what it cannot name in advance (ACTIONSPEC.md 8.7, 8.8 and 9.7): the shipped restore action declares @file dump, and running it opens a picker over the WORKSPACE's files, newest first, so the dump a backup just wrote is the first row; the dump-objects starter declares @dir out and asks with the workspace's directories instead, the root first and a New directory... row for one that does not exist yet: the typed path is asked about ("Create x/y?") and created only on the yes, never in silence. Typing filters, Escape runs nothing, and the picked path becomes {dump} (or {out}) in the command, the @env values, the hooks and the @confirm, which is what lets the question name the very file about to be restored. The picker is Monaco's quick pick, the same widget every other picker in PlumeSQL is.

@var (ACTIONSPEC.md 8.9) opens one more name the same way, from one of two sources. With = it DECLARES the value: @var tree={out}/{db}-objects names the path once, and the command, the @confirm and the alert all say {tree}, so the dialogs can never drift from what the command actually does; the value may use the registry's placeholders and this file's inputs, never another @var and never {password}, and a declared name nothing reads is reported. The shipped backup, dump-schema, backup-globals and dump-objects starters all name their output file this way. Without = it ASKS: @var jobs How many parallel jobs? puts a field in the command's input form, the same one form every asked input of the command opens in (the section "Asking for a value: the input form" above: a file and a directory field carry Browse, a @var a field of its own, Cmd+Enter runs, Escape runs nothing); the form's footer says that the values are visible where the file spends them, because a typed value lands in the command line, and a password never belongs in one. A command whose inputs are files and directories alone opens their pickers directly. The declared values resolve on the page, one substitution step deep: inputs fill in, the registry placeholders inside the value keep riding to the server like every other.

The dump-objects starter is also the first action whose command is PlumeSQL itself: plumesql dump-tree (a CLI subcommand, internal/dumptree) writes every schema object into <schema>/<type>/<name>.sql, byte for byte as pg_restore prints it, cut at the TOC headers the tool writes between objects. In an action, plumesql as the command resolves to the running binary itself, so it works from the desktop app without any PATH entry.

The completion hooks are two families of three (ACTIONSPEC.md sections 8.10 through 8.15, the family rules in 9.6). @open-on-success, @open-on-failure and @open-on-done open a file in PlumeSQL when the command finishes that way, read against the workspace unless absolute, so a dump lands in a tab rather than in a directory to go looking for; the outcome-blind @open-on-done opens its file only if it exists and stays silent when it does not, while a missing file under the other two goes to the Log. @alert-on-success, @alert-on-failure and @alert-on-done show an ALERT dialog (one OK button, nothing to decide) for the outcome the terminal does not make obvious; a failure alert is the file's own message, and the exit code and output stay in the terminal, where failures already live. An alert (and a @confirm, in either kind of file) may title its own dialog with the spec's two-parameter value form, "<title>", "<text>" (ACTIONSPEC.md 6.4); otherwise the action's name titles it. All six are expanded server side, titles included, because they may name the connection's own values. The run itself is in the Log whatever the outcome: every command and query an action executes on a gesture is written there, with what ran and where. And when a command ends while its tab is NOT the one on screen, the exit lands there too (code and duration), the corner card shows it, and the row's Show the run button leads back; a run watched to its end writes nothing, because the terminal already said it.

What @hide is for, and what it is not for ​

A column a button NEEDS but nobody wants to read. $column takes its value from the result set, so the column has to be selected; @hide keeps it out of the grid. That is the only reason to reach for it: a column nothing uses is a column to leave out of the query, and no shipped action hides anything.

Before @on this was also how a button was given a value the user was looking at anyway: hide the column, then label a button after it. When the column is worth reading, @on is the shorter answer and @hide is not involved.

The file and its view ​

An action is two things on screen, and they are told apart on purpose.

The FILE is a script tab like any other, opened by a click on the row: a session, Run running the buffer exactly as any script's (the selection rule included), Messages, Problems, the dirty mark, Ctrl+S, auto save, the stale write guard, the close prompt, the external change watch. For a command action the file tab is the command file's, with its own grammar. Nothing about editing an action is new.

The VIEW is the action's ANSWER, a tab of its own: for a query action a RESULT DOCUMENT (the tab a script's result opens in, or a browsed table: the same grid, trail, Compare With, foreign key links and row editing) carrying what the file's header declares (@refresh, @hide, the buttons, @confirm, the placeholders) and a header naming the action; for a command action the terminal watching its program. It opens from the doors that mean "do this": Run Extension in the file tab's toolbar (the bolt, before Run on a query action's, the one button of a command action's) and in the Explorer row's menu; the title bar's @toolbar button; the Go to picker; the run keybinding (F5) with the view active. View Source, beside it in the row's menu, is the way to the file whatever the row's click does. The view opens BESIDE the file's tab when that tab is open (right after it, so the pair reads as a pair in the strip), and the bolt FILLS while the view is open (the icon's state, the top bar's layout buttons' own way, never a pressed chip, which would read as an on/off switch): filled, its click leads to the view and asks a query again, and the view's own X closes it. The bolt never closes the view and never stops a command: while the bolt is in front the view is not, so a bolt that toggled could never lead to the answer, and a stop belongs where the process is, the view's stop button and the tab's close.

The two never disagree about what was asked, by one rule: the view asks the file as SAVED, read again at every refresh, never the buffer. What is typed and not yet saved is not what the view asks, which the file tab's dirty mark already says, and a save is what moves the view's next answer. The view's Edit opens the file's own tab, never a second buffer. The view runs on the connection its door came with (the file tab's, since a file remembers its connection like any script; the active tab's for a title bar button), and its own readout says which and changes it, unless the file names one (@connection, below).

What a click opens: @face ​

A click on an action's row opens the FILE, like a click on any file, and the view is one door further. A file that is a dashboard, whose text nobody needs to see, may say otherwise:

sql
-- @description What every backend on this database is doing
-- @face result
-- @toolbar Activity
select ...

@face result makes the row click (and Enter on the row) open the view; @face source is the default, spelled out. For a command action result means the click RUNS the program, so such a file should carry a @confirm. Only the take-me-there gestures read it: Run Extension, View Source, the @toolbar button and the Go to picker's Actions section do what they say regardless (ACTIONSPEC.md 8.24). The tree reads the head of the file, so the change shows the moment the file is saved. Every shipped query starter carries @face result: a dashboard is what they are.

A connection of the file's own: @connection ​

An action that belongs to one server says so, and then runs there from every door, whatever tab it was asked from:

sql
-- @description Replication lag on the primary
-- @connection production
-- @refresh 10s
select ...

The value is a connection's ID exactly, or else its display name as it reads in the connections tree, case aside. It is resolved when the action runs, never stored: a renamed connection stops matching, and the file says which name it wanted. A name none carries puts the reason in the Log and leaves the view on a calm line ("This action names the connection "production", and no connection carries that name") with Try again.

A name is not an identity, so two connections may carry it (the usual case: the same database in the Vault and as a team share). The action then runs on the FIRST, in the tree's order (Vault, Shared, Discovered, then as the tree lists them), and the Log says so once per gesture ("2 connections are named "production"; ran on the first, Vault › production (a1b2…). Put the ID in the file to say which") with Use the ID on the line, which rewrites the file's @connection line to the ID (read on the click, written only when it changes, logged in turn). The ID is the connection's permanent identity (never changes with the parameters): the connection editor shows it under the name with a Copy ID link, the tree row's menu has Copy Connection ID, and the editor completes @connection with names, or with IDs (name and kind beside each) where a name repeats. The connection editor also says, under the name, when another connection already carries it. The view's readout shows the connection with no picker behind it, and a command action with @connection never asks which database it means (ACTIONSPEC.md 8.25).

One icon per verb, on every surface: the BOLT asks an action, wherever it is asked from (the file tab's Run Extension, the view's own toolbar and header, a title bar button with no icon of its own); the PLAY runs a SQL buffer as a script (the script tab's Run, and the Run beside the bolt on a query action's file tab); the circular arrows refresh a document (a result document's, a query action's view included, which asks its file again).

A COMMAND action's view exists once its program has run: its answer IS the terminal, so before there is a program there is nothing to show, and Run Extension starts one. A restart empties it again, since a PTY does not survive one.

The tab IS the process. Closing the tab while its program still runs stops the program (its whole process group), and asks first, in the dirty file's own shape: "Stop pg_restore? pg_restore is still running on dvdrental. Closing the tab stops it; whatever it has done so far stays done", Stop and close, or Cancel, which keeps both. Close Others, Close All and the other bulk closes ask once for every running tab they would take. The stop is logged ("Stopped pg_restore on dvdrental: the tab was closed") and never raises the file's failure alert, since the outcome was asked for. A long dump or restore therefore means keeping its tab open, and nothing outlives the application either: every terminal is killed when PlumeSQL exits, exactly as psql dies with the terminal window it was started in. A stop sends the program Ctrl+C first (SIGINT), which psql, pg_dump and pg_restore answer by cancelling the statement they are running, so the backend stops now rather than when it next notices its client is gone; then the hangup, then a kill for a program that ignores both. While the program runs its view's Run button is Stop, behind the same question. A running program is never reaped for being unwatched: the tab may be anywhere, the page may show another, and the program keeps running until it ends or is stopped; its completion hooks fire when it ends, whichever tab is on screen (a tab off screen asks the server every two seconds).

Extensions for the file's results: @extension ​

A query action (or a plain script) shapes its own result grid by naming the grid extensions it wants. WHERE the line sits sets its reach. In the file's header (with @description and friends, a blank line before the first query) it applies to EVERY result the file produces:

sql
-- @description Sales by month
-- @extension money
-- @extension bar-chart

select month, amount from sales order by month;

Directly ABOVE one query (in that query's own comment block, no blank line between) it applies to THAT query's result only, which is how one script can try a different extension on each of several queries:

sql
-- @extension booleans
select active, name from users;

-- @extension bar-chart
select month, amount from sales order by month;

Either way the extension's formatting rules and result views attach whatever the extension's own scope says. The value is a bare NAME (the extension's file name without .plumesql.js, e.g. bar-chart) or its store path ($global/, $scripts/, $workspace/, or a relative ./); typing -- @extension completes from the loaded extensions, and Go to Definition (F12, or Cmd/Ctrl-click) on the name opens the extension's source file. A name that matches no loaded extension is flagged as an error, squiggled under the name and listed in the Problems tab (an ambiguous bare name, one two loaded files share, the same), so a typo does not fail silently; the error clears the moment the name resolves. Several are several lines, or one comma separated. as <title> after a name titles the extension's view tab for that scope, so two charts from one extension, or one with a telling name, read as what they show:

sql
-- @extension sanddance as Flights by hour
-- @extension chartjs:Chart as Delays, "$global/Extensions/Charts/uplot.plumesql.js" as "Load, by minute"
select dep_hour, duration_min, delay_min from flights;

:<tab> picks one view of an extension that declares several (without it every view of the extension takes the title), a title holding a comma goes in quotes, and the query's own line wins over the header's. The same as works on -- @view. It is the file's side of the attachment a grid extension's own // @for / // @query markers make from theirs: writing it here means a general purpose extension shared between scripts never has to list them. The result grid's Extensions menu writes the line for you: For this query puts it above the query, Always for <file> in the header; Scope to this query / directory still scope the extension itself. Grid extensions and their scope are docs/GRIDRULES.md; the annotation is docs/ACTIONSPEC.md 8.29.

Opening on a view: @view, and its inputs: @inputs ​

Sometimes the chart IS the point and the grid is noise. -- @view names the result view a run opens on: the extension applies as @extension would, and after Run its tab is the one showing, the grid a click away. Add only and the grid tab is hidden for that result (Messages and Problems stay); add beside instead and the run opens SIDE BY SIDE, the grid on the left and the view on the right, the results pane's layout toggle flipped for you once (it is yours to flip back; only with beside is a problem, one hides what the other stands beside). The side by side layout is the same one that pairs the grid with its messages: the left is always the grid, the right whichever tab you click, Messages or any view, and the sash between them is remembered per workspace. The view's own menu (the header's dots, a right-click in the chart) has Beside the Grid, one checkable row: it writes or removes the beside word on this query's -- @view line (adding the line when the query has none, retiring an only that stood there) and shows the result at once, so the script and the pane never disagree; the grid's Extensions menu offers the same as Open beside the grid for this query. :<tab> picks one view of an extension that declares several. -- @inputs answers the view's inputs in the file (which column is X, the series, how many rows), so the chart draws with no dialog; a pair runs to the next key=, so a comma list needs no quotes:

sql
-- @view plot-focus only
-- @inputs x=ts, series=signal_a,signal_b, top=1000000
select ts, signal_a, signal_b from readings order by ts;

Both sit where @extension sits and reach as far: above a query, that query; in the file's header, every result. The @inputs values are the view's defaults for that query (a pick made in the dialog still wins, but only a CHANGE is a pick: a field left as the dialog showed it, blank included, stores nothing, so a line added later is never shadowed by a blank; and a query that answers in the file inherits no picks from another query; the dialog's Reset restores the file's values); a view with a required input still open asks for it first and draws on Run. -- @view completes from the loaded extensions like -- @extension , an unknown name is the same error in Problems, and the result grid's Extensions picker writes the line for you (Open on its view for this query / for <file>). With @refresh this is a live chart that needs no hand at all. The annotations are docs/ACTIONSPEC.md 8.30 and 8.31.

The configure dialog writes @inputs too, with your permission. Picks made in the dialog are otherwise remembered per view and per query (the query's text, so re-running it anywhere reuses them; an edited query is a new query) only while the app runs, invisibly. Tick Save in the script and Run puts every answered input above the query as one -- @inputs line (a long set wraps over several, a value with a comma or an = is quoted), merged into the query's own @inputs lines when it already carries some. @inputs is one namespace per query shared by every view in scope (a key no view declares is ignored, so two views with an x read the same x): a second view's picks keep the first view's keys and rewrite only their own. From then on the file is the source of truth for that query: the session's picks for it are dropped, the view draws from the line without asking (it IS the "don't ask again": the dialog has no separate flag for that where a script exists; a view promoted to a tab of its own, with no script, keeps a Don't ask again checkbox in the session instead), a hand edit of the line is what the view draws next, and the dialog opens pre-filled from it, with the checkbox already ticked so a change lands back in the file. The line is written into the buffer and saved when the buffer was clean; unsaved edits stay yours to save.

The same line answers the query's own PARAMETERS. A script that carries $1, $name, {name} or :name asks for the values in the run parameters form before it runs (see "Run parameters" in docs/EDITOR.md where it exists, and README); -- @inputs id=42, since=2026-01-01 above the query binds them without the form ($1 is written as 1=42 or $1=42), and the form's own Save in the script writes the values you typed there, one line above each statement that uses them. A statement's line answers that statement; the header's every statement.

When the form opens is the script's to say, with two more annotations, each reaching like @inputs (above a query, that query; in the header, every query; the query's line wins):

  • -- @ask asks EVERY time, the values prefilled from @inputs, the defaults and what the tab remembers, to confirm or change them; -- @ask since, until asks for those inputs alone and takes the rest from the line. For a report you want to look at before it runs.
  • -- @noask never asks: a view draws from @inputs and its defaults even with a required input open (its render sees it unanswered), and a run whose parameter has no value REFUSES with a Log entry naming it, never a silent NULL. For a dashboard on @refresh or a script that must not stop at a dialog.

Without either word the extension's own ask (docs/RESULTVIEWS.md, "Inputs") decides, and without that the form opens on a view's first draw. Both forms carry the same control: Next time ("ask when something is missing", "always ask", "never ask") writes @ask / @noask above the query or takes them away, and Problems flags an unknown key on @ask, an @ask and @noask on one query, and an @noask over a parameter that has no @inputs value.

The editor helps with the line. On -- @inputs the keys the views in scope declare complete (and the query's parameters with them) (inserting key=, each with its label, kind and which view asks for it), after key= the values do (a column input from the query's last result's columns, a choice from its options, the default), a multi-column list keeps completing columns after its commas, and a key explains itself on hover. Problems checks the line where a view is in scope: a key no view declares is a warning (the view ignores it), a choice value outside its options or a non-number for a number input is an error, and a column the last result does not have is a warning. A line with no view in scope is left alone (the view may attach itself through its own @for / @query).

An action for a kind of object: @for ​

An action can belong to a KIND of object instead of to the actions list alone: @for table puts it in the row menu of every table in the object tree and in the dots menu of every open table's header (as Run <action>, directly in the menu while there are up to five, in an Actions submenu beyond that), and opens it ON that table. While a table's DDL tab, or the result document browsing its rows, is the active tab, the title bar shows the same actions as a group of pins of its own after the standing ones: a hairline, the table's name, then a pin per action; the group leaves with the tab. The file then reads the object it was opened on through four placeholders, {schema}, {name}, {object} (the qualified name, quoted as SQL needs it) and {type}, bound like every other placeholder: a query never pastes the name into its text, a command gets it as one argument. Several types read as @for table, materialized view; relation and routine name a whole family. The extensions' words name what they manage, under the dictionary type (so @for table reaches a hypertable and a distributed table too, and @for hypertable reaches hypertables alone): TimescaleDB's hypertable and continuous aggregate, Citus's distributed table and reference table; the object tree and the hover lead with those words on such a row. The starters' Objects folder carries four: the size, rows and maintenance state of a table, the calls of a routine, where a sequence stands, and a pg_dump of one table. The TimescaleDB folder carries six more on what the extension manages: a hypertable's chunks, its compression and its policies (the policies one attaches to a continuous aggregate too, translating the aggregate's name to its materialization hypertable), and three that write and ask first, compressing every chunk not yet compressed, adding a compression policy and adding a retention policy, the last two with the age asked in a form. The Citus folder carries the shards of the distributed or reference table it was opened on, beside three the folder answers without an object: every table the extension manages (each opening its shards), the cluster's nodes, and the moves the rebalancer would make. The pg_cron folder lists the scheduled jobs (each opening its runs) and the recent runs of all of them, and schedules or removes a job from a form; the pg_partman folder lists the partition sets (each opening its partitions), runs the maintenance and sets a set's retention, the extension's schema asked in the form and inlined as an identifier, since an installer chooses it.

One view per action AND object: the same question about two tables is two tabs, each titled with its object, each refreshing its own. Opened from the actions list instead (the file is still an action there), the object placeholders have no value and the server answers the query as it would any statement with a missing value; the editor flags a file that uses them without @for, because nothing could ever supply them. The specification is docs/ACTIONSPEC.md 8.28 and 9.10.

With nothing connected ​

Clicking an action is asking for its answer, so an action with no connection asks the only question it cannot answer itself: the view opens on a line saying so ("This action needs a connection to ask") with Pick a connection beside it, the same picker the view's readout opens, and answering it runs the action. A tab that cannot go anywhere is worse than one that says what it needs.

A COMMAND action asks the same question in the same place, from its own Run: the picker opens, and answering it starts the program. Any other door (the title bar button, the row menu's Run, F5) that reaches a command needing a connection with none chosen refuses and says so in the Log, rather than running with an empty {db}. A command that asks for nothing from a connection (psql --version, a directory listing) is never asked which database it means.

On the user's GESTURE only: the row clicked, the Go to picker, Run again. A restored tab that cannot run sits there with its line and waits, because startup opens nothing the user did not leave open and steals no focus, and a picker over a window that has just come up is exactly that. Clicking the row of a tab that is already open asks again, since that click is the same request as the first one.

Reading a value ​

A cell's full value has two faces. Space (or the cell menu's View value) PEEKS it: a dialog over the grid, Wrap and Copy, gone on Escape, for a glance. Open in Tab (the peek's button, the cell menu's Open Value in Tab, Shift+Enter on the cell) opens it as a tab of its own: a read-only editor with the language the column's type picks (json and jsonb, and any text that parses as JSON, get the JSON grammar, with a Pretty print toggle; everything else is plain text, wrapped), folding, find and a whole tab's room, for a value that has to be READ. The tab is bound to the result it came from the way a foreign key browse is bound to its grid: Go to Cell (the header's button, or the source line under the name) brings the result's tab to the front with that very cell selected; its Refresh reads the same place again (the row index and the column name, a place rather than a row, the grid state's own rule), and a query run again since the read is said in the line, never applied under you. A result that is gone (the tab closed, the query run again without the column, the row past the end) makes the value tab DISCONNECTED the moment it happens: the tab wears the broken link, the line says so in amber and keeps the value as it was read, Go to Cell and Refresh wait, and Close is offered; run the query again and Refresh reconnects to the place. Nothing closes on its own. Over a result document the tab survives a restart and reads its place once the document has rows again; over a script tab's result it does not, since that result is not restored either.

When the connection does not answer ​

A dial that fails is not an answer to the action's question, it is the CONNECTION being broken, and a broken connection is state, not a message (AGENTS.md): the tab says so calmly, <connection> is not answering. with Try again and Pick another, nothing beneath it pretends to run, and the verbatim words go to the Log where every failure's words are. A @refresh timer keeps trying (giving up on a database is always temporary), and its retries add a Log entry only when what is wrong CHANGES: the same dial error or the same statement error every three seconds is one entry, not a panel that opens itself every three seconds. It used to print the driver's whole dial report into the view, which is the one thing errors are not supposed to do here. A connection that dies MID-run (a FATAL from a stopping server, a killed backend) folds through the same state: the tab breaks its link and the words go to the Log, exactly as a script tab has always done, never presented as if the question had been answered.

The attempt counts as a Test, in both directions and on every surface: a failed dial marks the connection not available exactly as a failed Test does (sidebar dot, tooltip, the object browser's header), and the next run that gets through clears it exactly as a successful one does. That is the standing rule for every real contact with a database, and an action's own connection is not an exception to it.

What the result tab tells you ​

The connection first: its own color, its mark and its full url, on the line above the grid and in the footer's readout, because a grid with a terminate button on every row must never leave which server it is talking to to a nickname. Clicking it changes the connection.

Above it, the header every document-like tab wears (ViewHeader, the DDL viewer's first): the action's icon, its name with ACTION beside it, its @description under that (or the trail, once the tab has been somewhere), and its actions on the right as icon buttons. Back and Forward only while there is a trail, then Refresh and Edit (the file's own tab), and the three dots holding every one of them plus Copy Command. Primary ones inline, all of them in the menu: growth costs a row in a list and never a redesign, which is the bargain the DDL viewer's header and the side panels' headers already make.

A row action's click is recorded in the Log (what ran, with which values, on which connection, and the server's command tag), because cancelling somebody's query is the kind of act a chronology is owed. A gesture's run of the action itself is recorded the same way (the actions.logQuery switch, the SQL and its bound values riding the entry as code). A run that FAILS is an ERROR, and errors have one home: the verbatim words go to the Log, which reveals itself, exactly as every other failure in the app does, on every gesture; the refresh timer's retries add an entry only when what is wrong CHANGES, or a failing 3s timer would take the panel over every three seconds for the same news. The view never shows the error text (an action is deliberately NOT part of the script tabs' statement-error exception): it shows the calm failed STATE, "The run failed; the error is in the Log.", with Try again beside it, and a row button's failure leaves the grid standing untouched. Nothing else about an action is announced: a refreshed grid is its own answer.

The title bar button ​

An action can put a button in the title bar, where the Activity panel's and Top queries' buttons used to be: one click from any tab, whatever is open. Those two are actions now, which is what the title bar is for. The button DOES the action: a query action is asked, a command action is run, and its tab comes up with the terminal watching it. The file asked for this button, so the click does the thing rather than opening the file that describes it.

@toolbar [label] in the file is the only way to get one, and the label comes from there too (empty is fine: the file name without its extension is a good name for a button). A button up there is part of what an action IS, so it is declared where everything else about the action is declared, in one line anybody can read, copy and version. The row menu's Pin to (a submenu of every place, the current one checked: the title bar's start or end, either strip at its top, below its tabs or at its bottom, the status bar, the editor toolbar, the result header) and Unpin from Toolbar are editors of exactly that line, nothing more, and the file stays the one answer, sent to a colleague pin and all; picking another place for a pinned file MOVES its line's place word and keeps its icon and name. The pin's own right-click menu carries the same entries, and the "…" menu of a crowded cluster checks the action whose tab is active. Unpinning never deletes the line (it may carry a drawn icon, a chosen name): it disarms it in place with the dash that turns any annotation into prose (-- - @toolbar ...), and pinning re-arms a disarmed line when one waits, icon and all; only a file with none gets a fresh bare @toolbar after its @description. An open tab takes the edit in its buffer (undo works); a clean one is saved at once, a dirty one keeps your unsaved work unsaved and the line waits for your own save.

The line can also DRAW the button, and it is written in the order the button is DRAWN: the icon, then the name. All three of these are complete answers:

sql
-- @toolbar Activity
-- @toolbar <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
-- @toolbar <svg viewBox="0 0 24 24" ...>...</svg> Activity

A first word can say WHERE the button stands, before the icon and the name: start (the default: the title bar's left cluster, after the traffic lights on macOS), end (the title bar's right end, before the layout buttons), left or right (the side strip of that dock, above its tabs; left-top and right-top say the same), left-below or right-below (that strip, under its tabs), left-bottom or right-bottom (the foot of that strip, above the app menu), statusbar (a chip in the status bar, before its connection readout), editor (the active script's toolbar, right after its connection, so the action runs where the script does) and results (the first result's header, beside its row count). At results an action that is also @for table (or any object type) becomes the result's own tool: the header offers it for the tables the statement reads, the ones its @for names, one click when the query reads one table and a pick among them when it reads several, the {schema} and {name} placeholders filled from the one picked. The Table details and Table indexes starters are pinned there, so the tables behind any result are one click from their state and their indexes. In a strip the button is the icon alone (a bolt when the line drew none) and the name is its tooltip; it is pressed while its tab is the active one, as in the title bar. Every place clips what does not fit and offers the rest behind a "…" button, so a long list never runs over anything:

sql
-- @toolbar end <svg ...>...</svg> Locks
-- @toolbar left-top <svg ...>...</svg> Activity

The button opens the action in the PREVIEW tab slot, the way a click on a file does: the next preview replaces it. A second click on the button while its tab is the active one keeps the tab (the double click's meaning), and so does the row's own double click.

A name alone gives the button its words, an icon alone gives it the icon (the file name is still its tooltip and its accessible name), and both give both, in that order on screen. A name written on the other side of the icon is taken as well, since the icon is unmistakable, but the file then reads in one order and the button in another, which is why it is not the form documented here. currentColor is the way to follow the title bar's own colour, and PlumeSQL sizes the icon: what a file may say is the SHAPE, never how much room it takes.

The icon is rebuilt, never passed through. An action file travels: it is copied out of a message, pasted from a page, kept in a repository, and markup rendered as received would hand every one of those a <script>, an onload=, a <foreignObject> full of HTML and a stylesheet that can move anything on screen. So the markup is read and an icon is REASSEMBLED from what was recognised: svg, g, path, circle, ellipse, rect, line, polyline, polygon, each with the drawing attributes only. There is no filter to get wrong, because nothing passes through; an element this reader does not know is dropped whole, contents and all. An @toolbar line whose icon draws nothing after that is reported, and the button falls back to its words (toolbarIcon in web/src/lib/actions.ts, tested in web/test/actions.test.ts).

Two of the shipped actions arrive with one: current activity draws a pulse line, blocking locks a padlock.

There was a gesture for this once, Pin to Toolbar, recording the answer in a setting that could contradict the file in either direction. Two answers to one question is one answer too many: the file says it now, and nothing else does.

A color of its own ​

@color red gives an action a color, and the color rides every surface that stands for the action, in the strength that surface can carry. Its row in the Explorer tints the lettermark AND the name (a tinted mark alone disappeared at row size; the name is what the eye reads). Its title bar button becomes a chip: the tinted words over a faint wash of the same color, deepening on hover. Its tab wears the tint the way a connection-colored tab does, in the MARK alone: the lettermark box painted with it, or the glyph tinted where the tab has no lettermark (which is also what the status bar's tab readout shows); the tab's face and edges stay every tab's, and while the tab is active the tint continues as a hairline across the content's top edge, over both faces, so the color does not end at the tab's bottom border. The result header's icon box is washed with it as well. One line in the file, exactly like @toolbar, because the color is part of what the action IS: the file says it, it travels with the file, and no gesture writes it.

The vocabulary is the spec's ten NAMES (ACTIONSPEC.md 8.5), never a value of the file's own, and PlumeSQL paints them with the connection palette's hues (ConnectionEditorView.svelte), so an action's red and a connection's red are one red: mid saturation, mid lightness, readable on both themes. What a color is FOR is telling actions apart before their names are read, above all the careful ones: a restore marked red is red on its row, red in the title bar and red as a tab. It is a signal and never a guard; the annotation that stops a run is @confirm.

Tabs were already tinted by the CONNECTION they effectively run on (a script tab's painted mark says which database it touches), so an action tab can have two colors asking for one surface. The rule: identity beats assignment. The tab IS the action, so the action's @color wins the tab; the tab merely RUNS on the connection, whose color keeps every surface that names the connection (the conn line above the grid, the sidebar, the footer readout). An action with no @color keeps the connection's tint on its tab, exactly as before: where the file says nothing, "where does this run" is still the most useful thing a tab edge can say.

Nine of the shipped starters arrive colored, working examples of a scheme worth copying rather than a rule: the restore is red (the careful one: it drops objects first), the two backups and the SQL file run are green (the protective ones, and the one that writes what you hand it), current activity is blue, replication status cyan and idle in transaction lavender (the ones that watch), blocking locks amber (monitoring that means trouble), server settings orange (it writes the server's own configuration). The rest stay plain, because a color that everything wears distinguishes nothing.

Writing one is picking, not typing: @ offers @color where it belongs, the value completes with the ten names, each behind a swatch of its own paint, and the name on a finished line wears the editor's swatch. Clicking the swatch opens Monaco's color picker, and whatever is picked lands in the file as the NEAREST of the ten names, so the picker cannot write anything the vocabulary does not hold (actionColorProvider in web/src/lib/monaco.ts). The engine's own hex detection stays off everywhere (defaultColorDecorators: 'never'): the one swatch in PlumeSQL is the @color name's.

Plain files carry the presentation pair ​

A workspace file or a script is not an action, but its header may still say what it IS: the annotations that describe rather than do, @description, @color, the @format-* family, @readonly, @confirm-writes, @parse, @extension and @connection (the registry rows carrying the files flag), read from the first command's comment run of any .sql file and from the leading # block of any .run file, in the Explorer and both script stores. The guard pair (ACTIONSPEC.md 8.17) may only tighten what the connection allows, never loosen it. @parse (ACTIONSPEC.md 8.23) pins the dynamic parser's mode for the file, overriding the Dynamic parser setting for that file alone; the Problems tab's header writes and clears the line with its mode buttons. @readonly is the REAL thing: the tab's session dials with default_transaction_read_only on and FOLLOWS the head line live (debounced as it is typed, re-checked forced at every run, docs/INTERNALSQL.md), so a write comes back as the server's own statement error in Messages, function bodies included, adding the line locks an already-connected session MID-TRANSACTION too (PostgreSQL permits hardening an open transaction at any point), and removing it frees the session again from the next transaction; the connection readout shows read only while it holds, its tooltip saying when the file is the reason. @confirm-writes goes through the production guard as Ask on both axes, one dialog per run, the dialog naming the line as the reason. Both are enforced where the editor runs; a query action's view keeps its own @confirm. The family (ACTIONSPEC.md 8.16: @format-keywords, @format-commas, @format-indent, @format-width) carries the FILE'S OWN formatter options, one line per option, laid over the Formatting settings at every format, so one script can keep one style and its neighbour another and the style travels with the file; each value completes like the color palette does. The toolbar's File Formatting button edits the lines as one-click picks, and the Formatting Settings button beside it edits the defaults they override. The description sits under the row and is searched by the tree's find, exactly as an action's; the color tints the row's mark and name and the file's tab, where it wins over the connection's tint under the same identity-beats-assignment rule an action tab follows. The tab and the tree row both follow the BUFFER live while the file is open: editing the line moves them together, because two surfaces showing two answers for one file read as the tree failing to notice. Rows of files that are not open read the listing, which now carries the head of every .sql and .run file (capped at 500 files and 4 KB each), and a save re-lists the owning root, so nothing waits for a manual refresh either way. Whether descriptions show is the Explorer's own choice (files.treeDescriptions, the Descriptions row in its pane menu), for actions and plain files alike.

The reading is deliberately LENIENT, the OPPOSITE conformance profile from an action's, and that difference is the whole design: a plain file comes from anywhere (a migration tool's changesets, a dump, an NpgsqlRest routine whose comments carry a vocabulary of their own), so an unknown annotation is somebody else's prose, an action-only annotation is prose too, an unknown color is nothing, and no diagnostic ever fires (parseScriptHead in web/src/lib/actions.ts, parseFileHead in web/src/lib/commands.ts). This is NpgsqlRest's own dialect of the carrier syntax (ACTIONSPEC.md 14.1), and a step toward hosting both vocabularies over one syntax.

The editor helps with exactly what is read and no more: in a plain file's header, @ completes the file names (their docs worded for a file, not an action), @color completes and swatches its palette, the finished line wears the swatch and the picker snaps to the vocabulary as everywhere, and hovering either name explains it. The plain SQL grammar lights exactly this pair as structure; everything else keeps reading as the comment it is, so a script full of some other tool's annotations looks exactly as it always did. A NEW script starts empty, exactly like a new file, with the editor's ghost text; the pair teaches itself through this completion and hover. (Scripts used to start with a short inert note teaching the pair; the reuse rule still recognizes those files as untouched, by shape, isUntouchedScriptNote.)

A script that names its connection ​

A plain .sql file may carry the same @connection line an action can (ACTIONSPEC.md 8.25), an ID or a name, and then its TAB runs there: the connection is assigned the way opening a file assigns one (no dial), and the toolbar's and the status bar's readouts show it and open no picker while the line stands; their tooltip says "The file names this connection (@connection production); remove the line to pick another", which is the one way to pick again. The line is read from the buffer, so typing it, saving it, restoring the tab or a connection appearing later all move the tab, but never a SESSION: a session the user opened on another connection stays until their own next Run, which moves it the way the picker would (the old session closed, the new dialed). A name no connection carries keeps the readout locked with the reason in its tooltip, and Run refuses with the words in the Log; a name several carry runs on the first and the Log says so once, with Use the ID on the line. Disconnect still ends the session; the next Run reconnects where the file says. The editor completes @connection in a plain file exactly as in an action's header.

Getting the starters back ​

The starters seed into Scripts, in a folder of their own, Actions, with a subfolder per feature (Activity, Objects, Citus, TimescaleDB, pg_cron, pg_partman and the rest), so the root of the store stays the user's. The seed runs once per installation and is recorded, so a deleted starter stays deleted: nobody wants a file they threw away reappearing every morning. Restore Starter Extensions, in the Explorer's pane menu, is the way back. It writes the ones that are MISSING by path, directories included, and never touches a file that is there, whatever is in it; what it wrote (or that nothing was missing) goes to the Log.

An installation that kept actions in the stores of earlier releases (actions/, wsactions/<workspace>/ in the global plumesql dir) finds them in Scripts and Workspace Scripts, under their action file names: a one-time bootstrap step moves them (migrate-actions, docs/GLOBALDIR.md) and says so in the Log. A name already taken stays where it was.

Starting one ​

New Query Extension, in the Explorer's menu of any folder (the workspace's, either script store's) and as PlumeSQL: New Query Extension… in the palette (into the shared Scripts store), asks which SHAPE the file starts as, from one list at both doors: Blank, a file that already runs (a @description, a command, a @refresh) under a short header that points at the reference; the examples the reference shows (a dashboard, a table with row buttons, a form); or the reference itself. New Command Extension (the folder's menu, PlumeSQL: New Command Extension in the palette) writes the blank command shape, in # comments, with the rule about the secret. Both land under the extension's file name (action1.plumesql.sql, action1.plumesql.run, numbered when taken), which is what makes them extensions. The headers used to carry the whole specification; a file that already does the thing taught more.

The shapes are data (web/src/lib/actiontemplates.ts), tested to parse clean, and the same the reference shows as examples.

Every annotation in it sits behind a dash on purpose: an annotation is read only when it OPENS its line, so the dash is what keeps the header prose instead of a stack of broken declarations, and the text says so. The rule is taught by the thing obeying it.

The other way in is a starter: the forty-eight shipped actions (thirty-nine queries and nine commands, in a feature folder each: Activity, Performance, Storage, Replication, Settings, Roles, Reference, Schema Management, Backup and Restore, PSQL, Objects, Citus, TimescaleDB, pg_cron and pg_partman) are working examples of every shape this document describes, and copying one in the Explorer is one gesture. Restore Starter Extensions puts back any that were deleted.

The Annotations Reference ​

Everything an action can say, in one place: PlumeSQL: Annotations Reference in the command palette opens a document-like view generated from the registries (the annotations, grouped by the header they belong above, with syntax, scope and the full description; the placeholders; a short "how it works"; and the example shapes, each with New Query Extension from this example). It cannot drift from what the parser reads, because it is built from the same rows. Copy as AI prompt at its top hands the whole format out as plain text, for a prompt to a model that should write actions (the secondary aim of keeping the format small).

Every door to the format leads there: the hover card of any annotation or placeholder ends with a Reference link that lands on that entry (lit for a moment); the editor's Insert Annotation menu ends with Annotations Reference…; the Explorer's pane menu has it beside Restore Starter Actions; the Log line about the starters carries it as a button, and so does every parse problem a command action reports (the button names the annotation). The view takes its place in the navigation trail like Settings or the System page.

New Query Extension in the Explorer asks what shape the file starts as: Blank (a description, a command, a timer, and a short header pointing at the reference), a Dashboard, a Table with row buttons, Ask for values, or the reference itself; PlumeSQL: New Query Extension… in the palette offers the same shapes into the shared Scripts store. The shapes are data (web/src/lib/actiontemplates.ts), tested to parse clean, and the same five the reference shows as examples.

Editing help ​

Typing @ above a command completes the annotations that belong THERE: every annotation of that header and that kind of file (the action's own header of a query offers @description, @refresh, @hide, @toolbar, @color, @face, @connection, @extension, @for, the declarations, @inline, @pick, @confirm, the @format-* family, @readonly, @confirm-writes and @parse; a button's offers @button, @open, @on, @at, @confirm, the declarations, @inline and @pick), each with its syntax and its explanation. @refresh completes intervals; @color its palette (with a swatch); @face its two words; @connection the names of the connections PlumeSQL knows, as the connections tree reads them (the pick replaces the whole name, spaces and all), or their IDs where a name repeats; @hide, @on and @at before|after complete with the columns that action's LAST RUN returned (empty until it has run once, because nothing is guessed). All of it reads this same registry, so the editor cannot offer something the parser would refuse, and none of it appears in a query tab or in a comment that is not above a command (web/src/lib/actionhelp.ts, wired in monaco.ts).

Typing { offers the placeholders, anywhere in either kind of file, because a placeholder is written into a command as often as into a header; each says where it comes from and what it becomes, and hovering one explains it and shows its LIVE value on the file's connection ("Now: legendea"), the clock ones included. The secret can never show: it is not among the values the page holds, by construction. {password} is offered on an @env line and nowhere else: the editor never suggests putting a secret where it would be refused.

The COMMAND LINE of a .run file has help of its own, from a registry of the PostgreSQL client tools (web/src/lib/pgcli.ts, data like the other two registries): the tool names complete at the head of the command (plumesql dump-tree among them), the resolved tool's flags complete once a dash is down, a flag with enumerable values completes them after its = (--format=custom|directory|tar|plain), and hovering a flag explains it, values and all, with a link to the tool's page in the PostgreSQL documentation. The link is versioned to the RESOLVED tool's own release (a docs page describes the binary's flags: pg_restore 18.1 links the 18 docs whatever the server is), falling back to the file's connection's server and then to /docs/current, and it opens in the system browser through the server's docs-only opener (the same door the setting descriptions' links use), because a webview never follows an external link itself. Hovering the TOOL's own word asks the server what would actually run and shows it: the binary the tool matrix picked and why, and the command line with the connection's values filled in. That preview never holds the secret (the password is removed from the value set before expansion, so a {password} stays literal), and a refusal (no suitable pg_dump installed) is shown as the answer, because "this would not run" is exactly what a preview is for (POST /api/actions/command/preview, which starts nothing).

A command action is edited in a grammar of its own (plumesql-command, registered in monaco.ts): # comments whose opening @annotation is a keyword, placeholders wherever they stand, quoted runs, options and the trailing backslash that joins a long command to the next line. It carries the same completions, the same hovers and the same squiggles, from its own parser.

The commands themselves are ordinary SQL in the buffer, so they get everything the editor already does for SQL: colouring, keyword and column completions, the run guard's reading, folding. The grammar is matched to the PostgreSQL version of the file tab's connection, the one the action RUNS on: MERGE lights up as a keyword on a 15 and reads as an ordinary word on a 13. The version comes from what this run has learned or from what the connection has on record; with neither, the full grammar stands, since suggesting too much beats hiding what the server can do.

The annotations are COLOURED too. An action's editor uses its own flavour of the SQL grammar, one that tokenizes @name inside a comment instead of swallowing the line whole (actionsLanguageFor in web/src/lib/pgsql-versions.ts, themed as the annotation token in monaco.ts). A flavour and not a change to the grammar, because a plain script that happens to say -- @param must not light up as if PlumeSQL knew what that meant. They keep the comment's italic: what colours them says "this is structure", what stays italic says "the server ignores it".

What the parser cannot read is MARKED on the line it is on, and every annotation explains itself on hover: hovering a squiggle gives the message first and the annotation's own documentation under it. Both come from the same parser, so the editor cannot say something the file does not mean. The pair costs Monaco's hover contribution, 170 KB of the bundle, taken deliberately (docs/WEIGHT.md). The Log still carries the same problems when the action RUNS: the squiggle is for the writing, the Log for the running.

The Outline shows the annotations ​

The Outline side panel lists a script's annotations with its commands, read from the same buffer as the rows (annotationIndex in web/src/lib/actions.ts, one pass, positions kept). A comment block above the first command that a blank line separates from it is the File header: its annotations sit under that row, always disclosed, because they are the file's own configuration and the block's whole content. A command's docstring annotations sit under the command's row with its other children, first, since they configure what the CTEs and references below describe; the folded row wears an @n count. Each annotation row shows the name, the value as written and a gloss of what the parsers make of it (describeAnnotation): the pin's place, the view tab's title and whether it draws beside the grid, the timer's interval, what an input asks for and how it starts, the connection a @connection resolves to, a color as a swatch. A row nothing reads here is flagged with the warning glyph and says why in its tooltip: an unknown name, a block a blank line detached from every command (Detached annotations), an annotation that belongs in a query or command extension rather than a plain script, a value the parsers would refuse. Click reveals the line; the row's menu goes there, copies the line and opens the Annotations Reference at that entry. The snap bar counts them. The panel reads; it does not yet write (the plan is to configure a file's annotations from here, step by step).

Example ​

sql
-- @description What every backend on this database is doing
-- @refresh 3s
select pid,
       coalesce(usename, '') as usename,
       coalesce(state, '') as state,
       coalesce(query, '') as query
from pg_catalog.pg_stat_activity
where datname = pg_catalog.current_database()
  and pid <> pg_catalog.pg_backend_pid()
order by state = 'active' desc, query_start;

-- @button cancel query
-- @on pid
-- @confirm Cancel the running query on backend $pid?
select pg_catalog.pg_cancel_backend($pid);

-- @button terminate
-- @confirm Terminate backend $pid? Any transaction it holds is rolled back.
-- @at end
select pg_catalog.pg_terminate_backend($pid);

How an action runs ​

A COMMAND action runs a program, not a statement: the server starts the argv on a PTY of its own, with the @env values in its environment and nothing between the file and the process. Its output is not held in the page, it lives in that PTY's scrollback and is replayed on attach, so turning the tab around to the source and back shows the run exactly as it was; nothing of it goes into the session. What the tab shows beside it is the connection it was given, the command that actually ran and the exit code.

A QUERY action runs on a connection of its own: it dials, asks and closes. It never borrows a tab's session, so it cannot join an open transaction, cannot see uncommitted work it did not make, and leaves no backend idle. Everything that makes a large result usable is shared with the results pane (the drain, the spool, the first window inline, the paged reads); the result outlives the connection in the engine's spool store, keyed by the action's path (docs/ARCHITECTURE.md, internal/db/spoolstore.go).

A @refresh timer runs only while the action's tab is the active one and the window is not hidden: nobody is reading a background tab and the database should not be asked. Asking again by hand is always available, timer or no timer: Refresh in the tab's header ("Ask this action again"), or the run keybinding (F5 by default) with that tab active.

PlumeSQL is a hercstack product.