@statezero/core 0.2.31 → 0.2.32

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.
@@ -21,11 +21,21 @@ export class QuerysetStoreGraph {
21
21
  /**
22
22
  * Check if parent queryset is a valid data source for creating an edge.
23
23
  * Parent must have data that is a superset of what child needs.
24
+ * Child with offset must sync independently (can't derive window from parent).
24
25
  *
25
26
  * @param {Object} parentOpts - Parent's serializerOptions
26
27
  * @param {Object} childOpts - Child's serializerOptions
28
+ * @param {Array|undefined} parentOrderBy - Parent's ordering
29
+ * @param {Array|undefined} childOrderBy - Child's ordering
27
30
  * @returns {boolean} - True if parent is valid for edge creation
28
31
  */
29
- _isValidParentForEdge(parentOpts?: Object, childOpts?: Object): boolean;
32
+ _isValidParentForEdge(parentOpts: Object | undefined, childOpts: Object | undefined, parentOrderBy: any[] | undefined, childOrderBy: any[] | undefined): boolean;
33
+ /**
34
+ * Check if two orderings are equivalent.
35
+ * @param {Array|undefined} orderBy1
36
+ * @param {Array|undefined} orderBy2
37
+ * @returns {boolean}
38
+ */
39
+ _orderingsMatch(orderBy1: any[] | undefined, orderBy2: any[] | undefined): boolean;
30
40
  clear(): void;
31
41
  }
@@ -28,7 +28,7 @@ export class QuerysetStoreGraph {
28
28
  // Determine if we can create an edge to parent
29
29
  // Parent must be a valid data source (superset of child's data needs)
30
30
  const canLinkToParent = currentKey !== parentKey &&
31
- this._isValidParentForEdge(current.__parent._serializerOptions, current._serializerOptions);
31
+ this._isValidParentForEdge(current.__parent._serializerOptions, current._serializerOptions, current.__parent._orderBy, current._orderBy);
32
32
  if (canLinkToParent) {
33
33
  this.graph.setEdge(currentKey, parentKey);
34
34
  }
@@ -98,17 +98,35 @@ export class QuerysetStoreGraph {
98
98
  /**
99
99
  * Check if parent queryset is a valid data source for creating an edge.
100
100
  * Parent must have data that is a superset of what child needs.
101
+ * Child with offset must sync independently (can't derive window from parent).
101
102
  *
102
103
  * @param {Object} parentOpts - Parent's serializerOptions
103
104
  * @param {Object} childOpts - Child's serializerOptions
105
+ * @param {Array|undefined} parentOrderBy - Parent's ordering
106
+ * @param {Array|undefined} childOrderBy - Child's ordering
104
107
  * @returns {boolean} - True if parent is valid for edge creation
105
108
  */
106
- _isValidParentForEdge(parentOpts = {}, childOpts = {}) {
107
- // Cannot link if parent has pagination (limit/offset)
108
- // Paginated parent only has a subset of data
109
- if (parentOpts.limit != null || parentOpts.offset != null) {
109
+ _isValidParentForEdge(parentOpts = {}, childOpts = {}, parentOrderBy, childOrderBy) {
110
+ // Cannot link if parent has offset > 0 (paginated parent has subset of data)
111
+ // Note: offset: 0 is treated as no offset (start from beginning)
112
+ if (parentOpts.offset != null && parentOpts.offset > 0) {
110
113
  return false;
111
114
  }
115
+ // Cannot link if parent has limit - child may need items beyond parent's limit window
116
+ // (filtered items matching child could exist beyond parent's limit cutoff)
117
+ // If child has same filter + same limit, they'd have same semanticKey (no edge needed)
118
+ if (parentOpts.limit != null) {
119
+ return false;
120
+ }
121
+ // Cannot link if child has offset > 0 - must sync independently
122
+ // We can't derive which items are at positions N-M from the parent's full data
123
+ // because that requires the server's ordering logic
124
+ // Note: offset: 0 is treated as no offset (start from beginning)
125
+ if (childOpts.offset != null && childOpts.offset > 0) {
126
+ return false;
127
+ }
128
+ // Note: ordering doesn't matter for linking since parent has no limit (checked above)
129
+ // Parent has all items, so child can re-sort locally regardless of ordering
112
130
  // Cannot link if parent has different depth
113
131
  // Different depth means different nested data structure
114
132
  if (parentOpts.depth != null && parentOpts.depth !== childOpts.depth) {
@@ -130,6 +148,25 @@ export class QuerysetStoreGraph {
130
148
  }
131
149
  return true;
132
150
  }
151
+ /**
152
+ * Check if two orderings are equivalent.
153
+ * @param {Array|undefined} orderBy1
154
+ * @param {Array|undefined} orderBy2
155
+ * @returns {boolean}
156
+ */
157
+ _orderingsMatch(orderBy1, orderBy2) {
158
+ // Both undefined/null = match
159
+ if (!orderBy1 && !orderBy2)
160
+ return true;
161
+ // One defined, one not = no match
162
+ if (!orderBy1 || !orderBy2)
163
+ return false;
164
+ // Different lengths = no match
165
+ if (orderBy1.length !== orderBy2.length)
166
+ return false;
167
+ // Compare each field
168
+ return orderBy1.every((field, i) => field === orderBy2[i]);
169
+ }
133
170
  clear() {
134
171
  this.graph = new Graph({ directed: true });
135
172
  this.processedQuerysets = new Set();
@@ -24,6 +24,11 @@ export namespace Type {
24
24
  let SUM: string;
25
25
  let AGGREGATE: string;
26
26
  }
27
+ export namespace OperationMembership {
28
+ let DEFINITELY_YES: string;
29
+ let DEFINITELY_NO: string;
30
+ let MAYBE: string;
31
+ }
27
32
  export class Operation {
28
33
  constructor(data: any, restore?: boolean);
29
34
  operationId: any;
@@ -72,6 +77,7 @@ export class Operation {
72
77
  export const operationRegistry: OperationRegistry;
73
78
  declare class OperationRegistry {
74
79
  _operations: Map<any, any>;
80
+ _querysetStates: Map<any, any>;
75
81
  /**
76
82
  * Registers a pre-constructed Operation instance in the registry.
77
83
  * Ensures the operationId is unique within the registry.
@@ -93,9 +99,41 @@ declare class OperationRegistry {
93
99
  * @returns {boolean} True if the operation exists, false otherwise.
94
100
  */
95
101
  has(operationId: string): boolean;
102
+ /**
103
+ * Sets the membership state for a queryset with respect to an operation.
104
+ * @param {string} operationId - The operation ID
105
+ * @param {string} semanticKey - The queryset's semantic key
106
+ * @param {string} state - One of OperationMembership values
107
+ */
108
+ setQuerysetState(operationId: string, semanticKey: string, state: string): void;
109
+ /**
110
+ * Gets the membership state for a queryset with respect to an operation.
111
+ * @param {string} operationId - The operation ID
112
+ * @param {string} semanticKey - The queryset's semantic key
113
+ * @returns {string|undefined} The membership state, or undefined if not set
114
+ */
115
+ getQuerysetState(operationId: string, semanticKey: string): string | undefined;
116
+ /**
117
+ * Gets all queryset states for an operation.
118
+ * @param {string} operationId - The operation ID
119
+ * @returns {Map<string, string>|undefined} Map of semanticKey -> state, or undefined
120
+ */
121
+ getQuerysetStates(operationId: string): Map<string, string> | undefined;
96
122
  /**
97
123
  * Clears all operations from the registry.
98
124
  */
99
125
  clear(): void;
126
+ /**
127
+ * Gets the most recently registered operation.
128
+ * Useful for testing when you need to find the operation created by the last action.
129
+ * @returns {Operation | undefined} The most recent operation or undefined if empty.
130
+ */
131
+ getLatest(): Operation | undefined;
132
+ /**
133
+ * Gets the most recently registered operation of a specific type.
134
+ * @param {string} type - The operation type (e.g., Type.CREATE, Type.UPDATE)
135
+ * @returns {Operation | undefined} The most recent operation of that type or undefined.
136
+ */
137
+ getLatestByType(type: string): Operation | undefined;
100
138
  }
101
139
  export {};
@@ -42,6 +42,15 @@ export const Type = {
42
42
  SUM: 'sum',
43
43
  AGGREGATE: 'aggregate',
44
44
  };
45
+ /**
46
+ * Membership state for a queryset with respect to an operation.
47
+ * Used to track whether a queryset was evaluated for an operation and the result.
48
+ */
49
+ export const OperationMembership = {
50
+ DEFINITELY_YES: 'definitely_yes', // Item matches filter, included in queryset
51
+ DEFINITELY_NO: 'definitely_no', // Item doesn't match filter, excluded from queryset
52
+ MAYBE: 'maybe', // Impossible to know based on slicing, needs server sync
53
+ };
45
54
  export class Operation {
46
55
  constructor(data, restore = false) {
47
56
  _Operation__instances.set(this, void 0);
@@ -182,6 +191,8 @@ _Operation__instances = new WeakMap(), _Operation__frozenInstances = new WeakMap
182
191
  class OperationRegistry {
183
192
  constructor() {
184
193
  this._operations = new Map();
194
+ // Map<operationId, Map<semanticKey, OperationMembership>>
195
+ this._querysetStates = new Map();
185
196
  }
186
197
  /**
187
198
  * Registers a pre-constructed Operation instance in the registry.
@@ -219,13 +230,62 @@ class OperationRegistry {
219
230
  has(operationId) {
220
231
  return this._operations.has(operationId);
221
232
  }
233
+ /**
234
+ * Sets the membership state for a queryset with respect to an operation.
235
+ * @param {string} operationId - The operation ID
236
+ * @param {string} semanticKey - The queryset's semantic key
237
+ * @param {string} state - One of OperationMembership values
238
+ */
239
+ setQuerysetState(operationId, semanticKey, state) {
240
+ if (!this._querysetStates.has(operationId)) {
241
+ this._querysetStates.set(operationId, new Map());
242
+ }
243
+ this._querysetStates.get(operationId).set(semanticKey, state);
244
+ }
245
+ /**
246
+ * Gets the membership state for a queryset with respect to an operation.
247
+ * @param {string} operationId - The operation ID
248
+ * @param {string} semanticKey - The queryset's semantic key
249
+ * @returns {string|undefined} The membership state, or undefined if not set
250
+ */
251
+ getQuerysetState(operationId, semanticKey) {
252
+ const states = this._querysetStates.get(operationId);
253
+ return states ? states.get(semanticKey) : undefined;
254
+ }
255
+ /**
256
+ * Gets all queryset states for an operation.
257
+ * @param {string} operationId - The operation ID
258
+ * @returns {Map<string, string>|undefined} Map of semanticKey -> state, or undefined
259
+ */
260
+ getQuerysetStates(operationId) {
261
+ return this._querysetStates.get(operationId);
262
+ }
222
263
  /**
223
264
  * Clears all operations from the registry.
224
265
  */
225
266
  clear() {
226
267
  console.log("OperationRegistry: Clearing all operations.");
227
268
  this._operations.clear();
269
+ this._querysetStates.clear();
228
270
  operationEvents.emit(Status.CLEAR);
229
271
  }
272
+ /**
273
+ * Gets the most recently registered operation.
274
+ * Useful for testing when you need to find the operation created by the last action.
275
+ * @returns {Operation | undefined} The most recent operation or undefined if empty.
276
+ */
277
+ getLatest() {
278
+ const ops = Array.from(this._operations.values());
279
+ return ops.length > 0 ? ops[ops.length - 1] : undefined;
280
+ }
281
+ /**
282
+ * Gets the most recently registered operation of a specific type.
283
+ * @param {string} type - The operation type (e.g., Type.CREATE, Type.UPDATE)
284
+ * @returns {Operation | undefined} The most recent operation of that type or undefined.
285
+ */
286
+ getLatestByType(type) {
287
+ const ops = Array.from(this._operations.values()).filter(op => op.type === type);
288
+ return ops.length > 0 ? ops[ops.length - 1] : undefined;
289
+ }
230
290
  }
231
291
  export const operationRegistry = new OperationRegistry();
@@ -1,4 +1,4 @@
1
- import { operationEvents, Status, Type } from './operation.js';
1
+ import { operationEvents, Status, Type, operationRegistry, OperationMembership } from './operation.js';
2
2
  import { modelStoreRegistry } from '../registries/modelStoreRegistry.js';
3
3
  import { querysetStoreRegistry } from '../registries/querysetStoreRegistry.js';
4
4
  import { metricRegistry } from '../registries/metricRegistry.js';
@@ -6,22 +6,136 @@ import { getFingerprint } from './utils.js';
6
6
  import { QuerySet } from '../../flavours/django/querySet.js';
7
7
  import { isEqual, isNil } from 'lodash-es';
8
8
  import hash from 'object-hash';
9
+ import { filter } from '../../filtering/localFiltering.js';
9
10
  /**
10
- * Returns all querysets (stores) for the same model.
11
- * Each queryset has its own ground truth and applies local filtering.
12
- * @param {QuerySet} queryset
13
- * @returns {Map<QuerySet, Store>}
11
+ * Evaluates and routes a CREATE operation to querysets, tracking membership state.
12
+ *
13
+ * For each queryset:
14
+ * - If offset > 0: mark as MAYBE (can't determine position based on slicing)
15
+ * - If item doesn't match filter: mark DEFINITELY_NO
16
+ * - If item matches filter AND (no limit OR count < limit): mark DEFINITELY_YES
17
+ * - If item matches filter AND count >= limit: mark MAYBE (ordering determines inclusion)
18
+ *
19
+ * @param {Operation} operation - The CREATE operation to route
20
+ * @param {Function} applyAction - Function to apply the operation to a store
21
+ */
22
+ function routeCreateOperation(operation, applyAction) {
23
+ const modelClass = operation.queryset.ModelClass;
24
+ const instances = operation.instances;
25
+ Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
26
+ if (store.modelClass !== modelClass)
27
+ return;
28
+ const serializerOptions = store.queryset?._serializerOptions || {};
29
+ const { offset, limit } = serializerOptions;
30
+ // Offset > 0: can't determine position based on slicing
31
+ if (offset != null && offset > 0) {
32
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.MAYBE);
33
+ return;
34
+ }
35
+ // Evaluate if instances match this queryset's filter
36
+ const ast = store.queryset.build();
37
+ const matchingInstances = filter(instances, ast, modelClass, false);
38
+ if (matchingInstances.length === 0) {
39
+ // No instances match - mark DEFINITELY_NO (no need to sync this queryset)
40
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
41
+ return;
42
+ }
43
+ // Item matches filter - check limit
44
+ if (limit != null) {
45
+ const currentCount = store.groundTruthPks?.length || 0;
46
+ // At capacity: check if ordering could affect position
47
+ if (currentCount >= limit) {
48
+ const hasCustomOrdering = store.queryset._orderBy && store.queryset._orderBy.length > 0;
49
+ if (hasCustomOrdering) {
50
+ // With ordering, new item could displace existing items - can't know without server
51
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.MAYBE);
52
+ }
53
+ else {
54
+ // No ordering - new items go at end, won't be in first N
55
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
56
+ }
57
+ return;
58
+ }
59
+ // Room for some or all items - continue to DEFINITELY_YES below
60
+ }
61
+ // Matches filter, has room or no limit - DEFINITELY_YES
62
+ applyAction(store);
63
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_YES);
64
+ });
65
+ }
66
+ /**
67
+ * Evaluates and tracks membership state for UPDATE operations.
68
+ *
69
+ * For UPDATE, we check filter FIRST (optimization), then offset:
70
+ * - If item didn't match before AND doesn't match after: DEFINITELY_NO (regardless of offset)
71
+ * - If item matched before OR matches after AND offset > 0: MAYBE (can't determine position)
72
+ * - If item matched before OR matches after AND offset = 0: DEFINITELY_YES
73
+ *
74
+ * @param {Operation} operation - The UPDATE operation
14
75
  */
15
- function getAllQuerysets(queryset) {
16
- const modelClass = queryset.ModelClass;
17
- const result = new Map();
18
- // Route to all stores for this model
76
+ function routeUpdateOperation(operation) {
77
+ const modelClass = operation.queryset.ModelClass;
78
+ const beforeInstances = operation.frozenInstances;
79
+ const afterInstances = operation.instances;
19
80
  Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
20
81
  if (store.modelClass !== modelClass)
21
82
  return;
22
- result.set(store.queryset, store);
83
+ // Check filter match FIRST (optimization: skip offset check if no match)
84
+ const ast = store.queryset.build();
85
+ const matchedBefore = filter(beforeInstances, ast, modelClass, false).length > 0;
86
+ const matchesAfter = filter(afterInstances, ast, modelClass, false).length > 0;
87
+ // If item never matched filter, definitely not affected (regardless of offset)
88
+ if (!matchedBefore && !matchesAfter) {
89
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
90
+ return;
91
+ }
92
+ // Item matches filter - now check offset
93
+ const serializerOptions = store.queryset?._serializerOptions || {};
94
+ const { offset } = serializerOptions;
95
+ if (offset != null && offset > 0) {
96
+ // Can't determine position in paginated window
97
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.MAYBE);
98
+ return;
99
+ }
100
+ // Item matched before OR matches after, no offset - definitely affected
101
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_YES);
102
+ });
103
+ }
104
+ /**
105
+ * Evaluates and tracks membership state for DELETE operations.
106
+ *
107
+ * For DELETE, we check filter FIRST (optimization), then offset:
108
+ * - If item didn't match before: DEFINITELY_NO (regardless of offset)
109
+ * - If item matched before AND offset > 0: MAYBE (can't determine position)
110
+ * - If item matched before AND offset = 0: DEFINITELY_YES
111
+ *
112
+ * @param {Operation} operation - The DELETE operation
113
+ */
114
+ function routeDeleteOperation(operation) {
115
+ const modelClass = operation.queryset.ModelClass;
116
+ const beforeInstances = operation.frozenInstances;
117
+ Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
118
+ if (store.modelClass !== modelClass)
119
+ return;
120
+ // Check filter match FIRST (optimization: skip offset check if no match)
121
+ const ast = store.queryset.build();
122
+ const matchedBefore = filter(beforeInstances, ast, modelClass, false).length > 0;
123
+ // If item never matched filter, definitely not affected (regardless of offset)
124
+ if (!matchedBefore) {
125
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
126
+ return;
127
+ }
128
+ // Item matched filter - now check offset
129
+ const serializerOptions = store.queryset?._serializerOptions || {};
130
+ const { offset } = serializerOptions;
131
+ if (offset != null && offset > 0) {
132
+ // Can't determine position in paginated window
133
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.MAYBE);
134
+ return;
135
+ }
136
+ // Item matched before, no offset - definitely affected
137
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_YES);
23
138
  });
24
- return result;
25
139
  }
26
140
  /**
27
141
  * Process an operation in the model store
@@ -77,32 +191,40 @@ function processQuerysetStores(operation, actionType) {
77
191
  }
78
192
  };
79
193
  let querysetStoreMap;
80
- // Route to all querysets for the model - each applies local filtering
194
+ // Route to querysets based on operation type
195
+ // All operation types track membership state for sync optimization
196
+ // CREATE: adds operation to store + tracks membership
197
+ // UPDATE/DELETE: only tracks membership (model store + reactivity handles rendering)
81
198
  switch (operation.type) {
82
199
  case Type.CREATE:
83
200
  case Type.BULK_CREATE:
84
201
  case Type.GET_OR_CREATE:
85
202
  case Type.UPDATE_OR_CREATE:
86
- // For creates, route to all querysets (each checks if new item matches its filter)
87
- querysetStoreMap = getAllQuerysets(queryset);
88
- break;
203
+ // For creates, evaluate each queryset and track membership state
204
+ // This allows us to skip syncing querysets that we know don't contain the item
205
+ routeCreateOperation(operation, applyAction);
206
+ return;
89
207
  case Type.UPDATE:
90
208
  case Type.UPDATE_INSTANCE:
209
+ // Track membership for sync optimization
210
+ // Check both before (frozenInstances) and after (instances) state
211
+ routeUpdateOperation(operation);
212
+ return;
91
213
  case Type.DELETE:
92
214
  case Type.DELETE_INSTANCE:
93
- // Model store handles the change, querysets re-render via local filtering
94
- querysetStoreMap = new Map();
95
- break;
215
+ // Track membership for sync optimization
216
+ // Check before state only (item being deleted)
217
+ routeDeleteOperation(operation);
218
+ return;
96
219
  case Type.CHECKPOINT:
97
220
  // Model store handles the change, querysets re-render via local filtering
98
- querysetStoreMap = new Map();
99
- break;
221
+ // No membership tracking needed for checkpoints
222
+ return;
100
223
  default:
101
- // For other operation types, route to all querysets
102
- querysetStoreMap = getAllQuerysets(queryset);
103
- break;
224
+ // For other operation types, route like creates
225
+ routeCreateOperation(operation, applyAction);
226
+ return;
104
227
  }
105
- Array.from(querysetStoreMap.values()).forEach(applyAction);
106
228
  }
107
229
  /**
108
230
  * Process an operation in the metric stores
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.2.31",
3
+ "version": "0.2.32",
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",