@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.
- package/dist/adaptors/vue/components/StateZeroDebugPanel.js +943 -1069
- package/dist/adaptors/vue/composables.d.ts +1 -1
- package/dist/adaptors/vue/composables.js +13 -2
- package/dist/adaptors/vue/reactivity.js +3 -3
- package/dist/core.css +1 -1
- package/dist/syncEngine/cache/cache.d.ts +1 -1
- package/dist/syncEngine/cache/cache.js +8 -1
- package/dist/syncEngine/metrics/metricOptCalcs.d.ts +9 -78
- package/dist/syncEngine/metrics/metricOptCalcs.js +24 -277
- package/dist/syncEngine/registries/metricRegistry.d.ts +17 -3
- package/dist/syncEngine/registries/metricRegistry.js +30 -6
- package/dist/syncEngine/stores/metricStore.d.ts +18 -18
- package/dist/syncEngine/stores/metricStore.js +52 -91
- package/dist/syncEngine/stores/modelStore.js +28 -12
- package/dist/syncEngine/stores/operationEventHandlers.js +83 -349
- package/dist/syncEngine/stores/querysetStore.d.ts +10 -5
- package/dist/syncEngine/stores/querysetStore.js +145 -51
- package/dist/syncEngine/sync.js +2 -2
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
*
|
|
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
|
|
74
|
+
function snapshotMetrics(operation) {
|
|
374
75
|
const queryset = operation.queryset;
|
|
375
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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.
|
|
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
|
-
|
|
395
|
-
|
|
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.
|
|
133
|
+
store.confirmDelta(operation.operationId);
|
|
399
134
|
break;
|
|
400
135
|
case 'reject':
|
|
401
|
-
store.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
157
|
+
// 3. Queryset stores — track membership state for sync
|
|
424
158
|
processQuerysetStores(operation, actionType);
|
|
425
|
-
//
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
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.
|