@statezero/core 0.2.53 → 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 || [];
@@ -191,6 +193,56 @@ export class QuerysetStore {
191
193
  const finalPks = filter(instances, ast, this.modelClass, false); // false = return PKs
192
194
  return finalPks;
193
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
+ }
194
246
  render(optimistic = true, fromCache = false) {
195
247
  // Check cache first if requested
196
248
  if (fromCache) {
@@ -210,32 +262,26 @@ export class QuerysetStore {
210
262
  return cachedResult;
211
263
  }
212
264
  }
213
- // If no ground truth AND hasn't been synced, render from model store
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);
265
+ const pks = this.renderFromData(optimistic);
225
266
  // Validate against model store and apply local filtering/sorting
226
267
  let result = this._getValidatedAndFilteredPks(pks);
227
268
  const preLimitCount = result.length;
228
269
  // Apply pagination limit
229
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
+ }
230
278
  if (limit) {
231
279
  result = result.slice(0, limit);
232
280
  }
233
281
  this.setCache(result);
234
282
  recordDebugEvent({
235
283
  type: "render",
236
- source: this.groundTruthPks.length === 0 && this.lastSync === null
237
- ? "modelStore"
238
- : "groundTruth",
284
+ source: "groundTruth",
239
285
  semanticKey: this.queryset.semanticKey,
240
286
  modelName: this.modelClass.modelName,
241
287
  configKey: this.modelClass.configKey,
@@ -250,26 +296,79 @@ export class QuerysetStore {
250
296
  return result;
251
297
  }
252
298
  renderFromData(optimistic = true) {
253
- const renderedPks = this.groundTruthSet;
254
- for (const op of this.operations) {
255
- if (op.status !== Status.REJECTED &&
256
- (optimistic || op.status === Status.CONFIRMED)) {
257
- 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();
258
318
  }
259
319
  }
260
- let result = Array.from(renderedPks);
261
- 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);
262
336
  }
263
- /**
264
- * Render by getting all instances from the model store and filtering locally.
265
- * Used when a queryset has no ground truth (temp stores, newly created stores, etc.)
266
- */
267
- renderFromModelStore() {
268
- const modelStore = modelStoreRegistry.getStore(this.modelClass);
269
- const allPks = modelStore.groundTruthPks;
270
- const allInstances = allPks.map((pk) => this.modelClass.fromPk(pk, this.queryset));
271
- const ast = this.queryset.build();
272
- 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;
273
372
  }
274
373
  applyOperation(operation, currentPks) {
275
374
  const pkField = this.pkField;
@@ -279,26 +378,9 @@ export class QuerysetStore {
279
378
  continue;
280
379
  }
281
380
  let pk = instance[pkField];
282
- switch (operation.type) {
283
- case Type.CREATE:
284
- case Type.BULK_CREATE:
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}`);
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);
302
384
  }
303
385
  }
304
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.53",
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",