Skip to content

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.

Interactive example

Compile a report

Portable IDs → compiled config → analytical result

The example loads when visible.

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.

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:

SourceDeclaration
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.

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.

Runtime registryPurpose
fieldsMap a stable field ID to a physical source property
columnsReattach column callbacks and application column extensions
aggregatorsReattach aggregate callbacks
placeholdersReattach complete column-collapse placeholder behavior
textsReattach localization callbacks
sourcesResolve application-owned original record arrays
enginesResolve trusted adapter/store options and capabilities
mountsResolve 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.

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.

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.

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.

Interactive example

Save and restore reports

Persist V1 envelope using store/user/view; bindings outside JSON

The example loads when visible.

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.

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.

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.