An audit against the code found every doc had drifted. pf_spec.md mostly described behaviour the app no longer has and missed auth, territory, layouts, load plans, segment naming and scope entirely; CLAUDE.md had grown to carry the history as well as the rules, and loads into every session. - CLAUDE.md: rules, invariants and single-definition pointers only, with stale claims corrected (project layout, routes, schema, the pf_ slice columns, the price/volume and footing rules, known issues, deferred). Each section that lost its reasoning links to the spec. - docs/pf_spec.md: rewritten as the current design doc under twelve fixed headings, holding the history and rationale moved out of CLAUDE.md, the code issues found on the way, and a proposed (not built) design for additional measures -- cost, rebates, surcharges -- as rates on a driver. - docs/PERSPECTIVE.md: cut to the cross-app lessons that still hold; the fork and its rebuild are in ui/vendor/README.md. - docs/pf_perspective_options.md: marked as a dated decision record. - ui/vendor/README.md: depth is sent floored at 1, not d + 1; rebuild steps. - ui/README.md: removed (Vite template boilerplate). - Two stale code comments (server.js, Forecast.jsx). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.5 KiB
Perspective — lessons and rules
What we have learned the hard way about configuring, feeding and upgrading
Perspective, written for any app that embeds <perspective-viewer> (pf_app,
dataflow). The pf_app specifics — the vendored 5.4.0 fork, its patch, and how
to rebuild it — live in ui/vendor/README.md; how pf_app uses the viewer day to
day is in CLAUDE.md and pf_spec.md → §The pivot.
Distribution: we use the
@perspective-dev/*packages (https://github.com/perspective-dev/perspective) — not FINOS/OpenJS@finos/perspective. Same engine lineage, but a separate npm scope, release cadence and "Pro" theme set (Pro Dark/Pro Light). Don't mix the scopes.
Perspective is one locked unit
These move together and are pinned together. Bumping one without the others is the source of nearly every Perspective bug we have hit:
- Loader — how the JS/WASM gets into the page (npm
/inline, never a CDN) - Package versions —
client,server,viewer,viewer-datagrid(andviewer-d3fcwhere charts are used) - Data format — Arrow IPC vs JSON rows
apache-arrowon the server, when delivering Arrow — it must write an IPC format the client WASM reads
Pin @perspective-dev/server explicitly. @perspective-dev/client declares
it as "@perspective-dev/server": "" — an empty range, which npm resolves to
latest. Installing an older client pulls whatever server is newest and the WASM
fails to link (Import #8 "env" "psp_opfs_load": function import requires a callable). Five packages, not four.
Load inline, never from a CDN
import perspective from '@perspective-dev/client/inline'
import '@perspective-dev/viewer/inline'
import '@perspective-dev/viewer-datagrid'
import '@perspective-dev/viewer/themes'
The /inline entrypoints bundle the WASM into the Vite build: no runtime fetch,
works behind a firewall, reproducible from the lockfile. The themes CSS is
imported in JS for the same reason, not <link>ed from index.html.
This is not hypothetical — a CDN load took pf_app down on 2026-08-10. The 4.x
CDN bundle resolves its server WASM with
new URL("../../../server/dist/wasm/perspective-server.wasm", import.meta.url),
which from .../client@4.4.0/dist/cdn/ lands on
.../npm/@perspective-dev/server/dist/wasm/perspective-server.wasm — no
version, so jsdelivr serves @latest. The moment a 5.x server published, every
page load linked a 5.x WASM against a 4.4.0 client and threw the LinkError
above. Nobody changed anything. The security framing matters as much as the
outage: an unversioned URL means users execute whatever that package publishes
next, unreviewed. /inline has no runtime fetch to hijack.
Charts versus inline bundling (apps that need d3fc)
pf_app uses the datagrid only and does not carry viewer-d3fc. For an app that
does, the constraint as of 2026-08: viewer-d3fc published nothing past 4.4.1,
while /inline and /themes exist only from 4.5.x. A 4.5.x viewer with a 4.4.1
d3fc does not work — every chart plugin throws get_static_config is not a function (reproduced in isolation, and seen in dataflow's install, where every
chart type but the datagrid fails). So it is inline bundling or d3fc charts,
not both: go coherent 4.4.1 for charts (still unverified that a bundled 4.4.1
suite renders them — check before relying on it), or 5.x without them. Re-check
when d3fc publishes a newer version.
Arrow delivery: three coupled constraints
Arrow IPC is the right format for large, numeric, incrementally-updated tables; JSON rows are simpler and fine for small read-only ones. Delivering Arrow couples three things:
- Numeric type parsers, server-side. pg returns
bigint/numericas strings; coerce them to numbers before encoding, orapache-arrowinfersDictionary<Utf8>instead ofInt/Float64. pf_app: the type parsers inserver.js(oids 20 and 1700). - One record batch. Per-batch encoding builds independent dictionaries, and
the WASM crashes on dictionary-replacement messages. Accumulate all rows and emit
a single batch — pf_app does
tableToIPC(tableFromArrays(columns), 'stream'). The client's "stream" is therefore a chunked download of one batch; nothing renders progressively. apache-arrowversus the client WASM. The client is built against an older arrow major than pf_app's server pins; the pair was verified working, but any arrow bump has to be gated on the ingest smoke test below.
Expression columns are row-level: ratios need a weighted mean
Perspective evaluates every expression against each raw row, then feeds the
result into the column's aggregate; there is no post-aggregate expression stage.
So price: '"revenue" / "qty"' under the default sum sums the per-row
ratios — wrong at every grouped level, with or without split_by. avg gives
the average of ratios, which is also wrong.
Weight the ratio by its own denominator — sum(price_i × qty_i) / sum(qty_i) is
exactly sum(revenue) / sum(qty), at every level of both axes:
expressions: { price: '"revenue" / "qty"' },
aggregates: { price: ['weighted mean', ['qty']] } // note the NESTED array
The nested array matters. Aggregate = string | [string, Array<string>], so
the weight column goes in its own array. The flat ['weighted mean', 'qty'] is
rejected with data did not match any variant of untagged enum Aggregate, which
reads like the feature is missing. Being engine-side, it stays correct across
incremental table.update().
- A sum of a measure (including
if(...)subtotal expressions) → defaultsum. - A ratio, rate, price or per-unit figure → must carry
['weighted mean', ['<denominator>']].
Type inference can silently break expressions. if("Year" == '2026', "Amount", 0) returns 0 on every row if Year was inferred as integer from
numeric-looking strings, with no error, and neither literal form fixes it. Give
period and year columns an explicit string type at table creation.
Theming
The viewer theme is a ViewerConfig field: viewer.restore({ theme: 'Pro Dark' }).
setAttribute('theme', …) does nothing in 5.x — the element observes no such
attribute. Apply it on load and whenever the app toggle changes. viewer.save()
includes theme, so strip it from stored layouts and ignore it when comparing
configs — otherwise restoring a layout reasserts its author's theme.
Layouts: guard every restore against schema drift
Before viewer.restore(cfg), filter the saved columns / group_by /
split_by / sort / filter against the columns the current table actually has
(plus its expressions); a stale layout naming a dropped column otherwise throws.
aggregates needs the same guard — once a layout carries a weighted mean, a
dropped column aborts the whole restore (Could not get dtype for column 'X').
An aggregate names a target column and, in the multi-arg form, a weight column;
validate both and drop the entry, never the layout, since restore() is
all-or-nothing. pf_app's cleanLayout() does all of this; dataflow's does not yet
guard aggregates.
Auto-pause deletes the view — turn it off for a static pivot
<perspective-viewer> auto-pauses by default (an IntersectionObserver on itself
plus the document's visibilitychange). Pausing is not a paint optimisation:
session.set_pause(true) runs view_sub.take().delete(), so the view is
destroyed, and becoming visible again rebuilds it with a full traversal — a
multi-second stall per tab switch on a large table, and any per-node
expand/collapse is lost with it.
await viewer.load(table)
try { if (viewer.setAutoPause) await viewer.setAutoPause(false) } catch {}
Leave it on only where a live stream feeds a table nobody needs kept current while away. Long-standing viewer behaviour, not a regression.
Upgrade smoke test
Run on any change to a Perspective package or apache-arrow (for pf_app, after
rebuilding the vendored tarballs per ui/vendor/README.md):
- All five packages (and
apache-arrow) pinned exact and matching; lockfile committed. vite build— no unresolved imports.- Arrow apps: load a real dataset;
worker.table(buffer)ingests without a dictionary error, and a numeric column comes through asFloat64/Int. - Charts, where used: open a d3fc plugin and confirm it renders.
- Toggle dark/light; the viewer re-themes.
- Save a layout, reload, confirm it restores; drop a column and confirm the cleaned restore does not throw.