@statezero/core 0.2.53 → 0.2.55
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 +1039 -1172
- package/dist/adaptors/vue/composables.js +5 -0
- package/dist/core.css +1 -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 +3 -0
- package/dist/syncEngine/registries/metricRegistry.js +10 -0
- 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 +11 -5
- package/dist/syncEngine/stores/querysetStore.js +142 -57
- package/dist/syncEngine/sync.js +2 -2
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { querysetEventEmitter } from './reactivity.js';
|
|
|
3
3
|
import { isNil, isEmpty, trim, isEqual } from 'lodash-es';
|
|
4
4
|
import { replaceTempPks, containsTempPk } from '../../flavours/django/tempPk.js';
|
|
5
5
|
import { modelStoreRegistry } from '../registries/modelStoreRegistry.js';
|
|
6
|
+
import { querysetStoreRegistry } from '../registries/querysetStoreRegistry.js';
|
|
6
7
|
import { processIncludedEntities } from '../../flavours/django/makeApiCall.js';
|
|
7
8
|
import { Cache } from '../cache/cache.js';
|
|
8
9
|
import { filter } from "../../filtering/localFiltering.js";
|
|
@@ -14,6 +15,8 @@ export class QuerysetStore {
|
|
|
14
15
|
this.queryset = queryset;
|
|
15
16
|
this.isSyncing = false;
|
|
16
17
|
this.lastSync = null;
|
|
18
|
+
this.lastHydrated = null;
|
|
19
|
+
this._createdAt = Date.now();
|
|
17
20
|
this.isTemp = options.isTemp || false;
|
|
18
21
|
this.pruneThreshold = options.pruneThreshold || 10;
|
|
19
22
|
this.groundTruthPks = initialGroundTruthPks || [];
|
|
@@ -46,20 +49,22 @@ export class QuerysetStore {
|
|
|
46
49
|
return this.queryset.semanticKey;
|
|
47
50
|
}
|
|
48
51
|
onHydrated(hydratedData) {
|
|
49
|
-
// Only use cached data if we haven't synced yet and have no ground truth.
|
|
50
|
-
// This prevents stale cache from overwriting real server data.
|
|
51
52
|
if (this.lastSync !== null)
|
|
52
53
|
return;
|
|
53
54
|
if (this.groundTruthPks.length > 0 || this.operationsMap.size > 0)
|
|
54
55
|
return;
|
|
55
56
|
const cached = this.qsCache.get(this.cacheKey);
|
|
56
|
-
if (!isNil(cached)
|
|
57
|
-
// Set ground truth WITHOUT updating lastSync — hydration is not a real sync
|
|
57
|
+
if (!isNil(cached)) {
|
|
58
58
|
this.groundTruthPks = Array.isArray(cached) ? cached : [];
|
|
59
|
-
this.
|
|
59
|
+
this.lastHydrated = Date.now();
|
|
60
|
+
if (cached.length > 0) {
|
|
61
|
+
this._emitRenderEvent();
|
|
62
|
+
}
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
setCache(result) {
|
|
66
|
+
if (this.lastSync === null)
|
|
67
|
+
return;
|
|
63
68
|
let nonTempPks = [];
|
|
64
69
|
result.forEach((pk) => {
|
|
65
70
|
if (typeof pk === "string" && containsTempPk(pk)) {
|
|
@@ -191,6 +196,56 @@ export class QuerysetStore {
|
|
|
191
196
|
const finalPks = filter(instances, ast, this.modelClass, false); // false = return PKs
|
|
192
197
|
return finalPks;
|
|
193
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* For offset pages that aren't full, expand with creates from the model store
|
|
201
|
+
* that sort after the first item on the page (direction-adjusted).
|
|
202
|
+
*/
|
|
203
|
+
_fillOffsetPage(currentPks, optimistic) {
|
|
204
|
+
const modelStore = modelStoreRegistry.getStore(this.modelClass);
|
|
205
|
+
if (!modelStore)
|
|
206
|
+
return null;
|
|
207
|
+
const orderBy = this.queryset._orderBy;
|
|
208
|
+
if (!orderBy || orderBy.length === 0)
|
|
209
|
+
return null;
|
|
210
|
+
// Get sort field and direction from first ordering field
|
|
211
|
+
const field = orderBy[0].replace(/^-/, '');
|
|
212
|
+
const isDesc = orderBy[0].startsWith('-');
|
|
213
|
+
// Get first item's sort value as the page boundary
|
|
214
|
+
const firstInstance = this.modelClass.fromPk(currentPks[0], this.queryset);
|
|
215
|
+
if (!firstInstance)
|
|
216
|
+
return null;
|
|
217
|
+
const boundary = firstInstance[field];
|
|
218
|
+
if (boundary == null)
|
|
219
|
+
return null;
|
|
220
|
+
// Find CREATE-type instances that sort into this page
|
|
221
|
+
const pkField = this.pkField;
|
|
222
|
+
const currentSet = new Set(currentPks);
|
|
223
|
+
const candidates = new Set(currentPks);
|
|
224
|
+
for (const op of modelStore.operations) {
|
|
225
|
+
if (op.status === Status.REJECTED)
|
|
226
|
+
continue;
|
|
227
|
+
if (!optimistic && op.status !== Status.CONFIRMED)
|
|
228
|
+
continue;
|
|
229
|
+
if (op.type !== Type.CREATE && op.type !== Type.BULK_CREATE &&
|
|
230
|
+
op.type !== Type.GET_OR_CREATE && op.type !== Type.UPDATE_OR_CREATE)
|
|
231
|
+
continue;
|
|
232
|
+
for (const inst of op.instances) {
|
|
233
|
+
if (!inst || inst[pkField] == null)
|
|
234
|
+
continue;
|
|
235
|
+
if (currentSet.has(inst[pkField]))
|
|
236
|
+
continue;
|
|
237
|
+
const val = inst[field];
|
|
238
|
+
if (val == null)
|
|
239
|
+
continue;
|
|
240
|
+
const sortsAfter = isDesc ? val <= boundary : val >= boundary;
|
|
241
|
+
if (sortsAfter)
|
|
242
|
+
candidates.add(inst[pkField]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (candidates.size === currentSet.size)
|
|
246
|
+
return null;
|
|
247
|
+
return this._getValidatedAndFilteredPks(Array.from(candidates));
|
|
248
|
+
}
|
|
194
249
|
render(optimistic = true, fromCache = false) {
|
|
195
250
|
// Check cache first if requested
|
|
196
251
|
if (fromCache) {
|
|
@@ -210,32 +265,26 @@ export class QuerysetStore {
|
|
|
210
265
|
return cachedResult;
|
|
211
266
|
}
|
|
212
267
|
}
|
|
213
|
-
|
|
214
|
-
// This handles chained optimistic filters, newly created stores, etc.
|
|
215
|
-
// (If synced with empty results, that's valid ground truth)
|
|
216
|
-
// Skip model store seeding for paginated querysets (offset > 0) — the model
|
|
217
|
-
// store contains items from other pages, so seeding would show wrong results.
|
|
218
|
-
const offset = this.queryset.build().serializerOptions?.offset;
|
|
219
|
-
const canSeedFromModelStore = this.groundTruthPks.length === 0
|
|
220
|
-
&& this.lastSync === null
|
|
221
|
-
&& !(offset != null && offset > 0);
|
|
222
|
-
const pks = canSeedFromModelStore
|
|
223
|
-
? this.renderFromModelStore()
|
|
224
|
-
: this.renderFromData(optimistic);
|
|
268
|
+
const pks = this.renderFromData(optimistic);
|
|
225
269
|
// Validate against model store and apply local filtering/sorting
|
|
226
270
|
let result = this._getValidatedAndFilteredPks(pks);
|
|
227
271
|
const preLimitCount = result.length;
|
|
228
272
|
// Apply pagination limit
|
|
229
273
|
const limit = this.queryset.build().serializerOptions?.limit;
|
|
274
|
+
const hasOffset = (this.queryset._serializerOptions?.offset ?? 0) > 0;
|
|
275
|
+
// Offset page not full: fill with creates from model store that sort into this page
|
|
276
|
+
if (hasOffset && limit && result.length > 0 && result.length < limit) {
|
|
277
|
+
const expanded = this._fillOffsetPage(result, optimistic);
|
|
278
|
+
if (expanded)
|
|
279
|
+
result = expanded;
|
|
280
|
+
}
|
|
230
281
|
if (limit) {
|
|
231
282
|
result = result.slice(0, limit);
|
|
232
283
|
}
|
|
233
284
|
this.setCache(result);
|
|
234
285
|
recordDebugEvent({
|
|
235
286
|
type: "render",
|
|
236
|
-
source:
|
|
237
|
-
? "modelStore"
|
|
238
|
-
: "groundTruth",
|
|
287
|
+
source: "groundTruth",
|
|
239
288
|
semanticKey: this.queryset.semanticKey,
|
|
240
289
|
modelName: this.modelClass.modelName,
|
|
241
290
|
configKey: this.modelClass.configKey,
|
|
@@ -250,26 +299,79 @@ export class QuerysetStore {
|
|
|
250
299
|
return result;
|
|
251
300
|
}
|
|
252
301
|
renderFromData(optimistic = true) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
302
|
+
const modelStore = modelStoreRegistry.getStore(this.modelClass);
|
|
303
|
+
// Temp stores: greedy seed from all model data (for op targeting, ignores pagination)
|
|
304
|
+
if (this.isTemp && this.lastSync === null && modelStore) {
|
|
305
|
+
const pkField = this.pkField;
|
|
306
|
+
return modelStore.render().map(inst => inst[pkField]).filter(pk => pk != null);
|
|
307
|
+
}
|
|
308
|
+
// Permanent never-synced and never-hydrated: seed from local data
|
|
309
|
+
if (!this.isTemp && this.lastSync === null && this.lastHydrated === null && modelStore) {
|
|
310
|
+
const hasOffset = (this.queryset._serializerOptions?.offset ?? 0) > 0;
|
|
311
|
+
if (!hasOffset) {
|
|
312
|
+
// No offset: seed from all model store data, filter handles membership
|
|
313
|
+
const pkField = this.pkField;
|
|
314
|
+
return modelStore.render().map(inst => inst[pkField]).filter(pk => pk != null);
|
|
315
|
+
}
|
|
316
|
+
// Offset: seed from root store if it's been synced
|
|
317
|
+
const { root: rootKey } = querysetStoreRegistry.querysetStoreGraph.findRoot(this.queryset);
|
|
318
|
+
const rootStore = rootKey ? querysetStoreRegistry._stores.get(rootKey) : null;
|
|
319
|
+
if (rootStore && rootStore !== this && rootStore.lastSync !== null) {
|
|
320
|
+
return rootStore.render();
|
|
258
321
|
}
|
|
259
322
|
}
|
|
260
|
-
|
|
261
|
-
|
|
323
|
+
const hasOffset = (this.queryset._serializerOptions?.offset ?? 0) > 0;
|
|
324
|
+
// Offset querysets after sync: just ground truth.
|
|
325
|
+
// Filled pages shouldn't mix in local ops (makes mess).
|
|
326
|
+
// Last-page fill is handled by _fillOffsetPage in render().
|
|
327
|
+
if (hasOffset) {
|
|
328
|
+
return Array.from(this.groundTruthSet);
|
|
329
|
+
}
|
|
330
|
+
// No-offset: greedy — filtering is perfect, so include all fresh ops.
|
|
331
|
+
if (!modelStore || !this._hasRecentOps(modelStore)) {
|
|
332
|
+
return Array.from(this.groundTruthSet);
|
|
333
|
+
}
|
|
334
|
+
const freshPks = this._buildFreshPks(modelStore, optimistic);
|
|
335
|
+
const basePks = this.groundTruthSet;
|
|
336
|
+
for (const pk of freshPks)
|
|
337
|
+
basePks.add(pk);
|
|
338
|
+
return Array.from(basePks);
|
|
262
339
|
}
|
|
263
|
-
/**
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
340
|
+
/** Are there any inflight or recent confirmed ops in the model store? */
|
|
341
|
+
_hasRecentOps(modelStore) {
|
|
342
|
+
const ops = modelStore.operations;
|
|
343
|
+
const cutoff = this.lastSync ?? this._createdAt;
|
|
344
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
345
|
+
if (ops[i].status === Status.REJECTED)
|
|
346
|
+
continue;
|
|
347
|
+
// Inflight ops are always relevant (not yet in ground truth)
|
|
348
|
+
if (ops[i].status !== Status.CONFIRMED)
|
|
349
|
+
return true;
|
|
350
|
+
// Confirmed ops: only relevant if newer than last sync
|
|
351
|
+
return ops[i].timestamp >= cutoff;
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
/** Build set of PKs from ops newer than lastSync. Only used for no-offset querysets. */
|
|
356
|
+
_buildFreshPks(modelStore, optimistic) {
|
|
357
|
+
const freshPks = new Set();
|
|
358
|
+
const pkField = this.pkField;
|
|
359
|
+
const cutoff = this.lastSync ?? this._createdAt;
|
|
360
|
+
for (const op of modelStore.operations) {
|
|
361
|
+
if (op.status === Status.REJECTED)
|
|
362
|
+
continue;
|
|
363
|
+
if (!optimistic && op.status !== Status.CONFIRMED)
|
|
364
|
+
continue;
|
|
365
|
+
// Inflight ops always included (not yet in ground truth).
|
|
366
|
+
// Confirmed ops: skip if older than last sync (already in ground truth).
|
|
367
|
+
if (op.status === Status.CONFIRMED && op.timestamp < cutoff)
|
|
368
|
+
continue;
|
|
369
|
+
for (const inst of op.instances) {
|
|
370
|
+
if (inst && inst[pkField] != null)
|
|
371
|
+
freshPks.add(inst[pkField]);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return freshPks;
|
|
273
375
|
}
|
|
274
376
|
applyOperation(operation, currentPks) {
|
|
275
377
|
const pkField = this.pkField;
|
|
@@ -279,26 +381,9 @@ export class QuerysetStore {
|
|
|
279
381
|
continue;
|
|
280
382
|
}
|
|
281
383
|
let pk = instance[pkField];
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
currentPks.add(pk);
|
|
286
|
-
break;
|
|
287
|
-
case Type.CHECKPOINT:
|
|
288
|
-
break;
|
|
289
|
-
case Type.UPDATE:
|
|
290
|
-
case Type.UPDATE_INSTANCE:
|
|
291
|
-
// Add PKs as candidates so items can move into this queryset
|
|
292
|
-
// when an update makes them match the filter.
|
|
293
|
-
// _getValidatedAndFilteredPks will filter out non-matching items.
|
|
294
|
-
currentPks.add(pk);
|
|
295
|
-
break;
|
|
296
|
-
case Type.DELETE:
|
|
297
|
-
case Type.DELETE_INSTANCE:
|
|
298
|
-
currentPks.delete(pk);
|
|
299
|
-
break;
|
|
300
|
-
default:
|
|
301
|
-
console.error(`[QuerysetStore ${this.modelClass.modelName}] Unknown operation type: ${operation.type}`);
|
|
384
|
+
// Only CREATE types add PKs. UPDATE/DELETE are handled by model store + refiltering.
|
|
385
|
+
if (CREATE_TYPES.has(operation.type)) {
|
|
386
|
+
currentPks.add(pk);
|
|
302
387
|
}
|
|
303
388
|
}
|
|
304
389
|
return currentPks;
|
package/dist/syncEngine/sync.js
CHANGED
|
@@ -377,8 +377,8 @@ export class SyncManager {
|
|
|
377
377
|
entry.store.sync();
|
|
378
378
|
continue;
|
|
379
379
|
}
|
|
380
|
-
//
|
|
381
|
-
if (this.isQuerysetFollowed(entry.queryset)) {
|
|
380
|
+
// Sync if the metric itself is followed, or its queryset is followed
|
|
381
|
+
if (registry.followedMetrics.has(key) || this.isQuerysetFollowed(entry.queryset)) {
|
|
382
382
|
entry.store.sync();
|
|
383
383
|
}
|
|
384
384
|
}
|
package/package.json
CHANGED