/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import type {StoreRecord} from '@xh/hoist/data'; import {isEqual, map} from 'lodash'; import type {Query} from '../Query'; import type {BaseRow} from '../row/BaseRow'; import {ExposedLeafRow, type LeafRow} from '../row/LeafRow'; import type {ParentRow} from '../row/ParentRow'; import type {View} from '../View'; /** * Cache of the BaseRows generated by a View, allowing rows - with their published data objects * and `cubeRowDigest` stamps - to be reused across regenerations of the View's results. * * A cached row is reused only when its published data can match a from-scratch rebuild: leaves * must be backed by the same (immutable) cube StoreRecord and match the view's exposed/hidden * leaf class; parents recompute in place as needed per {@link ParentRow.reuse}. Digests bump * only on actual value change, letting connected stores skip record rebuilds for unchanged rows. * * The cache invalidates itself at generation start when the query has changed. Unused entries * are retained for later reuse (e.g. filter widening), bounded by eviction of parents orphaned * by a grouping change and a sweep of dead-record rows when the cache outgrows the rows in use * by 50%+. * * @internal */ export class RowCache { private view: View; private rows = new Map(); private exposedLeaves = false; private sweptAsOf: number = null; private genStartDigest = 0; private lastQuery: Query = null; private usedParents: Set = null; // Row disposition for the current generation - for ViewDiagnostics reused = 0; rebuilt = 0; created = 0; constructor(view: View) { this.view = view; } get size(): number { return this.rows.size; } get(id: string): BaseRow { return this.rows.get(id); } /** * Return the cached row for id if reusable, else build via `fn` and cache. Leaves validate * against `record`; parents recompute in place per {@link ParentRow.reuse}. */ getOrCreate( id: string, children: BaseRow[], fn: () => T, record?: StoreRecord ): T { let ret = this.rows.get(id); if (ret) { if (ret.isLeaf) { if ( (ret as LeafRow).cubeRecord === record && ret instanceof ExposedLeafRow === this.exposedLeaves ) { this.reused++; return ret as T; } } else if ((ret as ParentRow).reuse(children, this.genStartDigest)) { this.usedParents?.add(ret); this.reused++; return ret as T; } this.rebuilt++; } else { this.created++; } ret = fn(); if (!ret.isLeaf) this.usedParents?.add(ret); this.rows.set(id, ret); return ret as T; } beginGeneration() { const {view} = this; this.pruneForQueryChange(view.query); this.genStartDigest = view._rowDigest; this.exposedLeaves = view.exposesLeaves; this.reused = this.rebuilt = this.created = 0; } endGeneration() { if (this.usedParents) { this.doEvictUnusedParents(); this.usedParents = null; } this.sweep(); } clear() { this.rows.clear(); this.lastQuery = null; this.usedParents = null; } //------------------ // Implementation //------------------ // Invalidate cached rows affected by a query change since the last generation. private pruneForQueryChange(query: Query) { const oldQuery = this.lastQuery; if (oldQuery === query) return; this.lastQuery = query; // 0) Fresh cache, or a filter-only change - nothing to prune. if (!oldQuery || oldQuery.equalsExcludingFilter(query)) return; const oldExposed = oldQuery.includeLeaves || oldQuery.provideLeaves, newExposed = query.includeLeaves || query.provideLeaves, oldFieldNames = new Set(map(oldQuery.fields, 'name')), fieldsGained = query.fields.some(it => !oldFieldNames.has(it.name)), bucketsRemoved = oldQuery.bucketSpecFn && !query.bucketSpecFn; // 1) Leaf-mode flips, field gains on exposed leaves, and bucket removal invalidate // wholesale - existing rows' data was minted without the gained fields. (Dropped fields // do NOT invalidate: they remain readable on existing rows, but are out of contract.) if (oldExposed !== newExposed || (newExposed && fieldsGained) || bucketsRemoved) { this.rows.clear(); return; } // 2) Field gains with hidden leaves, or a changed bucketSpecFn: leaves remain valid, // but parents hold never-computed aggregates or a stale BucketSpec. if (fieldsGained || oldQuery.bucketSpecFn !== query.bucketSpecFn) { this.removeParentRows(); return; } // 3) Otherwise retain everything... // but on a changed grouping, be sure to throw out any unused rows // at end, when we have already rescued any reusable "upper tree" nodes. if (!isEqual(oldQuery.dimensions, query.dimensions)) { this.usedParents = new Set(); } } // Remove all parent rows, nulling retained leaves' parent pointers so dead chains are not // pinned in memory. private removeParentRows() { const {rows} = this; rows.forEach((row, id) => { if (row.isLeaf) { row.parent = null; } else { rows.delete(id); } }); } // Drop parents untouched by this generation's grouping (their live-record children would // otherwise keep them from ever sweeping), then null dangling parent pointers so evicted // chains are not pinned in memory. private doEvictUnusedParents() { const {rows, usedParents} = this; let removed = 0; rows.forEach((row, id) => { if (!row.isLeaf && !usedParents.has(row)) { rows.delete(id); removed++; } }); if (!removed) return; rows.forEach(row => { const {parent} = row; if (parent && rows.get(parent.id) !== parent) row.parent = null; }); } // Drop rows whose cube records have been removed or replaced - rows only die with their // records. Skipped until the cache outgrows the generation's live rows by 50%+. private sweep() { const live = this.reused + this.rebuilt + this.created, asOf = this.view.cube.lastUpdated; if (asOf === this.sweptAsOf || this.size <= 1.5 * live) return; const start = performance.now(), {rows} = this, {store} = this.view.cube, memo = new Map(), startSize = rows.size; const isRetained = (row: BaseRow): boolean => { let ret = memo.get(row); if (ret === undefined) { ret = row.isLeaf ? store.getById((row as LeafRow).cubeRecordId) === (row as LeafRow).cubeRecord : !!row.children?.every(it => rows.get(it.id) === it && isRetained(it)); memo.set(row, ret); } return ret; }; rows.forEach((row, id) => { if (!isRetained(row)) rows.delete(id); }); this.sweptAsOf = asOf; this.view.logDebug( `Swept ${startSize - rows.size} of ${startSize} cached rows`, `${(performance.now() - start).toFixed(1)}ms` ); } }