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