@statezero/core 0.2.30 → 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.
- package/dist/syncEngine/registries/querysetStoreGraph.d.ts +11 -1
- package/dist/syncEngine/registries/querysetStoreGraph.js +42 -5
- package/dist/syncEngine/registries/querysetStoreRegistry.js +4 -0
- package/dist/syncEngine/stores/operation.d.ts +38 -0
- package/dist/syncEngine/stores/operation.js +60 -0
- package/dist/syncEngine/stores/operationEventHandlers.js +146 -24
- package/dist/syncEngine/stores/querysetStore.d.ts +5 -0
- package/dist/syncEngine/stores/querysetStore.js +20 -4
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
108
|
-
//
|
|
109
|
-
if (parentOpts.
|
|
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();
|
|
@@ -212,6 +212,7 @@ export class QuerysetStoreRegistry {
|
|
|
212
212
|
if (this._tempStores.has(queryset)) {
|
|
213
213
|
store = this._tempStores.get(queryset);
|
|
214
214
|
store.isTemp = false; // Promote to permanent store
|
|
215
|
+
store.registerWithModelStore(); // Register for model store changes now that it's permanent
|
|
215
216
|
this._stores.set(semanticKey, store);
|
|
216
217
|
this.syncManager.followModel(this, queryset.ModelClass);
|
|
217
218
|
}
|
|
@@ -219,6 +220,7 @@ export class QuerysetStoreRegistry {
|
|
|
219
220
|
else if (!this._stores.has(semanticKey)) {
|
|
220
221
|
store = this.getStore(queryset);
|
|
221
222
|
store.isTemp = false;
|
|
223
|
+
store.registerWithModelStore(); // Register for model store changes now that it's permanent
|
|
222
224
|
this._stores.set(semanticKey, store);
|
|
223
225
|
this.syncManager.followModel(this, queryset.ModelClass);
|
|
224
226
|
}
|
|
@@ -250,12 +252,14 @@ export class QuerysetStoreRegistry {
|
|
|
250
252
|
if (this._tempStores.has(queryset)) {
|
|
251
253
|
store = this._tempStores.get(queryset);
|
|
252
254
|
store.isTemp = false; // Promote to permanent store
|
|
255
|
+
store.registerWithModelStore(); // Register for model store changes now that it's permanent
|
|
253
256
|
this._stores.set(semanticKey, store);
|
|
254
257
|
}
|
|
255
258
|
else {
|
|
256
259
|
// Create a new permanent store
|
|
257
260
|
store = this.getStore(queryset);
|
|
258
261
|
store.isTemp = false;
|
|
262
|
+
store.registerWithModelStore(); // Register for model store changes now that it's permanent
|
|
259
263
|
this._stores.set(semanticKey, store);
|
|
260
264
|
}
|
|
261
265
|
}
|
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
16
|
-
const modelClass = queryset.ModelClass;
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
221
|
+
// No membership tracking needed for checkpoints
|
|
222
|
+
return;
|
|
100
223
|
default:
|
|
101
|
-
// For other operation types, route
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
@@ -32,6 +32,11 @@ export class QuerysetStore {
|
|
|
32
32
|
getInflightOperations(): any[];
|
|
33
33
|
prune(): void;
|
|
34
34
|
registerRenderCallback(callback: any): () => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Register this store with the model store for change notifications.
|
|
37
|
+
* Called when a temp store is promoted to permanent.
|
|
38
|
+
*/
|
|
39
|
+
registerWithModelStore(): void;
|
|
35
40
|
/**
|
|
36
41
|
* Helper to validate PKs against the model store and apply local filtering/sorting.
|
|
37
42
|
* This is the core of the rendering logic.
|
|
@@ -31,10 +31,14 @@ export class QuerysetStore {
|
|
|
31
31
|
this._lastRenderedPks = null;
|
|
32
32
|
this.renderCallbacks = new Set();
|
|
33
33
|
// Register for model store changes to re-render when model data changes
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// Only register permanent stores - temp stores are transient and should not
|
|
35
|
+
// accumulate callbacks (causes reactivity cascade when Vue creates new querysets)
|
|
36
|
+
if (!this.isTemp) {
|
|
37
|
+
const modelStore = modelStoreRegistry.getStore(this.modelClass);
|
|
38
|
+
this._modelStoreUnregister = modelStore.registerRenderCallback(() => {
|
|
39
|
+
this._emitRenderEvent();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
38
42
|
}
|
|
39
43
|
// Caching
|
|
40
44
|
get cacheKey() {
|
|
@@ -150,6 +154,18 @@ export class QuerysetStore {
|
|
|
150
154
|
this.renderCallbacks.add(callback);
|
|
151
155
|
return () => this.renderCallbacks.delete(callback);
|
|
152
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Register this store with the model store for change notifications.
|
|
159
|
+
* Called when a temp store is promoted to permanent.
|
|
160
|
+
*/
|
|
161
|
+
registerWithModelStore() {
|
|
162
|
+
if (this._modelStoreUnregister)
|
|
163
|
+
return; // Already registered
|
|
164
|
+
const modelStore = modelStoreRegistry.getStore(this.modelClass);
|
|
165
|
+
this._modelStoreUnregister = modelStore.registerRenderCallback(() => {
|
|
166
|
+
this._emitRenderEvent();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
153
169
|
/**
|
|
154
170
|
* Helper to validate PKs against the model store and apply local filtering/sorting.
|
|
155
171
|
* This is the core of the rendering logic.
|
package/package.json
CHANGED