import type { GridFilterBindTarget } from '@xh/hoist/cmp/grid'; import { AnyIterable, HoistBase, PlainObject, Some } from '@xh/hoist/core'; import { Field, FieldSpec, Filter, FilterBindTarget, FilterLike, FilterValueSource, StoreRecord, StoreRecordDigest, StoreRecordId, StoreRecordOrId, StoreValidationMessagesMap, StoreValidationResultsMap, ValidationResult } from '@xh/hoist/data'; import { StoreValidator } from '@xh/hoist/data/impl/StoreValidator'; import { RecordSet } from './impl/RecordSet'; import { StoreDiagnostics } from './impl/StoreDiagnostics'; /** * Configuration for a {@link Store}. At minimum, provide `fields` (or let them be inferred * from GridModel columns). Data can be supplied at construction via `data`, or loaded later * via `Store.loadData()`. * * Can also be passed inline as the `store` config on {@link GridConfig}, where it will be * used to construct a Store automatically. * * See the data package README (`data/README.md`) for tree data, filtering, validation, and * performance tuning guidance. * * @see Store * @see FieldSpec */ export interface StoreConfig { /** Field names, configs, or instances. */ fields?: Array; /** * Default configs applied to `Field` instances constructed internally by this Store. * @see FieldSpec */ fieldDefaults?: Omit; /** * Specification for producing an immutable unique id for each record. May be provided as * either a string property name (default is 'id') or a function that receives the raw data * and returns a string. This property will be normalized to a function upon Store construction. * If there is no natural id to select/generate, you can use `XH.genId` to generate a unique id * on the fly. NOTE that in this case, grids and other components bound to this store will not * be able to maintain record state across reloads. */ idSpec?: StoreRecordIdSpec; /** * Initial data to load in to the Store. */ data?: PlainObject[]; /** * Function to pre-process individual data objects presented to `loadData()` prior to creating * a `StoreRecord` from that object. For efficiency, apps may mutate and return the passed * object in place - typically the raw data is transient (e.g. freshly fetched) and there is * no need to allocate a clone. If the app *does* cache, share, or otherwise * re-use the raw data, be careful to return a modified clone instead. */ processRawData?: (data: PlainObject) => PlainObject; /** * One or more filters or configs to create one. If an array, a single 'AND' filter * will be created. */ filter?: FilterLike; /** True if all children of a passing record should also be considered passing (default false).*/ filterIncludesChildren?: boolean; /** True (default) to load hierarchical/tree data, if any. */ loadTreeData?: boolean; /** * The property on each raw data object that holds its (raw) child objects, if any. * Default 'children', no effect if `loadTreeData: false`. */ loadTreeDataFrom?: string; /** True to treat the root node in hierarchical data as the summary record (default false). */ loadRootAsSummary?: boolean; /** * True to freeze the internal data object of the record. May be set to false to maximize * performance. Note that the internal data of the record should in all cases be considered * immutable (default true). */ freezeData?: boolean; /** * Set to true to indicate that the id for a record implies a fixed position of the record * within the tree hierarchy. May be set to true to maximize performance (default false). */ idEncodesTreePath?: boolean; /** * Specification for a *digest* derived from each incoming raw object and snapshotted on the * record when built - a performance optimization for large datasets whose provider can cheaply * identify unchanged records across loads and updates. * * By default (null), Store reuses existing StoreRecord instances when new data is loaded or * updated with matching IDs and identical field values (determined via equality comparison). * This preserves row state in grids for unchanged records. * * Set this config to supply a cheaper, stronger signal for that reuse. A record is reused * whenever a later raw object for its id yields an equal digest, skipping raw data processing, * parsing, and construction entirely: * * - string - the digest is the named raw property, e.g. a server-provided timestamp or * sequence number. * - function - the digest is the returned value. Return null to disqualify a row from reuse. * * Digests must be primitives, compared via `===` - build composite keys as strings (e.g. * `raw => raw.type + '|' + raw.seq`) and digest timestamps as epoch ms, not `Date`s. A provider * that caches and re-supplies its own row objects should stamp each row with a revision it * bumps on every mutation, and digest that - a stamp is the only signal that distinguishes an * unchanged row from one mutated in place. * * Applies to `loadData()` and `updateData()` alike - an update yielding an unchanged digest * is dropped from the transaction as a no-op, intentionally preserving any uncommitted local * modifications on the record. An update with a changed digest builds a new record and * overwrites local modifications, as updates otherwise always do. * * Stores connected to a Cube {@link View} must leave this config unset - the View manages * reuse automatically, installing a digest that reads the stamp it maintains on every row * it publishes. Any explicit value throws at connection. * * Should not be used with a `processRawData` function that depends on external state as that * function will be bypassed for reused records. * * Default null. */ digestSpec?: StoreRecordDigestSpec; /** * True (default) to have each StoreRecord retain a reference to the raw data object from * which it was created, exposed as `StoreRecord.raw`. May be set to false to reduce memory * usage on large stores - raw data objects are then eligible for garbage collection after * parsing, and `StoreRecord.raw` will be null. */ retainRaw?: boolean; /** * True to mark this store as a read-only projection of data owned and parsed elsewhere. * Recommended for stores connected to a Cube {@link View} for improved performance, when no * additional record parsing or local data modification is required. Default null - a View * logs a warning when its connected stores leave this unset. Set explicitly to `false` to * opt out and silence the warning. * * Each incoming raw object is used *as* its record's `data`, by reference, skipping the * per-record parse and copy on every load and update. Raw data must already match what the * Store's Fields would parse - `type`, `parseVal`, and `defaultValue` are not applied. The * Store never modifies or freezes these objects (regardless of `freezeData`), leaving the * provider free to mutate rows in place. Rows re-supplied by reference are therefore always * treated as changed - no value comparison can detect an in-place mutation. A provider that * retains and mutates its own rows should supply a `digestSpec` - the only signal that * restores record reuse for such rows. * * `data` will carry every key on the raw object, not just declared Fields - but only declared * Field values participate in the equality checks `loadData()`/`updateData()` use to detect * unchanged records for reuse. As a read-only projection, local modification APIs * (`addRecords`, `modifyRecords`, `removeRecords`, `revertRecords`, and `revert`) throw - * data updates flow in via `loadData()`/`updateData()`. * Not compatible with `processRawData`. */ projectionOnly?: boolean; /** * Set to true to always validate all uncommitted records on every change to * uncommitted records (add, modify, or remove). Default false. */ validationIsComplex?: boolean; /** * Flags for experimental features. These features are designed for early client-access and * testing, but are not yet part of the Hoist API. Currently includes: * - `maxPatchRatio` - max size of a RecordSet patch layer as a fraction of total records, * clamped to [0, 0.5] (default 0, disabling patching). Set to e.g. 0.1 to make * transaction, filtering, and grid-sync costs scale with the size of the change rather * than the size of the store. Note record order then becomes stable-by-incumbency rather * than source-order: existing records keep their positions and additions append, including * records entering a filter incrementally and adds within partial reloads. Apply a grid * sort where deterministic order matters. The ratio is read live on each operation, so * it may also be changed on an existing Store at any time. */ experimental?: PlainObject; } /** * App-wide defaults for {@link Store}, applied to every Store constructed without an explicit * value - including those Hoist itself creates internally. Limited to configs appropriate for * every Store in an app. */ export interface StoreDefaults { freezeData?: boolean; } /** * Object representing data changes to perform on a Store's committed record set in a single * transaction. */ export interface StoreTransaction { /** * List of raw data objects representing records to be updated. * Updates must be matched to existing records by id in order to be applied. The form of the * update objects should be the same as presented to loadData(), with the exception that any * children property will be ignored, and any existing children for the record being updated * will be preserved. If the record is a child, the new updated instance will be assigned to * the same parent. (Meaning: parent/child relationships *cannot* be modified via updates.) */ update?: PlainObject[]; /** Raw data of new records to be added, */ add?: Array; /** IDs of existing records to be removed. Any descendents will also be removed. */ remove?: StoreRecordId[]; /** * Update to the dedicated summary record(s) for this store. If the store has its * `loadRootAsSummary` flag set to true, the summary record should instead be provided via the * `update` property. */ rawSummaryData?: Some; /** * Names of every field whose value changed across the `update` rows, when the producer can * supply them cheaply. Providing this asserts that updates change record values only - no * structural/parent changes - and that no field outside the set changed. Enables downstream * consumers (e.g. Grid) to prove a change cannot affect sort order and skip re-sorting. */ changedFields?: Set; } /** * Collection of changes made to a Store's RecordSet. Unlike `StoreTransaction` which is used to * specify changes, this object is used to report the actual changes made in a single transaction. * Removed records are as they existed prior to removal - no longer resolvable by id. */ export interface StoreChangeLog { update?: StoreRecord[]; add?: StoreRecord[]; remove?: StoreRecord[]; summaryRecords?: StoreRecord[]; } export interface ChildRawData { /** ID of the pre-existing parent record. */ parentId: string; /** * Data for the child record to be added. Can include a `children` property to be processed * into new (grand)child records. */ rawData: PlainObject; } export type StoreRecordIdSpec = string | ((data: PlainObject) => StoreRecordId); export type StoreRecordDigestSpec = string | ((data: PlainObject) => StoreRecordDigest); /** * A managed, observable collection of in-memory {@link StoreRecord}s - the core data container * in Hoist. Used directly by applications and as the data source for {@link GridModel}, * {@link DataViewModel}, and other data-bound components. * * Stores provide: * - Observable record collections with filtering via composable {@link Filter} objects * - Hierarchical/tree data with parent-child navigation * - Local modification tracking (add/modify/remove) with commit/revert * - Record reuse across data reloads to preserve grid row state * - Pluggable validation via {@link Field} rules * * Data is loaded via `loadData()` (full replacement) or `updateData()` (transactional). Fields * can be defined explicitly or inferred from GridModel columns. `Store.defaults` provides * app-wide configuration. * * Record `data` objects are automatically memory-optimized - read them by field name and never * enumerate them directly. See {@link StoreRecord.data}, and the experimental * `denseRecordThreshold` config to adjust or disable the optimization for testing. * * See the data package README (`data/README.md`) for full documentation including tree data, * filtering patterns, validation, and common pitfalls. * * @see StoreConfig * @see StoreRecord * @see Field * * @mcpHint in-memory data store used by grids and other data components */ export declare class Store extends HoistBase implements FilterBindTarget, FilterValueSource, GridFilterBindTarget { /** App-level defaults for Store. Instance config takes precedence. */ static defaults: StoreDefaults; static isStore(obj: unknown): obj is Store; readonly isFilterValueSource = true; fields: Field[]; idSpec: (data: PlainObject) => StoreRecordId; processRawData: (raw: any) => any; filterIncludesChildren: boolean; loadTreeData: boolean; loadTreeDataFrom: string; loadRootAsSummary: boolean; idEncodesTreePath: boolean; freezeData: boolean; retainRaw: boolean; readonly projectionOnly: boolean; validationIsComplex: boolean; filter: Filter; /** Timestamp (ms) of the last time this store's data was changed. */ lastUpdated: number; /** Timestamp (ms) of the last time this store's data was loaded.*/ lastLoaded: number; /** * Records containing summary data, such as top-level aggregations produced by a Hoist Cube * or any other custom aggregation(s) calculated and installed by the application. Set via * {@link loadData} or by loading a tree structure with `loadRootAsSummary` set to true. */ summaryRecords: StoreRecord[]; /** @internal - used internally by any StoreFilterField bound to this store. */ xhFilterText: string; validator: StoreValidator; private _committed; private _current; _filtered: RecordSet; private _dataTemplate; private _dataDefaults; private _denseRecordThreshold; private _digestSpec; private _digestFn; private _verifiedCachedParent; private _verifiedNewParent; private _recordBuildData; _created: number; private _fieldMap; experimental: any; /** @internal */ readonly diagnostics: StoreDiagnostics; constructor({ fields, fieldDefaults, idSpec, processRawData, filter, filterIncludesChildren, loadTreeData, loadTreeDataFrom, loadRootAsSummary, freezeData, idEncodesTreePath, digestSpec, retainRaw, projectionOnly, validationIsComplex, experimental, data }: StoreConfig); /** See {@link StoreConfig.digestSpec} - settable, taking effect on the next load. */ get digestSpec(): StoreRecordDigestSpec; set digestSpec(spec: StoreRecordDigestSpec); /** Remove all records from the store. Equivalent to calling `loadData([])`. */ clear(): void; /** * Load a new and complete dataset, replacing any/all pre-existing Records as needed. * * If raw data objects have a `children` property, it will be expected to be an array and its * items will be recursively processed into child Records, each created with a pointer to its * parent's newly assigned StoreRecord ID. * * Note that this process will re-use pre-existing StoreRecord object instances if they are present * in the new dataset (as identified by their ID), contain the same data, and occupy the same * place in any hierarchy across old and new loads. This is to maximize the ability of * downstream consumers (e.g. ag-Grid) to recognize Records that have not changed and do not * need to be re-evaluated / re-rendered. * * Note that record order is not a guaranteed property of a Store. Loads are free to preserve * incumbent record positions, and a payload differing from the current dataset only in its * ordering will be processed as a no-op. Apply an explicit sort - e.g. on an ordinal field * supplied with the source data - wherever deterministic order matters. * * Summary data can be provided via `rawSummaryData` or as the root data if the Store was * created with its `loadRootAsSummary` flag set to true. * * @param rawData - source data to load * @param rawSummaryData - source data for optional summary record(s), representing * custom aggregations for the dataset, if desired. */ loadData(rawData: PlainObject[], rawSummaryData?: Some): void; /** * Load a new and complete dataset from a streaming source, replacing any/all pre-existing * Records as needed - the streaming counterpart to {@link loadData}. * * Use to load very large datasets without buffering the complete raw dataset in a single * array - e.g. rows streamed incrementally from the server. The source may be a sync or * async iterable yielding individual raw records - see {@link FetchService.fetchNdjson} * for the natural source when streaming NDJSON, e.g. * `store.loadDataAsync(XH.fetchNdjson({url}).lines)`. * * The Store is not modified until the source has been fully consumed - all records are then * installed in a single observable transaction, exactly as with `loadData()`. If the source * throws, the Store remains unchanged. * * Note this method does not accept summary data - a summary is an aggregate, unavailable * until a stream completes. Any pre-existing summary records are cleared. Install summary * data via `updateData({rawSummaryData})` after loading, if desired. Not supported for * stores with `loadRootAsSummary` - such payloads nest all row data within a single root * node and cannot be streamed. * * @param rawData - iterable yielding raw records. */ loadDataAsync(rawData: AnyIterable): Promise; /** * Add, update, or delete Records in this Store. Note that objects passed to this method * for adds and updates should have all the raw source data required to create those Records - * i.e. they should be in the same form as when passed to `loadData()`. The added/updated * source data will be run through this Store's `idSpec` and `processRawData` functions. * * Adds can also be provided as a {@link ChildRawData} object of the form `{rawData, parentId}` * to add new Records under a known, pre-existing parent StoreRecord. * * Unlike `loadData()`, existing Records that are *not* included in this update transaction * will be left in place and as is. * * Records loaded or removed via this method will be considered to be "committed", with the * expectation that inputs to this method were provided by the server or other data source of * record. For modifying particular fields on existing Records, see `modifyRecords()`. For local * adds/removes not sourced from the server, see `addRecords()` and `removeRecords()`. Those * APIs will modify the current RecordSet but leave those changes in an uncommitted state. * * @param rawData - data changes to process. If provided as an array, rawData will be processed * into adds and updates, with updates determined by matching existing records by ID. * @returns changes applied, or null if no record changes were made. */ updateData(rawData: PlainObject[] | StoreTransaction): StoreChangeLog; /** * Re-runs the Filter on the current data. Applications only need to call this method if * the state underlying the filter, other than the record data itself, has changed. Store will * re-filter automatically whenever StoreRecord data is updated or modified. */ refreshFilter(): void; /** * Add new Records to this Store in a local, uncommitted state - i.e. with data that has yet to * be persisted back to, or sourced from, the server or other data source of record. * * Note that data objects passed to this method must include a literal `id` property - this * method does *not* run the Store's `idSpec` function. Callers can generate an id with * `XH.genId()` if no natural ID can be produced locally on the client. * * For StoreRecord additions that originate from the server, call `updateData()` instead. * * @param data - source data for new StoreRecord(s). Note that this data will * *not* be processed by this Store's `processRawData` or `idSpec` functions, but will be * parsed and potentially transformed according to this Store's Field definitions. * @param parentId - ID of the pre-existing parent record under which this new * record should be added, if any. */ addRecords(data: Some, parentId?: StoreRecordId): void; /** * Remove Records from the Store in a local, uncommitted state - i.e. when queuing up a set of * deletes on the client to be flushed back to the server at a later time. * * For StoreRecord deletions that originate from the server, call `updateData()` instead. * * @param records - list of StoreRecord IDs or Records to remove */ removeRecords(records: StoreRecordOrId | StoreRecordOrId[]): void; /** * Modify individual StoreRecord field values in a local, uncommitted state - i.e. when updating a * StoreRecord or Records via an inline grid editor or similar control. * * This method accepts partial updates for any Records to be modified; modifications need only * include the StoreRecord ID and any fields that have changed. * * For StoreRecord updates that originate from the server, call `updateData()` instead. * * @param modifications - field-level modifications to apply to existing * Records in this Store. Each object in the list must have an `id` property identifying * the StoreRecord to modify, plus any other properties with updated field values to apply, * e.g. `{id: 4, quantity: 100}, {id: 5, quantity: 99, customer: 'bob'}`. * @returns changes applied, or null if no record changes were made. */ modifyRecords(modifications: Some): StoreChangeLog; /** * Revert all changes made to the specified Records since they were last committed. * * This restores these Records to the state they were in when last loaded into this Store via * `loadData()` or `updateData()`, undoing any local modifications that might have been applied. * * @param records - StoreRecord IDs or instances to revert */ revertRecords(records: StoreRecordOrId | StoreRecordOrId[]): void; /** * Revert all changes made to the Store since data was last committed. * * This restores all Records to the state they were in when last loaded into this Store via * `loadData()` or `updateData()`, undoing any local modifications that might have been applied, * removing any uncommitted records added locally, and restoring any uncommitted deletes. */ revert(): void; /** Get a specific Field by name.*/ getField(name: string): Field; get fieldNames(): string[]; /** * Records in this store, respecting any filter (if applied). * Order is not a guaranteed property of a Store - sort explicitly where order matters. */ get records(): StoreRecord[]; /** * All records in this store, unfiltered. * Order is not a guaranteed property of a Store - sort explicitly where order matters. */ get allRecords(): StoreRecord[]; /** All records that were originally loaded into this store.*/ get committedRecords(): StoreRecord[]; /** Records added locally which have not been committed.*/ get addedRecords(): StoreRecord[]; /** Records removed locally which have not been committed.*/ get removedRecords(): StoreRecord[]; /** Records modified locally since they were last loaded. */ get dirtyRecords(): StoreRecord[]; /** Alias for {@link Store.dirtyRecords} */ get modifiedRecords(): StoreRecord[]; /** * Root records in this store, respecting any filter (if applied). * If this store is not hierarchical, this will be identical to 'records'. */ get rootRecords(): StoreRecord[]; /** * Root records in this store, unfiltered. * If this store is not hierarchical, this will be identical to 'allRecords'. */ get allRootRecords(): StoreRecord[]; /** * Single summary data record, if only one (or null if none). Maintained for convenience and * for backwards compat with app code predating support for multiple {@link summaryRecords}. */ get summaryRecord(): StoreRecord; /** True if the store has changes which need to be committed. */ get isDirty(): boolean; /** Alias for {@link Store.isDirty} */ get isModified(): boolean; /** * Set a filter on this store. * * @param filter - one or more filters or configs to create one. If an * array, a single 'AND' filter will be created. */ setFilter(filter: FilterLike): void; setFilterIncludesChildren(val: boolean): void; /** Convenience method to clear the Filter applied to this store. */ clearFilter(): void; /** * @returns true if the StoreRecord is in the store but currently excluded by a filter; * false if the record is either not in the Store at all or not filtered out. */ recordIsFiltered(recOrId: StoreRecordOrId): boolean; getValuesForFieldFilter(fieldName: string, filter?: Filter): any[]; /** * Set whether the root should be loaded as summary data in loadData(). */ setLoadRootAsSummary(loadRootAsSummary: boolean): void; /** The count of the filtered records in the store. */ get count(): number; /** The count of all records in the store. */ get allCount(): number; /** The count of the filtered root records in the store. */ get rootCount(): number; /** The count of all root records in the store. */ get allRootCount(): number; /** True if the store is empty after filters have been applied */ get empty(): boolean; /** True if the store is empty before filters have been applied */ get allEmpty(): boolean; get maxDepth(): number; get errors(): StoreValidationMessagesMap; get validationResults(): StoreValidationResultsMap; /** Count of all validation errors for the store. */ get errorCount(): number; /** Array of all errors for this store. */ get allErrors(): string[]; /** Array of all ValidationResults for this store. */ get allValidationResults(): ValidationResult[]; /** * Get a record by ID, or null if no matching record found. * * @param id - ID of record to be queried. * @param respectFilter - false (default) to return a StoreRecord with the given ID even if an * active filter is excluding it from the primary `records` collection. True to restrict * matches to this Store's post-filter StoreRecord collection only. */ getById(id: StoreRecordId, respectFilter?: boolean): StoreRecord; /** * Get children records for a record. * * See also the 'children' and 'allChildren' properties on StoreRecord - those getters will likely * be more convenient for most app-level callers. * * @param id - ID of record to be queried. * @param respectFilter - true to skip records excluded by any active filter. */ getChildrenById(id: StoreRecordId, respectFilter?: boolean): StoreRecord[]; /** * Get descendant records for a record. * * See also the 'descendants' and 'allDescendants' properties on StoreRecord - those getters will * likely be more convenient for most app-level callers. * * @param id - ID of record to be queried. * @param respectFilter - true to skip records excluded by any active filter. */ getDescendantsById(id: StoreRecordId, respectFilter?: boolean): StoreRecord[]; /** * Get ancestor records for a record. * * See also the 'ancestors' and 'allAncestors' properties on StoreRecord - those getters will * likely be more convenient for most app-level callers. * * @param id - ID of record to be queried. * @param respectFilter - true to skip records excluded by any active filter. */ getAncestorsById(id: StoreRecordId, respectFilter?: boolean): StoreRecord[]; /** True if the store is confirmed to be Valid. */ get isValid(): boolean; /** True if the store is confirmed to be NotValid. */ get isNotValid(): boolean; /** Recompute ValidationResults for all records and return true if the store is valid. */ validateAsync(): Promise; /** Destroy this store, cleaning up any resources used. */ destroy(): void; protected get defaultFieldClass(): typeof Field; setXhFilterText(s: string): void; private getOrThrow; private getCommittedOrThrow; private resetRecords; private parseFields; private incrementalRefilter; private fullRefilter; private createRecord; private getCachedRecord; private positionUnchanged; private createRecords; private createRecordDeep; private get summaryRecordIds(); /** * Parse a (pre-processed) raw object into record data, buffering each declared field's * parsed non-default value for buildData() in a single pass. * * Given a `cached` record, the pass also compares buffered values against its data, * returning null to direct the caller to reuse it - the "value rescue" that skips all * allocation for unchanged records. Soundness needs two checks beyond the deep-equal: * a matching cached value must itself be non-default (identity test vs the field default - * a deep match against an object/array default could mask another non-default cached * field), and non-default counts must agree (fields absent from the raw are never visited). */ private parseOrRescue; private parseUpdate; /** * Build a record `data` object from the non-default entries buffered in `_recordBuildData`, * choosing its representation by their count: * * - Below `denseRecordThreshold`, a sparse object - own properties for the buffered values * only, defaults reached through the shared `_dataDefaults` prototype. Costs nothing for * unpopulated fields, and stays safely inside V8's fast-properties mode at these counts. * - At or above it, a clone of the shared template carrying every Field. Wide objects built * by per-property adds are demoted to V8's dictionary mode - cloning sidesteps the adds * (overwriting an existing property is not an add), so all dense records share the * template's one fixed shape. * * The representation is decided per record, from parsed content alone - records with equal * field values always take equal shapes, which the deep-equal comparisons in modifyRecords() * require. */ private buildData; private throwIfProjectionOnly; /** * Shared template for record `data` objects - an own property for every Field, holding its * defaultValue. `parseOrRescue()` clones it per record, so all records in a Store share one * identical, fixed shape. That keeps them in V8's compact fast-properties mode: objects built * instead by per-field property adds are demoted to a per-object hashtable ("dictionary mode") * past ~20 adds, costing several times more memory per record. */ private createDataDefaults; private createFieldMap; private createDigestFn; private parseExperimental; private parseIdSpec; private revertSummaryRecords; }