@statezero/core 0.2.41 → 0.2.42
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.
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a membership state for a queryset with respect to an operation.
|
|
3
|
+
*
|
|
4
|
+
* @param {boolean} optimistic - Should we show this item immediately in the UI?
|
|
5
|
+
* @param {boolean} verify - Should remote sync check this queryset?
|
|
6
|
+
* @returns {{ optimistic: boolean, verify: boolean }}
|
|
7
|
+
*
|
|
8
|
+
* Usage patterns:
|
|
9
|
+
* - { optimistic: true, verify: false } - Certain it belongs, show immediately
|
|
10
|
+
* - { optimistic: false, verify: false } - Certain it doesn't belong, skip entirely
|
|
11
|
+
* - { optimistic: false, verify: true } - Uncertain, verify with server
|
|
12
|
+
*/
|
|
13
|
+
export function createMembershipState(optimistic: boolean, verify: boolean): {
|
|
14
|
+
optimistic: boolean;
|
|
15
|
+
verify: boolean;
|
|
16
|
+
};
|
|
1
17
|
export const operationEvents: any;
|
|
2
18
|
export namespace Status {
|
|
3
19
|
let CREATED: string;
|
|
@@ -24,11 +40,6 @@ export namespace Type {
|
|
|
24
40
|
let SUM: string;
|
|
25
41
|
let AGGREGATE: string;
|
|
26
42
|
}
|
|
27
|
-
export namespace OperationMembership {
|
|
28
|
-
let DEFINITELY_YES: string;
|
|
29
|
-
let DEFINITELY_NO: string;
|
|
30
|
-
let MAYBE: string;
|
|
31
|
-
}
|
|
32
43
|
export class Operation {
|
|
33
44
|
constructor(data: any, restore?: boolean);
|
|
34
45
|
operationId: any;
|
|
@@ -103,9 +114,12 @@ declare class OperationRegistry {
|
|
|
103
114
|
* Sets the membership state for a queryset with respect to an operation.
|
|
104
115
|
* @param {string} operationId - The operation ID
|
|
105
116
|
* @param {string} semanticKey - The queryset's semantic key
|
|
106
|
-
* @param {
|
|
117
|
+
* @param {{ optimistic: boolean, verify: boolean }} state - Membership state flags
|
|
107
118
|
*/
|
|
108
|
-
setQuerysetState(operationId: string, semanticKey: string, state:
|
|
119
|
+
setQuerysetState(operationId: string, semanticKey: string, state: {
|
|
120
|
+
optimistic: boolean;
|
|
121
|
+
verify: boolean;
|
|
122
|
+
}): void;
|
|
109
123
|
/**
|
|
110
124
|
* Gets the membership state for a queryset with respect to an operation.
|
|
111
125
|
* @param {string} operationId - The operation ID
|
|
@@ -43,14 +43,20 @@ export const Type = {
|
|
|
43
43
|
AGGREGATE: 'aggregate',
|
|
44
44
|
};
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* Creates a membership state for a queryset with respect to an operation.
|
|
47
|
+
*
|
|
48
|
+
* @param {boolean} optimistic - Should we show this item immediately in the UI?
|
|
49
|
+
* @param {boolean} verify - Should remote sync check this queryset?
|
|
50
|
+
* @returns {{ optimistic: boolean, verify: boolean }}
|
|
51
|
+
*
|
|
52
|
+
* Usage patterns:
|
|
53
|
+
* - { optimistic: true, verify: false } - Certain it belongs, show immediately
|
|
54
|
+
* - { optimistic: false, verify: false } - Certain it doesn't belong, skip entirely
|
|
55
|
+
* - { optimistic: false, verify: true } - Uncertain, verify with server
|
|
48
56
|
*/
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
MAYBE: 'maybe', // Impossible to know based on slicing, needs server sync
|
|
53
|
-
};
|
|
57
|
+
export function createMembershipState(optimistic, verify) {
|
|
58
|
+
return { optimistic, verify };
|
|
59
|
+
}
|
|
54
60
|
export class Operation {
|
|
55
61
|
constructor(data, restore = false) {
|
|
56
62
|
_Operation__instances.set(this, void 0);
|
|
@@ -191,7 +197,7 @@ _Operation__instances = new WeakMap(), _Operation__frozenInstances = new WeakMap
|
|
|
191
197
|
class OperationRegistry {
|
|
192
198
|
constructor() {
|
|
193
199
|
this._operations = new Map();
|
|
194
|
-
// Map<operationId, Map<semanticKey,
|
|
200
|
+
// Map<operationId, Map<semanticKey, { optimistic: boolean, verify: boolean }>>
|
|
195
201
|
this._querysetStates = new Map();
|
|
196
202
|
}
|
|
197
203
|
/**
|
|
@@ -234,7 +240,7 @@ class OperationRegistry {
|
|
|
234
240
|
* Sets the membership state for a queryset with respect to an operation.
|
|
235
241
|
* @param {string} operationId - The operation ID
|
|
236
242
|
* @param {string} semanticKey - The queryset's semantic key
|
|
237
|
-
* @param {
|
|
243
|
+
* @param {{ optimistic: boolean, verify: boolean }} state - Membership state flags
|
|
238
244
|
*/
|
|
239
245
|
setQuerysetState(operationId, semanticKey, state) {
|
|
240
246
|
if (!this._querysetStates.has(operationId)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { operationEvents, Status, Type, operationRegistry,
|
|
1
|
+
import { operationEvents, Status, Type, operationRegistry, createMembershipState } 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';
|
|
@@ -7,14 +7,27 @@ import { QuerySet } from '../../flavours/django/querySet.js';
|
|
|
7
7
|
import { isEqual, isNil } from 'lodash-es';
|
|
8
8
|
import hash from 'object-hash';
|
|
9
9
|
import { filter, applyOrderBy } from '../../filtering/localFiltering.js';
|
|
10
|
+
/**
|
|
11
|
+
* Check if two model classes represent the same model.
|
|
12
|
+
* Uses property comparison instead of reference equality to handle
|
|
13
|
+
* cases where different instances represent the same model.
|
|
14
|
+
*/
|
|
15
|
+
function isSameModel(modelClassA, modelClassB) {
|
|
16
|
+
return modelClassA.modelName === modelClassB.modelName &&
|
|
17
|
+
modelClassA.configKey === modelClassB.configKey;
|
|
18
|
+
}
|
|
10
19
|
/**
|
|
11
20
|
* Evaluates and routes a CREATE operation to querysets, tracking membership state.
|
|
12
21
|
*
|
|
22
|
+
* Membership state has two flags:
|
|
23
|
+
* - optimistic: Should we show this item immediately in the UI?
|
|
24
|
+
* - verify: Should remote sync check this queryset?
|
|
25
|
+
*
|
|
13
26
|
* For each queryset:
|
|
14
|
-
* - If offset > 0:
|
|
15
|
-
* - If
|
|
16
|
-
* - If item matches filter AND
|
|
17
|
-
* - If
|
|
27
|
+
* - If offset > 0: { optimistic: false, verify: true } - can't determine position
|
|
28
|
+
* - If local filter says no match: { optimistic: false, verify: true } - local filtering unreliable
|
|
29
|
+
* - If item matches filter AND has room: { optimistic: true, verify: false } - show it
|
|
30
|
+
* - If at limit and ordering excludes: { optimistic: false, verify: false } - certain it's out
|
|
18
31
|
*
|
|
19
32
|
* @param {Operation} operation - The CREATE operation to route
|
|
20
33
|
* @param {Function} applyAction - Function to apply the operation to a store
|
|
@@ -23,21 +36,23 @@ function routeCreateOperation(operation, applyAction) {
|
|
|
23
36
|
const modelClass = operation.queryset.ModelClass;
|
|
24
37
|
const instances = operation.instances;
|
|
25
38
|
Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
|
|
26
|
-
if (store.modelClass
|
|
39
|
+
if (!isSameModel(store.modelClass, modelClass))
|
|
27
40
|
return;
|
|
28
41
|
const serializerOptions = store.queryset?._serializerOptions || {};
|
|
29
42
|
const { offset, limit } = serializerOptions;
|
|
30
43
|
// Offset > 0: can't determine position based on slicing
|
|
31
44
|
if (offset != null && offset > 0) {
|
|
32
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
45
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
|
|
33
46
|
return;
|
|
34
47
|
}
|
|
35
48
|
// Evaluate if instances match this queryset's filter
|
|
49
|
+
// Use fromPk to get live representation with proper FK structure
|
|
50
|
+
const liveInstances = instances.map(inst => modelClass.fromPk(inst[modelClass.primaryKeyField])).filter(Boolean);
|
|
36
51
|
const ast = store.queryset.build();
|
|
37
|
-
const matchingInstances = filter(
|
|
52
|
+
const matchingInstances = filter(liveInstances, ast, modelClass, true);
|
|
38
53
|
if (matchingInstances.length === 0) {
|
|
39
|
-
//
|
|
40
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
54
|
+
// Local filter says no match - trust it (local filtering is robust)
|
|
55
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
|
|
41
56
|
return;
|
|
42
57
|
}
|
|
43
58
|
// Item matches filter - check limit
|
|
@@ -59,7 +74,7 @@ function routeCreateOperation(operation, applyAction) {
|
|
|
59
74
|
const currentItems = renderedItems.filter(Boolean);
|
|
60
75
|
// If some ground truth items couldn't be rendered, we can't trust local sort
|
|
61
76
|
if (currentItems.length < store.groundTruthPks.length) {
|
|
62
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
77
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
|
|
63
78
|
return;
|
|
64
79
|
}
|
|
65
80
|
const allItems = [...currentItems, ...matchingInstances];
|
|
@@ -72,15 +87,15 @@ function routeCreateOperation(operation, applyAction) {
|
|
|
72
87
|
const anyInTopN = newItemPks.some(pk => topNPks.has(pk));
|
|
73
88
|
if (anyInTopN) {
|
|
74
89
|
applyAction(store);
|
|
75
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
90
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
|
|
76
91
|
}
|
|
77
92
|
else {
|
|
78
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
93
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
|
|
79
94
|
}
|
|
80
95
|
}
|
|
81
96
|
else {
|
|
82
97
|
// No ordering - new items go at end, won't be in first N
|
|
83
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
98
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
|
|
84
99
|
}
|
|
85
100
|
return;
|
|
86
101
|
}
|
|
@@ -88,33 +103,35 @@ function routeCreateOperation(operation, applyAction) {
|
|
|
88
103
|
}
|
|
89
104
|
// Matches filter, has room or no limit - DEFINITELY_YES
|
|
90
105
|
applyAction(store);
|
|
91
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
106
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
|
|
92
107
|
});
|
|
93
108
|
}
|
|
94
109
|
/**
|
|
95
110
|
* Evaluates and tracks membership state for UPDATE operations.
|
|
96
111
|
*
|
|
97
|
-
* For UPDATE, we check filter FIRST
|
|
98
|
-
* - If
|
|
99
|
-
* - If
|
|
100
|
-
* - If
|
|
112
|
+
* For UPDATE, we check filter FIRST, then offset:
|
|
113
|
+
* - If local filter says no match: { optimistic: false, verify: true } - local filtering unreliable
|
|
114
|
+
* - If matched AND offset > 0: { optimistic: false, verify: true } - can't determine position
|
|
115
|
+
* - If matched AND offset = 0: { optimistic: true, verify: false } - show it
|
|
101
116
|
*
|
|
102
117
|
* @param {Operation} operation - The UPDATE operation
|
|
103
118
|
*/
|
|
104
119
|
function routeUpdateOperation(operation) {
|
|
105
120
|
const modelClass = operation.queryset.ModelClass;
|
|
121
|
+
// frozenInstances is already serialized with proper FK structure
|
|
106
122
|
const beforeInstances = operation.frozenInstances;
|
|
107
|
-
|
|
123
|
+
// For after state, use fromPk to get current live representation
|
|
124
|
+
const afterInstances = operation.instances.map(inst => modelClass.fromPk(inst[modelClass.primaryKeyField])).filter(Boolean);
|
|
108
125
|
Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
|
|
109
|
-
if (store.modelClass
|
|
126
|
+
if (!isSameModel(store.modelClass, modelClass))
|
|
110
127
|
return;
|
|
111
128
|
// Check filter match FIRST (optimization: skip offset check if no match)
|
|
112
129
|
const ast = store.queryset.build();
|
|
113
130
|
const matchedBefore = filter(beforeInstances, ast, modelClass, false).length > 0;
|
|
114
131
|
const matchesAfter = filter(afterInstances, ast, modelClass, false).length > 0;
|
|
115
|
-
// If
|
|
132
|
+
// If local filter says no match before AND after, trust it
|
|
116
133
|
if (!matchedBefore && !matchesAfter) {
|
|
117
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
134
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
|
|
118
135
|
return;
|
|
119
136
|
}
|
|
120
137
|
// Item matches filter - now check offset
|
|
@@ -122,35 +139,36 @@ function routeUpdateOperation(operation) {
|
|
|
122
139
|
const { offset } = serializerOptions;
|
|
123
140
|
if (offset != null && offset > 0) {
|
|
124
141
|
// Can't determine position in paginated window
|
|
125
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
142
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
|
|
126
143
|
return;
|
|
127
144
|
}
|
|
128
145
|
// Item matched before OR matches after, no offset - definitely affected
|
|
129
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
146
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
|
|
130
147
|
});
|
|
131
148
|
}
|
|
132
149
|
/**
|
|
133
150
|
* Evaluates and tracks membership state for DELETE operations.
|
|
134
151
|
*
|
|
135
|
-
* For DELETE, we check filter FIRST
|
|
136
|
-
* - If
|
|
137
|
-
* - If
|
|
138
|
-
* - If
|
|
152
|
+
* For DELETE, we check filter FIRST, then offset:
|
|
153
|
+
* - If local filter says no match: { optimistic: false, verify: true } - local filtering unreliable
|
|
154
|
+
* - If matched AND offset > 0: { optimistic: false, verify: true } - can't determine position
|
|
155
|
+
* - If matched AND offset = 0: { optimistic: true, verify: false } - show deletion
|
|
139
156
|
*
|
|
140
157
|
* @param {Operation} operation - The DELETE operation
|
|
141
158
|
*/
|
|
142
159
|
function routeDeleteOperation(operation) {
|
|
143
160
|
const modelClass = operation.queryset.ModelClass;
|
|
161
|
+
// frozenInstances is already serialized with proper FK structure
|
|
144
162
|
const beforeInstances = operation.frozenInstances;
|
|
145
163
|
Array.from(querysetStoreRegistry._stores.entries()).forEach(([semanticKey, store]) => {
|
|
146
|
-
if (store.modelClass
|
|
164
|
+
if (!isSameModel(store.modelClass, modelClass))
|
|
147
165
|
return;
|
|
148
166
|
// Check filter match FIRST (optimization: skip offset check if no match)
|
|
149
167
|
const ast = store.queryset.build();
|
|
150
168
|
const matchedBefore = filter(beforeInstances, ast, modelClass, false).length > 0;
|
|
151
|
-
// If
|
|
169
|
+
// If local filter says no match before, trust it
|
|
152
170
|
if (!matchedBefore) {
|
|
153
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
171
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, false));
|
|
154
172
|
return;
|
|
155
173
|
}
|
|
156
174
|
// Item matched filter - now check offset
|
|
@@ -158,11 +176,11 @@ function routeDeleteOperation(operation) {
|
|
|
158
176
|
const { offset } = serializerOptions;
|
|
159
177
|
if (offset != null && offset > 0) {
|
|
160
178
|
// Can't determine position in paginated window
|
|
161
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
179
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(false, true));
|
|
162
180
|
return;
|
|
163
181
|
}
|
|
164
182
|
// Item matched before, no offset - definitely affected
|
|
165
|
-
operationRegistry.setQuerysetState(operation.operationId, semanticKey,
|
|
183
|
+
operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
|
|
166
184
|
});
|
|
167
185
|
}
|
|
168
186
|
/**
|
|
@@ -37,10 +37,11 @@ export class SyncManager {
|
|
|
37
37
|
manageRegistry(registry: any): void;
|
|
38
38
|
removeRegistry(registry: any): void;
|
|
39
39
|
/**
|
|
40
|
-
* Sync querysets that
|
|
40
|
+
* Sync querysets that need verification for a local operation.
|
|
41
41
|
* Called when a backend event arrives for an operation we initiated locally.
|
|
42
|
+
* Checks the `verify` flag on membership state to determine which querysets need syncing.
|
|
42
43
|
*/
|
|
43
|
-
|
|
44
|
+
syncQuerysetsNeedingVerification(operationId: any): void;
|
|
44
45
|
handleEvent: (event: any) => void;
|
|
45
46
|
processBatch(reason?: string): void;
|
|
46
47
|
isQuerysetFollowed(queryset: any): boolean;
|
package/dist/syncEngine/sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getAllEventReceivers } from "../core/eventReceivers.js";
|
|
2
|
-
import { operationRegistry
|
|
2
|
+
import { operationRegistry } from "./stores/operation.js";
|
|
3
3
|
import { initializeAllEventReceivers } from "../config.js";
|
|
4
4
|
import { getEventReceiver } from "../core/eventReceivers.js";
|
|
5
5
|
import { querysetStoreRegistry, QuerysetStoreRegistry, } from "./registries/querysetStoreRegistry.js";
|
|
@@ -57,7 +57,7 @@ export class SyncManager {
|
|
|
57
57
|
if (isLocalOperation) {
|
|
58
58
|
// Check if any querysets were marked MAYBE for this operation
|
|
59
59
|
// These need to be synced since we couldn't determine membership client-side
|
|
60
|
-
this.
|
|
60
|
+
this.syncQuerysetsNeedingVerification(payload.operation_id);
|
|
61
61
|
return;
|
|
62
62
|
}
|
|
63
63
|
// Add to batch for queryset/model processing
|
|
@@ -241,10 +241,11 @@ export class SyncManager {
|
|
|
241
241
|
this.registries.delete(registry.constructor);
|
|
242
242
|
}
|
|
243
243
|
/**
|
|
244
|
-
* Sync querysets that
|
|
244
|
+
* Sync querysets that need verification for a local operation.
|
|
245
245
|
* Called when a backend event arrives for an operation we initiated locally.
|
|
246
|
+
* Checks the `verify` flag on membership state to determine which querysets need syncing.
|
|
246
247
|
*/
|
|
247
|
-
|
|
248
|
+
syncQuerysetsNeedingVerification(operationId) {
|
|
248
249
|
const states = operationRegistry.getQuerysetStates(operationId);
|
|
249
250
|
if (!states)
|
|
250
251
|
return;
|
|
@@ -253,7 +254,8 @@ export class SyncManager {
|
|
|
253
254
|
return;
|
|
254
255
|
const storesToSync = [];
|
|
255
256
|
for (const [semanticKey, membership] of states.entries()) {
|
|
256
|
-
if
|
|
257
|
+
// Check the verify flag - if true, this queryset needs server verification
|
|
258
|
+
if (membership?.verify) {
|
|
257
259
|
const store = registry._stores.get(semanticKey);
|
|
258
260
|
if (store) {
|
|
259
261
|
storesToSync.push(store);
|
|
@@ -262,7 +264,7 @@ export class SyncManager {
|
|
|
262
264
|
}
|
|
263
265
|
if (storesToSync.length === 0)
|
|
264
266
|
return;
|
|
265
|
-
console.log(`[SyncManager] Syncing ${storesToSync.length}
|
|
267
|
+
console.log(`[SyncManager] Syncing ${storesToSync.length} querysets needing verification for operation ${operationId}`);
|
|
266
268
|
const syncOperationId = `maybe-sync-${uuidv7()}`;
|
|
267
269
|
const dbSyncedKeys = new Set([...this.followedQuerysets].map(qs => qs.semanticKey));
|
|
268
270
|
Promise.all(storesToSync.map(store => registry.groupSync(store.queryset, syncOperationId, dbSyncedKeys)));
|
package/package.json
CHANGED