Skip to main content

Plots

A Plot is a named, typed computation that a Pane owns. It turns data into the rows a Pane shows: open tickets, a monthly cost total, a category for each support message. Plots exist so the logic behind a number lives in one named, checkable place with a declared input and output, instead of being scattered through display code. A Plot always lives on a Pane. The Pane’s Perception declares each Plot’s contract: the parameters it accepts and the columns and types of the rows it returns. A Pane’s type is the kind of the rows its Plot returns: a Plot that the Pane owns and its Perception does not declare returns rows of that type, any of the type’s fields, as columns of the same names and types, with no parameters. Return a Temporal.Date field as a DATE column and a Temporal.DateTime field as a TIMESTAMP: CAST a day or time held as text in the SQL. SQL implements the Plot on the server; that SQL is the Plot’s implementation, saved with the Parable’s draft and versioned with it. The Pane’s React code renders the result and handles interaction; it calls its Plots by name through the Pane’s generated bindings, for example plots.salesByRegion() to read rows and plotApi.run(‘salesByRegion’) from a button. A Pane can also call the Plots of a Pane it declares, through that Pane’s component, without rendering it. Other Pieces read a Plot by address: a Page or Pane that declares the Plot’s Pane in its Perception and calls the Plot in code, or a Plot whose SQL reads it. The Plots tab lists those Pages and Panes under Read by, and deleting the Plot or its Pane while one of them reads it is refused with those readers named. A Plot that only SQL reads is not listed, and deleting it is not refused. A Plot can read Provider tables, published artifacts, the Panes of its Parable by name, and other Plots, which appear as parables.<parable>.<plot>. A Plot’s SQL reads a Pane’s saved rows by name, as typed rows of the Pane’s type, where <pane> is the snake_case of the Pane’s handle, the rule a new Plot’s slug follows when its author sets none (the Pane salesScope is sales_scope, the Plot revenueForecast is revenue_forecast); a Plot whose slug is a Pane’s name keeps it, and that Pane cannot be read by name until one is renamed. parables.<parable>.<pane> returns one row for each item, with a column for each field of the type, layered for the reader field by field: the reader’s own row, then This Parable’s, then the Parable’s default; a read with no reader, such as a Refresh, layers This Parable and the default only. The value the Pane’s code holds on screen (preferences.current(value)) comes first. parables.<parable>.<pane>_by_actor returns each actor’s own rows, with actor_kind and actor_id, for example every team’s, and the reader’s own person rows only. parables.<parable>.<pane>_data returns the Pane’s own data. A filter on id reads only that item. Fields that no layer sets are null, and the Plot supplies defaults with COALESCE. A Plot never reads workspace.preferences: it holds raw JSON with no type, and the SQL check refuses it (typed-read). A Pane’s override rows (overrides) change only the reader’s live view; a Plot that must count them reads _by_actor. A Pipeline, which a Report stores, reads as its completed output. A Plot has no schedule of its own: Report plans are the only schedule. Any other Plot whose SQL takes no parameters reads as a view of its SQL, so small Plots compose the way SQL views do: one mapping Plot per Provider, then a Plot that combines them. Name every Provider that holds the kind of data a Plot reads, however rare: a Provider the Workspace has not connected reads as an empty table with its declared columns, so the Plot still answers, and its rows appear once the Provider is connected. A stream the Workspace has not turned on, on a Provider it has connected, reads the same way: Slack’s replies, GitHub’s commits and other streams that are off by default are off in many Workspaces, so never assume a stream is on, never ask anyone to turn one on just so a Parable fits, and give every Pane an empty state for no rows. A misspelled table, or a name no stream of the Provider has, still fails as an unknown table. Write it for every permutation of data (see the Parables Power): any column can be null, any mix of Providers can be connected, and two Providers of one kind, such as two HRIS, can describe the same person, so merge their rows rather than count them twice. ImproveParable then tells the reader which Providers would add to what they see, and which need a stream turned on (see the Panes Power). A view’s SQL runs again inside every read that names it, so make the combining Plot a Pipeline, and every categorization and heavy join with it: Parables are product experiences, and a Page must never wait on work a stored output could have done once (see the Pipelines Power). Views can read views but not each other in a cycle, and a view’s Pane Policies apply inside every Plot that reads it. Plot Plugins add model-backed functions a query can call. The Ponder plugin makes one model call for every row that reaches it, so filter first and keep a limit while developing. A live query reads Current data; a Report runs Pipelines and stores their output. A Pane whose type is null has no kind, so a Plot it owns has no contract: the Pane’s code has no plots.salesByRegion and the editor’s Preview reports that the Plot’s contract is unavailable. Declare the Pane’s kind, a named interface in scalar-lib types of the rows the Plot returns, as its type. For a Plot that takes parameters, or returns rows of another shape, import PlotContract from “parable/foundation” and pass the Plot as the second type argument of BasePerception, keyed by its handle: with import type { Generic } from “@psgen/scalar-lib”, export interface SalesByRegion { readonly region: Generic.Text | null; readonly amount: Generic.Float64 } and export interface Perception extends BasePerception<SalesByRegion, { salesByRegion: PlotContract<readonly [], readonly SalesByRegion[]> }> { readonly panes: {} }. The first PlotContract argument is the tuple of SQL parameters and the second is the array of rows of the kind, one field per selected column. A Plot on a Pane whose type is null is refused with PA-AU-049. The Perceptions Power lists the column types. plots.salesByRegion() returns rows, status and rerun. status is “running” while a read is in flight, the first one included, then “ready”. rows is empty until the first answer arrives, so empty rows mean no data only when status is “ready”. A Pane draws three states in this order: reading while status is “running” with no rows yet, empty when status is “ready” with no rows, then the rows. For example: const facts = props.plots.salesByRegion(); if (facts.status === "running" && facts.rows.length === 0) return <p>Reading sales…</p>; if (facts.rows.length === 0) return <p>No sales in this reading.</p>; then draw the rows. Write no error branch: a read that fails never reaches the Pane’s code. The hook retries a short server failure twice, then the host replaces the Pane with an error that names the Plot and offers Retry, which reads it again, “Too many queries are running” (QL-IN-008) included. plotApi.run rejects with the error instead, for the action that called it to handle: the error is an object with a message and a code, so show error.message and never put a plain object in JSX. A Pane that shows its empty state without checking status reports no data during every read. A read that repeats when the window regains focus keeps the rows already shown until its answer arrives; any other repeat clears them first. Read only what a Pane draws: a Pane’s Plot read returns at most 10,000 rows. In this order: first, aggregate in SQL to the grain the Pane shows, with filters, the scope and corrections applied before the GROUP BY, and never send raw rows to add up in the browser. Totals come from COUNT(*) or SUM, never from adding up pages; a reader’s filter Policies remove rows before the aggregate sees them, so the total is exact for every reader and under View as. Second, make heavy work a Pipeline and read its stored output. Third, only for a table, list or drill-in a person browses, read a page at a time: a Plot with parameters that takes the last row shown and returns the rows after it, ordered by columns that end in a unique key, with a fixed LIMIT, for example SELECT item_key, day, title, amount FROM parables.<parable>.<plot> WHERE owner_key = $1 AND ($2 IS NULL OR day < $2 OR (day = $2 AND item_key < $3)) ORDER BY day DESC, item_key DESC LIMIT 200, and read the next page when the person asks for it. Every page is a full read that plans, runs and sorts the whole result again, so use OFFSET only for the first few pages. While the Page reads Latest, pages are not pinned to one Refresh, so rows can move between pages when a new Refresh lands. When a Plot has more rows than 10,000, the Pane gets the first 10,000 and its hook’s truncated is true; the host shows no notice, so the Pane says so, and a Pane that is cut off is a bug to fix with the rules above. When you read Plot data yourself, ask the aggregate question in SQL instead of paging through rows. Write SQL whose cost grows with the data, not with its square. Bound every time range, and never include future instances of calendar or recurring data: a recurring series expands into years of occurrences that have not happened. Join events by a time bucket, the day or the week, together with the person, never against a person’s whole history: a join on the person alone with a time-overlap condition compares every event with every other event that person has. Filter before you expand or cross join: join the short list that decides which rows matter first, then the large table, so an empty list reads nothing. When several stored Plots read the same union, make the union one Pipeline and read its stored output, instead of computing it again inside each of them. Never write a Plot per scope, such as one per team: one Plot reads every team’s rows from parables.<parable>.<pane>_by_actor and groups by actor_id, and a twin repeats the whole computation. Two gotchas. Pools can hold the same record many times. Before you join on a natural key, compare count(*) with count(DISTINCT key). Collapse to one row per key with row_number() OVER (PARTITION BY key ORDER BY <sync time> DESC) and keep that row, so its columns stay together. A join on a duplicated key multiplies the copies on both sides. And one text column holds at most 2 GB in a batch: past that, the read fails with “Offset overflow” and the Report run fails at once with QR-QU-012. Cut long text before joins, such as left(text, 120) for a title, and never gather every row’s text into one group, as a join on a scope id that is the same on every row does. A Plot has no Policies of its own; its Pane’s Policies govern it. Editing a Plot’s SQL, inspecting it, and reading its output are separate acts with separate checks: the plots target covers the SQL, and the output follows the preferences target, with the Pane’s filters narrowing the rows. A Pane’s code changes a Plot’s SQL only by a proposal, propose.change(plot, { sql }), when its Perception lists “plot” in proposes; the reader confirms it, and the change needs the same rights as the edit in the editor, held by both the reader and the Pane’s author. A reader can use an output without seeing its SQL. Parameters are always bound values, never pasted into the SQL text, and they cannot change the requester, the Workspace, or the definition that runs. The requester comes from the trusted execution context, never from an editable email or actor parameter. A restriction that must apply to every query over a Provider table belongs in that table’s Provider Policy, not in one Pane’s Policies. Planning and live execution require active Workspace membership. An invalid filter refuses execution rather than returning unfiltered rows.
  1. State the Plot’s job, its owning Pane, and its result before any SQL: one row per what, which columns, units, nullability, ordering, what an empty result means, and whether a Pipeline stores it. Save the job as the Plot’s description, in one sentence: every Plot needs one, and a save without it is refused.
  2. Find the real tables and columns with Pool search, and the records themselves with Row search, and the functions through the catalog. Do not guess names or signatures, and do not keep a search in a Plot: search_available_data and search_everything run only in interactive queries.
  3. Ask Ponder to write the SQL and declare the contract. Ponder creates the Plot under its owning Pane. Declare the Pane’s kind as its type, with a field for every selected column, and, for a Plot that takes parameters, the Plot in its Perception under the Plot’s handle, in that order or in one save. Ponder previews the Plot, which runs the edited SQL under the same contract and the Pane’s Policies without saving anything, and lists what the Plot will return. Trust columns and types only after a successful plan.
  4. Save the definition. Personal mode saves directly; Publishing mode saves to Changes. Server validation records findings, and an unchecked Plot saves but cannot be published. Select exactly the columns the Perception declares, or for rows of the Pane’s type only columns the type has, never SELECT *: a column it does not declare or the type does not have, a declared column the SQL leaves out, or a column of another type is an error under Warnings & Errors (PA-AU-047), every read of the Plot is refused, and Prepare and Publish refuse it with each column named. Read a Piece’s problems returns each finding of the Plot with its line and column. Fix one with Edit part of a Piece’s source, which answers with the findings after its save. A save uses the revision last read; after a refusal, keep the draft and reload before replacing it.
  5. For a query people build inside the app, Kysely compiles it into SQL plus a separate parameters array; run it with plotApi.preview, which applies the declared Plot’s contract and the Pane’s Policies and saves nothing. SQL is saved only as a Plot’s implementation.
  6. When a Plot feeds a chart, write the finding the chart must make obvious and use it as the title. Keep scales honest: bars start at zero and axis ranges come from the data. Annotate the takeaway inside the chart, use the shared design system’s colors rather than fixed hex values, then render it and look at it with real, empty, and single-point data.
A good Plot runs through the real backend with representative inputs, returns exactly its declared columns, shows errors in the results area, and refuses an invalid input and a denied reader. The result’s metadata names the data that answered, including a resolved Report, and the implementation row and version its SQL came from. SQL written as text, code compiled in the browser, or a Ponder Pane picture is not proof of execution; pictures use captured data and cannot run live SQL. Say what you ran and what remains unverified. Related Powers: Pool search, Row search, Panes, Perceptions, Policies, Preferences, Pipelines, Reports, Palettes. Find each by its title; do not guess stored names.