@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.
@@ -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 (this.groundTruthPks.length === 0 && this.operationsMap.size === 0) {
50
- const cached = this.qsCache.get(this.cacheKey);
51
- if (!isNil(cached) && !isEmpty(cached)) {
52
- this.setGroundTruth(cached);
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
- // If no ground truth AND hasn't been synced, render from model store
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: this.groundTruthPks.length === 0 && this.lastSync === null
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 renderedPks = this.groundTruthSet;
242
- for (const op of this.operations) {
243
- if (op.status !== Status.REJECTED &&
244
- (optimistic || op.status === Status.CONFIRMED)) {
245
- this.applyOperation(op, renderedPks);
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
- let result = Array.from(renderedPks);
249
- return result;
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
- * Render by getting all instances from the model store and filtering locally.
253
- * Used when a queryset has no ground truth (temp stores, newly created stores, etc.)
254
- */
255
- renderFromModelStore() {
256
- const modelStore = modelStoreRegistry.getStore(this.modelClass);
257
- const allPks = modelStore.groundTruthPks;
258
- const allInstances = allPks.map((pk) => this.modelClass.fromPk(pk, this.queryset));
259
- const ast = this.queryset.build();
260
- return filter(allInstances, ast, this.modelClass, false);
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
- switch (operation.type) {
271
- case Type.CREATE:
272
- case Type.BULK_CREATE:
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;
@@ -377,8 +377,8 @@ export class SyncManager {
377
377
  entry.store.sync();
378
378
  continue;
379
379
  }
380
- // Check if this queryset (or any parent) is being followed
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.2.52",
3
+ "version": "0.2.54",
4
4
  "type": "module",
5
5
  "module": "ESNext",
6
6
  "description": "The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate",