@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
JavaScript
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;
}
}
}