Skip to content

The console line ​

The Log panel ends in one line of JavaScript, the devtools console's idea applied to PlumeSQL's own state: what is on screen (the result, the tab, its connection, the script's outline and parse tree, the connection's object dictionary) is there as plain globals, with completion, and an expression's answer is a Log entry like every other message.

The shape of the API ​

One rule holds everywhere: a property is a view, a method is an action. Anything that can be named is a property with completion (results[123].first_name, db.public.actor.columns.first_name, conn.state); a method exists only where something is done (reveal(), ddl(), fetch(), copy()), and no method takes a name as a string when that name could be a property. The types the editor completes with are generated from the same snapshot the values come from, so completion and runtime never disagree: the result's columns become a row interface, every schema of the dictionary an interface of its objects.

The scope is a view, never the app's state: values are copies and read-only proxies, so an expression cannot move anything except through the few methods named. A connection's password is never on the page and cannot be here either.

The globals ​

They form ONE hierarchy, the way the app owns things: the tab owns its result, its connection, its outline and its parse tree (tab.results, tab.connection, tab.outline, tab.ast), the connection owns its object dictionary (tab.connection.db). The globals below are aliases of these, the short way to type them (results IS tab.results, conn is tab.connection, db is conn.db; the parse tree has no alias; extensions, the loaded extensions, stands on its own, being the app's rather than the tab's); completion from tab. reaches everything. The links are not enumerable, so tab prints as its facts and tab.copy() never drags a result along. The shape is ready for tabs and connections (every open tab, every connection) when they come.

  • results (alias rows): the result on screen, its DATA: an array of row objects addressable by column name (results[123].first_name, results.filter(r => Number(r.amount) > 100); cells are the server's text, null for NULL). results.length is the loaded window (the first 5,000 rows, gathered page by page), results.count the rows stored (every row of the result, unless the spill disk fell under its reserve or the run was cut short) and results.total the whole result's count either way; await results.fetch(from, n) reads a page of rows from the server. A row past the window is still a row: a bare results[1000000] on the line fetches and prints it (one indexed read of the spool), and results[1000000].reveal() scrolls the grid to it (the grid fetches the block). A row past the STORED ones exists only after the disk reserve or a cut short run, and then results[124000000].reveal() answers with WHY, naming the folder and the free space seen, with the reserve setting one click away (a Results Settings button on the row), so what to free is said rather than guessed. results.first, results.middle and results.last are the first, middle and last stored row, views exactly like results[i] (null on an empty result). The array's own methods (filter, map, slice) walk the loaded window alone. results.columns.first_name is { name, type, index, typeOid, tableOid, attnum, editable } (the OIDs and attribute number where the column comes from a real table column, null for an expression), and a column writes the predicates for the grid's filter: .contains(x), .eq(x), .isNull(), .notNull(). results[i].index is the row's index.
  • results.grid: the grid SHOWING the result, every action on it in one place, the data staying on results. results.grid.filter(pred, …) is the server-side filter the column header sets (one per column, over the whole result, so the header, the stats line and the console show one state), DECLARATIVE: what is passed is what stands, results.grid.filter(results.columns.first_name.contains('foster'), results.columns.amount.eq('100')), and results.grid.filter() with nothing clears it. (results.filter(r => …) is JavaScript's Array.filter over the loaded window, answered as a table, the grid untouched.) results.grid.filters is what stands, results.grid.count the rows the grid shows after the filter (null while the server counts); results.grid.reveal(i) scrolls to a row and selects it; results.grid.hide(col, …) and results.grid.show(col, …) are the header menu's column switches (a column or its name; show() with nothing shows every hidden one), hidden columns staying out of Copy all and Save All; printed, the grid is one line of where it stands; results.grid.select() is the grid's own Select All and results.grid.copy() its Copy all: the same clipboard road, the same format settings (results.copyFormat, results.copyHeaders) and the same results.copyMaxRows cap the grid's menu applies, so a copy of a hundred million rows is the first ten thousand, said so in the Log; the call itself answers with nothing, the clipboard's "Copied ..." line being its record (copy(results) is something else: the loaded window as JSON). When only a view shows the result (-- @view … only), the data is there and the grid is not: results.grid.visible is false and every action answers with that, never an error. The filter operators are the grid's own, so a new one there is a new one here.
  • tab: the active tab: id, name, path, root, kind, dirty, size, savedAt, connectionId, statements, and lastRun (at, durationMs, running, results[] with each statement's command, row count, columns and error; printed, a table of the results). tab.run() is the Run button and tab.save() the Save command, both for the active tab (tab.reveal() makes it so); tab.copy() copies the script's text. Printed, a line and a table of its facts.
  • conn: the tab's connection: id, name, host, port, database, user, url (never with the password, which is not on the page), origin, readOnly, state (no connection, not connected, connected, disconnected, the app's own words), tx (idle, in transaction, transaction failed), pid, connectedAt, ageMs, serverVersion, searchPath. conn.connect() dials the tab's session now (the first run would) and conn.disconnect() closes it, the tab's own Disconnect: a call from the line is the user's own gesture; conn.reveal() walks the Connections tree to it; conn.copy() copies the URL. Printed, a line of its state and a table of its facts.
  • outline: the statements the Outline shows, each { n, kind, verb, dangerous, head, label, line, lines, from, to, text, refs, tables, reveal(), copy() }: kind is the run guard's verdict (read, data, schema; exact from libpg-query when the statement parses, the keyword walk otherwise), dangerous what the guard flags, refs every relation or function the statement names, tables those the dictionary knows as relations. Printed, a table (number, kind, statement, tables, line), so outline answers what a long script touches and where it writes before it runs: outline.filter(s => s.kind !== 'read'), outline.filter(s => s.dangerous), outline.flatMap(s => s.tables); outline[results.statement] is the statement the result on screen came from, outline[2].reveal() puts the cursor on the third, select() selects it, run() runs it the editor's own way (the selection, then Run with its guard: the console never runs SQL around the tab). Printed, one statement is a line of what it is, its text as code and its facts under it. The raw parse tree is tab.ast (statements[i], error, copy() the JSON; printed, the statements' top nodes), reachable for tooling, not a global: nothing everyday is done with a raw tree.
  • db: the connection's object dictionary (docs/OBJECTINDEX.md). db.<schema>.<name> is an object with a SHORT top level, what one does with a table rather than everything the index knows: name, schema, type, comment; columns (by name: { name, num, type, def, comment, ref }, printed as a table), usedBy and deps (paths, printed as a table of objects), facts (everything else: oid, path, params, routine, extension, timescale, citus; printed key / value); ddl, its DDL from the snapshot, and script.*, the tree's Script menu as properties (script.select, script.count, script.insert, script.insert.returning, script.insert.conflictUpdate, script.drop ... exactly the menu's list for that object type, from the same objectscripts.ts, so a new template there is a new property here). A script is a VALUE, not an action: printed, it is the Log's code block; await x.script.select.sql is its text; .copy() puts it on the clipboard and .script() opens it in a new unsaved script tab on the object's connection (the menu's Script and Copy Script). Never a run(): the console is not a way to run SQL, the tab is. browse() opens a table or a view in a result document tab (Browse Data), reveal() walks the Objects tree to its row, copy() puts its DDL on the clipboard (what printing shows; the facts are on the page already). db.tables, db.views and db.functions resolve a bare name the way SQL would, along the connection's search path; db.schemas lists the schemas, db.searchPath the path. A routine is under its bare name (the first overload stands for it). An object named like no identifier is reached the JavaScript way, db.public["Order Items"], and completion writes the quotes. PRINTED, a list of objects is a table and one object is its DDL: db.tables draws the tables by search path (name, schema, type, columns, comment; the user's schemas first, the catalog's last), db.public a schema's objects, db and db.schemas the schemas with their counts, and db.tables.actor the CREATE TABLE as the Log's own code block (clamped, More, Copy code), headed by what it is (the value itself, and $_, stays the object). A table in the Log shows log.tableRows rows at first (fifty) with Show more and Show all under it, psql's pager idea; an answer keeps at most a thousand rows and says how many more there were. The rule behind it holds for everything the line prints: Markdown and code over a JSON dump; JSON is the last resort for a shape nothing else fits. An object registers how it prints (printsAs in console.ts) and the line asks there first.
  • copy(x): any value to the clipboard (through the one clipboard road, so the Log records it), and every object the line builds has a .copy() of its own with completion. CELLS copy in the Copy Format setting the grid's own menu uses (tab or comma separated, JSON array or objects, headers per the switch), so a copy reads the same from the grid and from the line: a row (results.last.copy()), rows picked in JavaScript (results.filter(r => …).copy(), results.slice(0, 5).copy(), a fetched page) and the result itself (results.copy(), the first results.copyMaxRows rows like the grid's Copy All; results.grid.copy() stays the grid as painted, the filtered view and the painted columns). What is not cells copies what it IS: a tab its script's text, a connection its URL (never the password), an outline row its statement's text, a dictionary object its DDL, a column its facts as JSON, tab.ast the tree as JSON. The call answers with nothing, the clipboard's "Copied ..." line being its record. save() exists on the result and its grid alone, because a save is the server writing the spool into a file the desktop shell's dialog named (no row passes through the page, no cap): results.save() writes every stored row in the Save Format setting, results.grid.save() is the menu's Save All (the filtered view, the painted columns); rows picked in JavaScript are copied, not saved, and their save() says so. In the browser there is no dialog and save() says that too. $_: the last answer (DevTools' own name, so it needs no help line). help (or help()): the INDEX of the globals, a short table with the topic to read in its last column; a topic is a property with completion, help.results, help.results.columns, help.results.grid, help.tab, help.conn, help.outline, help.ast, help.db (console and $_ have no topic, being JavaScript's and DevTools' own; copy(x) explains itself in the index's row), and prints its own section alone (what it is, every member on a line of its own with a note, one thing to try, the topics under it named), read top down in the Log's width; the hover on a topic says what it is before anything prints. Never one long text and never a name as a string. The topics (HELP_TOPICS in web/src/lib/console.ts) and this section are updated in the SAME change as any global they describe: a new property, method or rename lands in both, or the line answers with a stale word.
  • extensions: every extension PlumeSQL has loaded, one property per file stem: the grid extensions (.plumesql.js files and unsaved drafts, extensions.sanddance), the query extensions (.plumesql.sql, extensions.current_activity) and the command extensions (.plumesql.run, extensions.psql). Printed, a table: name, kind, store path, how many rules and which views (grid), the @for / @query scope in words, whether a grid extension applies to the result on screen, and where a query or command one is pinned. A query or command extension is { kind, name, path, root, description, pinned } and does what its pin does: extensions.current_activity.run() asks it on the active tab's connection (a command runs in a terminal tab), the answer in a preview tab; open() opens the file; pin("statusbar") and unpin() edit its @toolbar line exactly as the Pin to menu does (no place means the title bar's start). A grid extension is { name, path, root, scope, rules[], views[], appliesHere } (a rule: index, kind formatter / computed / companion, match in words, add, priority; a view: tab, inputs keys) and answers the question the Log and Problems never do: extensions.sanddance.explain() prints why it does or does not shape the result on screen, in steps: the road that forces it (a -- @extension or -- @view line above the query or in the header, the grid menu's apply once) or its scope's verdict with the reason (@for not covering this file, @query matching neither the text nor the source table, no marker at all), then a table of its rules (the columns each matches, its when guard, what it formats or which rule outranked it) and of its views. open() opens the file, apply() is the grid menu's "apply here once", await extensions.reload() the header's Reload. results.rules is the other end of the same answer: which rule shapes which column of the result, as the grid's planner decided it. Both read the functions the grid and the view tabs read (app.rulesFor, app.viewsFor, one scope verdict), so the explanation is the grid's own truth. extensions.running is what the extensions have running now, a table: every view alive (a mounted iframe with its library, its rows and, while drawing, the thread's attention: drawing for 12 s or drawn) and every @refresh timer ticking, each row with a kill(); extensions.sanddance.kill() tears down every live view of that extension in every tab. A killed view shows a calm stopped face and is drawn again by Reload, a click on its tab or Run in its dialog; a held timer starts again with Refresh. This reaches what holds memory or the GPU while the page still runs (a view paging 300,000 rows, a chart animating); a render that blocks the thread outright cannot be reached by anything on the page, the console included. The Log's own road to the same stop: the entry a hung render writes after 90 seconds ("the render gave no answer") carries a Stop the view button.
  • console: the line's own. console.log(...), info, debug, warn, error write Log rows (the page's devtools console is not where you are looking), console.table(x) draws a Markdown table like an answer does; the call itself answers with nothing, its line being the record.

An action answers with a short note as the row's message (row 5 revealed in the grid, the help text), one row per call (copy() answers with nothing of its own: the clipboard's "Copied ..." line is its record); only an expression that genuinely yields undefined prints undefined.

A promise the line evaluates to is awaited on its own: results.fetch(0, 5) or db.public.actor.ddl() answers with the rows or the DDL, never with Promise {}. await is needed only INSIDE an expression, where JavaScript itself cannot wait for you: (await results.fetch(0, 5)).length. A line that is not an expression (a const, a loop) runs as statements, and its answer is what it returns.

The line ​

Enter runs (when the suggest widget is not taking it); Up / Down recall the history, kept per workspace in the session (the last hundred, most recent last); a pasted newline becomes a space. The answer lands in the Log as a row whose expression is drawn as the prompt line it was typed on (› help, dim, DevTools' way: no block and no button, the row's Copy takes it with the answer; the entry's expr; a code answer, an object's DDL, is the entry's code, the Log's clamped block), and whose message is the value (a string quoted, a number bare; a row or an array of rows as a TABLE (up to a thousand rows, log.tableRows of them shown at first, Show more for the rest), written as Markdown and drawn by the Markdown viewer, with Copy as Markdown under it for a document or a ticket; any other object or array summarized, its JSON in the detail, capped so a million rows never reach the log file); an evaluation error is a red row that starts dismissed, answered where it was typed, never a takeover. An answer is always brought into view: the panel scrolls to its tail on every line run, however far up it was scrolled (a background entry never moves the scroll). Source console, so the source filter narrows to the conversation.

psql on the line ​

A line that starts with a backslash is one of the editor's psql commands (docs/ARCHITECTURE.md, "psql meta-commands in the editor"): \dt, \d film, \df, \sf f, \sv v, \conninfo, \? and the rest of the list web/src/lib/psqlmeta.ts keeps. The line answers them from the same road the editor uses (the dictionary, the snapshot's files, the connection's facts; nothing is asked of the server), as the Log's code block in psql's own shape, the command as the prompt line. Completion is the editor's: the commands while the backslash is typed, the argument's family from the dictionary, and, in both the editor and the line, the commands on a bare word too (type dt, accept \dt, the backslash is added), psql's muscle memory without the backslash first. A backslash that is no command answers with that and \?. help.psql lists them.

Completion ​

The line completes with the same TypeScript service the extension editors use, over types generated from the screen, but FOCUSED: at the top level the list is the console's own globals and the JavaScript a one-line expression reaches for (Math, JSON, Date, await, new ...), never the DOM (alert, addEventListener, AbortController: there is no page to talk to from here) and never a module or declaration keyword; inside a member access (results.first.) the type's own members stand, and no word of the line itself is ever offered. The line has a language of its own (plumesql-console, the JavaScript grammar) so the filter never touches an extension editor; the allowlist is consoleCompletionAllow in web/src/lib/console.ts, fed by CONSOLE_GLOBALS, the one list of the line's globals (the scope binds by it too).

The line and the editor ​

The line is a Monaco editor of its own, and Monaco keeps ONE keybinding service for every editor on the page: a keybinding added to the line without a context of its own would fire in the SQL editor too (Enter and the arrows did, once). The line's Enter, Up and Down are therefore bound under a context key set on that editor alone, so they resolve there and nowhere else; the SQL editor's keys are untouched whether the line is mounted or not. Which pane has the keyboard is what the app already shows: the focused zone's edge and the focus ring, and the line's own accent rule while it has the caret.

What it is not ​

Not a way to run SQL (that is Run, with its guard, its parser and its Log conventions), not a handle on the app's internals (nothing here mutates state), and not a second extension API: a row here has the same shape a rule's row has in a .plumesql.js, by column name.

PlumeSQL is a hercstack product.