@statezero/core 0.2.52 → 0.2.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Store for managing a single metric with optimistic updates
2
+ * Store for managing a single metric with optimistic updates.
3
+ *
4
+ * Uses a delta-based approach: groundTruthValue + sum(deltas) = current value.
5
+ * Deltas are computed externally (by processMetricStores) and stored by operationId.
3
6
  */
4
7
  export class MetricStore {
5
8
  constructor(metricType: any, modelClass: any, queryset: any, field: null | undefined, ast: null | undefined, fetchFn: any);
@@ -11,40 +14,37 @@ export class MetricStore {
11
14
  fetchFn: any;
12
15
  groundTruthValue: any;
13
16
  isSyncing: boolean;
14
- strategy: import("../metrics/metricOptCalcs.js").MetricCalculationStrategy;
15
- operations: any[];
16
- confirmedOps: Set<any>;
17
+ deltas: Map<any, any>;
17
18
  metricCache: Cache;
18
19
  _lastCalculatedValue: any;
19
20
  reset(): void;
20
21
  get cacheKey(): string;
21
22
  /**
22
- * Add an operation to this metric store
23
- * @param {Operation} operation - The operation to add
23
+ * Add a delta for an operation
24
24
  */
25
- addOperation(operation: Operation): void;
25
+ addDelta(opId: any, delta: any): void;
26
26
  /**
27
- * Update an operation in this metric store
28
- * @param {Operation} operation - The operation to update
27
+ * Mark a delta as confirmed
29
28
  */
30
- updateOperation(operation: Operation): void;
29
+ confirmDelta(opId: any): void;
31
30
  /**
32
- * Confirm an operation in this metric store
33
- * @param {Operation} operation - The operation to confirm
31
+ * Remove a rejected delta
34
32
  */
35
- confirm(operation: Operation): void;
33
+ rejectDelta(opId: any): void;
36
34
  /**
37
- * Reject an operation in this metric store
38
- * @param {Operation} operation - The operation to reject
35
+ * Update a delta (e.g., when operation is mutated)
39
36
  */
40
- reject(operation: Operation): void;
37
+ updateDelta(opId: any, delta: any): void;
38
+ addOperation(operation: any): void;
39
+ updateOperation(operation: any): void;
40
+ confirm(operation: any): void;
41
+ reject(operation: any): void;
41
42
  onHydrated(): void;
42
43
  setCache(): void;
43
44
  clearCache(): void;
44
45
  setGroundTruth(value: any): void;
45
46
  /**
46
- * Render the metric with current operations
47
- * @returns {any} Calculated metric value
47
+ * Render: groundTruthValue + sum of all deltas
48
48
  */
49
49
  render(): any;
50
50
  /**
@@ -1,11 +1,12 @@
1
1
  import { Cache } from '../cache/cache.js';
2
- import { MetricStrategyFactory } from "../metrics/metricOptCalcs.js";
3
2
  import hash from 'object-hash';
4
3
  import { isNil, isEmpty, isEqual } from 'lodash-es';
5
4
  import { metricEventEmitter } from './reactivity.js';
6
- import { Status } from './operation.js';
7
5
  /**
8
- * Store for managing a single metric with optimistic updates
6
+ * Store for managing a single metric with optimistic updates.
7
+ *
8
+ * Uses a delta-based approach: groundTruthValue + sum(deltas) = current value.
9
+ * Deltas are computed externally (by processMetricStores) and stored by operationId.
9
10
  */
10
11
  export class MetricStore {
11
12
  constructor(metricType, modelClass, queryset, field = null, ast = null, fetchFn) {
@@ -17,21 +18,16 @@ export class MetricStore {
17
18
  this.fetchFn = fetchFn;
18
19
  this.groundTruthValue = null;
19
20
  this.isSyncing = false;
20
- this.strategy = MetricStrategyFactory.getStrategy(metricType, modelClass);
21
- // Store operations related to this metric
22
- this.operations = [];
23
- // Keep track of which operations have been confirmed
24
- this.confirmedOps = new Set();
21
+ // Delta storage: opId → { delta: number, confirmed: boolean }
22
+ this.deltas = new Map();
25
23
  // Initialize cache with AST-specific key
26
24
  this.metricCache = new Cache("metric-store-cache", {}, this.onHydrated.bind(this));
27
- // Initialize _lastCalculatedValue
28
25
  this._lastCalculatedValue = null;
29
26
  }
30
27
  reset() {
31
28
  this.groundTruthValue = null;
32
29
  this._lastCalculatedValue = null;
33
- this.operations = [];
34
- this.confirmedOps = new Set();
30
+ this.deltas = new Map();
35
31
  this.isSyncing = false;
36
32
  this.clearCache();
37
33
  }
@@ -39,86 +35,56 @@ export class MetricStore {
39
35
  return `${this.modelClass.configKey}::${this.modelClass.modelName}::metric::${this.metricType}::${this.field || "null"}::${this.ast ? hash(this.ast) : "global"}`;
40
36
  }
41
37
  /**
42
- * Add an operation to this metric store
43
- * @param {Operation} operation - The operation to add
38
+ * Add a delta for an operation
44
39
  */
45
- addOperation(operation) {
46
- // Only track operations for this model
47
- if (operation.queryset.ModelClass !== this.modelClass) {
48
- return;
49
- }
50
- // Check if operation already exists to avoid duplicates
51
- const existingIndex = this.operations.findIndex((op) => op.operationId === operation.operationId);
52
- if (existingIndex !== -1) {
53
- // Update existing operation instead of adding a duplicate
54
- this.operations[existingIndex] = operation;
55
- }
56
- else {
57
- // Add to our operations list
58
- this.operations.push(operation);
59
- }
60
- // Trigger a render to update the metric value
61
- if (!isNil(this.groundTruthValue)) {
62
- this.render();
63
- }
40
+ addDelta(opId, delta) {
41
+ this.deltas.set(opId, { delta, confirmed: false });
42
+ this.render();
64
43
  }
65
44
  /**
66
- * Update an operation in this metric store
67
- * @param {Operation} operation - The operation to update
45
+ * Mark a delta as confirmed
68
46
  */
69
- updateOperation(operation) {
70
- // Only track operations for this model
71
- if (operation.queryset.ModelClass !== this.modelClass) {
72
- return;
73
- }
74
- // Find and update the operation - use operationId not id
75
- const index = this.operations.findIndex((op) => op.operationId === operation.operationId);
76
- if (index !== -1) {
77
- this.operations[index] = operation;
78
- }
79
- else {
80
- // If not found, add it
81
- this.operations.push(operation);
82
- }
83
- // Trigger a render to update the metric value
84
- if (!isNil(this.groundTruthValue)) {
47
+ confirmDelta(opId) {
48
+ const entry = this.deltas.get(opId);
49
+ if (entry) {
50
+ entry.confirmed = true;
85
51
  this.render();
86
52
  }
87
53
  }
88
54
  /**
89
- * Confirm an operation in this metric store
90
- * @param {Operation} operation - The operation to confirm
55
+ * Remove a rejected delta
91
56
  */
92
- confirm(operation) {
93
- // Only track operations for this model
94
- if (operation.queryset.ModelClass !== this.modelClass) {
95
- return;
96
- }
97
- // Update operation status in our list and mark as confirmed - use operationId not id
98
- const index = this.operations.findIndex((op) => op.operationId === operation.operationId);
99
- if (index !== -1) {
100
- this.operations[index] = operation;
101
- this.confirmedOps.add(operation.operationId);
57
+ rejectDelta(opId) {
58
+ if (this.deltas.delete(opId)) {
59
+ this.render();
102
60
  }
103
- // Trigger a sync to update ground truth
104
- this.render();
105
61
  }
106
62
  /**
107
- * Reject an operation in this metric store
108
- * @param {Operation} operation - The operation to reject
63
+ * Update a delta (e.g., when operation is mutated)
109
64
  */
110
- reject(operation) {
111
- // Only track operations for this model
112
- if (operation.queryset.ModelClass !== this.modelClass) {
113
- return;
114
- }
115
- // Remove the operation as it's now rejected - use operationId not id
116
- this.operations = this.operations.filter((op) => op.operationId !== operation.operationId);
117
- this.confirmedOps.delete(operation.operationId);
118
- // Trigger a render to update the metric value
119
- if (!isNil(this.groundTruthValue)) {
65
+ updateDelta(opId, delta) {
66
+ const entry = this.deltas.get(opId);
67
+ if (entry) {
68
+ entry.delta = delta;
120
69
  this.render();
121
70
  }
71
+ else {
72
+ this.addDelta(opId, delta);
73
+ }
74
+ }
75
+ // Legacy API — kept for backward compatibility with existing callers.
76
+ // processMetricStores in operationEventHandlers still calls these.
77
+ addOperation(operation) {
78
+ // No-op: deltas are computed externally
79
+ }
80
+ updateOperation(operation) {
81
+ // No-op: deltas are computed externally
82
+ }
83
+ confirm(operation) {
84
+ this.confirmDelta(operation.operationId);
85
+ }
86
+ reject(operation) {
87
+ this.rejectDelta(operation.operationId);
122
88
  }
123
89
  onHydrated() {
124
90
  if (this.groundTruthValue === null) {
@@ -141,11 +107,9 @@ export class MetricStore {
141
107
  this.metricCache.delete(this.cacheKey);
142
108
  }
143
109
  setGroundTruth(value) {
144
- // Check if the value has actually changed
145
110
  const valueChanged = !isEqual(this.groundTruthValue, value);
146
111
  this.groundTruthValue = value;
147
112
  this.setCache();
148
- // Only emit event if the value changed
149
113
  if (valueChanged) {
150
114
  metricEventEmitter.emit("metric::render", {
151
115
  metricType: this.metricType,
@@ -157,20 +121,18 @@ export class MetricStore {
157
121
  }
158
122
  }
159
123
  /**
160
- * Render the metric with current operations
161
- * @returns {any} Calculated metric value
124
+ * Render: groundTruthValue + sum of all deltas
162
125
  */
163
126
  render() {
164
- // Check if ground truth value is null
165
127
  if (isNil(this.groundTruthValue)) {
166
128
  return null;
167
129
  }
168
- // Calculate the new value using the operations-based approach
169
- const newValue = this.strategy.calculateWithOperations(this.groundTruthValue, this.operations, this.field, this.modelClass);
170
- // Check if the value has actually changed
130
+ let newValue = this.groundTruthValue;
131
+ for (const { delta } of this.deltas.values()) {
132
+ newValue += delta;
133
+ }
171
134
  if (!isEqual(this._lastCalculatedValue, newValue)) {
172
135
  this._lastCalculatedValue = newValue;
173
- // Only emit event if the value changed
174
136
  metricEventEmitter.emit("metric::render", {
175
137
  metricType: this.metricType,
176
138
  ModelClass: this.modelClass,
@@ -191,19 +153,18 @@ export class MetricStore {
191
153
  }
192
154
  this.isSyncing = true;
193
155
  try {
194
- // Use fetchFn to get server metric value
195
156
  const result = await this.fetchFn({
196
157
  metricType: this.metricType,
197
158
  modelClass: this.modelClass,
198
159
  field: this.field,
199
160
  ast: this.ast,
200
161
  });
201
- // After syncing, remove all confirmed operations
202
- if (this.confirmedOps.size > 0) {
203
- this.operations = this.operations.filter((op) => !this.confirmedOps.has(op.operationId));
204
- this.confirmedOps.clear();
162
+ // Remove confirmed deltas server value already includes them
163
+ for (const [opId, entry] of this.deltas) {
164
+ if (entry.confirmed) {
165
+ this.deltas.delete(opId);
166
+ }
205
167
  }
206
- // Update ground truth
207
168
  this.setGroundTruth(result);
208
169
  return result;
209
170
  }
@@ -308,19 +308,35 @@ export class ModelStore {
308
308
  // Add completely new instances (these don't need checkpoint operations)
309
309
  updatedGroundTruth.push(...Array.from(pkMap.values()));
310
310
  this.groundTruthArray = updatedGroundTruth;
311
- // Create CHECKPOINT operation for instances that already existed
311
+ // Create CHECKPOINT operation for instances that already existed,
312
+ // but skip instances that have inflight UPDATE operations — a stale re-fetch
313
+ // must not overwrite optimistic update data.
312
314
  if (checkpointInstances.length > 0) {
313
- const checkpointOperation = new Operation({
314
- operationId: `checkpoint_${Date.now()}_${Math.random()
315
- .toString(36)
316
- .substr(2, 9)}`,
317
- type: Type.CHECKPOINT,
318
- instances: checkpointInstances,
319
- status: Status.CONFIRMED,
320
- timestamp: Date.now(),
321
- queryset: this.modelClass.objects.all(),
322
- });
323
- this.operationsMap.set(checkpointOperation.operationId, checkpointOperation);
315
+ const inflightUpdatePks = new Set();
316
+ for (const op of this.operations) {
317
+ if ((op.type === Type.UPDATE || op.type === Type.UPDATE_INSTANCE) &&
318
+ op.status !== Status.CONFIRMED && op.status !== Status.REJECTED) {
319
+ for (const inst of op.instances) {
320
+ if (inst && inst[pkField] != null) {
321
+ inflightUpdatePks.add(inst[pkField]);
322
+ }
323
+ }
324
+ }
325
+ }
326
+ const safeCheckpointInstances = checkpointInstances.filter(inst => !inflightUpdatePks.has(inst[pkField]));
327
+ if (safeCheckpointInstances.length > 0) {
328
+ const checkpointOperation = new Operation({
329
+ operationId: `checkpoint_${Date.now()}_${Math.random()
330
+ .toString(36)
331
+ .substr(2, 9)}`,
332
+ type: Type.CHECKPOINT,
333
+ instances: safeCheckpointInstances,
334
+ status: Status.CONFIRMED,
335
+ timestamp: Date.now(),
336
+ queryset: this.modelClass.objects.all(),
337
+ });
338
+ this.operationsMap.set(checkpointOperation.operationId, checkpointOperation);
339
+ }
324
340
  }
325
341
  // reactivity - use all the newly added instances (both new and updated)
326
342
  emitEvents(this, EventData.fromInstances([...checkpointInstances, ...Array.from(pkMap.values())], this.modelClass));