Portable Reports
A portable report describes fields, measures, filters, calculations, totals, and presentation preferences. Its field and measure IDs remain independent of display labels and physical source properties. Save the report as JSON; supply callbacks, source records, adapters, and mounts from application-owned runtime registries.
Define and compile a report
Section titled “Define and compile a report”Compile a report
Portable IDs → compiled config → analytical result
import { compilePivotReport, parsePivotReport, serializePivotReport, type PivotReport,} from '@revolist/pivot';
const report: PivotReport = { kind: 'revogrid:pivot.report', version: 1, fields: [ { id: 'region', sourceProp: 'region', column: { name: 'Region' } }, { id: 'revenue', sourceProp: 'sales' }, ], rows: ['region'], columns: [], filters: [], measures: [{ id: 'revenue-total', fieldId: 'revenue', aggregator: 'sum', label: 'Revenue', format: { preset: 'currency', currency: 'USD' }, }], totals: { grandTotal: true },};
const json = serializePivotReport(report);const restored = parsePivotReport(json);const { config } = compilePivotReport(restored);
// Set original records before enabling Pivot on this grid.grid.source = records;grid.pivot = config;Validation returns detached report data and identifies invalid references before
execution. The compiler produces the existing PivotConfig; existing local and
remote owners still perform the aggregation and rendering.
Explicit version 2 source documents
Section titled “Explicit version 2 source documents”PivotReport, PivotReportField, and PIVOT_REPORT_VERSION retain their version 1
contracts. Default validatePivotReport and parsePivotReport calls accept
version 1. To inspect or store a version 2 source document, explicitly select its
schema with { version: 2 }.
import { upgradePivotReport, validatePivotReport, type PivotReportV2,} from '@revolist/pivot';
// Pure conversion: field IDs and other authored settings remain unchanged.const upgraded = upgradePivotReport(report);const sourceDocument: PivotReportV2 = { ...upgraded, fields: [ ...upgraded.fields, { id: 'adjusted-revenue', dataType: 'number', source: { kind: 'expression', expression: { type: 'binary', operator: '*', left: { type: 'field', fieldId: 'revenue' }, right: { type: 'literal', value: 1.2 }, }, }, }, ],};
const validated = validatePivotReport(sourceDocument, { version: 2 });const sourceJson = serializePivotReport(validated);const sourceRestored = parsePivotReport(sourceJson, { version: 2 });Parsing, validation and serialization never evaluate fields or upgrade a document
implicitly. To execute version 2, pass the actual accepted source to the same
compiler: compilePivotReport(sourceDocument, runtime, { version: 2, source }).
Assign its issued config to grid.pivot. Immutable configuration spreads retain
that authority; JSON copies do not. Default compilation and raw configuration
migration remain strict version 1. Saved-report envelopes currently retain their
version 1 contract; persist a version 2 document with its explicit parser instead.
Version 2 fields replace sourceProp with exactly one source declaration:
| Source | Declaration |
|---|---|
| Physical | { kind: 'physical', prop }; prop is a string or finite number. |
| Expression | { kind: 'expression', expression }; references use stable field IDs. |
| Runtime | { kind: 'runtime', bindingId, dependencies }; dependencies are ordered, unique field IDs. No callback is serialized. |
Expression and runtime fields require a declared scalar dataType: string,
number, or boolean. Physical fields retain optional descriptive datatype
metadata, including date. Document validation does not evaluate facts or infer
an expression’s output type.
The closed expression grammar supports scalar/null literal nodes, field
references, numeric binary nodes (+, -, *, /), concat with at least two
args, and coalesce with at least one argument. Executable JavaScript, formula
source strings, arbitrary functions, measure references, and date operations are
outside this grammar.
Validation checks references across the entire field catalog, including unused fields and every coalesce branch. Forward references are allowed; self references, cycles, and duplicate runtime dependencies are rejected. Catalog order is retained. Limits are 32 AST levels, 1,024 nodes per expression, 8,192 AST nodes per report, and 16,384 dependency references per report; repeated references count separately. Root nodes count as level one and as a node. Unknown keys, accessors, sparse arrays, and non-JSON values are rejected rather than silently removed.
Reattach custom aggregates
Section titled “Reattach custom aggregates”Each field can declare an explicit aggregate list. Runtime entries reference a named application callback. Built-in entries can expose a built-in aggregate under an application-selected name.
import type { PivotReportRuntime } from '@revolist/pivot';
const positiveSum = (values: unknown[]) => values.reduce<number>( (sum, value) => { const amount = Number(value); return sum + (Number.isFinite(amount) ? Math.max(0, amount) : 0); }, 0,);
const customReport: PivotReport = { ...report, fields: report.fields.map(field => field.id === 'revenue' ? { ...field, aggregators: [{ id: 'sum', kind: 'runtime' as const, bindingId: 'positive-revenue', }], } : field),};
const runtime: PivotReportRuntime = { aggregators: new Map([['positive-revenue', positiveSum]]),};
const saved = serializePivotReport(customReport);const compiled = compilePivotReport(parsePivotReport(saved), runtime);A nonempty aggregate list replaces the default aggregate map for that field,
matching existing dimension behavior. Include every aggregate that the field’s
measures need. A runtime aggregate named sum overrides built-in sum.
An omitted or empty list uses the existing built-ins.
Bindings and field mapping
Section titled “Bindings and field mapping”| Runtime registry | Purpose |
|---|---|
fields | Map a stable field ID to a physical source property |
columns | Reattach column callbacks and application column extensions |
aggregators | Reattach aggregate callbacks |
placeholders | Reattach complete column-collapse placeholder behavior |
texts | Reattach localization callbacks |
sources | Resolve application-owned original record arrays |
engines | Resolve trusted adapter/store options and capabilities |
mounts | Resolve application-owned configurator mount elements |
Reference those registries from the corresponding report properties:
fields[].runtimeColumnId, fields[].aggregators[].bindingId for runtime
aggregates, presentation.columnCollapse.placeholder.bindingId, and
bindings.sourceId, bindings.engineId, bindings.mountId, bindings.i18nId,
or bindings.fieldPanelTextsId. Physical field overrides use the field ID
directly as the registry key.
Text entries normally contain strings. Existing localization callback keys can
also contain { kind: 'builtin-default' } to select the corresponding built-in
callback explicitly. Compilation restores that callback’s original identity;
custom callbacks still come from the texts registry. An omitted field-panel
text inherits the global localization, while an explicit built-in callback
overrides it. Migration preserves this distinction.
When a report binds a source, compilation returns it as compiled.source;
the compiler does not assign records to a grid or execute a query. Install the
original records through the application’s existing grid/source owner before
enabling the compiled Pivot configuration.
For example, fields: new Map([['revenue', 'net_sales']]) changes the physical
mapping while the report still references revenue. The compiler rewrites axes,
measure fields, field-based calculations, member filters, subtotal rules, and
other source-field references consistently. Mappings that collide in the current
engine’s string-keyed properties are rejected.
Formula operands reference stable measure IDs. Field-based calculations use
fieldId. Ranking uses measureId, so reordering measures does not change the
measure used for ranking. Conditional-format generatedFields selectors retain
literal generated-grid properties; they are compatibility selectors and do not
automatically follow arbitrary layout changes.
Result filters currently use the existing summary selectors: sum, min, max,
avg, and count. A selector must match its configured measure, including its
calculation. Other aggregate names can be used by measures but are not currently
supported by result-filter selectors; validation rejects them explicitly.
Runtime bindings use Map entries in distinct categories. Report JSON cannot
grant backend field access, provide SQL, or override engine capabilities. Supply
that authority through the application’s engine and backend configuration.
Handle diagnostics
Section titled “Handle diagnostics”import { PivotReportError } from '@revolist/pivot';
try { compilePivotReport(restored, runtime);} catch (error) { if (!(error instanceof PivotReportError)) throw error; for (const diagnostic of error.diagnostics) { console.error(diagnostic.code, diagnostic.path, diagnostic.message); }}Missing bindings, unknown fields/measures, duplicate IDs, unsupported versions, and ambiguous physical mappings fail explicitly. Functions, unsupported objects, cycles, non-finite numbers, and other values that cannot roundtrip through the report’s JSON contract require a runtime binding or a corrected report.
Use JSON strings or string arrays for formatting locales. Place functional column
metadata, custom placeholders, and localization callbacks in their corresponding
runtime registries. The legacy createSerializablePivotConfig helper remains a
lossy projection and cannot preserve these behaviors by itself.
Migrate an existing configuration
Section titled “Migrate an existing configuration”Use createPivotReportFromConfig to convert an existing PivotConfig into a
portable report. Supply explicit IDs when your application already has names for
its fields and measures.
import { createPivotReportFromConfig, migratePivotReport, type PivotConfig,} from '@revolist/pivot';
const legacyConfig: Partial<PivotConfig> = { rows: ['region'], values: [{ prop: 'sales', aggregator: 'sum' }],};
const migrationOptions = { fieldIds: [{ sourceProp: 'sales', fieldId: 'revenue' }], measureIds: [{ valueIndex: 0, measureId: 'revenue-total' }],};
const migrated = createPivotReportFromConfig(legacyConfig, migrationOptions);
const oldState = JSON.stringify({ kind: 'revogrid:pivot.state', version: 1, config: legacyConfig,});const migratedState = migratePivotReport(oldState, migrationOptions);migratePivotReport accepts portable reports, saved reports, state JSON version 1,
saved-view version 2, and raw legacy configurations. Unknown envelopes and
unsupported versions fail explicitly. Existing measure IDs are preserved;
conflicting explicit measure IDs are rejected.
Mapping selectors use the configuration’s actual physical properties, preserving the distinction between numeric and string properties. Compiled configurations retain immutable identity metadata through ordinary object spreads, so subsequent exports preserve their original portable field IDs and source properties. Export derives axes, values, filters, and presentation from the current configuration.
Custom callbacks and handles need matching runtime registries during migration. If multiple binding names resolve to the same callback, select a name explicitly with the migration options. Migration rejects values or properties that would otherwise disappear; it does not use the legacy lossy projection.
Import and export through the plugin
Section titled “Import and export through the plugin”Use the PivotPlugin instance to export the current configuration, including
changes made through the built-in configurator. The plugin keeps field and measure
identity while deriving the report from the current configuration.
import { PivotPlugin } from '@revolist/pivot';
async function transferCurrentReport(targetGrid: HTMLRevoGridElement) { const plugins = await grid.getPlugins(); const pivot = plugins.find(plugin => plugin instanceof PivotPlugin); if (!pivot) throw new Error('Install PivotPlugin on the source grid');
pivot.setPivotReportRuntime({ runtime }); const current = pivot.getPivotReport(); const portableJson = pivot.exportPivotReportJson();
const targetPlugins = await targetGrid.getPlugins(); const targetPivot = targetPlugins.find(plugin => plugin instanceof PivotPlugin); if (!targetPivot) throw new Error('Install PivotPlugin on the target grid');
const accepted = await targetPivot.importPivotReport(portableJson, { runtime }); return { current, accepted };}setPivotReportRuntime replaces the plugin’s default report options without
executing Pivot. Per-call options apply to that call only; a supplied runtime
replaces the whole registry object rather than merging its categories. Runtime
callbacks, adapters, mounts, and sources retain their application-owned identity.
Import validates, migrates, and compiles before emitting the preventable Pivot
configuration event. Invalid reports and missing bindings throw PivotReportError
before execution. The returned boolean is true only when the operation commits
or is an unchanged successful report (noop). Prevention, execution failure,
cancellation, and supersession return false.
A pending or failed remote import retains the last successfully committed report
and analytical model. Read getAuthoringController().getSnapshot() for loading,
error, and pending report state; a proposal is not a successful import. Runtime
callbacks and backend requests can have application-owned side effects, so this
retention does not roll back external systems.
The plugin obtains the physical source from the grid’s source owner; its report
options do not accept a source override. A report with a source binding resolves
that source from the runtime registry on import. If the application later replaces
grid.source, update the corresponding source registry entry before exporting;
an entry that still points to the old array produces a binding diagnostic.
Reports without a source binding remain reusable with the target grid’s data.
The context-menu Pivot Report JSON export uses this same live export path. Export failures display a diagnostic dialog and do not start a download. Export currently rejects active linked-view member-filter contributions with an actionable diagnostic, because saving the base report would omit that additional scope. Remove those contributions before exporting; an explicit choice between saving the base report and effective linked-view scope is not yet available.
Save and load a portable report
Section titled “Save and load a portable report”Save and restore reports
Persist V1 envelope using store/user/view; bindings outside JSON
savePivotReport, loadPivotReport, and createPivotSavedReportState support
version 1 report documents only, using your existing PivotRemoteStore.
Their saved-report envelope version is separate from backend record versions.
Runtime registries remain application-owned and are not stored in the report.
For an explicitly opted-in V2 report, persist serializePivotReport(document)
through application-owned storage and restore it with
parsePivotReport(json, { version: 2 }). Then execute it with the existing V2
controller or plugin import options. Passing V2 to the V1 saved-report helpers is
rejected; those helpers have no V2 admission option.
import { savePivotReport, loadPivotReport, type PivotRemoteStore,} from '@revolist/pivot';
async function persistReport(store: PivotRemoteStore) { await savePivotReport(store, { userId: 'current-user', viewId: 'sales-analysis', name: 'Sales analysis', report: migrated, });
const loaded = await loadPivotReport(store, { userId: 'current-user', viewId: 'sales-analysis', });
return compilePivotReport(loaded.report, runtime);}The stored envelope is revogrid:pivot.saved-report version 1. Validation happens
before the store receives a save request. Load options can include migration
options for legacy payloads and an abort signal; save options also accept an
abort signal. The store owns request execution and cancellation.
The returned version describes the portable payload schema. recordVersion,
when present, comes from the backend response and is separate from schema
selection. Use loadPivotReport for portable reports; loadPivotView retains its
existing configuration-based contract.
Preserve remote viewport state
Section titled “Preserve remote viewport state”presentation.viewport stores optional rows and columns axis settings:
offset, limit, expandedPaths, and collapsedPaths. It requires an
engineId binding. This keeps changed analytical windows and server collapse
paths in the report while adapters, backend identifiers, and capabilities remain
in the application-owned engine binding.
A supplied axis object replaces that axis’s bound options; {} clears them.
An omitted axis inherits its engine binding’s options. Compilation preserves
adapter and store identities while applying the portable viewport.
Execute version 2 without a grid
Section titled “Execute version 2 without a grid”The existing headless controller executes both versions in one lifetime when
created with reportVersion: 2. Omitting that option keeps the version 1 contract.
Its normal commands, transactions, source replacement, undo and redo retain the
same state owner; version 2 does not create a second engine.
import { createPivotController, type PivotReportRuntimeV2 } from '@revolist/pivot/headless';
const runtime: PivotReportRuntimeV2 = {};// This document uses a portable expression and needs no runtime accessor.const controller = createPivotController({ reportVersion: 2, report: sourceDocument, source: records, runtime, query: { sourceFilter: [{ kind: 'field', fieldId: 'adjusted-revenue' }, '>', 100], sort: [{ selector: { kind: 'field', fieldId: 'region' }, bySummary: 'revenue-total', desc: true }], },});await controller.refresh();const committed = controller.getSnapshot();await controller.replaceQuery({});controller.dispose();Use IDs actually present in your report for selectors and bySummary. A tagged
{ kind: 'field', fieldId } addresses a semantic field. Bare strings and numbers
always address physical source properties, including uncatalogued properties in
WHERE; an internal generated selector is not a public field reference. Each sort
descriptor must identify exactly one active row or column axis. bySummary is a
configured measure ID, so different descriptors can sort by different measures.
query.sourceFilter applies before aggregation. query.having applies afterward
and identifies a configured measureId, columnPath, columnKind, operation
and value. Authored result filters, bridge filters and public HAVING remain
conjoined. replaceQuery({}) clears only the public query layer; it does not clear
native member selections, Core filters or linked-view contributions.
Query inputs are owned JSON. They are separate from portable report history.
snapshot.query is null before the first successful result, then describes only
the committed view (the default is {}). Pending or failed input does not change
the visible query. Queries retain original tagged intent across source changes;
an undo or report replacement that removes a queried field or measure rejects
without moving the history cursor. Public tagged input is admitted against the
committed catalog and revalidated against the operation’s target catalog.
rowOffset, rowLimit, columnOffset and columnLimit accept nonnegative safe
integers. Zero is an empty window. Omitted local windows include the complete
result; remote engines retain their existing viewport defaults.
Runtime accessors receive the original row, its original numeric sourceIndex,
and get(fieldId) for declared dependencies. get is valid only during that
callback frame. Outputs must be synchronous scalar values of the declared type,
or null. The evaluator permits at most 128 simultaneously active runtime frames.
Callbacks are application code with application side effects; they are not
sandboxed. Physical fields preserve raw values for custom aggregators while
expressions and accessor dependencies use the typed scalar lane.
Initial source selection uses a bound source registry entry, then the supplied
source, then an empty array. Explicit source replacements take precedence;
changing the report’s source ID selects its new registry entry, otherwise the
current source remains. Empty arrays are valid. Replacing sources or runtime
accessors rebuilds evaluation authority; presentation-only changes can reuse
aggregates. Field configuration commands cannot change source or sourceProp;
replace the report to edit source definitions.
Remote engines accept physical-only version 2 catalogs and receive physical backend selectors. Any expression or runtime field in the catalog, including an unused field, rejects before remote authority is called.
For plugin imports, explicitly pass { reportVersion: 2, runtime } to
importPivotReport. V2 exports use the same explicit options on getPivotReport
or exportPivotReportJson; provide caller-owned bindings for all required
callbacks. Private session bindings are never implicit export authority.
loadPivotFilterValues accepts its existing request object with field set to a
tagged selector for semantic fields, and reads the committed result scope.
Native Core conditions on semantic fields use the captured stock Core evaluator locally, including its casing, coercion, Boolean grouping and blank rules. Custom or overridden semantic predicates, cell parsers and blank callbacks are rejected before application field evaluation. Physical-only remote catalogs use the existing V1 canonical transport mapping; its comparison semantics belong to the backend and are not claimed to match local Core predicates. A native blank condition without a transport mapping is rejected rather than omitted. Remote drillthrough retains the existing backend view-ID and cell scope contract.
Clearing Pivot restores physical filters to the raw grid and retires derived conditions. A physical alias can be restored only when its raw destination is unique and its captured parser/blank settings are equivalent. Conflicting aliases or different raw settings reject clear atomically; remove the conflicting native filter before clearing. Destroy still disposes the plugin after such a rejection.