UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

223 lines (222 loc) 8.5 kB
import { Cache } from '../cache/cache.js'; import { MetricStrategyFactory } from "../metrics/metricOptCalcs"; import hash from 'object-hash'; import { isNil, isEmpty, isEqual } from 'lodash-es'; import { metricEventEmitter } from './reactivity.js'; import { Status } from './operation.js'; /** * Store for managing a single metric with optimistic updates */ export class MetricStore { constructor(metricType, modelClass, queryset, field = null, ast = null, fetchFn) { this.metricType = metricType; this.modelClass = modelClass; this.queryset = queryset; this.field = field; this.ast = ast; this.fetchFn = fetchFn; this.groundTruthValue = null; this.isSyncing = false; this.strategy = MetricStrategyFactory.getStrategy(metricType, modelClass); // Store operations related to this metric this.operations = []; // Keep track of which operations have been confirmed this.confirmedOps = new Set(); // Initialize cache with AST-specific key this.metricCache = new Cache("metric-store-cache", {}, this.onHydrated.bind(this)); // Initialize _lastCalculatedValue this._lastCalculatedValue = null; } reset() { this.groundTruthValue = null; this._lastCalculatedValue = null; this.operations = []; this.confirmedOps = new Set(); this.isSyncing = false; this.clearCache(); } get cacheKey() { return `${this.modelClass.configKey}::${this.modelClass.modelName}::metric::${this.metricType}::${this.field || "null"}::${this.ast ? hash(this.ast) : "global"}`; } /** * Add an operation to this metric store * @param {Operation} operation - The operation to add */ addOperation(operation) { // Only track operations for this model if (operation.queryset.ModelClass !== this.modelClass) { return; } // Check if operation already exists to avoid duplicates const existingIndex = this.operations.findIndex((op) => op.operationId === operation.operationId); if (existingIndex !== -1) { // Update existing operation instead of adding a duplicate this.operations[existingIndex] = operation; } else { // Add to our operations list this.operations.push(operation); } // Trigger a render to update the metric value if (!isNil(this.groundTruthValue)) { this.render(); } } /** * Update an operation in this metric store * @param {Operation} operation - The operation to update */ updateOperation(operation) { // Only track operations for this model if (operation.queryset.ModelClass !== this.modelClass) { return; } // Find and update the operation - use operationId not id const index = this.operations.findIndex((op) => op.operationId === operation.operationId); if (index !== -1) { this.operations[index] = operation; } else { // If not found, add it this.operations.push(operation); } // Trigger a render to update the metric value if (!isNil(this.groundTruthValue)) { this.render(); } } /** * Confirm an operation in this metric store * @param {Operation} operation - The operation to confirm */ confirm(operation) { // Only track operations for this model if (operation.queryset.ModelClass !== this.modelClass) { return; } // Update operation status in our list and mark as confirmed - use operationId not id const index = this.operations.findIndex((op) => op.operationId === operation.operationId); if (index !== -1) { this.operations[index] = operation; this.confirmedOps.add(operation.operationId); } // Trigger a sync to update ground truth this.render(); } /** * Reject an operation in this metric store * @param {Operation} operation - The operation to reject */ reject(operation) { // Only track operations for this model if (operation.queryset.ModelClass !== this.modelClass) { return; } // Remove the operation as it's now rejected - use operationId not id this.operations = this.operations.filter((op) => op.operationId !== operation.operationId); this.confirmedOps.delete(operation.operationId); // Trigger a render to update the metric value if (!isNil(this.groundTruthValue)) { this.render(); } } onHydrated() { if (this.groundTruthValue === null) { const cached = this.metricCache.get(this.cacheKey); if (!isNil(cached) && !isEmpty(cached)) { console.log(`[MetricStore] Hydrated ${this.metricType} metric for ${this.modelClass.modelName} from cache`); this.setGroundTruth(cached?.value); } } } setCache() { let groundTruthValue = this.groundTruthValue; let serializedGroundTruth = groundTruthValue?.value ? groundTruthValue.value : groundTruthValue; this.metricCache.set(this.cacheKey, { value: serializedGroundTruth, }); } clearCache() { this.metricCache.delete(this.cacheKey); } setGroundTruth(value) { // Check if the value has actually changed const valueChanged = !isEqual(this.groundTruthValue, value); this.groundTruthValue = value; this.setCache(); // Only emit event if the value changed if (valueChanged) { metricEventEmitter.emit("metric::render", { metricType: this.metricType, ModelClass: this.modelClass, field: this.field, ast: hash(this.ast), valueChanged: true, }); } } /** * Render the metric with current operations * @returns {any} Calculated metric value */ render() { // Check if ground truth value is null if (isNil(this.groundTruthValue)) { console.log(`groundTruthValue is null, returning null`); return null; } // Calculate the new value using the operations-based approach const newValue = this.strategy.calculateWithOperations(this.groundTruthValue, this.operations, this.field, this.modelClass); // Check if the value has actually changed if (!isEqual(this._lastCalculatedValue, newValue)) { this._lastCalculatedValue = newValue; // Only emit event if the value changed metricEventEmitter.emit("metric::render", { metricType: this.metricType, ModelClass: this.modelClass, field: this.field, ast: hash(this.ast), valueChanged: true, }); } return newValue; } /** * Sync metric with server */ async sync() { if (this.isSyncing) { console.warn(`[MetricStore] Already syncing ${this.metricType} for ${this.modelClass.modelName}`); return; } this.isSyncing = true; try { console.log(`[MetricStore] Syncing ${this.metricType} metric for ${this.modelClass.modelName}`); // Use fetchFn to get server metric value const result = await this.fetchFn({ metricType: this.metricType, modelClass: this.modelClass, field: this.field, ast: this.ast, }); // After syncing, remove all confirmed operations if (this.confirmedOps.size > 0) { this.operations = this.operations.filter((op) => !this.confirmedOps.has(op.operationId)); this.confirmedOps.clear(); } // Update ground truth this.setGroundTruth(result); console.log(`[MetricStore] Synced ${this.metricType} metric with value:`, result); return result; } catch (error) { console.error(`[MetricStore] Failed to sync ${this.metricType} metric:`, error); return null; } finally { this.isSyncing = false; } } }