@statezero/core 0.1.1 → 0.1.3-9.1

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.
@@ -7,6 +7,7 @@ export class ModelStore {
7
7
  isSyncing: boolean;
8
8
  pruneThreshold: any;
9
9
  modelCache: Cache;
10
+ _lastRenderedData: Map<any, any>;
10
11
  /**
11
12
  * Load operations from data and add them to the operations map,
12
13
  * reusing existing operations from the registry if they exist
@@ -1,12 +1,22 @@
1
1
  import { Operation, Status, Type, operationRegistry } from './operation.js';
2
- import { isNil, isEmpty, trim } from 'lodash-es';
2
+ import { isNil, isEmpty, trim, isEqual } from 'lodash-es';
3
3
  import { modelEventEmitter } from './reactivity.js';
4
4
  import { Cache } from '../cache/cache.js';
5
5
  import { replaceTempPks, containsTempPk } from '../../flavours/django/tempPk.js';
6
- const emitEvents = (modelClass, events) => {
7
- // helper method to emit every event
8
- events.forEach(event => {
9
- modelEventEmitter.emit(`${modelClass.configKey}::${modelClass.modelName}::render`, event);
6
+ const emitEvents = (store, events) => {
7
+ if (!Array.isArray(events))
8
+ return;
9
+ events.forEach((event) => {
10
+ const pk = event.pk;
11
+ if (isNil(pk))
12
+ return;
13
+ const newRenderedDataArray = store.render([pk], true);
14
+ const newRenderedData = newRenderedDataArray.length > 0 ? newRenderedDataArray[0] : null;
15
+ const lastRenderedData = store._lastRenderedData.get(pk);
16
+ if (!isEqual(newRenderedData, lastRenderedData)) {
17
+ store._lastRenderedData.set(pk, newRenderedData);
18
+ modelEventEmitter.emit(`${store.modelClass.configKey}::${store.modelClass.modelName}::render`, event);
19
+ }
10
20
  });
11
21
  };
12
22
  class EventData {
@@ -62,6 +72,7 @@ export class ModelStore {
62
72
  this._loadOperations(initialOperations);
63
73
  }
64
74
  this.modelCache = new Cache('model-cache', {}, this.onHydrated.bind(this));
75
+ this._lastRenderedData = new Map();
65
76
  }
66
77
  /**
67
78
  * Load operations from data and add them to the operations map,
@@ -164,26 +175,26 @@ export class ModelStore {
164
175
  if (this.operationsMap.size > this.pruneThreshold) {
165
176
  this.prune();
166
177
  }
167
- emitEvents(this.modelClass, EventData.fromOperation(operation));
178
+ emitEvents(this, EventData.fromOperation(operation));
168
179
  }
169
180
  updateOperation(operation) {
170
181
  if (!this.operationsMap.has(operation.operationId))
171
182
  return false;
172
183
  this.operationsMap.set(operation.operationId, operation);
173
- emitEvents(this.modelClass, EventData.fromOperation(operation));
184
+ emitEvents(this, EventData.fromOperation(operation));
174
185
  return true;
175
186
  }
176
187
  confirm(operation) {
177
188
  if (!this.operationsMap.has(operation.operationId))
178
189
  return;
179
190
  this.operationsMap.set(operation.operationId, operation);
180
- emitEvents(this.modelClass, EventData.fromOperation(operation));
191
+ emitEvents(this, EventData.fromOperation(operation));
181
192
  }
182
193
  reject(operation) {
183
194
  if (!this.operationsMap.has(operation.operationId))
184
195
  return;
185
196
  this.operationsMap.set(operation.operationId, operation);
186
- emitEvents(this.modelClass, EventData.fromOperation(operation));
197
+ emitEvents(this, EventData.fromOperation(operation));
187
198
  }
188
199
  setOperations(operations = []) {
189
200
  const prevOps = this.operations;
@@ -192,7 +203,7 @@ export class ModelStore {
192
203
  this.operationsMap.set(op.operationId, op);
193
204
  });
194
205
  const allOps = [...prevOps, ...this.operations];
195
- emitEvents(this.modelClass, EventData.fromOperations(allOps));
206
+ emitEvents(this, EventData.fromOperations(allOps));
196
207
  }
197
208
  // Ground truth data methods
198
209
  setGroundTruth(groundTruth) {
@@ -200,7 +211,7 @@ export class ModelStore {
200
211
  this.groundTruthArray = Array.isArray(groundTruth) ? groundTruth : [];
201
212
  // reactivity - gather all ops
202
213
  const allOps = [...prevGroundTruth, ...this.groundTruthArray];
203
- emitEvents(this.modelClass, EventData.fromInstances(allOps, this.modelClass));
214
+ emitEvents(this, EventData.fromInstances(allOps, this.modelClass));
204
215
  }
205
216
  getGroundTruth() {
206
217
  return this.groundTruthArray;
@@ -263,7 +274,7 @@ export class ModelStore {
263
274
  console.log(`[ModelStore ${this.modelClass.modelName}] Created CHECKPOINT operation for ${checkpointInstances.length} existing instances`);
264
275
  }
265
276
  // reactivity - use all the newly added instances (both new and updated)
266
- emitEvents(this.modelClass, EventData.fromInstances([...checkpointInstances, ...Array.from(pkMap.values())], this.modelClass));
277
+ emitEvents(this, EventData.fromInstances([...checkpointInstances, ...Array.from(pkMap.values())], this.modelClass));
267
278
  }
268
279
  _filteredOperations(pks, operations) {
269
280
  if (!pks)
@@ -6,8 +6,16 @@ export class QuerysetStore {
6
6
  operationsMap: Map<any, any>;
7
7
  groundTruthPks: never[];
8
8
  isSyncing: boolean;
9
+ lastSync: number | null;
10
+ needsSync: boolean;
11
+ isTemp: any;
9
12
  pruneThreshold: any;
13
+ getRootStore: any;
10
14
  qsCache: Cache;
15
+ _lastRenderedPks: any[] | null;
16
+ renderCallbacks: Set<any>;
17
+ _rootUnregister: any;
18
+ _currentRootStore: any;
11
19
  get cacheKey(): any;
12
20
  onHydrated(hydratedData: any): void;
13
21
  setCache(result: any): void;
@@ -25,7 +33,17 @@ export class QuerysetStore {
25
33
  getTrimmedOperations(): any[];
26
34
  getInflightOperations(): any[];
27
35
  prune(): void;
36
+ registerRenderCallback(callback: any): () => boolean;
37
+ _ensureRootRegistration(): void;
38
+ /**
39
+ * Helper to validate PKs against the model store and apply local filtering/sorting.
40
+ * This is the core of the rendering logic.
41
+ * @private
42
+ */
43
+ private _getValidatedAndFilteredPks;
28
44
  render(optimistic?: boolean, fromCache?: boolean): any[];
45
+ renderFromRoot(optimistic: boolean | undefined, rootStore: any): any[];
46
+ renderFromData(optimistic?: boolean): any[];
29
47
  applyOperation(operation: any, currentPks: any): any;
30
48
  sync(): Promise<void>;
31
49
  }
@@ -1,18 +1,24 @@
1
1
  import { Operation, Status, Type, operationRegistry } from './operation.js';
2
2
  import { querysetEventEmitter } from './reactivity.js';
3
- import { isNil, isEmpty, trim } from 'lodash-es';
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
6
  import { processIncludedEntities } from '../../flavours/django/makeApiCall.js';
7
7
  import hash from 'object-hash';
8
8
  import { Cache } from '../cache/cache.js';
9
+ import { filter } from "../../filtering/localFiltering.js";
10
+ import { mod } from 'mathjs';
9
11
  export class QuerysetStore {
10
12
  constructor(modelClass, fetchFn, queryset, initialGroundTruthPks = null, initialOperations = null, options = {}) {
11
13
  this.modelClass = modelClass;
12
14
  this.fetchFn = fetchFn;
13
15
  this.queryset = queryset;
14
16
  this.isSyncing = false;
17
+ this.lastSync = null;
18
+ this.needsSync = false;
19
+ this.isTemp = options.isTemp || false;
15
20
  this.pruneThreshold = options.pruneThreshold || 10;
21
+ this.getRootStore = options.getRootStore || null;
16
22
  this.groundTruthPks = initialGroundTruthPks || [];
17
23
  this.operationsMap = new Map();
18
24
  if (Array.isArray(initialOperations)) {
@@ -22,7 +28,12 @@ export class QuerysetStore {
22
28
  this.operationsMap.set(op.operationId, op);
23
29
  }
24
30
  }
25
- this.qsCache = new Cache('queryset-cache', {}, this.onHydrated.bind(this));
31
+ this.qsCache = new Cache("queryset-cache", {}, this.onHydrated.bind(this));
32
+ this._lastRenderedPks = null;
33
+ this.renderCallbacks = new Set();
34
+ this._rootUnregister = null;
35
+ this._currentRootStore = null;
36
+ this._ensureRootRegistration();
26
37
  }
27
38
  // Caching
28
39
  get cacheKey() {
@@ -40,7 +51,7 @@ export class QuerysetStore {
40
51
  setCache(result) {
41
52
  let nonTempPks = [];
42
53
  result.forEach((pk) => {
43
- if (typeof pk === 'string' && containsTempPk(pk)) {
54
+ if (typeof pk === "string" && containsTempPk(pk)) {
44
55
  pk = replaceTempPks(pk);
45
56
  if (isNil(pk) || isEmpty(trim(pk))) {
46
57
  return;
@@ -64,7 +75,22 @@ export class QuerysetStore {
64
75
  return new Set(this.groundTruthPks);
65
76
  }
66
77
  _emitRenderEvent() {
67
- querysetEventEmitter.emit(`${this.modelClass.configKey}::${this.modelClass.modelName}::queryset::render`, { ast: this.queryset.build(), ModelClass: this.modelClass });
78
+ const newPks = this.render(true, false);
79
+ // 1. Always notify direct child stores to trigger their own re-evaluation.
80
+ // They will perform their own check to see if their own results have changed.
81
+ this.renderCallbacks.forEach((callback) => {
82
+ try {
83
+ callback();
84
+ }
85
+ catch (error) {
86
+ console.warn("Error in render callback:", error);
87
+ }
88
+ });
89
+ // 2. Only emit the global event for UI components if the final list of PKs has actually changed.
90
+ if (!isEqual(newPks, this._lastRenderedPks)) {
91
+ this._lastRenderedPks = newPks; // Update the cache with the new state
92
+ querysetEventEmitter.emit(`${this.modelClass.configKey}::${this.modelClass.modelName}::queryset::render`, { ast: this.queryset.build(), ModelClass: this.modelClass });
93
+ }
68
94
  }
69
95
  async addOperation(operation) {
70
96
  this.operationsMap.set(operation.operationId, operation);
@@ -93,9 +119,7 @@ export class QuerysetStore {
93
119
  this._emitRenderEvent();
94
120
  }
95
121
  async setGroundTruth(groundTruthPks) {
96
- this.groundTruthPks = Array.isArray(groundTruthPks)
97
- ? groundTruthPks
98
- : [];
122
+ this.groundTruthPks = Array.isArray(groundTruthPks) ? groundTruthPks : [];
99
123
  this._emitRenderEvent();
100
124
  }
101
125
  async setOperations(operations) {
@@ -109,10 +133,10 @@ export class QuerysetStore {
109
133
  }
110
134
  getTrimmedOperations() {
111
135
  const cutoff = Date.now() - 1000 * 60 * 2;
112
- return this.operations.filter(op => op.timestamp > cutoff);
136
+ return this.operations.filter((op) => op.timestamp > cutoff);
113
137
  }
114
138
  getInflightOperations() {
115
- return this.operations.filter(operation => operation.status != Status.CONFIRMED &&
139
+ return this.operations.filter((operation) => operation.status != Status.CONFIRMED &&
116
140
  operation.status != Status.REJECTED);
117
141
  }
118
142
  prune() {
@@ -120,20 +144,68 @@ export class QuerysetStore {
120
144
  this.setGroundTruth(renderedPks);
121
145
  this.setOperations(this.getInflightOperations());
122
146
  }
147
+ registerRenderCallback(callback) {
148
+ this.renderCallbacks.add(callback);
149
+ return () => this.renderCallbacks.delete(callback);
150
+ }
151
+ _ensureRootRegistration() {
152
+ if (this.isTemp)
153
+ return;
154
+ const { isRoot, rootStore } = this.getRootStore(this.queryset);
155
+ // If the root store hasn't changed, nothing to do
156
+ if (this._currentRootStore === rootStore) {
157
+ return;
158
+ }
159
+ // Root store changed - clean up old registration if it exists
160
+ if (this._rootUnregister) {
161
+ this._rootUnregister();
162
+ this._rootUnregister = null;
163
+ }
164
+ // Set up new registration if we're derived and have a root store
165
+ if (!isRoot && rootStore) {
166
+ this._rootUnregister = rootStore.registerRenderCallback(() => {
167
+ this._emitRenderEvent();
168
+ });
169
+ }
170
+ // Update current root store reference (could be null now)
171
+ this._currentRootStore = rootStore;
172
+ }
173
+ /**
174
+ * Helper to validate PKs against the model store and apply local filtering/sorting.
175
+ * This is the core of the rendering logic.
176
+ * @private
177
+ */
178
+ _getValidatedAndFilteredPks(pks) {
179
+ // 1. Convert PKs to instances, filtering out any that are null (deleted).
180
+ const instances = Array.from(pks)
181
+ .map((pk) => this.modelClass.fromPk(pk, this.queryset))
182
+ .filter((instance) => modelStoreRegistry.getEntity(this.modelClass, instance.pk) !== null);
183
+ // 2. Apply the queryset's AST (filters, ordering) to the validated instances.
184
+ const ast = this.queryset.build();
185
+ const finalPks = filter(instances, ast, this.modelClass, false); // false = return PKs
186
+ return finalPks;
187
+ }
123
188
  render(optimistic = true, fromCache = false) {
189
+ this._ensureRootRegistration();
124
190
  if (fromCache) {
125
191
  const cachedResult = this.qsCache.get(this.cacheKey);
126
192
  if (Array.isArray(cachedResult)) {
127
193
  return cachedResult;
128
194
  }
129
195
  }
130
- const renderedPks = this.groundTruthSet;
131
- for (const op of this.operations) {
132
- if (op.status !== Status.REJECTED && (optimistic || op.status === Status.CONFIRMED)) {
133
- this.applyOperation(op, renderedPks);
196
+ let pks;
197
+ if (this.getRootStore &&
198
+ typeof this.getRootStore === "function" &&
199
+ !this.isTemp) {
200
+ const { isRoot, rootStore } = this.getRootStore(this.queryset);
201
+ if (!isRoot && rootStore) {
202
+ pks = this.renderFromRoot(optimistic, rootStore);
134
203
  }
135
204
  }
136
- let result = Array.from(renderedPks);
205
+ if (isNil(pks)) {
206
+ pks = this.renderFromData(optimistic);
207
+ }
208
+ let result = this._getValidatedAndFilteredPks(pks);
137
209
  let limit = this.queryset.build().serializerOptions?.limit;
138
210
  if (limit) {
139
211
  result = result.slice(0, limit);
@@ -141,12 +213,30 @@ export class QuerysetStore {
141
213
  this.setCache(result);
142
214
  return result;
143
215
  }
216
+ renderFromRoot(optimistic = true, rootStore) {
217
+ let renderedPks = rootStore.render(optimistic);
218
+ let renderedData = renderedPks.map((pk) => {
219
+ return this.modelClass.fromPk(pk, this.queryset);
220
+ });
221
+ let ast = this.queryset.build();
222
+ let result = filter(renderedData, ast, this.modelClass, false);
223
+ return result;
224
+ }
225
+ renderFromData(optimistic = true) {
226
+ const renderedPks = this.groundTruthSet;
227
+ for (const op of this.operations) {
228
+ if (op.status !== Status.REJECTED &&
229
+ (optimistic || op.status === Status.CONFIRMED)) {
230
+ this.applyOperation(op, renderedPks);
231
+ }
232
+ }
233
+ let result = Array.from(renderedPks);
234
+ return result;
235
+ }
144
236
  applyOperation(operation, currentPks) {
145
237
  const pkField = this.pkField;
146
238
  for (const instance of operation.instances) {
147
- if (!instance ||
148
- typeof instance !== 'object' ||
149
- !(pkField in instance)) {
239
+ if (!instance || typeof instance !== "object" || !(pkField in instance)) {
150
240
  console.warn(`[QuerysetStore ${this.modelClass.modelName}] Skipping instance in operation ${operation.operationId} due to missing PK '${String(pkField)}' or invalid format.`);
151
241
  continue;
152
242
  }
@@ -175,23 +265,44 @@ export class QuerysetStore {
175
265
  console.warn(`[QuerysetStore ${id}] Already syncing, request ignored.`);
176
266
  return;
177
267
  }
268
+ // Check if we're delegating to a root store
269
+ if (this.getRootStore &&
270
+ typeof this.getRootStore === "function" &&
271
+ !this.isTemp) {
272
+ const { isRoot, rootStore } = this.getRootStore(this.queryset);
273
+ if (!isRoot && rootStore) {
274
+ // We're delegating to a root store - don't sync, just mark as needing sync
275
+ console.log(`[${id}] Delegating to root store, marking sync needed.`);
276
+ this.needsSync = true;
277
+ this.lastSync = null; // Clear last sync since we're not actually syncing
278
+ this.setOperations(this.getInflightOperations());
279
+ return;
280
+ }
281
+ }
282
+ // We're in independent mode - proceed with normal sync
178
283
  this.isSyncing = true;
179
284
  console.log(`[${id}] Starting sync...`);
180
285
  try {
181
286
  const response = await this.fetchFn({
182
287
  ast: this.queryset.build(),
183
- modelClass: this.modelClass
288
+ modelClass: this.modelClass,
184
289
  });
185
290
  const { data, included } = response;
291
+ if (isNil(data)) {
292
+ return;
293
+ }
186
294
  console.log(`[${id}] Sync fetch completed. Received: ${JSON.stringify(data)}.`);
187
295
  // Persists all the instances (including nested instances) to the model store
188
296
  processIncludedEntities(modelStoreRegistry, included, this.modelClass);
189
297
  this.setGroundTruth(data);
190
298
  this.setOperations(this.getInflightOperations());
299
+ this.lastSync = Date.now();
300
+ this.needsSync = false;
191
301
  console.log(`[${id}] Sync completed.`);
192
302
  }
193
303
  catch (e) {
194
304
  console.error(`[${id}] Failed to sync ground truth:`, e);
305
+ this.needsSync = true; // Mark as needing sync on error
195
306
  }
196
307
  finally {
197
308
  this.isSyncing = false;
@@ -15,10 +15,15 @@ export class SyncManager {
15
15
  followedModels: Map<any, any>;
16
16
  followAllQuerysets: boolean;
17
17
  followedQuerysets: Map<any, any>;
18
+ periodicSyncTimer: NodeJS.Timeout | null;
18
19
  /**
19
20
  * Initialize event handlers for all event receivers
20
21
  */
21
22
  initialize(): void;
23
+ startPeriodicSync(): void;
24
+ syncStaleQuerysets(): void;
25
+ isStoreFollowed(registry: any, semanticKey: any): boolean;
26
+ cleanup(): void;
22
27
  followModel(registry: any, modelClass: any): void;
23
28
  unfollowModel(registry: any, modelClass: any): void;
24
29
  manageRegistry(registry: any): void;
@@ -58,6 +58,7 @@ export class SyncManager {
58
58
  // Map of querysets to keep synced
59
59
  this.followAllQuerysets = true;
60
60
  this.followedQuerysets = new Map();
61
+ this.periodicSyncTimer = null;
61
62
  }
62
63
  /**
63
64
  * Initialize event handlers for all event receivers
@@ -74,13 +75,68 @@ export class SyncManager {
74
75
  receiver.addModelEventHandler(this.handleEvent.bind(this));
75
76
  }
76
77
  });
78
+ this.startPeriodicSync();
79
+ }
80
+ startPeriodicSync() {
81
+ if (this.periodicSyncTimer)
82
+ return;
83
+ try {
84
+ const config = getConfig();
85
+ const intervalSeconds = config.periodicSyncIntervalSeconds;
86
+ // If null or undefined, don't start periodic sync
87
+ if (!intervalSeconds) {
88
+ console.log("[SyncManager] Periodic sync disabled (set to null)");
89
+ return;
90
+ }
91
+ const intervalMs = intervalSeconds * 1000;
92
+ this.periodicSyncTimer = setInterval(() => {
93
+ this.syncStaleQuerysets();
94
+ }, intervalMs);
95
+ console.log(`[SyncManager] Periodic sync started: ${intervalSeconds}s intervals`);
96
+ }
97
+ catch (error) {
98
+ // If no config, don't start periodic sync by default
99
+ console.log("[SyncManager] No config found, periodic sync disabled by default");
100
+ }
101
+ }
102
+ syncStaleQuerysets() {
103
+ let syncedCount = 0;
104
+ // Sync all followed querysets - keep it simple
105
+ const querysetRegistry = this.registries.get("QuerysetStoreRegistry");
106
+ if (querysetRegistry) {
107
+ for (const [semanticKey, store] of querysetRegistry._stores.entries()) {
108
+ // Only sync if this store is actually being followed
109
+ const isFollowed = this.isStoreFollowed(querysetRegistry, semanticKey);
110
+ if (this.followAllQuerysets || isFollowed) {
111
+ store.sync();
112
+ syncedCount++;
113
+ }
114
+ }
115
+ }
116
+ if (syncedCount > 0) {
117
+ console.log(`[SyncManager] Periodic sync: ${syncedCount} stores synced`);
118
+ }
119
+ }
120
+ isStoreFollowed(registry, semanticKey) {
121
+ const followingQuerysets = registry.followingQuerysets.get(semanticKey);
122
+ if (!followingQuerysets)
123
+ return false;
124
+ return [...followingQuerysets].some((queryset) => {
125
+ return this.isQuerysetFollowed(queryset);
126
+ });
127
+ }
128
+ cleanup() {
129
+ if (this.periodicSyncTimer) {
130
+ clearInterval(this.periodicSyncTimer);
131
+ this.periodicSyncTimer = null;
132
+ }
77
133
  }
78
134
  followModel(registry, modelClass) {
79
135
  const models = this.followedModels.get(registry) || new Set();
80
136
  this.followedModels.set(registry, models);
81
137
  if (models.has(modelClass))
82
138
  return;
83
- const alreadyFollowed = [...this.followedModels.values()].some(set => set.has(modelClass));
139
+ const alreadyFollowed = [...this.followedModels.values()].some((set) => set.has(modelClass));
84
140
  models.add(modelClass);
85
141
  if (!alreadyFollowed) {
86
142
  getEventReceiver(modelClass.configKey)?.subscribe(modelClass.modelName, this.handleEvent);
@@ -91,7 +147,7 @@ export class SyncManager {
91
147
  if (!models)
92
148
  return;
93
149
  models.delete(modelClass);
94
- const stillFollowed = [...this.followedModels.values()].some(set => set.has(modelClass));
150
+ const stillFollowed = [...this.followedModels.values()].some((set) => set.has(modelClass));
95
151
  if (!stillFollowed) {
96
152
  getEventReceiver(modelClass.configKey)?.unsubscribe(modelClass.modelName, this.handleEvent);
97
153
  }
@@ -104,10 +160,10 @@ export class SyncManager {
104
160
  this.registries.delete(registry.constructor.name);
105
161
  }
106
162
  isQuerysetFollowed(queryset) {
163
+ const activeSemanticKeys = new Set([...this.followedQuerysets].map((qs) => qs.semanticKey));
107
164
  let current = queryset;
108
- // All followed querysets and their descendents get updated
109
165
  while (current) {
110
- if (this.followedQuerysets.has(current)) {
166
+ if (activeSemanticKeys.has(current.semanticKey)) {
111
167
  return true;
112
168
  }
113
169
  current = current.__parent;
@@ -127,7 +183,7 @@ export class SyncManager {
127
183
  const followingQuerysets = registry.followingQuerysets.get(semanticKey);
128
184
  if (followingQuerysets) {
129
185
  // Use some() to break early when we find a match
130
- const shouldSync = [...followingQuerysets].some(queryset => {
186
+ const shouldSync = [...followingQuerysets].some((queryset) => {
131
187
  return this.isQuerysetFollowed(queryset);
132
188
  });
133
189
  if (shouldSync) {