Store: zero-copy "adopt raw data" mode for connected Cube Views
Summary
Add an opt-in Store mode that treats each record's pre-parsed raw object as its data object, shared by reference, instead of re-parsing and copying it. Targets the dominant Cube use-case: a Cube feeding one or more connected Views, each driving a Store behind a (tree) grid.
Today every row is two retained objects: the ViewRowData the View builds and caches, and a separate per-record data object the Store re-parses from it via parseRaw. The Store's copy is redundant here - it re-runs parseVal (already applied by the Cube) and allocates a second object holding the same values, while the ViewRowData stays alive anyway as StoreRecord.raw.
This mode makes StoreRecord.data === the ViewRowData, collapsing 2N row objects to N and skipping the per-row re-parse on every load and tick. As a bonus, the framework-level cube* fields (cubeLabel, cubeRowType, cubeDimension, cubeBuckets) and the isCubeLeaf / cubeLeaves getters are present on records for free - no field registration required.
Builds on #4500/#4502 (record data factory, View row assigner). retainRaw (#4504) is moot on this path - raw === data.
Motivation
Target pattern (real client apps): non-editable, ticking/streaming data loaded into a Cube, fanned out to several connected Views, each driving a tree grid. Rows are display-only; the grid never edits store records locally. At 10k-100k rows these apps are memory- and GC-sensitive, and #4501/#4502 already showed this is where V8 object cost dominates.
In that pattern the Store's re-parse buys nothing: leaf values arrive already typed (they came out of the Cube's own internal store, parsed by the same CubeField instances the Store uses), aggregates are computed by aggregators, and the Store is a read-only projection - the committed/dirty/revert machinery the copy exists to support is never exercised. Yet we pay for it twice per row, for the life of the grid.
Headline win is memory (one fewer ~30-field object per row, plus no second retained copy). Performance is a modest secondary win: skips one object allocation + the parseRaw field loop per row per tick, reducing GC churn on high-frequency updates.
Why sharing is safe (verified against current code)
Hoist grid updates are driven at the record-identity level, not on data:
StoreRecord.data is a plain, non-observable readonly prop (StoreRecord.ts).
Grid.genTransaction diffs by StoreRecord reference identity - a record lands in the ag-Grid update list iff existing !== rec (Grid.ts), never by inspecting .data. (Note: Hoist does not use ag-Grid's immutable-data / delta-row mode; it computes transactions itself and calls agApi.applyTransaction(), with updateGridOptions({rowData}) only on the initial load.)
- The tick path (
updateData -> RecordSet.withTransaction) unconditionally installs the new record for each update (newRecords.set(id, rec)) - no data-equality dedup. So every tick mints a new identity and the grid always sees an update, regardless of whether data was shared/mutated.
The two mutation contracts that would otherwise collide (Store freezes + snapshots; View mutates ViewRowData in place) are reconciled by scoping this to read-only projections with freezeData: false, and by the Store never touching data internals in this mode.
Checked the one path that could wrongly reuse a record under shared+mutated data - the loadData reuse path (RecordSet.areRecordsEqual, deep-equals .data). It cannot misfire: the only in-place mutation happens in View.dataOnlyUpdate -> updateData (no dedup). The loadData path only ever sees unchanged leaves (query-only change) or freshly-built rows (row cache cleared on noteCubeLoaded).
Proposal
Add a first-class boolean flag to StoreConfig (proposed name: adoptRawData; default false). When set:
- Zero-copy record creation - adopt the incoming raw object as
data directly, by reference; skip processRawData and parseRaw. record.data === the raw object (typically the ViewRowData), so cube* fields and the isCubeLeaf / cubeLeaves getters are present with no field registration.
- Never touch
data - do not parseRaw, do not assign data.id, do not freeze. The Store does not own this object in this mode.
freezeData forced to false - the View mutates rows in place on tick updates; a frozen shared object would throw on the next mutation.
- Read-only - editing/commit/revert APIs are unsupported (throw), since committed === current always. Field registry becomes metadata for sort/filter/columns/export, not the definition of
data's shape.
- Records are still recreated on load and
updateData, reusing the (in-place-updated) data refs -> new identities -> correct ag-Grid transactions. This already happens; no new View->Store contract is needed (View already passes ViewRowData into loadData/updateData).
Mode contract (document prominently on the flag)
This is effectively an "unsafe"/"turbo" mode - it puts substantial trust in the data provider.
- Raw data MUST already be parsed. Because
parseRaw is skipped, store field type/parseVal are not applied to data. Fields are pure metadata (sort/filter/columns/export); values are exactly what the provider supplied. This is always true for Cube/View data - the Cube already parsed it, typically using the same CubeField instances the Store uses. A dev defining a store field type expecting coercion in this mode would be surprised.
- The Store does not own
data. It never reads or writes data internals. The provider (the View) is authoritative and may mutate rows in place.
- Rows may be shared across connected stores.
View.loadStores feeds the same _rowDatas array to every connected store; with by-reference adoption, their records point at one object set. Do not mutate these objects from app code.
Intended use / generality
Primary use-case is ViewRowData fed by a connected View. The mechanism is general enough to work with any already-parsed data whose provider follows the same contract (pre-parsed, read-only, in-place updates mint new records). Decision to make when finalizing: expose it as generally usable (documented caveats) vs. document it as intended only for ViewRowData from a connected View. Lean general-but-clearly-caveated unless the data.id verification (below) surfaces a reason to restrict.
idSpec / data.id
StoreRecord's constructor normally does data.id = id. In this mode we skip that assignment (we never touch data). ViewRowData.id already matches the default idSpec: 'id' read, so identity is consistent with zero writes. Default idSpec: 'id' for this mode.
Verification item (must do before finalizing): grep/confirm nothing internal reads record.data.id on the read-only path. Known writers/consumers of data.id (ctor assignment, parseUpdate, getModifiedValues) are all on the edit machinery this mode disables - confirm there are no others. Document the caveat that data.id is not maintained by the Store in this mode.
Guardrails
Construction-time (throw):
processRawData present - the skipped parse would make it a silent no-op.
reuseRecords: true - already rejected by View.parseStores for connected stores; also assert at the Store level for this flag.
freezeData: true (if explicitly set) - or silently force to false.
Runtime (throw / no-op with clear error):
modifyRecords, addRecords, removeRecords, revertRecords, revert - unsupported in this read-only mode.
Scope of changes
Store - add adoptRawData to StoreConfig and the class; branch in createRecord to adopt the raw object as data (skip processRawData/parseRaw/data.id/freeze); force freezeData: false; add construction guardrails; guard the edit APIs.
View - no new data contract required for the zero-copy path (already passes ViewRowData into loadData/updateData). Optionally: validate/encourage that connected stores using this flag are configured compatibly.
GridModel / Grid - no code changes expected (transaction generation is already identity-based). Covered by the acceptance test below.
ag-Grid cell change-detection - required acceptance test (primary risk)
This is the one behavior not guaranteed by Hoist code alone; it depends on ag-Grid internals and must be demonstrated, not assumed.
Under this mode the View mutates the ViewRowData before the new record is built, so when ag-Grid processes the update transaction, newRecord.data === oldRecord.data === the already-mutated object. Whether a simple-renderer cell repaints then depends on ag-Grid recomputing its valueGetter and diffing against its own per-cell cached last-rendered value (expected), rather than diffing old-vs-new data-object references. Hoist force-refreshes only complex-renderer columns (Grid.ts); simple columns ride entirely on ag-Grid's change detection. Today's copy-based design masks this (the prior frozen copy genuinely differs); zero-copy removes that cushion.
Setup: Cube -> connected View -> Store with adoptRawData: true -> tree grid. Include at least one column with a plain/simple renderer (plain text or numberRenderer, not rendererIsComplex) plus one complex-renderer column for contrast.
Action: Cube.updateDataAsync with a leaf value change that does not alter dimensions or row population, forcing the dataOnlyUpdate (tick) path, not fullUpdate. Cover a changed leaf value, a changed aggregate/parent value, and a value that does not change.
Assert:
- Simple-renderer cell repaints to the new value.
- Aggregate/parent-row cells repaint (mutated via
parent.applyDataUpdate).
- Unchanged cells do not flicker.
includeRoot "Total" row renders and updates.
- Pinned summary row (if any) stays correct.
If simple columns go stale, fall back to force-refreshing all visible columns for updated rows in this mode (or a targeted refresh), and note the added cost.
Non-goals
- No change to default Store behavior (opt-in flag, default
false) - non-breaking.
- Not making View row updates non-mutating/immutable (larger change, not needed here).
- No editing support in this mode.
Store: zero-copy "adopt raw data" mode for connected Cube Views
Summary
Add an opt-in
Storemode that treats each record's pre-parsed raw object as itsdataobject, shared by reference, instead of re-parsing and copying it. Targets the dominant Cube use-case: a Cube feeding one or more connectedViews, each driving aStorebehind a (tree) grid.Today every row is two retained objects: the
ViewRowDatathe View builds and caches, and a separate per-recorddataobject the Store re-parses from it viaparseRaw. The Store's copy is redundant here - it re-runsparseVal(already applied by the Cube) and allocates a second object holding the same values, while theViewRowDatastays alive anyway asStoreRecord.raw.This mode makes
StoreRecord.data === the ViewRowData, collapsing 2N row objects to N and skipping the per-row re-parse on every load and tick. As a bonus, the framework-levelcube*fields (cubeLabel,cubeRowType,cubeDimension,cubeBuckets) and theisCubeLeaf/cubeLeavesgetters are present on records for free - no field registration required.Builds on #4500/#4502 (record
datafactory, View row assigner).retainRaw(#4504) is moot on this path -raw === data.Motivation
Target pattern (real client apps): non-editable, ticking/streaming data loaded into a Cube, fanned out to several connected Views, each driving a tree grid. Rows are display-only; the grid never edits store records locally. At 10k-100k rows these apps are memory- and GC-sensitive, and #4501/#4502 already showed this is where V8 object cost dominates.
In that pattern the Store's re-parse buys nothing: leaf values arrive already typed (they came out of the Cube's own internal store, parsed by the same
CubeFieldinstances the Store uses), aggregates are computed by aggregators, and the Store is a read-only projection - the committed/dirty/revert machinery the copy exists to support is never exercised. Yet we pay for it twice per row, for the life of the grid.Headline win is memory (one fewer ~30-field object per row, plus no second retained copy). Performance is a modest secondary win: skips one object allocation + the
parseRawfield loop per row per tick, reducing GC churn on high-frequency updates.Why sharing is safe (verified against current code)
Hoist grid updates are driven at the record-identity level, not on
data:StoreRecord.datais a plain, non-observablereadonlyprop (StoreRecord.ts).Grid.genTransactiondiffs by StoreRecord reference identity - a record lands in the ag-Gridupdatelist iffexisting !== rec(Grid.ts), never by inspecting.data. (Note: Hoist does not use ag-Grid's immutable-data / delta-row mode; it computes transactions itself and callsagApi.applyTransaction(), withupdateGridOptions({rowData})only on the initial load.)updateData->RecordSet.withTransaction) unconditionally installs the new record for each update (newRecords.set(id, rec)) - no data-equality dedup. So every tick mints a new identity and the grid always sees an update, regardless of whetherdatawas shared/mutated.The two mutation contracts that would otherwise collide (Store freezes + snapshots; View mutates
ViewRowDatain place) are reconciled by scoping this to read-only projections withfreezeData: false, and by the Store never touchingdatainternals in this mode.Checked the one path that could wrongly reuse a record under shared+mutated data - the
loadDatareuse path (RecordSet.areRecordsEqual, deep-equals.data). It cannot misfire: the only in-place mutation happens inView.dataOnlyUpdate->updateData(no dedup). TheloadDatapath only ever sees unchanged leaves (query-only change) or freshly-built rows (row cache cleared onnoteCubeLoaded).Proposal
Add a first-class boolean flag to
StoreConfig(proposed name:adoptRawData; defaultfalse). When set:datadirectly, by reference; skipprocessRawDataandparseRaw.record.data === the raw object(typically theViewRowData), socube*fields and theisCubeLeaf/cubeLeavesgetters are present with no field registration.data- do notparseRaw, do not assigndata.id, do not freeze. The Store does not own this object in this mode.freezeDataforced tofalse- the View mutates rows in place on tick updates; a frozen shared object would throw on the next mutation.data's shape.updateData, reusing the (in-place-updated)datarefs -> new identities -> correct ag-Grid transactions. This already happens; no new View->Store contract is needed (View already passesViewRowDataintoloadData/updateData).Mode contract (document prominently on the flag)
This is effectively an "unsafe"/"turbo" mode - it puts substantial trust in the data provider.
parseRawis skipped, store fieldtype/parseValare not applied todata. Fields are pure metadata (sort/filter/columns/export); values are exactly what the provider supplied. This is always true for Cube/View data - the Cube already parsed it, typically using the sameCubeFieldinstances the Store uses. A dev defining a store fieldtypeexpecting coercion in this mode would be surprised.data. It never reads or writesdatainternals. The provider (the View) is authoritative and may mutate rows in place.View.loadStoresfeeds the same_rowDatasarray to every connected store; with by-reference adoption, their records point at one object set. Do not mutate these objects from app code.Intended use / generality
Primary use-case is
ViewRowDatafed by a connectedView. The mechanism is general enough to work with any already-parsed data whose provider follows the same contract (pre-parsed, read-only, in-place updates mint new records). Decision to make when finalizing: expose it as generally usable (documented caveats) vs. document it as intended only forViewRowDatafrom a connected View. Lean general-but-clearly-caveated unless thedata.idverification (below) surfaces a reason to restrict.idSpec/data.idStoreRecord's constructor normally doesdata.id = id. In this mode we skip that assignment (we never touchdata).ViewRowData.idalready matches the defaultidSpec: 'id'read, so identity is consistent with zero writes. DefaultidSpec: 'id'for this mode.Verification item (must do before finalizing): grep/confirm nothing internal reads
record.data.idon the read-only path. Known writers/consumers ofdata.id(ctor assignment,parseUpdate,getModifiedValues) are all on the edit machinery this mode disables - confirm there are no others. Document the caveat thatdata.idis not maintained by the Store in this mode.Guardrails
Construction-time (throw):
processRawDatapresent - the skipped parse would make it a silent no-op.reuseRecords: true- already rejected byView.parseStoresfor connected stores; also assert at the Store level for this flag.freezeData: true(if explicitly set) - or silently force tofalse.Runtime (throw / no-op with clear error):
modifyRecords,addRecords,removeRecords,revertRecords,revert- unsupported in this read-only mode.Scope of changes
Store- addadoptRawDatatoStoreConfigand the class; branch increateRecordto adopt the raw object asdata(skipprocessRawData/parseRaw/data.id/freeze); forcefreezeData: false; add construction guardrails; guard the edit APIs.View- no new data contract required for the zero-copy path (already passesViewRowDataintoloadData/updateData). Optionally: validate/encourage that connected stores using this flag are configured compatibly.GridModel/Grid- no code changes expected (transaction generation is already identity-based). Covered by the acceptance test below.ag-Grid cell change-detection - required acceptance test (primary risk)
This is the one behavior not guaranteed by Hoist code alone; it depends on ag-Grid internals and must be demonstrated, not assumed.
Under this mode the View mutates the
ViewRowDatabefore the new record is built, so when ag-Grid processes the update transaction,newRecord.data === oldRecord.data ===the already-mutated object. Whether a simple-renderer cell repaints then depends on ag-Grid recomputing itsvalueGetterand diffing against its own per-cell cached last-rendered value (expected), rather than diffing old-vs-new data-object references. Hoist force-refreshes only complex-renderer columns (Grid.ts); simple columns ride entirely on ag-Grid's change detection. Today's copy-based design masks this (the prior frozen copy genuinely differs); zero-copy removes that cushion.Setup: Cube -> connected View ->
StorewithadoptRawData: true-> tree grid. Include at least one column with a plain/simple renderer (plain text ornumberRenderer, notrendererIsComplex) plus one complex-renderer column for contrast.Action:
Cube.updateDataAsyncwith a leaf value change that does not alter dimensions or row population, forcing thedataOnlyUpdate(tick) path, notfullUpdate. Cover a changed leaf value, a changed aggregate/parent value, and a value that does not change.Assert:
parent.applyDataUpdate).includeRoot"Total" row renders and updates.If simple columns go stale, fall back to force-refreshing all visible columns for updated rows in this mode (or a targeted refresh), and note the added cost.
Non-goals
false) - non-breaking.