pf_segment and pf_bucket were resolved at read time through a chain of fallbacks (label, tag, note, the version's unlabeled_load; the version's adjustment defaults), repeated in /agg, the /data cursor and the decorate proc, so nothing but the pivot could say what bucket a row was in. Now they are written onto pf.log when the entry is created: a load's label is required and its bucket defaults to the label; an adjustment is stamped with the version's adjustment_segment/adjustment_bucket, else the built-ins. Reads become l.label / l.bucket. A log_named CHECK guarantees both are present, and 01_schema.sql backfills existing entries with the old read-time rule so no displayed name changes. The Baseline page's "Fallback names" become "Defaults for new adjustments"; unlabeled_load is no longer read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
59 KiB
Pivot Forecast — CLAUDE.md
What this app is
A web app for building named forecast scenarios against any PostgreSQL table. The workflow: load historical actuals as a baseline (optionally date-shifted into the forecast period), then apply incremental adjustments (scale, recode, clone) to build a plan. All changes are append-only, fully audited, and reversible by log entry.
Full spec: pf_spec.md
Data transport architecture options: pf_perspective_options.md
Tech stack
- Backend: Node.js / Express (
server.js) - Database: PostgreSQL — isolated
pfschema - Frontend: React + Vite + Tailwind CSS in
ui/; built output lands inpublic/app/ - Pivot: Perspective (
@perspective-dev/*distribution, not FINOS@finos/perspective) 5.4.0 from a patched build vendored inui/vendor— see its README for what is patched and which host-tool versions the rebuild needs; bundled inline via the/inlineentrypoints — never from a CDN (the 4.x CDN bundle resolves its server WASM to an unversioned path and silently pulls whatever is newest). SeePERSPECTIVE.md. - Dev:
npm run dev(nodemon) in root;npm run buildinui/
Project layout
server.js Express entry point; pg pool; session; type parsers for bigint/numeric
routes/
auth.js POST /api/login, /api/logout, GET /api/me; login throttle
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
sources.js Source registration, col_meta, SQL generation
versions.js Version CRUD, baseline/reference load, data stream, tag completions
operations.js scale, recode, clone, undo — the core forecast ops
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
layouts.js Named pivot layouts — list per version, create, patch, delete
plans.js Planned load segments — list, create, patch, delete
lib/
sql_generator.js buildFilterClause, token substitution helpers
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
utils.js
setup_sql/
01_schema.sql pf schema DDL — run once to install
02_auth.sql pf.app_user + pf.session
ui/src/
auth.jsx AuthProvider/useAuth; wraps fetch so any 401 returns to login
views/
Login.jsx Sign-in form
Setup.jsx DB browser, source registration, col_meta editor
Baseline.jsx Version management, baseline workbench, reference load
Forecast.jsx Perspective pivot, selection handling, operation dispatch
components/
DepthButtons.jsx One axis's collapse control; both axes are drawn from it
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
Sidebar.jsx Forecast list + the 3 steps; collapsible
StatusBar.jsx Forecast name · row count · calculating · theme; source on Setup only
ReadOnlyNotice.jsx The banner over a page a non-admin may read but not change
Timeline.jsx Date-range preview bar for baseline segments
Database schema (pf)
pf.source— registered source tablespf.col_meta— column roles:dimension|value|units|date|filter|ignore;dim_groupgroups functionally dependent columns (e.g. a part and its attributes, or a date and its derived year/month dimensions);dim_period_colmaps a dimension to apf.dim_periodcolumn so date-adjacent values are derived at load time rather than copied raw;in_grainflags dimension/date columns that define the display grain (see below);is_keyis described under §is_keybelowpf.version— named forecast scenarios;exclude_iters(default["reference"]) blocks those iter values from all operationspf.fc_{tname}_{version_id}— one forecast table per version; contains both operational rows (pf_iter = baseline|scale|recode|clone) and reference rows (pf_iter = reference). Indexed onpf_logid, which is how undo, the change-log aggregate and the grain key all find their rows — without it each is a sequential scan of the whole table. Tables created before that index was added do not have it.pf.log— audit log; every write gets one entry;slice+paramsstored as jsonbpf.layout— named Perspective view configs; see §Pivot layoutspf.load_plan— load segment definitions, before and apart from having been run; see §Cloning a version's definitionpf.sql— generated SQL templates per source/operation; tokens substituted at request timepf.app_user— login accounts; scryptpass_hash,is_active,last_login_atpf.session— express-session store (connect-pg-simple layout)pf.dim_period— calendar lookup table (2018–2035); one row per month keyed onsdat(month start date); provides cal/fiscal year, quarter, and month columns; populated bysetup_sql/gen_dim_period.sqlwith a configurable fiscal year start month
is_key
Read in four places, and not the one it sounds like — slices are validated
against filterCols, which is every dimension plus every date column
regardless of is_key.
- The key of a
dim_group—resolveGroup()inroutes/sources.jstakesmembers.find(c => c.is_key), the column every other member is keyed on forpf.dim_member - Value completion — only
is_keycolumns get a dropdown, and/sources/:id/values/:colrefuses anything else - Sibling autofill — fires on blur only when
is_key && dim_group - The
dim_periodanchor —role === 'date' && is_key && dim_grouppicks the date whose siblings are derived from the calendar
Uses 2 and 3 want several columns flagged; use 1 needs exactly one per group.
When a group has more than one, .find() silently takes the lowest opos.
That is not hypothetical: segment_new (opos 12) outranked part (opos 15) in
the part group, so a refresh keyed on a column that is null throughout, matched
nothing, and reported success with zero members. customer's group has four keys
and picks the right one only by opos luck.
The two meanings want separating — a per-group key choice, or a rule that the
group key is the column named by the group (which these groups nearly follow
already, except sdate → sdate_e). Until then, a refresh that finds two keys
should refuse rather than guess.
Key token substitution tokens
{{fc_table}}, {{where_clause}}, {{exclude_clause}}, {{logid}}, {{pf_user}}, {{value_incr}}, {{units_incr}}, {{pct}}, {{set_clause}}, {{scale_factor}}, {{date_offset}}, {{filter_clause}}
Core data flow
Initial load (Forecast view)
Forecast.jsx fetches col_meta first, then picks the endpoint:
- grain mode (any
in_graincolumn) —GET /api/versions/:id/agg, rows pre-aggregated to the grain, table indexed onpf_gkey - raw mode (no grain) —
GET /api/versions/:id/data, raw forecast rows, table indexed onpf_id
Either way: Arrow IPC binary stream → worker.table(buffer) in Perspective WASM. fetchArrow() handles both.
Why one batch (not streaming): pg returns bigint/numeric as strings by default — type parsers in server.js coerce them to numbers. Per-batch Arrow encoding creates independent dictionaries that cause Perspective WASM to crash on dictionary replacement messages. Server accumulates all rows, emits one record batch.
Display grain
Aggregating to the grain the pivot actually displays is the load-time fix — measured 534,902 → 6,154 rows on osm_stack. It keeps the native Perspective engine, so expand/collapse/depth/sort/filter all still work. Set the grain in Setup (in_grain per column); it is baked into pf.sql at Generate SQL time so load and operations agree. grainOf() in lib/sql_generator.js is the single definition of what the grain is — Setup.jsx and routes/log.js mirror it. Full design: pf_spec.md → §Display-grain pre-aggregation. Why not a DuckDB virtual server: pf_perspective_options.md → §Spike findings.
Segment and note columns
/data and /agg both LEFT JOIN pf.log and emit two columns the forecast table
does not itself carry:
pf_segment—pf.log.labelpf_bucket—pf.log.bucketpf_note— the free text on a scale/recode/clone; null on loads
Both names are stored, not derived — see §Every log entry is named.
They are deliberately separate: commingling a segment name with an adjustment note
makes neither pivotable. In grain mode pf_logid is part of the grain, so the join
adds no rows. The operation routes stamp the same two fields onto the rows they push
back incrementally, since those come from RETURNING * and would otherwise arrive
without them.
Column order is stored text
Perspective orders column groups by the value string, and SortDir's col asc /
col desc only reverses that — so Prior Year → Plan → Actual → Forecast is
alphabetical in neither direction and expressible as neither. A "01 - " prefix
is the only lever, and it lives in pf.log.label (and pf.log.bucket),
typed by whoever names the segment. Nothing derives it.
SEGMENT_EXPR / BUCKET_EXPR / NOTE_EXPR in lib/sql_generator.js are the
single definition, shared with the /data cursor in routes/operations.js —
/agg is generated, /data is not, and the two have to agree.
The one hardcoded ordinal is ADJUSTMENT_SEGMENT = '99 - Adjustments', the
name an adjustment is stamped with when its version sets no default, which keeps
those adjustments after every numbered segment. That proviso is the
whole scheme, not a caveat on it: ordering is string ordering, so 99 only lands
last once the loads carry 01–0n, and an unnumbered segment sorts after it
(digits precede letters — 9 is 0x39, A is 0x41). The old '(adjustment)'
sorted first for the same reason read the other way, ( being 0x28.
Relabelling an adjustment's own log row afterwards is how one kind of adjustment
is split out from the rest.
Every log entry is named
pf.log.label is a row's segment and pf.log.bucket what it counts toward, and
every entry carries both — the log_named CHECK refuses one without. So a
report gets the pivot's segment and bucket by joining the forecast table to
pf.log on pf_logid and selecting two columns; there is no rule to copy.
They are stamped when the entry is written:
- A load —
labelis required (the routes answer 400 without one);bucketdefaults to the label.loadNames()inroutes/operations.js. - An adjustment — scale, recode and clone have no name inputs, so they are
stamped with the version's
adjustment_segment/adjustment_bucket, else the built-insADJUSTMENT_SEGMENT=99 - AdjustmentsandADJUSTMENT_BUCKET=04 - Forecast(ADJUSTMENT DEFAULTSinlib/sql_generator.js).adjustmentNames()resolves them once per request; they go into the template's INSERT as{{label}}/{{bucket}}and onto the rows pushed back to the pivot.
The two version defaults are set on the Baseline page under Defaults for new
adjustments. Changing one names the next adjustment; existing entries keep
what they were written with. Renaming past entries is PATCH /api/log/:logid,
one at a time, which renames but refuses to clear.
What this replaced. The names used to be derived at read time: a load's
label, else tag, else note, else the version's unlabeled_load, else
Unlabeled; an adjustment's label, else the version default, else the
constant — read through a join on pf.version. That made the pivot the only
reader that knew what bucket a row was in. The same CASE was copied into
/agg, the /data cursor and rlarp.load_osm_pf in /opt/forecast, and any
direct query had to copy it again. It also meant editing a version's fallback
name silently renamed every past adjustment. The 2026-09-24 backfill in
setup_sql/01_schema.sql stamped the existing entries with that rule; before it
ran, the pre-backfill label/bucket values were saved to
pf.bak_log_names_20260924. pf.version.unlabeled_load is read by nothing now;
the column remains.
What this replaced. The prefix used to be computed client-side, as
Perspective expression columns (pf_bucket_ord, pf_segment_ord) built from
pf.log.seq and pf.version.bucket_order. It ordered the pivot and nothing
else, so every other reader disagreed with it; restore() replaces
expressions wholesale, so it had to be re-applied after every layout load; and
ExprTK's string scanner tests each byte with isprint(), so a label
containing anything outside printable ASCII could not be ordered at all (· is
two bytes, of which isprint(0xC2) is false). DEAD_ORDER_EXPRS in
Forecast.jsx strips the expression names out of layouts saved under that
scheme. pf.log.seq and pf.version.bucket_order are no longer read; the
columns remain.
Relabelling now needs a page reload to show, because the label is part of the
aggregated row rather than something the pivot can re-derive. Editing a label
afterwards is a PATCH /api/log/:logid and needs nothing regenerated, but a
source registered before this change needs Generate SQL run once, so its
stored load templates write label and bucket onto the log row at all.
Forecast operations
POST to /api/versions/:id/{scale|recode|clone} → SQL executed with RETURNING * → new rows returned as JSON → pspTable.update(rows) — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since pf_logid is part of pf_gkey those keys are always new, so update() appends and the view re-sums.
Price or volume (plug)
Price does not move unless it is named. That is the rule the whole section implements, and it is a rule because a price move is invisible in the figure being edited: the dollars land on target whether volume followed or not, and only the implied price says which happened.
A sales figure alone does not say which of price or volume moved, so scale takes
plug — 'volume' (the default: price holds, units scale in proportion) or
'price' (volume holds, price moves). Resolved in resolveIncrs() in
routes/operations.js; the panel only offers it when the edit is dollars-only,
because naming units or price has already answered it, but it stays on screen
greyed out in the other cases rather than vanishing. The semantics come from the
predecessor Excel model, /opt/forecast_api/VBA/fpvt.frm → calc_val /
calc_price:
plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
plug price: fVol = pVol + bVol
A qty input follows the same rule. With dollars unnamed they follow at the
current price rather than holding — value = (curValue / curUnits) * unitsIncr.
Holding them made every qty edit a price edit: 10% more cases at the same revenue
is a 9% price cut nobody asked for. A slice with no units has no price to hold,
so there the dollars do stay put and the edit is a pure qty change.
The two deliberate ways to move price, both of which outrank the above:
- name the dollars and the qty together — price falls out of the two
- name the price (
target_price), which is the Excel form's Edit Price mode: with atarget_unitsalongside, both are inputs and dollars fall out
The ledger's Result line previews value, units and price together using the
same rules, so what you see is what gets written, and the column that cannot move
is labelled · holds.
What this replaced. plug defaulted to 'price' — volume holds, price
absorbs — because that is what the API did before the flag existed. So an
unqualified dollar edit moved price, and a qty edit moved price, and the only
edit that did not was one where somebody had picked 'volume' by hand. The
client's stored preference is keyed pf_scale_plug_v2 for that reason: a browser
carrying the old 'price' choice would have gone on silently moving price.
Undo
DELETE /api/log/:logid → removes rows by logid → table.remove() of the affected index values (pf_gkeys in grain mode, pf_ids in raw mode); the view re-sums. No full reload.
Axis depth (collapse / expand)
Both axes collapse the same way: a depth in ViewConfig, set through
restore().
- Rows —
group_by_depth, driven by theRowsbuttons viaapplyDepth() - Columns —
split_by_depth, driven by theColumnsbuttons viaapplySplitDepth()
Both axes are drawn by components/DepthButtons.jsx from the axis's own field
list (groupFull / splitFull), as a stepper: ◂ partgroup ▸, with the
middle button opening the full level list for a jump rather than a step.
Three controls per axis whatever the depth. It was one button per level, which
grows with the pivot — a 5-by-3 layout rendered ten of them — and the current
level was a blue button among the greys, so reading the state meant scanning.
Before that the row axis was a fixed 0 1 2 3, which named nothing and offered
levels a two-field group_by does not have.
depth is null until the first layout has been read back; the stepper shows an
em dash rather than guessing a level.
Both are 1-based counts of the levels to show, and so is the stepper's own
depth — DepthButtons labels d as levels[d - 1]. Same unit on both
sides, so the value crosses unchanged in each direction. The engine agrees:
server.cpp turns group_by_depth into set_depth(n - 1) and
t_traversal::set_depth adds the 1 back, so n renders n levels, on both
axes (ctx2->set_depth(HEADER_COLUMN, column_pivot_depth - 1) is the same
arithmetic).
The toolbar used to send d + 1 and subtract the same 1 when reading a layout
back. That was self-consistent — the stepper always agreed with itself — and
off by one against the grid at every position: picking partgroup showed one
level past it. The +1 was there to dodge depth 0, which reaches the engine
as set_depth(-1) on an unsigned t_depth, underflows, and clamps to fully
expanded. applyDepth now floors the value at 1 instead, so the stepper's
"Total" slot still shows the first level rather than a bare total — the
long-standing wart, kept deliberately rather than traded for the underflow. A
real total needs group_rollup_mode: 'total', which is a field in
ViewConfigUpdate but is unverified against the local WASM engine.
Because a depth lives in the config, it survives every view rebuild, rides into
the persisted and named layouts through viewer.save(), and needs nothing
re-applied afterwards.
This required patching the engine. ViewConfig::apply_update applied ten
fields and neither depth, so a depth sent through restore() was accepted,
deserialized and dropped — and since Session::update_view_config returns early
when apply_update reports no change, no view was rebuilt at all. See
ui/vendor/0001-apply-depth-fields-on-config-update.patch; the vendored build
carries it.
What this replaced
Worth knowing, because a lot of machinery existed to work around it and is now gone:
- Row depth used to be imperative —
getView()thenview.set_depth()— which put it on an object the viewer discards whenever it re-renders. Restoring it meant guessing when that had happened: anobserverShim.jspatchingwindow.IntersectionObserverandwindow.ResizeObserver, focus and visibility listeners, a retry loop forgetView()throwingNo table setwhilegetTable()resolved, and a flag tracking whether the viewer had "gone away". That guesswork produced three distinct visible faults — the tree fully expanding on refocus, snapping on any reflow, and snapping when Perspective's settings sidebar opened. - Column collapse used to restore a truncated
split_by. The discarded levels therefore had to be remembered separately (splitFull, persisted assplit_full), our own collapse had to be told apart from the user rearranging the pivot (collapsingRefplus a prefix test), and the selection was cleared on every collapse because the axis was changing shape.
split_full is still read on load, for layouts saved under the old scheme.
Auto-pause is off
<perspective-viewer> auto-pauses by default: an IntersectionObserver on
itself plus the document's visibilitychange drive AutoPauseState::apply(),
and pausing deletes the view (session.set_pause(true) →
view_sub.take().delete()). Returning to the tab is therefore not a redraw but
restore_and_render() — a fresh view and a fresh traversal of the whole grain,
which on a large one is a multi-second chug on every tab switch, and takes any
per-node expansion with it.
initViewer() calls viewer.setAutoPause(false) right after viewer.load().
Nothing updates the table while the tab is hidden — every operation is driven
from this page — so the pause bought nothing and cost a rebuild. The view is now
held while the tab is backgrounded.
Still not solved: per-node expansion
Expanding one specific branch is view state with no config representation, and the API has no getter:
expand(row_index: number): Promise<number>
collapse(row_index: number): Promise<number>
It can be set but not read, so it cannot be captured and replayed — this is why
it is lost on every rebuild, and why no client-side fix has worked. The honest
route is a ViewConfig field carrying expanded row paths (indices shift as
the tree opens), applied in server.cpp where the depths are. Bigger than the
depth patch: it needs a way to enumerate expanded nodes in the C++ traversal, a
proto field, and the apply step.
The fork's own header_click.ts / expand_column / collapse_column is the
column-axis equivalent and has the same limitation.
Limitation that remains either way: depth is whole-axis. Excel can collapse
2025 while 2026 stays expanded; a depth collapses every group at that level
together. columns selects which measures appear, not individual split
combinations.
Tag completions are per version
GET /versions/:id/tags — the tags already used on this version, with their
use counts, for completing the next adjustment. Forecast.jsx refetches on
versionId and after every tagged write.
It used to be GET /sources/:id/tags, joined through pf.version and grouped
across every version of the source. A tag names an initiative inside a
scenario, so that put last year's work in front of this year's and reported a
uses count earned on a version the tag did not belong to. The cost of the
narrower scope is that a fresh version completes from nothing until it has a tag
of its own — which is the honest state of it, since cloning a definition copies
no adjustments.
The pf_bucket datalist on the Baseline page was already per version
(bucketsInUse), so the two now agree.
The list is managed on the Baseline page, not the Forecast one. A tag is
not a row anywhere — only a value repeated across this version's pf.log
entries — so there is no /tags/:tag to address and no list to curate ahead of
use: a tag exists because somebody typed it on an adjustment.
PATCH /versions/:id/tags takes { from, to } and rewrites every entry
carrying from. to naming a tag that already exists merges the two, which
is usually the reason to be there: the bridge groups on this column, so
Pull Forward and Pulled Forward are one initiative reported as two. An empty
to clears the tag. The Baseline panel confirms both before sending, since
neither is visible as a diff afterwards.
Permission is the pf.log rule applied to every entry the tag is on, not
most of them — a half-applied rename leaves the initiative split, which is the
state the rename was fixing. GET /versions/:id/tags therefore returns owners
and can_edit, and the panel greys the row out rather than offering a rename
that answers 403. Editing one entry's tag is still the change log on the
Forecast page; this is the whole-version view of the same column.
Completion matches on words, not substrings. TagInput in
OperationPanel.jsx replaces the native datalist on the tag field: both sides
are lowercased, stripped of punctuation, relieved of stopwords and lightly
stemmed, so Pulled Forward finds Pull Forward and moved to different sku
finds moved to a different sku — neither of which a substring match reaches,
one inserted word being enough to hide the tag that already exists. Typing
something that reduces to an existing tag without being it says so outright
("Already used as … — use that"), because the ledger will otherwise report the
two as separate initiatives. Nothing is merged automatically; the suggestion is
offered and the typing stays yours. The change log's inline tag cell still uses
the plain datalist.
Completion is otherwise the list and nothing else. The operation panel used to
carry six quick-pick pills under the tag field as well. They wrapped to two rows
as soon as the tags were longer than a word, and the panel docked at the bottom
has a fixed height, so they pushed the form past its own border. The datalist
costs no space, is not capped at six, and now shows the tag alone — an option's
text content renders as a second column beside the value, so the use count was
widening every row of the pick list to carry a number nobody picks on. uses
and last_used still come back from the route; nothing reads them.
Choosing a forecast
The forecasts are listed in the sidebar, above the three steps, each row carrying its status and a star for the default. They were two dropdowns in the status bar, which is otherwise read-only — so the one pair of controls in it did not read as controls at all.
Not nested under the Forecast step, which is where the idea started:
Baseline works on the same version, so hanging the list off one step would
either misrepresent the scope or duplicate it under two. It reads what am I
working on, then what step am I on.
The source is not in the nav. gs.osm_skinny means nothing to someone
editing a forecast. The sidebar lists every source's versions and sets
sourceId from the one picked, since pf.version.source_id comes with it — so
choosing a forecast settles the source, and the picker is only offered on Setup,
the one page where the raw table name is the right label.
versions stays scoped to the selected source, because Baseline and Forecast
are; the sidebar gets its own allVersions instead, so widening it does not
change what versions means everywhere else. It is a client-side fan-out over
/sources/:id/versions rather than a GET /versions — one request, at one
registered source.
The status bar keeps what follows from the selection: the forecast's name and its row count. The table written to is in the hover panel, still click-to-copy, because pasting it into psql is the reason it was ever on screen.
Known collision: "forecast" is both the scenario and the third step. The
scenario should own the word — it is what people name and say aloud — which
leaves the step wanting a different one (Adjust). Undecided.
Where a browser lands
App.jsx restores pf_sourceId / pf_versionId from localStorage and
re-checks both against the live list. What it falls back to when there is
nothing stored — a new account, a new machine, a cleared browser — is
pf.version.is_default, one per source by partial unique index
(version_one_default), the way pf.layout.is_default works.
It used to fall back to versions[0], and the list is ordered
created_at DESC, so that meant the newest version — whatever somebody
created last, a scratch copy as readily as the plan. Accounts that never pick a
version themselves, which is every sales account, therefore followed the
forecasting work around.
Admin only, enforced in PUT /api/versions/:id: it decides where everyone
else lands, so it follows the pf.log rule rather than being the version's own
setting like its name. The checkbox on the Baseline page's This forecast
panel is greyed out for other accounts rather than offering a click that
answers 403. The sidebar's forecast list marks the default with a star.
A stored selection still wins. The default is the fallback, not an override — someone who has been working in another version stays there. The corollary is that an account which already landed in the wrong scenario keeps landing there until it switches once.
Setting the default is two statements — clear the old, set the new — so the route runs them in one transaction; the index refuses the instant in between.
Renaming a version
Safe at any time, including on a closed one. Nothing derives from the name: the
forecast table is fc_{tname}_{version_id}, pf.log and pf.layout key on the
id, and the UI's localStorage keys are id-based. The Baseline page's This
forecast panel saves name and description on blur through
PUT /api/versions/:id, which has accepted both all along — it was only ever
called for the fallback names.
Two edges the panel handles: (source_id, name) is unique, so the route
answers 409 on a collision rather than a raw Postgres error; and the UPDATE
COALESCEs a falsy name back to the existing one, so a blank box is refused
client-side rather than looking saved and changing nothing.
Cloning a version's definition
POST /sources/:id/versions takes clone_from. It copies the template
version's settings (description, exclude_iters, the three fallback names,
bucket_order), its version-scoped layouts, and its load segments — and
runs nothing. The reason to clone a definition is to change the dates before
anything loads, and a clone that fetched two million rows first would have to be
undone before it could be edited.
Not copied: adjustments, in any form. A scale's target is a figure sized against
the rows that were there, so replaying one against a different load is
arithmetic nobody asked for. Source-wide layouts (version_id IS NULL) are not
copied either — they already apply to the new version, and a copy would
duplicate the name.
pf.load_plan
A segment used to exist only once it had been run: a pf.log entry with
forecast rows behind it. A plan is the definition on its own — kind, label,
bucket, tag, note, filters, raw_where, date_offset, seq — with
run_logid naming the pf.log entry it produced, or null while it is still a
plan.
Deliberately not pf.log. Every row there means a write happened; holding
unexecuted intent in the audit log would cost it that meaning, and the change
log would list entries that never ran.
run_logid is ON DELETE SET NULL, so undoing a load returns its plan to unrun
rather than deleting the definition with it. The plans list reads the row count
back through that FK, which is how it tells run from unrun.
Derivation from an old version. cloneDefinition() in routes/versions.js
reads pf.load_plan when the template has plans and falls back to deriving them
from its baseline/reference log entries when it does not — which is every
version built before plans existed. pf.log.params already stores filters,
raw_where and date_offset, so this is a read, not a reconstruction.
Run all stops at the first failure. The segments of a definition are not independent — a baseline followed by references that date-shift off it — so carrying on past a broken one builds something nobody described.
Pivot layouts
A layout is a named ViewConfig, stored in pf.layout and owned by an
account. Two kinds, one table:
- published — everyone on the forecast lists it and can apply it; only its owner or an admin may change it
- private — yours, nobody else lists it
Scope is the version by default, because that is where people enter the app.
version_id IS NULL means the layout applies to every version of the source;
that is where the old source default went, and what a brand-new version picks up
before anyone has published anything for it. is_default (at most one per scope,
by partial unique index) is what initViewer() restores on a first load — a
version-scoped default beating a source-wide one, the narrower answer winning.
Permissions are the pf.log rule verbatim — your own, or an admin's
override, and the UI greys out the rest rather than offering a click that answers
403. can_edit rides on every row so the menu knows which. Applying is never
restricted: the guarantee is that a published layout cannot be changed out from
under people, not that it cannot be adapted — Save is withheld on a layout that
isn't yours, Save as… forks it into your own.
No territory clause. A layout is display config, and territory restricts rows,
not columns; cleanLayout() already drops anything the live schema lacks.
What is still local. LAYOUT_KEY (pf_layout_v{vid}) — the unnamed
last-used config — stays in localStorage, because it is per-browser session
continuity rather than a thing anyone names or shares. LAYOUTS_KEY
(pf_layouts_v{vid}) is the old named list; loadLayouts() lifts it into
pf.layout as private rows once per version and then clears the key.
The dirty dot is a comparison, not a flag. restore() itself fires
perspective-config-update, so anything set unconditionally in that handler
would light up the moment a layout was applied. activeConfigRef holds what the
pivot last matched and sameConfig() compares against it, ignoring table
(the per-load table name, different on every refresh).
What this replaced. Named layouts lived only in localStorage — invisible to
anyone else, gone on another machine. The one server-side layout was
pf.source.default_layout, a single anonymous blob that PUT /sources/:id/default-layout let any account overwrite for every account: a
published layout with no owner. That route is gone; the column remains, migrated
and read by nothing.
cleanLayout() guards aggregates along with the axes — including the weight
column of the multi-arg form — and drops the offending entry rather than the
layout, since restore() is all-or-nothing.
The Forecast toolbar is two zones
Left, on its own tinted ground: view — Layout ▾ and the Rows and
Columns depth buttons. Nothing there reaches the server or changes the
forecast; it is all ViewConfig against rows already loaded.
Right, in a bordered panel of its own: forecast — Refresh data,
Change log, Bridge, Operations. These re-run the load, open something over
the pivot, or write to the version.
They were one undifferentiated row separated by identical hairlines, which made
Fit and Bridge read as peers when one is a column width and the other is the
version's history. Three weights do the separating now, and they were all once
the same: .pf-section-label names a group (Layout, Rows, Columns) and is
heavier and darker than the controls under it, .pf-group-rule separates groups
within a zone, and the zone borders separate the zones. A label that matched
its buttons, and a group rule that matched a zone border, is what made the row
read as one undifferentiated thing.
Fit widths is gone, button and fitColumns both, for three independent
reasons. Auto-size measures the leaf header over the rows currently in the
viewport, so anything below the fold never counted; draw({invalid_columns})
passes an option DrawOptions does not have, and draw() is throttled, so
clicking during a pending draw cleared the widths without re-measuring and the
columns fell to their 52/60px floors; and the persistLayout after it saved
nothing, since saveColumnSizes() returns only overrides, which resetAutoSize()
had just emptied — and this fork discards column_size_override whenever
split_by is non-empty, which here is always. Recoverable from git if it is ever
worth fixing rather than dropping.
A Calculating… indicator sits in the app's status bar while the pivot
rebuilds. Perspective has its own beaker, but it is rendered only while the
settings panel is open, so it vanishes exactly when nothing else tells you to
wait. Forecast.jsx raises a counter — a counter, because rebuilds overlap —
in applyDepth, applySplitDepth and applyLayout, lowers it after
viewer.flush(), and broadcasts pf-pivot-busy on the window for StatusBar
to render, the same way pf-data-changed already travels between them.
Deliberately not driven from perspective-config-update, which is the
obvious hook and is the wrong one: measured on a depth change over a large
grain, the event arrives after the rebuild — seconds late — and the flush that
follows resolves in 6ms. It reports the end of the work. The cost is that
sorting or filtering from the viewer's own chrome shows nothing, because the app
is never told those began.
Slice mechanics
When the user clicks a pivot cell, perspective-click fires. The handler in Forecast.jsx extracts [col, '==', value] filters from detail.config.filter — only role = dimension and role = date columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a list of slices sent as slices in operation POST bodies (the single slice object is still accepted server-side).
Dragging across a block of cells selects a region. The datagrid runs in edit_mode: SELECT_REGION (forced on restore, so a saved layout can't switch it off) and reports the region as a perspective-select event carrying a Perspective ViewWindow — { start_row, end_row, start_col, end_col }, not the per-row insertConfigs payload an older API used. It fires on every mouseover as the region grows, so the handler only records the latest window and a window-level mouseup commits it. A single-cell region is ignored there: perspective-click already owns plain and modifier clicks, and handling it in both places would undo a ctrl-click toggle.
Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's __ROW_PATH__ (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. The grand-total row resolves to no dimension at all and is skipped; that would mean "the whole version".
Selection highlight. The datagrid highlights whatever sits in its own model._selection_state.selected_areas, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. Forecast.jsx keeps areasRef, a sliceKey -> rectangles map parallel to slices, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree.
pf_iter is not a col_meta column, so it is stripped when a slice is built: two cells differing only by iter band produce the same effective slice. Duplicates are collapsed before the request — without that, apply_mode: each would apply the same change twice.
A blank dimension value is a value. Both blanks occur in this data and they
are different rows — on fc_osm_skinny_34, partgroup is null on 51,964 rows
and '' on 111,696 — so null and '' stay distinct from the clicked cell
through to the WHERE clause, where one becomes IS NULL and the other = ''.
Only undefined means absent, which is how a subtotal row (a shorter row path)
and a collapsed column level still read as "not part of the slice".
They used to be dropped, and a dropped key does not narrow the write — it widens
it. Clicking the blank partgroup node under ALTMAN PLANTS selected the
customer's whole $27.6M rather than the $6.2M under the node clicked, and the
ledger agreed with it, because the panel totals through the same filter list.
The source of the drop is the engine: getCellConfig builds the click's
filter list with pivot_value ? [pivot, '==', pivot_value] : undefined, so the
blank is gone before perspective-click is dispatched — and being a falsy test
it takes a dimension whose value is 0 with it. cellFilters() in
Forecast.jsx therefore re-derives both axes from detail.row.__ROW_PATH__ and
the clicked column name rather than trusting that list; the click and a dragged
region now go through the same derivation. The ledger filters a null with
is null rather than == null, since the engine matches on type and a null
threshold against a string column is the same dropped predicate again.
buildWhere() renders a null as IS NULL; it used to fall through esc() to
= '', which selected the other blank — the slice named one node and the
operation wrote the other. In a list, the null arm is ORed on rather than folded
in, because IN (NULL) is never true.
The panel prints (null) and (blank) rather than nothing, and — only when
the column is genuinely absent from the slice: a slice isolated to the blank node
and one the column plays no part in are opposite statements and looked identical.
A blank on the column axis cannot be isolated. A column name is its values
joined with |, and a null joins as the empty string, so by the time the name
reaches the client the two blanks are the same text. Those cells are refused —
with a message, and counted once for a drag — rather than widened, since a slice
missing a split_by level means every value of it. Filtering on the column
instead (scope, which carries is null) is the way to reach them.
The pivot's own filter travels alongside, as scope. A slice is
{col: value} and can only mean equality, so a view narrowed to
sseas_e <= 2027 has no slice form at all — the filter is read off
viewer.save() and sent as [col, op, value] triples, which is also what the
ledger totals against, so the panel and the write agree. SCOPE_OPS in
lib/sql_generator.js is the whole allowed list, under Perspective's
operator names; anything outside it is refused rather than dropped, since a
scope silently discarded is a write wider than the panel that authorised it.
The substring operators — contains, begins with, ends with and their
negations — map to ILIKE, not LIKE, because that is what the engine
does: t_tscalar::contains lowercases both sides before std::string::find.
On partd, contains 'Keysto' matches 16,864 rows case-insensitively and 35
case-sensitively, so LIKE would have written a fortieth of what the grid
showed. Narrower is no safer than wider — either way it is not the set the
ledger counted. The term is literal in the engine, so %, _ and \ are
escaped out of it before it becomes a pattern.
matches / not matches stay refused. They are RE2 in the engine, and
Postgres regexes are not RE2 — \b is a word boundary in one and a backspace
in the other — so a pattern accepted by both could quietly match a different
set of rows, which is worse than a 400.
Every operator that needs a term refuses an empty one: an unfinished filter row
in the pivot arrives as [col, '==', null], and ILIKE '%%' or = '' would
have read as "every row" or "no rows" rather than as the mistake it is.
Limitation: computed columns created by Perspective's split_by (e.g. Month, YearDate) don't map back to raw rows — only native dimension columns work for slice extraction.
Operation SQL patterns
All three operations follow the same structure: insert a pf.log row in a CTE, then insert forecast rows referencing its id. {{where_clause}} is built from the slice; {{exclude_clause}} blocks exclude_iters rows.
Every date column travels, not just the first. dataCols is
[...dims, ...dateCols, value, units]. It used to be dateCols[0], so on a
source with order, requested and ship dates, scale and recode wrote rows whose
rdate and sdate_e were null — and because the derived period dimensions
(rseas, smon_e) are dimensions and were copied with the rest, the labels
read correctly while the dates under them were gone. Both columns are
role = 'date' and therefore sliceable, so an adjustment scoped to a ship date
matched no adjustment row that had ever been written. Clone and the loads were
always right. The wider GROUP BY costs almost nothing on osm_skinny
(971,185 → 971,352 groups) because ordnum/ordline already make a row
near-unique.
- Scale — distributes
value_incr/units_incrproportionally across rows in the slice using window functions - Recode — inserts negative rows (zero out original) + positive rows with
{{set_clause}}dimension overrides; both share the same logid - Clone — copies the slice with
{{set_clause}}overrides and{{scale_factor}}multiplier; original untouched
build_where() validates every slice key against col_meta (only role = dimension allowed). Values are escaped but not parameterized — consistent with existing patterns, debuggable in pg logs.
Authentication
Everything under /api except the auth routes sits behind a session; the React
app is only mounted once there is one (Gate in main.jsx), because its load
effects call the API immediately.
- Accounts:
pf.app_user— scrypt hashes fromlib/auth.js, never plaintext. Managed with./pf.sh add-user | passwd | list-users | disable-user | enable-user; the password is read on stdin and hashed before it reaches psql. - Sessions:
express-session+connect-pg-simpleinpf.session, so a restart doesn't sign everyone out and a session can be revoked by deleting its row (disable-userdoes exactly that). Cookiepf.sid: httpOnly, SameSite=Lax, Secure unlessCOOKIE_SECURE=false, 12h rolling. - Config:
SESSION_SECRETis required — the server exits at boot without one.TRUST_PROXY(default 1) makesreq.ipand secure-cookie detection correct behind the TLS proxy.CORS_ORIGINis the only way CORS is enabled at all; a wildcard origin plus a session cookie would be cross-site request forgery by construction. - Login hardening:
routes/auth.jsthrottles to 10 failures per IP per 15 minutes (in-memory), returns one message for unknown/wrong/disabled alike, and regenerates the session id on success.
Identity is server-side. pf_user, created_by and closed_by come from
sessionUser(req), never from the request body — the UI used to send a hardcoded
pf_user: 'admin', which any client could have set to anything. The audit log
now names the account that made the change.
Setup and Baseline are admin-only to write
requireAdmin sat in lib/auth.js, exported, and applied to nothing — so
every non-admin account (26 of the 29) could call every Setup and Baseline
endpoint directly. It is now on the writes those two pages lead to:
- sources.js —
POST /sources,PUT /sources/:id/cols,POST /sources/:id/generate-sql,POST /sources/:id/dim/:group/refresh,DELETE /sources/:id - versions.js —
POST /sources/:id/versions,PUT /versions/:id,POST /versions/:id/{close,reopen},DELETE /versions/:id - operations.js — the loads:
POST /versions/:id/{baseline,reference},PUT /versions/:id/baseline/:logid,DELETE /versions/:id/baseline - plans.js — every write
Not gated, and each for a reason. No read is, including the table browser —
the pages stay visible, and gating what a page needs in order to render turns
it into a 403 (see below). The forecast operations are not, because scale,
recode, clone and undo carry their own owner and territory rules. Nor is
PATCH /versions/:id/tags: its permission is ownership, and a rep may rename
the initiatives they named themselves.
The pages stay visible and go read-only. One <fieldset disabled> per page
rather than a flag threaded through every control — Baseline alone has
forty-five, and the one that got missed would be the one that mattered — plus a
ReadOnlyNotice banner saying why. Setup wraps a real box because that fieldset
is its flex row; Baseline uses display: contents so the page's layout is
untouched, and disabled applies either way, being a DOM property. Baseline's
Tags panel sits outside the fieldset, for the ownership reason above. None of
this is the permission — it only stops an account being offered a button that
would answer 403.
A 403 used to blank the whole app. Setup fed responses straight into state
and rendered them with .map; an error body is {error}, and throwing during
render unmounts React. Its list fetches now coerce to an array, so an unexpected
response costs an empty panel rather than the session. That fragility predated
the gating — the gating only made it reachable.
Source columns come from pg_catalog
information_schema.columns omits materialized views — they are not in the
SQL standard — and gs.osm_skinny is one. So the source the whole app is built
on looked like it had no columns: registering it seeded nothing, and creating a
version failed with "No usable columns in col_meta" while col_meta plainly held
thirty-six.
RELATION_COLUMNS_SQL in lib/utils.js is the replacement, used by version
creation, source registration and the table preview. It returns the same shape
information_schema did, so mapType and the callers were unchanged:
data_type is format_type with the modifier stripped, which gives the same
spelling (character varying, numeric), and precision and scale are unpacked
from atttypmod. The table browser lists from pg_class by relkind for the
same reason.
Territory scoping
An account sees and changes only its own territory. The list lives on
pf.app_user.territory (jsonb array) with is_admin for the accounts that see
everything, and col_meta.is_territory marks which column of a given source
the values belong to — one per source, flagged rather than named in code so a
second source can be divided by something other than a sales rep.
Fail closed. No territory and not an admin means no rows.
buildTerritoryClause() returns FALSE, not TRUE, for an empty list or an
unflagged source: an account somebody forgot to configure sees nothing instead
of the whole book.
Built from the session, never the request. This is the difference between
it and scope, which the browser sends and which is right to send, being a
filter the user chose. A permission cannot come from the thing it restrains, so
the territory predicate is ANDed on last, in sliceUnits() for writes and per
route for reads, where nothing in the payload can remove it.
Enforced at:
/data— clause on the cursor and on the count behindX-Row-Count/agg— a{{territory_clause}}token applied before the GROUP BY, since the territory column need not be part of the grain and may not survive it- every operation, through
sliceUnits() /sources/:id/values/:col— completion reads the source table, which no scope has touched, so without it a dropdown enumerates the whole businessDELETE /log/:logidandPATCH /log/:logid— by owner, not territory. Undo removes an entry's rows wholesale, and half-undoing one would leave a state nothing describes. The PATCH looks like a private annotation and is not:labelandbucketname the pivot's columns for everyone in the version, so unguarded it let any account rename the company's segments. Your own entries, or an admin's override, and the UI greys out the rest rather than offering a click that answers 403.- recode's
set— a scoped account cannot set the territory column at all. Moving a row between territories is reassignment, not forecasting, and it would vanish from the view that would have shown what happened.
Whose entries the change log lists. Everyone's, as before, with an
Everyone / Mine control in the modal header and each account's own entries
marked in place — a blue rule down the left of the row and its By cell in
blue. On the version this was built against that is 127 adjustments from five
accounts, one of them the reader's, so finding your own meant reading the By
column down the whole list.
Marked and filterable on purpose: the mark is how you tell without hiding
anybody, the filter is for when you want only yours. It resets to Mine on
every open and is not persisted — opening the log is almost always to check
your own recent work, and a stale "Everyone" from last session is a worse
default than the reverse would be. isMine() is strict authorship,
deliberately not canUndo(): an admin can undo everything, and marking every
row as theirs would say nothing.
The fetch stays unfiltered, since the counts on the control need the whole set.
The change log shows an entry's full impact, not the reader's share. The
totals stamped on pf.log are company-wide, so an admin's version-wide scale
reads the same in every account — deliberate, and labelled, rather than
re-aggregating per territory.
Managed with ./pf.sh set-territory | set-admin | orphan-territory.
orphan-territory lists values present in the data that no account owns; work
under one is invisible to everyone but an admin, which a typo causes easily and
nothing inside the app reveals.
Light / dark mode
Theme state lives in ui/src/theme.jsx — a React context (ThemeContext) with a ThemeProvider that wraps the app in main.jsx.
-
Storage key:
pf_darkinlocalStorage; falls back towindow.matchMedia('(prefers-color-scheme: dark)')on first visit -
Toggle:
setDark(d => !d)inStatusBar.jsx; effect writeslocalStorageand toggles the.darkclass on<html> -
CSS:
ui/src/index.cssdefines CSS custom properties under:root(light) and.dark. All Tailwind color overrides are written as.dark .bg-white { ... }etc. — no Tailwind dark-mode config needed -
Palette: dark mode uses Perspective's "Pro Dark" colours (
--bg-primary: #242526, panels#2a2c2f, gridlines#3b3f46, text#c5c9d0) -
Perspective viewer:
Forecast.jsxcallsviewer.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' })both on initial load and in auseEffect([dark, versionId]). A partial restore, so it does not rebuild the view the wayapplyDepth's whole-config one does.It used to be
setAttribute('theme', …), which in 5.4.0 does nothing — the theme is aViewerConfigfield (ViewerConfig.theme, a theme name), and the element observes no such attribute. The pivot simply stayed light whatever the toggle said.Half the fix is in
cleanLayout, which dropstheme.viewer.save()includes it, so every stored layout had one baked in, and restoring one reasserted the author's theme and undid the toggle. It is the reader's choice, not a property of the layout — which also means a published layout cannot impose its theme on whoever applies it.sameConfig()ignoresthemefor the same reason: toggling is not a layout edit and must not light the dirty dot. -
Consuming the theme:
import useTheme from '../theme.jsx'thenconst { dark, setDark } = useTheme()
After a change: restart, and Generate SQL
Two steps, and which one you need depends on what changed.
- Restart the server for anything in
routes/,lib/orserver.js. - Generate SQL (Setup, per source) whenever
lib/sql_generator.jschanges. The templates are stored inpf.sql, so editing the generator changes nothing until they are rebuilt — and a template carrying a token the running code does not substitute fails at the database rather than in JS, which reads as an unrelated client-side error.
The order matters: restart first, then Generate SQL, or the old code writes the templates.
Schema changes are applied to the live database directly and mirrored into
setup_sql/ for a fresh install; 01_schema.sql is idempotent but is not a
migration runner, so running it is not how an existing database gets a new
column.
Known issues / active work
-
Rows written before 2026-09-21 have null
rdate/sdate_e. The generator fix above changes what gets written from here on; it does not repair what is there. Onfc_osm_skinny_29that is ~270k scale and recode rows. A backfill would have to re-derive each from the baseline rows at the same coordinate, which is possible but is inference, not recovery — deleting and replaying those log entries is the honest repair -
Zero-row operations report success. Scale refuses with "Nothing to scale…" when its slice matches nothing; recode and clone commit an empty log entry and return
rows_affected: 0. A recode of a rep whose rows are allreferencelooked like it worked and did nothing -
The change log does not show an entry's id, so there is no way to name one when asking about it
-
Territory is read onto the session at login, so granting or changing one does not reach a signed-in account until it signs in again. Re-reading it per request in
requireAuthwould also make disabling someone immediate -
Depth buttons rebuild the view. A depth lives in
ViewConfig, so changing it goes throughrestore()andSession::update_view_configtears down and rebuilds the view — a full traversal — where a manual collapse mutates the existing one in place. Noticeable on a large grain. The fix is to detect a depth-only change and callview.set_depth()imperatively while still writing it to the config, at the cost of the two being able to drift -
Load time is dominated by row count, not payload size. On
fc_osm_skinny_29(2.56M raw rows) a 24-column grain still yields 285,685 rows: ~2s to aggregate in pg, but ~15s to serialise those rows out of Postgres and parse them into JS, then ~3s to build Arrow. Halving the payload (thepf_gkeymd5) barely moved it. The remaining lever is dynamic grain — group by the fields the current pivot actually uses rather than everyin_graincolumn; seepf_spec.md→ §Display-grain pre-aggregation, "the dynamic variant" -
Per-node expand/collapse is lost whenever the view rebuilds — set-only API, no getter; see §Axis depth
-
Default pivot layout should be configurable per source (currently hardcodes first 2 dimensions)
-
Source/version selection persists in
localStorage(pf_sourceId/pf_versionId,App.jsx). It is re-validated against the live list whenever that list changes, so a deregistered source or deleted version re-points at the first remaining one instead of leaving a dead id that 404s every call -
Col_meta / version schema drift: if col_meta roles change after a version's forecast table is created, SQL and DDL go out of sync — workaround is to delete and recreate the version
-
Grain drift: changing
in_grainafter a load requires Generate SQL + a page reload, since the loaded table's index and columns are fixed at load time.routes/log.jsderives the grain from live col_meta, so a grain changed mid-session yieldspf_gkeysthat don't match the loaded table and undo silently removes nothing -
Grain is static per source — a dimension left unflagged cannot be pivoted on. Dynamic per-cut grain (intersect the viewer's field set with the eligible set) is the additive next step; see
pf_spec.md→ §Display-grain pre-aggregation
Deferred (not in v1)
Baseline replay (replay: true returns 501), approval workflow, territory filtering, export, version comparison, multi-DB connections. Live server-side aggregation (Path A / DuckDB virtual server) is parked on branch spike/duckdb-virtual-server.