@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.
@@ -2,11 +2,7 @@ import { operationEvents, Status, Type, operationRegistry, createMembershipState
2
2
  import { modelStoreRegistry } from '../registries/modelStoreRegistry.js';
3
3
  import { querysetStoreRegistry } from '../registries/querysetStoreRegistry.js';
4
4
  import { metricRegistry } from '../registries/metricRegistry.js';
5
- import { getFingerprint } from './utils.js';
6
- import { QuerySet } from '../../flavours/django/querySet.js';
7
- import { isEqual, isNil } from 'lodash-es';
8
- import hash from 'object-hash';
9
- import { filter, applyOrderBy } from '../../filtering/localFiltering.js';
5
+ import { computeMetricFromQueryset } from '../metrics/metricOptCalcs.js';
10
6
  import { recordDebugEvent } from '../../debug/statezeroDebug.js';
11
7
  /**
12
8
  * Check if two model classes represent the same model.
@@ -17,267 +13,8 @@ function isSameModel(modelClassA, modelClassB) {
17
13
  return modelClassA.modelName === modelClassB.modelName &&
18
14
  modelClassA.configKey === modelClassB.configKey;
19
15
  }
20
- /**
21
- * Evaluates and routes a CREATE operation to querysets, tracking membership state.
22
- *
23
- * Membership state has two flags:
24
- * - optimistic: Should we show this item immediately in the UI?
25
- * - verify: Should remote sync check this queryset?
26
- *
27
- * For each queryset:
28
- * - If offset > 0: { optimistic: false, verify: true } - can't determine position
29
- * - If local filter says no match: { optimistic: false, verify: true } - local filtering unreliable
30
- * - If item matches filter AND has room: { optimistic: true, verify: false } - show it
31
- * - If at limit and ordering excludes: { optimistic: false, verify: false } - certain it's out
32
- *
33
- * @param {Operation} operation - The CREATE operation to route
34
- * @param {Function} applyAction - Function to apply the operation to a store
35
- */
36
- function routeCreateOperation(operation, applyAction) {
37
- const modelClass = operation.queryset.ModelClass;
38
- const instances = operation.instances;
39
- Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
40
- if (!isSameModel(store.modelClass, modelClass))
41
- return;
42
- recordDebugEvent({
43
- type: "routing",
44
- operationId: operation.operationId,
45
- operationType: operation.type,
46
- semanticKey,
47
- modelName: store.modelClass?.modelName,
48
- configKey: store.modelClass?.configKey,
49
- phase: "start",
50
- });
51
- const serializerOptions = store.queryset?._serializerOptions || {};
52
- const { offset, limit } = serializerOptions;
53
- // Offset > 0: can't determine position based on slicing
54
- if (offset != null && offset > 0) {
55
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
56
- recordDebugEvent({
57
- type: "routing",
58
- operationId: operation.operationId,
59
- operationType: operation.type,
60
- semanticKey,
61
- modelName: store.modelClass?.modelName,
62
- configKey: store.modelClass?.configKey,
63
- decision: "offset>0",
64
- optimistic: false,
65
- verify: true,
66
- });
67
- return;
68
- }
69
- // Evaluate if instances match this queryset's filter
70
- // Use fromPk to get live representation with proper FK structure
71
- const liveInstances = instances.map(inst => modelClass.fromPk(inst[modelClass.primaryKeyField])).filter(Boolean);
72
- const ast = store.queryset.build();
73
- const matchingInstances = filter(liveInstances, ast, modelClass, true);
74
- if (matchingInstances.length === 0) {
75
- // Local filter says no match - trust it (local filtering is robust)
76
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
77
- recordDebugEvent({
78
- type: "routing",
79
- operationId: operation.operationId,
80
- operationType: operation.type,
81
- semanticKey,
82
- modelName: store.modelClass?.modelName,
83
- configKey: store.modelClass?.configKey,
84
- decision: "filter:no-match",
85
- optimistic: false,
86
- verify: false,
87
- });
88
- return;
89
- }
90
- // Item matches filter - check limit
91
- if (limit != null) {
92
- const currentCount = store.groundTruthPks?.length || 0;
93
- // At capacity: check if ordering could affect position
94
- if (currentCount >= limit) {
95
- // Check explicit ordering on queryset OR implicit ordering from Django Meta
96
- const hasExplicitOrdering = store.queryset._orderBy && store.queryset._orderBy.length > 0;
97
- const hasImplicitOrdering = (modelClass.schema?.default_ordering?.length || 0) > 0;
98
- if (hasExplicitOrdering || hasImplicitOrdering) {
99
- // Use local sorting to determine if new items would be in top N
100
- const orderBy = hasExplicitOrdering
101
- ? store.queryset._orderBy
102
- : modelClass.schema.default_ordering;
103
- // Get current items from model store
104
- const modelStore = modelStoreRegistry.getStore(modelClass);
105
- const renderedItems = modelStore.render(store.groundTruthPks, true, false) || [];
106
- const currentItems = renderedItems.filter(Boolean);
107
- // If some ground truth items couldn't be rendered, we can't trust local sort
108
- if (currentItems.length < store.groundTruthPks.length) {
109
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
110
- recordDebugEvent({
111
- type: "routing",
112
- operationId: operation.operationId,
113
- operationType: operation.type,
114
- semanticKey,
115
- modelName: store.modelClass?.modelName,
116
- configKey: store.modelClass?.configKey,
117
- decision: "limit:ordering:incomplete-render",
118
- optimistic: false,
119
- verify: true,
120
- });
121
- return;
122
- }
123
- const allItems = [...currentItems, ...matchingInstances];
124
- // Sort and take top N
125
- const sorted = applyOrderBy(allItems, orderBy, modelClass);
126
- const topN = sorted.slice(0, limit);
127
- const topNPks = new Set(topN.map(item => item[modelClass.primaryKeyField]));
128
- // Check if any new items made it into the top N
129
- const newItemPks = matchingInstances.map(item => item[modelClass.primaryKeyField]);
130
- const anyInTopN = newItemPks.some(pk => topNPks.has(pk));
131
- if (anyInTopN) {
132
- applyAction(store);
133
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
134
- recordDebugEvent({
135
- type: "routing",
136
- operationId: operation.operationId,
137
- operationType: operation.type,
138
- semanticKey,
139
- modelName: store.modelClass?.modelName,
140
- configKey: store.modelClass?.configKey,
141
- decision: "limit:ordering:in-top",
142
- optimistic: true,
143
- verify: false,
144
- });
145
- }
146
- else {
147
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
148
- recordDebugEvent({
149
- type: "routing",
150
- operationId: operation.operationId,
151
- operationType: operation.type,
152
- semanticKey,
153
- modelName: store.modelClass?.modelName,
154
- configKey: store.modelClass?.configKey,
155
- decision: "limit:ordering:out",
156
- optimistic: false,
157
- verify: false,
158
- });
159
- }
160
- }
161
- else {
162
- // No ordering - new items go at end, won't be in first N
163
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
164
- recordDebugEvent({
165
- type: "routing",
166
- operationId: operation.operationId,
167
- operationType: operation.type,
168
- semanticKey,
169
- modelName: store.modelClass?.modelName,
170
- configKey: store.modelClass?.configKey,
171
- decision: "limit:no-ordering",
172
- optimistic: false,
173
- verify: false,
174
- });
175
- }
176
- return;
177
- }
178
- // Room for some or all items - continue to DEFINITELY_YES below
179
- }
180
- // Matches filter, has room or no limit - DEFINITELY_YES
181
- applyAction(store);
182
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
183
- recordDebugEvent({
184
- type: "routing",
185
- operationId: operation.operationId,
186
- operationType: operation.type,
187
- semanticKey,
188
- modelName: store.modelClass?.modelName,
189
- configKey: store.modelClass?.configKey,
190
- decision: "match:has-room",
191
- optimistic: true,
192
- verify: false,
193
- });
194
- });
195
- }
196
- /**
197
- * Evaluates and tracks membership state for UPDATE operations.
198
- *
199
- * For UPDATE, we check filter FIRST, then offset:
200
- * - If local filter says no match: { optimistic: false, verify: true } - local filtering unreliable
201
- * - If matched AND offset > 0: { optimistic: false, verify: true } - can't determine position
202
- * - If matched AND offset = 0: { optimistic: true, verify: false } - show it
203
- *
204
- * @param {Operation} operation - The UPDATE operation
205
- */
206
- function routeUpdateOperation(operation, applyAction) {
207
- const modelClass = operation.queryset.ModelClass;
208
- // frozenInstances is already serialized with proper FK structure
209
- const beforeInstances = operation.frozenInstances;
210
- // For after state, use fromPk to get current live representation
211
- const afterInstances = operation.instances.map(inst => modelClass.fromPk(inst[modelClass.primaryKeyField])).filter(Boolean);
212
- Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
213
- if (!isSameModel(store.modelClass, modelClass))
214
- return;
215
- // Check filter match FIRST (optimization: skip offset check if no match)
216
- const ast = store.queryset.build();
217
- const matchedBefore = filter(beforeInstances, ast, modelClass, false).length > 0;
218
- const matchesAfter = filter(afterInstances, ast, modelClass, false).length > 0;
219
- // If local filter says no match before AND after, trust it
220
- if (!matchedBefore && !matchesAfter) {
221
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
222
- return;
223
- }
224
- // Item matches filter - now check offset
225
- const serializerOptions = store.queryset?._serializerOptions || {};
226
- const { offset } = serializerOptions;
227
- if (offset != null && offset > 0) {
228
- // Can't determine position in paginated window
229
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
230
- return;
231
- }
232
- // Item matched before OR matches after, no offset - definitely affected
233
- // Apply the operation so the queryset can add/remove PKs from its candidate set
234
- applyAction(store);
235
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
236
- });
237
- }
238
- /**
239
- * Evaluates and tracks membership state for DELETE operations.
240
- *
241
- * For DELETE, we check filter FIRST, then offset:
242
- * - If local filter says no match: { optimistic: false, verify: true } - local filtering unreliable
243
- * - If matched AND offset > 0: { optimistic: false, verify: true } - can't determine position
244
- * - If matched AND offset = 0: { optimistic: true, verify: false } - show deletion
245
- *
246
- * @param {Operation} operation - The DELETE operation
247
- */
248
- function routeDeleteOperation(operation, applyAction) {
249
- const modelClass = operation.queryset.ModelClass;
250
- // frozenInstances is already serialized with proper FK structure
251
- const beforeInstances = operation.frozenInstances;
252
- Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
253
- if (!isSameModel(store.modelClass, modelClass))
254
- return;
255
- // Check filter match FIRST (optimization: skip offset check if no match)
256
- const ast = store.queryset.build();
257
- const matchedBefore = filter(beforeInstances, ast, modelClass, false).length > 0;
258
- // If local filter says no match before, trust it
259
- if (!matchedBefore) {
260
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
261
- return;
262
- }
263
- // Item matched filter - now check offset
264
- const serializerOptions = store.queryset?._serializerOptions || {};
265
- const { offset } = serializerOptions;
266
- if (offset != null && offset > 0) {
267
- // Can't determine position in paginated window
268
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
269
- return;
270
- }
271
- // Item matched before, no offset - definitely affected
272
- applyAction(store);
273
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
274
- });
275
- }
276
16
  /**
277
17
  * Process an operation in the model store
278
- *
279
- * @param {Operation} operation - The operation to process
280
- * @param {string} actionType - The action to perform ('add', 'update', 'confirm', 'reject')
281
18
  */
282
19
  function processModelStore(operation, actionType) {
283
20
  const ModelClass = operation.queryset.ModelClass;
@@ -300,115 +37,110 @@ function processModelStore(operation, actionType) {
300
37
  }
301
38
  }
302
39
  /**
303
- * Process an operation in the queryset stores based on operation type
304
- * Uses different routing strategies based on the operation type
305
- *
306
- * @param {Operation} operation - The operation to process
307
- * @param {string} actionType - The action to perform ('add', 'update', 'confirm', 'reject')
40
+ * Determine if a queryset store needs server verification after a local operation.
41
+ * Offset querysets always need it. No-offset querysets only need it when the page
42
+ * is full (an item beyond the limit might need to scroll in).
43
+ */
44
+ function needsVerify(store) {
45
+ const hasOffset = (store.queryset._serializerOptions?.offset ?? 0) > 0;
46
+ if (hasOffset)
47
+ return true;
48
+ const limit = store.queryset._serializerOptions?.limit;
49
+ if (!limit)
50
+ return false;
51
+ return store.render().length >= limit;
52
+ }
53
+ /**
54
+ * Track membership state for sync optimization.
55
+ * No-offset querysets with unfilled pages skip sync — local filtering is perfect.
56
+ * Offset querysets and full pages need server verification.
308
57
  */
309
58
  function processQuerysetStores(operation, actionType) {
59
+ if (operation.type === Type.CHECKPOINT)
60
+ return;
61
+ if (actionType !== 'add')
62
+ return;
310
63
  const ModelClass = operation.queryset.ModelClass;
311
- const queryset = operation.queryset;
312
- // Apply the appropriate action to a single queryset store
313
- const applyAction = (store) => {
314
- switch (actionType) {
315
- case 'add':
316
- store.addOperation(operation);
317
- break;
318
- case 'update':
319
- store.updateOperation(operation);
320
- break;
321
- case 'confirm':
322
- store.confirm(operation);
323
- break;
324
- case 'reject':
325
- store.reject(operation);
326
- break;
327
- }
328
- };
329
- let querysetStoreMap;
330
- // Route to querysets based on operation type
331
- // All operation types track membership state for sync optimization
332
- // CREATE: adds operation to store + tracks membership
333
- // UPDATE/DELETE: only tracks membership (model store + reactivity handles rendering)
334
- switch (operation.type) {
335
- case Type.CREATE:
336
- case Type.BULK_CREATE:
337
- case Type.GET_OR_CREATE:
338
- case Type.UPDATE_OR_CREATE:
339
- // For creates, evaluate each queryset and track membership state
340
- // This allows us to skip syncing querysets that we know don't contain the item
341
- routeCreateOperation(operation, applyAction);
342
- return;
343
- case Type.UPDATE:
344
- case Type.UPDATE_INSTANCE:
345
- // Route update to affected querysets so items can move between filtered sets
346
- // (e.g., an item changing name from 'beta' to 'alpha' needs to appear in the alpha queryset)
347
- routeUpdateOperation(operation, applyAction);
348
- return;
349
- case Type.DELETE:
350
- case Type.DELETE_INSTANCE:
351
- // Add delete to matching queryset stores for optimistic removal
352
- routeDeleteOperation(operation, applyAction);
353
- return;
354
- case Type.CHECKPOINT:
355
- // Model store handles the change, querysets re-render via local filtering
356
- // No membership tracking needed for checkpoints
357
- return;
358
- default:
359
- // For other operation types, route like creates
360
- routeCreateOperation(operation, applyAction);
361
- return;
64
+ for (const [semanticKey, store] of querysetStoreRegistry._stores.entries()) {
65
+ if (!isSameModel(store.modelClass, ModelClass))
66
+ continue;
67
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, needsVerify(store)));
362
68
  }
363
69
  }
364
70
  /**
365
- * Process an operation in the metric stores
366
- *
367
- * For metrics, we route operations UP the family tree - any metric on an ancestor
368
- * queryset should receive the operation so it can check if it affects the metric.
369
- *
370
- * @param {Operation} operation - The operation to process
371
- * @param {string} actionType - The action to perform ('add', 'update', 'confirm', 'reject')
71
+ * Snapshot current metric values BEFORE any changes are applied.
72
+ * Returns a Map of metricStoreKey → currentValue.
372
73
  */
373
- function processMetricStores(operation, actionType) {
74
+ function snapshotMetrics(operation) {
374
75
  const queryset = operation.queryset;
375
- const allMetricStores = new Set();
376
- // Walk up the queryset family tree and collect all metrics
76
+ const snapshots = new Map();
377
77
  let current = queryset;
378
78
  while (current) {
379
79
  const stores = metricRegistry.getAllStoresForQueryset(current);
380
80
  if (stores && stores.length > 0) {
381
- stores.forEach(store => allMetricStores.add(store));
81
+ for (const store of stores) {
82
+ const key = store.cacheKey;
83
+ if (!snapshots.has(key)) {
84
+ snapshots.set(key, {
85
+ store,
86
+ value: store._lastCalculatedValue ?? store.groundTruthValue
87
+ });
88
+ }
89
+ }
382
90
  }
383
91
  current = current.__parent;
384
92
  }
385
- if (allMetricStores.size === 0) {
93
+ return snapshots;
94
+ }
95
+ /**
96
+ * Process metric stores using before/after delta approach.
97
+ *
98
+ * For 'add'/'update': compute new metric value from queryset store,
99
+ * calculate delta vs snapshot, store delta.
100
+ * For 'confirm'/'reject': delegate to metric store.
101
+ *
102
+ * CHECKPOINTs are data refreshes, not business operations — they must not
103
+ * affect aggregation metrics.
104
+ */
105
+ function processMetricStores(operation, actionType, metricSnapshots) {
106
+ if (operation.type === Type.CHECKPOINT)
386
107
  return;
387
- }
388
- // Apply the action to each matching metric store
389
- allMetricStores.forEach(store => {
108
+ if (!metricSnapshots || metricSnapshots.size === 0)
109
+ return;
110
+ for (const [key, snapshot] of metricSnapshots) {
111
+ const store = snapshot.store;
390
112
  switch (actionType) {
391
- case 'add':
392
- store.addOperation(operation);
113
+ case 'add': {
114
+ const qsStore = querysetStoreRegistry.getStore(store.queryset);
115
+ const newValue = computeMetricFromQueryset(store.metricType, qsStore, store.field, store.modelClass);
116
+ const beforeValue = snapshot.value ?? 0;
117
+ const delta = newValue - beforeValue;
118
+ if (delta !== 0) {
119
+ store.addDelta(operation.operationId, delta);
120
+ }
393
121
  break;
394
- case 'update':
395
- store.updateOperation(operation);
122
+ }
123
+ case 'update': {
124
+ // Recompute — the operation may have been mutated
125
+ const qsStore = querysetStoreRegistry.getStore(store.queryset);
126
+ const newValue = computeMetricFromQueryset(store.metricType, qsStore, store.field, store.modelClass);
127
+ const beforeValue = snapshot.value ?? 0;
128
+ const delta = newValue - beforeValue;
129
+ store.updateDelta(operation.operationId, delta);
396
130
  break;
131
+ }
397
132
  case 'confirm':
398
- store.confirm(operation);
133
+ store.confirmDelta(operation.operationId);
399
134
  break;
400
135
  case 'reject':
401
- store.reject(operation);
136
+ store.rejectDelta(operation.operationId);
402
137
  break;
403
138
  }
404
- });
139
+ }
405
140
  }
406
141
  /**
407
142
  * Common processing logic for operations, handling validation and routing
408
143
  * to the appropriate store processors
409
- *
410
- * @param {Operation} operation - The operation to process
411
- * @param {string} actionType - The action to perform ('add', 'update', 'confirm', 'reject')
412
144
  */
413
145
  function processOperation(operation, actionType) {
414
146
  if (!operation || !operation.queryset || !operation.queryset.ModelClass) {
@@ -418,12 +150,14 @@ function processOperation(operation, actionType) {
418
150
  if (operation.doNotPropagate) {
419
151
  return;
420
152
  }
421
- // Process model store first
153
+ // 1. Snapshot metric values BEFORE changes
154
+ const metricSnapshots = snapshotMetrics(operation);
155
+ // 2. Model store — updates instance data, triggers renders on ALL queryset stores
422
156
  processModelStore(operation, actionType);
423
- // Then process queryset stores with improved routing
157
+ // 3. Queryset stores track membership state for sync
424
158
  processQuerysetStores(operation, actionType);
425
- // Finally process metric stores
426
- processMetricStores(operation, actionType);
159
+ // 4. Metrics (before/after diff)
160
+ processMetricStores(operation, actionType, metricSnapshots);
427
161
  }
428
162
  // Define handlers as named arrow functions at the top level
429
163
  const handleOperationCreated = operation => processOperation(operation, 'add');
@@ -7,6 +7,7 @@ export class QuerysetStore {
7
7
  groundTruthPks: never[];
8
8
  isSyncing: boolean;
9
9
  lastSync: number | null;
10
+ _createdAt: number;
10
11
  isTemp: any;
11
12
  pruneThreshold: any;
12
13
  includedPks: Map<any, any>;
@@ -43,13 +44,17 @@ export class QuerysetStore {
43
44
  * @private
44
45
  */
45
46
  private _getValidatedAndFilteredPks;
46
- render(optimistic?: boolean, fromCache?: boolean): any[];
47
- renderFromData(optimistic?: boolean): any[];
48
47
  /**
49
- * Render by getting all instances from the model store and filtering locally.
50
- * Used when a queryset has no ground truth (temp stores, newly created stores, etc.)
48
+ * For offset pages that aren't full, expand with creates from the model store
49
+ * that sort after the first item on the page (direction-adjusted).
51
50
  */
52
- renderFromModelStore(): any[];
51
+ _fillOffsetPage(currentPks: any, optimistic: any): any[] | null;
52
+ render(optimistic?: boolean, fromCache?: boolean): any[];
53
+ renderFromData(optimistic?: boolean): any;
54
+ /** Are there any inflight or recent confirmed ops in the model store? */
55
+ _hasRecentOps(modelStore: any): boolean;
56
+ /** Build set of PKs from ops newer than lastSync. Only used for no-offset querysets. */
57
+ _buildFreshPks(modelStore: any, optimistic: any): Set<any>;
53
58
  applyOperation(operation: any, currentPks: any): any;
54
59
  /**
55
60
  * Sync this queryset with the database.