@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 {string} state - One of OperationMembership values
117
+ * @param {{ optimistic: boolean, verify: boolean }} state - Membership state flags
107
118
  */
108
- setQuerysetState(operationId: string, semanticKey: string, state: string): void;
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
- * 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.
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 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
- };
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, OperationMembership>>
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 {string} state - One of OperationMembership values
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, OperationMembership } from './operation.js';
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: 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)
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 !== 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, OperationMembership.MAYBE);
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(instances, ast, modelClass, true);
52
+ const matchingInstances = filter(liveInstances, ast, modelClass, true);
38
53
  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);
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, OperationMembership.MAYBE);
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, OperationMembership.DEFINITELY_YES);
90
+ operationRegistry.setQuerysetState(operation.operationId, semanticKey, createMembershipState(true, false));
76
91
  }
77
92
  else {
78
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
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, OperationMembership.DEFINITELY_NO);
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, OperationMembership.DEFINITELY_YES);
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 (optimization), then offset:
98
- * - If item didn't match before AND doesn't match after: DEFINITELY_NO (regardless of offset)
99
- * - If item matched before OR matches after AND offset > 0: MAYBE (can't determine position)
100
- * - If item matched before OR matches after AND offset = 0: DEFINITELY_YES
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
- const afterInstances = operation.instances;
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 !== 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 item never matched filter, definitely not affected (regardless of offset)
132
+ // If local filter says no match before AND after, trust it
116
133
  if (!matchedBefore && !matchesAfter) {
117
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
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, OperationMembership.MAYBE);
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, OperationMembership.DEFINITELY_YES);
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 (optimization), then offset:
136
- * - If item didn't match before: DEFINITELY_NO (regardless of offset)
137
- * - If item matched before AND offset > 0: MAYBE (can't determine position)
138
- * - If item matched before AND offset = 0: DEFINITELY_YES
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 !== 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 item never matched filter, definitely not affected (regardless of offset)
169
+ // If local filter says no match before, trust it
152
170
  if (!matchedBefore) {
153
- operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.DEFINITELY_NO);
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, OperationMembership.MAYBE);
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, OperationMembership.DEFINITELY_YES);
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 were marked MAYBE for a local operation.
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
- syncMaybeQuerysets(operationId: any): void;
44
+ syncQuerysetsNeedingVerification(operationId: any): void;
44
45
  handleEvent: (event: any) => void;
45
46
  processBatch(reason?: string): void;
46
47
  isQuerysetFollowed(queryset: any): boolean;
@@ -1,5 +1,5 @@
1
1
  import { getAllEventReceivers } from "../core/eventReceivers.js";
2
- import { operationRegistry, OperationMembership } from "./stores/operation.js";
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.syncMaybeQuerysets(payload.operation_id);
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 were marked MAYBE for a local operation.
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
- syncMaybeQuerysets(operationId) {
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 (membership === OperationMembership.MAYBE) {
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} MAYBE querysets for local operation ${operationId}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.2.41",
3
+ "version": "0.2.42",
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",