install.sh duplicated pf.sh config, db-setup and add-user, defaulted to one person's DB host and account, never built the UI, and pointed at `npm run dev` as the way to start. Neither script ran gen_dim_period.sql, so a fresh install had no pf.dim_period at all. db-setup now runs it between the schema and the auth tables; it only inserts missing months, so re-running leaves an existing calendar untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
38 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, including the rationale and history this file leaves out: docs/pf_spec.md
Data transport architecture options: docs/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 CDN bundle resolves its server WASM to an unversioned path and silently pulls whatever is newest). Seedocs/PERSPECTIVE.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
pf.sh Install, deploy and admin: config, db-setup, deploy, install-service,
add-user, passwd, set-territory, … (fresh install: config → db-setup
→ add-user → deploy → install-service)
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;
GET /sources/:id/values/:col, /sources/:id/lookup,
/sources/:id/dim/:group (+ POST …/refresh), /dim-period/cols
versions.js Version CRUD and clone; GET /versions/:id/tags (+ PATCH),
/versions/:id/bridge, /versions/:id/values/:col, /versions/:id/table-info
operations.js Data streams (GET /versions/:id/data, /versions/:id/agg); loads
(baseline, reference); scale, recode, clone
log.js GET /api/versions/:id/log, /log/:logid/debug; DELETE and PATCH /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 grainOf, buildWhere/buildWhereAny, SCOPE_OPS, token substitution helpers
auth.js scrypt hash/verify, requireAuth, requireAdmin, sessionUser; `node lib/auth.js hash` CLI
utils.js RELATION_COLUMNS_SQL, mapType
setup_sql/
01_schema.sql pf schema DDL — idempotent, not a migration runner
02_auth.sql pf.app_user + pf.session
gen_dim_period.sql Populates pf.dim_period; fiscal year start month is configurable
ui/vendor/ Patched Perspective build and its patches (see its README)
ui/src/
main.jsx ThemeProvider, AuthProvider, Gate (mounts the app only once signed in)
App.jsx Page routing; source/version selection and its localStorage restore
theme.jsx ThemeContext / useTheme
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, tags panel
Forecast.jsx Perspective pivot, selection handling, operation dispatch
components/
DepthButtons.jsx One axis's collapse stepper; 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; effectiveScaleInputs
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;is_territorymarks the column territories are expressed in (one per source);is_keyis described under §is_keybelowpf.dim_member— members of eachdim_group, keyed on the group's key column; refreshed byPOST /sources/:id/dim/:group/refreshpf.version— named forecast scenarios;exclude_iters(default["reference"]) blocks those iter values from all operations;adjustment_segment/adjustment_bucketname new adjustments;is_default(one per source)pf.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. Tables created before that index was added do not have it.pf.log— audit log; every write gets one entry;slice+paramsstored as jsonb;label/bucketrequired (log_namedCHECK). Stamped at write time:row_count,value_total,units_total,measure_cols(which columns the totals are denominated in),env(the state the intent executed against),sql_text(the statement as executed)pf.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_at,is_admin,territory(jsonb array)pf.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.sql
Dead columns, kept but read by nothing: pf.version.unlabeled_load, pf.version.bucket_order, pf.log.seq, pf.source.default_layout.
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 —
a refresh keyed on the wrong column matches nothing and reports success with
zero members. Until the two meanings are separated, a refresh that finds two
keys should refuse rather than guess. Rationale: docs/pf_spec.md → §Data model.
Key token substitution tokens
{{fc_table}}, {{where_clause}}, {{exclude_clause}}, {{territory_clause}}, {{logid}}, {{pf_user}}, {{label}}, {{bucket}}, {{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 (both in routes/operations.js):
- 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.
One batch, not streaming. Per-batch Arrow encoding creates independent dictionaries that crash Perspective WASM on dictionary replacement messages, so the server accumulates all rows and emits one record batch. pg returns bigint/numeric as strings by default — the type parsers in server.js coerce them to numbers; do not remove them.
Display grain
Aggregating to the grain the pivot displays is the load-time fix, and 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 the grain — routes/log.js calls it directly and Setup.jsx mirrors it. Design: docs/pf_spec.md → §Display-grain pre-aggregation. Why not a DuckDB virtual server: docs/pf_perspective_options.md → §Spike findings.
Log-derived columns
/data and /agg both LEFT JOIN pf.log and emit four columns the forecast
table does not itself carry:
pf_segment—pf.log.label(SEGMENT_EXPR)pf_bucket—pf.log.bucket(BUCKET_EXPR)pf_note— tag, else note, on a scale/recode/clone; null on loads (NOTE_EXPR)pf_op—pf.log.operation
Only segment and bucket are stored names (see §Every log entry is named); the
note is derived. 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. LABEL_GROUP_COLS lists the pf.log columns they read, for
/agg's GROUP BY. In grain mode pf_logid is part of the grain, so the join
adds no rows. The operation routes stamp the same 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, so a "01 - " prefix in
pf.log.label / pf.log.bucket, typed by whoever names the segment, is
the only lever; nothing derives it. ADJUSTMENT_SEGMENT = '99 - Adjustments'
lands last only when the loads carry 01–0n prefixes (digits precede
letters). Do not reintroduce ordering as Perspective expressions — restore()
replaces expressions wholesale and ExprTK cannot handle non-ASCII labels;
DEAD_ORDER_EXPRS in Forecast.jsx strips the old ones from saved layouts.
Rationale: docs/pf_spec.md → §Naming: segments, buckets, notes.
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. A
report gets the pivot's segment and bucket by joining the forecast table to
pf.log on pf_logid; there is no rule to copy. Never derive them at read time.
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. A relabel needs a page
reload to show, since the label is part of the aggregated row. A source
registered before names were stored needs Generate SQL run once so its load
templates write label and bucket at all. Rationale: docs/pf_spec.md → §Naming: segments, buckets, notes.
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. A price move is invisible in the
figure being edited — the dollars land on target either way — so every default
holds price. All resolution is in resolveIncrs() in routes/operations.js;
the semantics come from the predecessor Excel model
(/opt/forecast_api/VBA/fpvt.frm → calc_val / calc_price).
- Dollars only —
plugdecides:'volume'(the default: price holds, units scale in proportion) or'price'(volume holds, price moves). - Qty only — dollars follow at the current price. A slice with no units has no price to hold, so the dollars stay put.
- Dollars and qty — both land as named; price falls out.
- A named price (
target_price) holds, and the other named measure decides the rest: price alone → qty holds; price + qty → qty lands as named, dollars follow; price + dollars → dollars land, qty follows. All three is refused server-side (400). - The held price is the adjustable rows' own, never blended with the fixed
(excluded-iter) rows, even under
target_basis: 'selected'— the scale SQL prorates over the adjustable rows only, so a blended price drives qty and dollars apart (and qty negative when a slice is taken to its fixed total).
The three boxes foot. At most two are inputs and the third is derived.
effectiveScaleInputs() in OperationPanel.jsx is the single resolver: the
panel renders from it and buildPayload in Forecast.jsx builds the request
from it, so what is posted is what is shown. Typing a third drops one — the plug
decides when dollars came last, else the older of the other two. The ledger's
Result line previews value, units and price by the same rules, and the
column that cannot move is labelled · holds.
The plug control is labelled "Moves": it lights whatever the edit moves (both if both do) and takes a click only while it is the one deciding; otherwise it stays on screen, not hidden.
Logged. resolved.method (price | both | volume | the plug) records
which side moved, since the request alone cannot say. params also keeps
plug and expected (what the Result line promised), beside resolved, so a
panel/server disagreement can be found by reading the log — nothing compares
them yet. The client's stored preference is keyed pf_scale_plug_v2; do not
reuse the old key, which could carry a stale 'price'. Rationale: docs/pf_spec.md → §Operations.
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() — group_by_depth via applyDepth(), split_by_depth via
applySplitDepth(). Both steppers are drawn by components/DepthButtons.jsx from the axis's own field list
(groupFull / splitFull), as a stepper ◂ level ▸, the middle button opening
the full level list. depth is null until the first layout has been read back;
the stepper then shows an em dash.
Depths are 1-based counts of levels to show, on both axes and in
DepthButtons (levels[d - 1]); the value crosses unchanged in each direction.
The engine agrees (set_depth(n - 1), and the traversal adds the 1 back). Never
send 0: it reaches the engine as set_depth(-1) on an unsigned t_depth,
underflows, and renders fully expanded. applyDepth floors at 1, so the
stepper's "Total" slot shows the first level rather than a bare total — kept
deliberately. A real total would need group_rollup_mode: 'total', unverified
against the local engine.
A depth lives in the config, so it survives view rebuilds and rides into saved
layouts through viewer.save(). This required patching the engine —
ViewConfig::apply_update dropped both depth fields; see
ui/vendor/0001-apply-depth-fields-on-config-update.patch. Do not go back to
imperative view.set_depth() alone: the viewer discards that view on every
re-render. split_full is still read on load, for layouts saved under the
old truncated-split_by scheme.
Auto-pause is off. initViewer() calls viewer.setAutoPause(false) right
after viewer.load(). Pausing deletes the view, so returning to the tab would
rebuild the whole grain and lose per-node expansion.
Per-node expansion is not solved. expand(row_index) / collapse(row_index)
are set-only with no getter, so they cannot be captured and replayed and are
lost on every rebuild. The route is a ViewConfig field of expanded row paths
applied in server.cpp. Depth is also whole-axis — one level collapses every
group at that level. Rationale: docs/pf_spec.md → §The pivot.
Tag completions are per version
GET /versions/:id/tags — the tags used on this version, with uses,
last_used, owners and can_edit; Forecast.jsx refetches on versionId
and after every tagged write. A tag is not a row anywhere — only a value
repeated across pf.log entries.
Managed on the Baseline page's Tags panel, which lists each with its use
count and last-used date. PATCH /versions/:id/tags takes { from, to } and
rewrites every entry carrying from; a to that already exists merges, an
empty to clears; the panel confirms both. Permission is the pf.log rule
applied to every entry the tag is on (a half-applied rename leaves the
initiative split), so a row with can_edit false is greyed out. One entry's
tag is edited in the Forecast page's change log.
Completion matches on words, not substrings. TagInput in
OperationPanel.jsx normalises both sides (case, punctuation, stopwords, light
stemming) and says so when a typed tag reduces to an existing one; nothing is
merged automatically. Rationale: docs/pf_spec.md → §Bridge and change log.
Choosing a forecast
The sidebar lists every source's versions (allVersions, a client-side
fan-out over /sources/:id/versions) and sets sourceId from the one picked.
versions stays scoped to the selected source, because Baseline and Forecast
are — do not widen it. The source picker is only on Setup. Known collision:
"forecast" is both the scenario and the third step; undecided.
Where a browser lands. App.jsx restores pf_sourceId / pf_versionId
from localStorage, re-checked against the live list. With nothing stored it
falls back to pf.version.is_default (one per source, partial unique index
version_one_default), not versions[0], which is the newest. A stored
selection wins. Setting the default is admin only, in PUT /api/versions/:id,
as two statements in one transaction — the index refuses the instant between.
Renaming a version is safe at any time; nothing derives from the name.
PUT /api/versions/:id answers 409 on a (source_id, name) collision, and
COALESCEs a falsy name back to the existing one, so a blank box is refused
client-side.
Cloning a version's definition
POST /sources/:id/versions takes clone_from. It copies the template
version's settings (description, exclude_iters, the fallback names,
bucket_order), its version-scoped layouts, and its load segments — and
runs nothing, so the dates can be changed before anything loads.
Not copied: adjustments, in any form (a scale's target was sized against the
rows that were there), and source-wide layouts (version_id IS NULL), which
already apply to the new version.
pf.load_plan
A plan is a load 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 unrun. Deliberately not
pf.log: every row there means a write happened.
run_logid is ON DELETE SET NULL, so undoing a load returns its plan to unrun.
The plans list reads the row count back through that FK.
cloneDefinition() in routes/versions.js reads pf.load_plan when the
template has plans and otherwise derives them from its baseline/reference
log entries (pf.log.params stores filters, raw_where, date_offset).
Run all stops at the first failure — segments are not independent. Rationale: docs/pf_spec.md → §Loads and segments.
Pivot layouts
A layout is a named ViewConfig in pf.layout, owned by an account:
published (everyone on the forecast lists and can apply it; only its owner
or an admin may change it) or private. Scope is the version by default;
version_id IS NULL means every version of the source. 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.
Permissions are the pf.log rule verbatim — your own, or an admin's
override; can_edit rides on every row and the UI greys out the rest. Applying
is never restricted; Save is withheld on a layout that isn't yours, Save as…
forks it. No territory clause — a layout is display config.
Local state. LAYOUT_KEY (pf_layout_v{vid}), the unnamed last-used
config, stays in localStorage; loadLayouts() lifts the old named list
(LAYOUTS_KEY) into pf.layout as private rows once, then clears it.
The dirty dot is a comparison, not a flag. restore() itself fires
perspective-config-update, so activeConfigRef holds what the pivot last
matched and sameConfig() compares against it, ignoring table and theme.
Grand totals ride in the layout under pf_totals — { row, col }. The
engine has no "subtotals but no grand total" mode, so both are hidden in the
grid's DOM; captureConfig() adds the key and cleanLayout() strips it before
restore(). Without it: row shown, column hidden.
cleanLayout() drops anything the live schema lacks — axes, aggregates
(including the weight column of the multi-arg form), theme, pf_totals, dead
expressions — dropping the offending entry rather than the layout, since
restore() is all-or-nothing. Rationale: docs/pf_spec.md → §The pivot.
The Forecast toolbar is two zones
Left, view — Layout ▾ and the Rows / Columns steppers with their
Total toggles; all ViewConfig, nothing reaches the server. Right,
forecast — Refresh data, Change log, Bridge, Operations. Blue is
for actions, pressed grey (.pf-pressed) for state. Fit widths was removed
deliberately; do not restore it without fixing auto-size (see git history).
Calculating… in the status bar: Forecast.jsx raises a counter (rebuilds
overlap) in applyDepth, applySplitDepth and applyLayout, lowers it after
viewer.flush(), and broadcasts pf-pivot-busy on the window for StatusBar.
Not driven from perspective-config-update, which arrives after the
rebuild. Rationale: docs/pf_spec.md → §The pivot.
Slice mechanics
A click (perspective-click) or a dragged region becomes a slice — a
{col: value} object. A plain click replaces the selection; ctrl/⌘/shift-click
toggles a slice in or out, so the panel holds a list sent as slices in
operation POST bodies (a single slice is still accepted server-side).
The slice is re-derived by cellFilters() in Forecast.jsx, from
detail.row.__ROW_PATH__ (row axis, raw values, so dates stay epoch millis)
and the clicked column name (column axis, split by |). The click and a dragged
region go through the same derivation. detail.config.filter is used only as
the fallback when there is no cell to index the column axis with — it cannot be
trusted otherwise, because the engine builds it with a falsy test that drops
blank and 0 pivot values, and a dropped key widens the write.
Regions. The datagrid runs in edit_mode: SELECT_REGION (forced on
restore). perspective-select carries a ViewWindow —
{ start_row, end_row, start_col, end_col } — and fires on every mouseover, so
the handler records the latest window and a window-level mouseup commits it.
A single-cell region is ignored there: perspective-click owns it, and handling
it twice would undo a ctrl-click toggle.
A total cell is its displayed amount. The grand-total row resolves to no
row dimension, so its slice is whatever the column axis and the pivot's ==
filters leave — possibly {}. An empty slice is accepted only alongside a
non-empty scope; with no filter at all it would be the whole version, and is
refused on both sides. buildWhereAny() reads a union containing {} as
TRUE, the scope then ANDed on — dropping it would narrow the write.
The pivot's pf_ columns stay in the slice. pf_segment, pf_bucket,
pf_note and pf_op are resolved through the log (COMPUTED_SLICE_COLS);
pf_iter, pf_user and pf_logid are compared directly (TABLE_SLICE_COLS).
Stripping any of them made the write wider than the ledger's total. They narrow
a slice but do not make it selective on their own. Duplicates are collapsed
before the request — otherwise apply_mode: each applies the change twice.
Selection highlight. The datagrid wipes model._selection_state.selected_areas
on every mousedown, so Forecast.jsx keeps areasRef (sliceKey -> rectangles,
parallel to slices) and an effect pushes the full set back and redraws after
every change. Every deselect path prunes it by live slice key.
A blank dimension value is a value. null and '' are different rows and
stay distinct through to the WHERE clause: IS NULL vs = ''. Only undefined
means absent (a subtotal row's shorter path, a collapsed column level). On the
column axis, t_tscalar::to_string renders a null as the literal text "null"
and an empty string as '', so cellFilters() maps "null" → null. The ledger
filters a null with is null, never == null (the engine matches on type). The
panel prints (null) and (blank), and — only when the column is absent from
the slice.
The pivot's own filter travels alongside, as scope — [col, op, value]
triples read off viewer.save(), which is also what the ledger totals against.
SCOPE_OPS in lib/sql_generator.js is the whole allowed list, under
Perspective's operator names; anything outside it is refused, not dropped.
- Substring operators (
contains,begins with,ends withand negations) map toILIKE, matching the engine's case-insensitivet_tscalar::contains;%,_and\are escaped out of the term. matches/not matchesstay refused: the engine is RE2, Postgres is not.- Every operator that needs a term refuses an empty one (an unfinished filter
row arrives as
[col, '==', null]).
Limitation: computed columns created by Perspective expressions don't map back to raw rows — only native columns work for slice extraction. Rationale: docs/pf_spec.md → §Selection.
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 slices; {{exclude_clause}} blocks exclude_iters rows; the territory clause is ANDed on last.
buildWhere() in lib/sql_generator.js turns a slice into SQL: it allows
dimension + date columns plus COMPUTED_SLICE_COLS and TABLE_SLICE_COLS,
skips unknown non-pf_ keys, and refuses an unknown pf_ key (skipping
one would widen the write). A null renders as IS NULL; in a list the null arm
is ORed on, since IN (NULL) is never true. Values are escaped but not
parameterized — consistent with existing patterns, debuggable in pg logs. The
"must name a filterable column" refusal is assertSelective() in
routes/operations.js.
Every date column travels. dataCols is [...dims, ...dateCols, value, units]
— never just the first date column, or scale and recode write rows with null
secondary dates under correctly labelled period dimensions.
- Scale — distributes
value_incr/units_incrproportionally across rows in the slice using window functions; refuses a pool whose net is ~zero relative to its gross (assertProratable) - 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
Recode takes a date shift too. date_offset moves the put-back leg only,
so a shifted recode is a timing move, and with a shift set may be empty. The
moved rows' period dimensions are re-derived from pf.dim_period against the
shifted date; a row whose date is null keeps the label it had. Without a shift
nothing is derived. Clone still blanks those labels on a null date.
A date offset must be whole days. assertInterval() refuses anything finer,
because every shifted date is cast back to a date — and Postgres reads a bare
number as seconds. 30 days is not a month; use 1 month.
Scale refuses with "Nothing to scale…" and recode with "Nothing to recode…" when there is nothing to write. Rationale: docs/pf_spec.md → §Operations.
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).
- 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:
pf.session(connect-pg-simple); revoke by deleting the row. 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) forreq.ipand secure cookies behind the TLS proxy.CORS_ORIGINis the only way CORS is enabled; never a wildcard with a session cookie. - 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.
Setup and Baseline are admin-only to write
requireAdmin (lib/auth.js) is on:
- 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, deliberately: reads (gating what a page needs to render turns it
into a 403); scale/recode/clone/undo, which carry their own owner and territory
rules; PATCH /versions/:id/tags, whose permission is ownership.
The pages stay visible and go read-only — one <fieldset disabled> per page
plus a ReadOnlyNotice banner. Setup wraps a real box; Baseline uses
display: contents. Baseline's Tags panel sits outside the fieldset. None of
this is the permission — the server check is. Setup's list fetches coerce
responses to an array: an error body rendered with .map throws during render
and unmounts the whole app. Rationale: docs/pf_spec.md → §Access.
Source columns come from pg_catalog
information_schema.columns omits materialized views, and gs.osm_skinny is
one. RELATION_COLUMNS_SQL in lib/utils.js is the replacement, used by version
creation, source registration and the table preview; it returns the
information_schema shape (data_type from format_type with the modifier
stripped, precision/scale from atttypmod). The table browser lists from
pg_class by relkind. Do not query information_schema.columns for source
columns.
Territory scoping
An account sees and changes only its own territory: pf.app_user.territory
(jsonb array), is_admin for accounts that see everything, and
col_meta.is_territory marks the column (one per source).
Fail closed. buildTerritoryClause() returns FALSE, not TRUE, for an
empty list or an unflagged source.
Built from the session, never the request. Unlike scope, 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—{{territory_clause}}, applied before the GROUP BY, since the territory column need not be in the grain- every operation, through
sliceUnits() /sources/:id/values/:col— completion reads the source tableDELETE /log/:logidandPATCH /log/:logid— by owner, not territory: your own entries, or an admin's override. Undo removes an entry wholesale, andlabel/bucketname the pivot's columns for everyone in the version- recode's
set— a scoped account cannot set the territory column at all
The change log lists everyone's entries, with an Everyone / Mine control
(resets to Mine on every open, not persisted) and the reader's own entries
marked. isMine() is strict authorship, deliberately not canUndo(). The fetch
stays unfiltered, since the control's counts need the whole set. It shows an
entry's full impact — the totals on pf.log are company-wide, deliberately.
Managed with ./pf.sh set-territory | set-admin | orphan-territory.
orphan-territory lists values present in the data that no account owns.
Rationale: docs/pf_spec.md → §Access.
Light / dark mode
ui/src/theme.jsx — ThemeProvider (in main.jsx) and useTheme() →
{ dark, setDark }. Stored as pf_dark in localStorage, else
prefers-color-scheme; the effect toggles .dark on <html>. ui/src/index.css
defines custom properties under :root and .dark, with Tailwind overrides
written as .dark .bg-white { ... } — no Tailwind dark-mode config. Palette is
Perspective's "Pro Dark".
The viewer's theme is viewer.restore({ theme: dark ? 'Pro Dark' : 'Pro Light' }),
on load and in useEffect([dark, versionId]) — a partial restore, no
rebuild. Not setAttribute('theme', …), which does nothing in 5.4.0.
cleanLayout drops theme and sameConfig() ignores it: the theme is the
reader's choice, never a layout's.
After a change: restart, and Generate SQL
- 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, which reads as an unrelated client-side error.
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.
Known issues / active work
- Rows written before the every-date-column fix have null secondary dates
(
rdate/sdate_e) on scale and recode rows. Deleting and replaying those log entries is the honest repair; a backfill would be inference - Zero-row clones report success — clone commits an empty log entry and
returns
rows_affected: 0, where scale and recode refuse - 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 a change does not reach a signed-in account until it signs in again
- The bridge dedupes overlapping slices by
pf_id, which grain-mode rows do not carry, so overlapping slices double-count there; and it keeps only dimension/date slice keys, dropping thepf_keys operations now honour (BridgeView.jsx, the per-slice view loop) - Depth buttons rebuild the view —
restore()tears down and rebuilds, a full traversal, where a manual collapse mutates in place. Fix: detect a depth-only change and callview.set_depth()too, at the cost of drift - Load time is dominated by row count, not payload size — serialising rows out of Postgres into JS dominates. The lever is dynamic grain; see docs/pf_spec.md → §Display-grain pre-aggregation
- Per-node expand/collapse is lost whenever the view rebuilds; see §Axis depth
- 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 — delete and recreate the version
- Grain drift: changing
in_grainafter a load requires Generate SQL + a page reload.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 — an unflagged dimension cannot be pivoted on
Deferred (not in v1)
Baseline replay (not built), approval workflow, export, version comparison, multi-DB connections. Live server-side aggregation (Path A / DuckDB virtual server) is parked on branch spike/duckdb-virtual-server. See docs/pf_spec.md → §Open questions.