@statezero/core 0.1.68 → 0.1.70

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.
@@ -128,8 +128,15 @@ export class ModelStore {
128
128
  return;
129
129
  }
130
130
  }
131
- item[pkField] = pk;
132
- nonTempPkItems.push(item);
131
+ if (item && typeof item.serialize === "function") {
132
+ const serializedItem = item.serialize();
133
+ serializedItem[pkField] = pk;
134
+ nonTempPkItems.push(serializedItem);
135
+ }
136
+ else {
137
+ item[pkField] = pk;
138
+ nonTempPkItems.push(item);
139
+ }
133
140
  });
134
141
  this.modelCache.set(this.cacheKey, nonTempPkItems);
135
142
  }
@@ -16,6 +16,12 @@ export class SyncManager {
16
16
  followAllQuerysets: boolean;
17
17
  followedQuerysets: Map<any, any>;
18
18
  periodicSyncTimer: NodeJS.Timeout | null;
19
+ eventBatch: Map<any, any>;
20
+ debounceTimer: NodeJS.Timeout | null;
21
+ maxWaitTimer: NodeJS.Timeout | null;
22
+ debounceMs: number;
23
+ maxWaitMs: number;
24
+ batchStartTime: number | null;
19
25
  /**
20
26
  * Initialize event handlers for all event receivers
21
27
  */
@@ -29,8 +35,9 @@ export class SyncManager {
29
35
  manageRegistry(registry: any): void;
30
36
  removeRegistry(registry: any): void;
31
37
  handleEvent: (event: any) => void;
38
+ processBatch(reason?: string): void;
32
39
  isQuerysetFollowed(queryset: any): boolean;
33
- processQuerysets(event: any): void;
40
+ processQuerysetsBatch(representativeEvent: any, allEvents: any): void;
34
41
  processMetrics(event: any): void;
35
42
  processModels(event: any): void;
36
43
  }
@@ -2,8 +2,8 @@ import { getAllEventReceivers } from "../core/eventReceivers";
2
2
  import { operationRegistry } from "./stores/operation";
3
3
  import { initializeAllEventReceivers } from "../config";
4
4
  import { getEventReceiver } from "../core/eventReceivers";
5
- import { querysetStoreRegistry, QuerysetStoreRegistry } from "./registries/querysetStoreRegistry";
6
- import { modelStoreRegistry, ModelStoreRegistry } from "./registries/modelStoreRegistry";
5
+ import { querysetStoreRegistry, QuerysetStoreRegistry, } from "./registries/querysetStoreRegistry";
6
+ import { modelStoreRegistry, ModelStoreRegistry, } from "./registries/modelStoreRegistry";
7
7
  import { metricRegistry, MetricRegistry } from "./registries/metricRegistry";
8
8
  import { getModelClass, getConfig } from "../config";
9
9
  import { isNil } from "lodash-es";
@@ -22,14 +22,15 @@ export class EventPayload {
22
22
  return getModelClass(this.model, this.configKey);
23
23
  }
24
24
  async getFullInstances() {
25
- if (this.event === 'delete') {
25
+ if (this.event === "delete") {
26
26
  throw new Error("Cannot fetch full instances for delete operation bozo...");
27
27
  }
28
28
  if (isNil(this._cachedInstances)) {
29
29
  this._cachedInstances = await this.modelClass.objects
30
30
  .filter({
31
31
  [`${this.modelClass.primaryKeyField}__in`]: this.instances.map((instance) => instance[this.modelClass.primaryKeyField]),
32
- }).fetch();
32
+ })
33
+ .fetch();
33
34
  }
34
35
  return this._cachedInstances;
35
36
  }
@@ -39,19 +40,36 @@ export class SyncManager {
39
40
  this.handleEvent = (event) => {
40
41
  let payload = new EventPayload(event);
41
42
  let isLocalOperation = operationRegistry.has(payload.operation_id);
43
+ // Always process metrics immediately (they're lightweight)
42
44
  if (this.registries.has(MetricRegistry)) {
43
45
  this.processMetrics(payload);
44
46
  }
45
47
  if (isLocalOperation) {
46
- // This is a local operation, so don't resync querysets or models
47
48
  return;
48
49
  }
49
- if (this.registries.has(QuerysetStoreRegistry)) {
50
- this.processQuerysets(payload);
50
+ // Add to batch for queryset/model processing
51
+ const key = `${event.model}::${event.configKey}`;
52
+ const now = Date.now();
53
+ // If this is the first event in the batch, start max wait timer
54
+ if (this.eventBatch.size === 0) {
55
+ this.batchStartTime = now;
56
+ this.maxWaitTimer = setTimeout(() => {
57
+ this.processBatch("maxWait");
58
+ }, this.maxWaitMs);
51
59
  }
52
- if (this.registries.has(ModelStoreRegistry)) {
53
- this.processModels(payload);
60
+ this.eventBatch.set(key, {
61
+ event: payload,
62
+ firstSeen: this.eventBatch.has(key)
63
+ ? this.eventBatch.get(key).firstSeen
64
+ : now,
65
+ });
66
+ // Reset debounce timer
67
+ if (this.debounceTimer) {
68
+ clearTimeout(this.debounceTimer);
54
69
  }
70
+ this.debounceTimer = setTimeout(() => {
71
+ this.processBatch("debounce");
72
+ }, this.debounceMs);
55
73
  };
56
74
  this.registries = new Map();
57
75
  // Map of registries to model sets
@@ -60,6 +78,13 @@ export class SyncManager {
60
78
  this.followAllQuerysets = true;
61
79
  this.followedQuerysets = new Map();
62
80
  this.periodicSyncTimer = null;
81
+ // Batching for event processing
82
+ this.eventBatch = new Map(); // model::configKey -> { event, firstSeen }
83
+ this.debounceTimer = null;
84
+ this.maxWaitTimer = null;
85
+ this.debounceMs = 100; // Wait for rapid events to settle
86
+ this.maxWaitMs = 2000; // Maximum time to hold events
87
+ this.batchStartTime = null;
63
88
  }
64
89
  /**
65
90
  * Initialize event handlers for all event receivers
@@ -131,6 +156,15 @@ export class SyncManager {
131
156
  clearInterval(this.periodicSyncTimer);
132
157
  this.periodicSyncTimer = null;
133
158
  }
159
+ // Clean up batch timers
160
+ if (this.debounceTimer) {
161
+ clearTimeout(this.debounceTimer);
162
+ this.debounceTimer = null;
163
+ }
164
+ if (this.maxWaitTimer) {
165
+ clearTimeout(this.maxWaitTimer);
166
+ this.maxWaitTimer = null;
167
+ }
134
168
  }
135
169
  followModel(registry, modelClass) {
136
170
  const models = this.followedModels.get(registry) || new Set();
@@ -160,6 +194,43 @@ export class SyncManager {
160
194
  removeRegistry(registry) {
161
195
  this.registries.delete(registry.constructor);
162
196
  }
197
+ processBatch(reason = "unknown") {
198
+ if (this.eventBatch.size === 0)
199
+ return;
200
+ // Clear timers
201
+ if (this.debounceTimer) {
202
+ clearTimeout(this.debounceTimer);
203
+ this.debounceTimer = null;
204
+ }
205
+ if (this.maxWaitTimer) {
206
+ clearTimeout(this.maxWaitTimer);
207
+ this.maxWaitTimer = null;
208
+ }
209
+ const events = Array.from(this.eventBatch.values()).map((item) => item.event);
210
+ const waitTime = Date.now() - this.batchStartTime;
211
+ this.eventBatch.clear();
212
+ this.batchStartTime = null;
213
+ console.log(`[SyncManager] Processing batch of ${events.length} events (reason: ${reason}, waited: ${waitTime}ms)`);
214
+ // Group events by model for efficient processing
215
+ const eventsByModel = new Map();
216
+ events.forEach((event) => {
217
+ const key = `${event.model}::${event.configKey}`;
218
+ if (!eventsByModel.has(key)) {
219
+ eventsByModel.set(key, []);
220
+ }
221
+ eventsByModel.get(key).push(event);
222
+ });
223
+ // Process each model's events as a batch
224
+ eventsByModel.forEach((modelEvents, modelKey) => {
225
+ const event = modelEvents[0]; // Use first event as representative
226
+ if (this.registries.has(QuerysetStoreRegistry)) {
227
+ this.processQuerysetsBatch(event, modelEvents);
228
+ }
229
+ if (this.registries.has(ModelStoreRegistry)) {
230
+ this.processModels(event);
231
+ }
232
+ });
233
+ }
163
234
  isQuerysetFollowed(queryset) {
164
235
  const activeSemanticKeys = new Set([...this.followedQuerysets].map((qs) => qs.semanticKey));
165
236
  let current = queryset;
@@ -171,29 +242,36 @@ export class SyncManager {
171
242
  }
172
243
  return false;
173
244
  }
174
- processQuerysets(event) {
245
+ processQuerysetsBatch(representativeEvent, allEvents) {
175
246
  const registry = this.registries.get(QuerysetStoreRegistry);
247
+ // Collect all stores that need syncing for this model
248
+ const storesToSync = [];
176
249
  for (const [semanticKey, store] of registry._stores.entries()) {
177
- if (store.modelClass.modelName === event.model &&
178
- store.modelClass.configKey === event.configKey) {
250
+ if (store.modelClass.modelName === representativeEvent.model &&
251
+ store.modelClass.configKey === representativeEvent.configKey) {
179
252
  if (this.followAllQuerysets) {
180
- store.sync();
253
+ storesToSync.push(store);
181
254
  continue;
182
255
  }
183
- // Get the set of querysets following this semantic key
184
256
  const followingQuerysets = registry.followingQuerysets.get(semanticKey);
185
257
  if (followingQuerysets) {
186
- // Use some() to break early when we find a match
187
258
  const shouldSync = [...followingQuerysets].some((queryset) => {
188
259
  return this.isQuerysetFollowed(queryset);
189
260
  });
190
261
  if (shouldSync) {
191
- console.log(`syncing store for semantic key: ${semanticKey}`);
192
- store.sync();
262
+ storesToSync.push(store);
193
263
  }
194
264
  }
195
265
  }
196
266
  }
267
+ // Sync all relevant stores for this model
268
+ console.log(`[SyncManager] Syncing ${storesToSync.length} queryset stores for ${representativeEvent.model}`);
269
+ storesToSync.forEach((store) => {
270
+ // Don't await - let them run in parallel
271
+ store.sync().catch((error) => {
272
+ console.error(`[SyncManager] Failed to sync queryset store:`, error);
273
+ });
274
+ });
197
275
  }
198
276
  processMetrics(event) {
199
277
  const registry = this.registries.get(MetricRegistry);
@@ -215,8 +293,32 @@ export class SyncManager {
215
293
  }
216
294
  }
217
295
  processModels(event) {
218
- // No need to sync models, because everything that is live, is synced
219
- return;
296
+ const registry = this.registries.get(ModelStoreRegistry);
297
+ if (!registry)
298
+ return;
299
+ // Get the model class for this event
300
+ const modelClass = event.modelClass;
301
+ if (!modelClass)
302
+ return;
303
+ // Get the model store for this model class
304
+ const modelStore = registry.getStore(modelClass);
305
+ if (!modelStore)
306
+ return;
307
+ // Event instances are just PKs - find which ones we have locally
308
+ const eventPks = event.instances || [];
309
+ // Get all currently rendered instances (includes ground truth + operations)
310
+ const renderedInstances = modelStore.render();
311
+ const localPks = new Set(renderedInstances.map((instance) => instance[modelClass.primaryKeyField]));
312
+ const pksToSync = eventPks.filter((pk) => localPks.has(pk));
313
+ if (pksToSync.length === 0) {
314
+ console.log(`[SyncManager] No locally cached instances to sync for ${event.model}`);
315
+ return;
316
+ }
317
+ // Sync only the PKs that are both in the event and locally cached
318
+ console.log(`[SyncManager] Syncing ${pksToSync.length} model instances for ${event.model}`);
319
+ modelStore.sync(pksToSync).catch((error) => {
320
+ console.error(`[SyncManager] Failed to sync model store for ${event.model}:`, error);
321
+ });
220
322
  }
221
323
  }
222
324
  const syncManager = new SyncManager();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
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",