@statezero/core 0.2.9 → 0.2.11

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.
@@ -27,7 +27,9 @@ export function ModelAdaptor(modelInstance, reactivityFn = reactive) {
27
27
  const renderHandler = (eventData) => {
28
28
  const isRef = reactivityFn === ref;
29
29
  const model = isRef ? wrapper.value : wrapper;
30
- if (eventData.pk === model[pkField]) {
30
+ const modelPk = model[pkField];
31
+ // Check if this model's pk is in the event's pks array
32
+ if (eventData.pks && eventData.pks.includes(modelPk)) {
31
33
  if (isRef) {
32
34
  wrapper.value.touch();
33
35
  }
@@ -44,10 +44,9 @@ export function processIncludedEntities(modelStoreRegistry, included, ModelClass
44
44
  pksSet.add(Number(pk));
45
45
  }
46
46
  }
47
- // Register each entity in the model store
48
- for (const [pk, entity] of Object.entries(entityMap)) {
49
- modelStoreRegistry.setEntity(EntityClass, pk, entity);
50
- }
47
+ // Register all entities in the model store in a single batch call
48
+ const entities = Object.values(entityMap);
49
+ modelStoreRegistry.setEntities(EntityClass, entities);
51
50
  }
52
51
  }
53
52
  catch (error) {
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Register a callback to be notified when a temp pk is resolved to a real pk
3
+ * @param {Function} callback - Called with (tempPk, realPk) when resolved
4
+ * @returns {Function} - Unsubscribe function
5
+ */
6
+ export function onTempPkResolved(callback: Function): Function;
1
7
  /**
2
8
  * Check if a string contains a temporary PK template
3
9
  * @param {string} str - The string to check
@@ -12,6 +18,12 @@ export function createTempPk(uuid: any): string;
12
18
  * Register a real PK for a temporary PK
13
19
  */
14
20
  export function setRealPk(uuid: any, realPk: any): void;
21
+ /**
22
+ * Get the real PK for a temp PK if it exists in the registry
23
+ * @param {any} pk - The pk to resolve (may be temp or real)
24
+ * @returns {any} - The real pk if found, otherwise the original pk
25
+ */
26
+ export function resolveToRealPk(pk: any): any;
15
27
  /**
16
28
  * Replace all temporary PKs in an object (or string) with their real values
17
29
  */
@@ -2,6 +2,17 @@ import Handlebars from 'handlebars';
2
2
  import superjson from 'superjson';
3
3
  // Simple map to store temporary PK to real PK mappings
4
4
  export const tempPkMap = new Map();
5
+ // Callbacks to notify when a temp pk is resolved to a real pk
6
+ const onResolveCallbacks = new Set();
7
+ /**
8
+ * Register a callback to be notified when a temp pk is resolved to a real pk
9
+ * @param {Function} callback - Called with (tempPk, realPk) when resolved
10
+ * @returns {Function} - Unsubscribe function
11
+ */
12
+ export function onTempPkResolved(callback) {
13
+ onResolveCallbacks.add(callback);
14
+ return () => onResolveCallbacks.delete(callback);
15
+ }
5
16
  /**
6
17
  * Check if a string contains a temporary PK template
7
18
  * @param {string} str - The string to check
@@ -22,9 +33,41 @@ export function createTempPk(uuid) {
22
33
  * Register a real PK for a temporary PK
23
34
  */
24
35
  export function setRealPk(uuid, realPk) {
36
+ const tempPk = `{{TempPK_${uuid}}}`;
25
37
  const key = `"TempPK_${uuid}"`;
26
38
  const value = typeof realPk === 'string' ? `"${realPk}"` : String(realPk);
27
39
  tempPkMap.set(key, value);
40
+ // Notify listeners so they can migrate cache entries
41
+ for (const cb of onResolveCallbacks) {
42
+ try {
43
+ cb(tempPk, realPk);
44
+ }
45
+ catch (e) {
46
+ console.warn('[tempPk] onResolve callback error:', e);
47
+ }
48
+ }
49
+ }
50
+ /**
51
+ * Get the real PK for a temp PK if it exists in the registry
52
+ * @param {any} pk - The pk to resolve (may be temp or real)
53
+ * @returns {any} - The real pk if found, otherwise the original pk
54
+ */
55
+ export function resolveToRealPk(pk) {
56
+ if (!containsTempPk(String(pk)))
57
+ return pk;
58
+ // Extract the uuid from {{TempPK_uuid}}
59
+ const match = String(pk).match(/\{\{\s*TempPK_([^}\s]+)\s*\}\}/);
60
+ if (!match)
61
+ return pk;
62
+ const key = `"TempPK_${match[1]}"`;
63
+ const realPkStr = tempPkMap.get(key);
64
+ if (!realPkStr)
65
+ return pk;
66
+ // Parse the real pk (remove quotes if string, parse if number)
67
+ if (realPkStr.startsWith('"') && realPkStr.endsWith('"')) {
68
+ return realPkStr.slice(1, -1);
69
+ }
70
+ return parseInt(realPkStr, 10);
28
71
  }
29
72
  /**
30
73
  * Replace all temporary PKs in an object (or string) with their real values
@@ -6,5 +6,6 @@ export class ModelStoreRegistry {
6
6
  getStore(modelClass: any): any;
7
7
  getEntity(modelClass: any, pk: any): any;
8
8
  setEntity(modelClass: any, pk: any, data: any): any;
9
+ setEntities(modelClass: any, entities: any): void;
9
10
  }
10
11
  export const modelStoreRegistry: ModelStoreRegistry;
@@ -20,7 +20,7 @@ export class ModelStoreRegistry {
20
20
  const fetchModels = async ({ pks, modelClass }) => {
21
21
  return await modelClass.objects.filter({
22
22
  [`${modelClass.primaryKeyField}__in`]: pks
23
- }).fetch();
23
+ }).fetch().serialize();
24
24
  };
25
25
  this._stores.set(modelClass, new ModelStore(modelClass, fetchModels, null, null));
26
26
  this.syncManager.followModel(this, modelClass);
@@ -51,6 +51,13 @@ export class ModelStoreRegistry {
51
51
  store.addToGroundTruth([data]);
52
52
  return data;
53
53
  }
54
+ // Batch add or update entities in the store ground truth
55
+ setEntities(modelClass, entities) {
56
+ if (isNil(modelClass) || !entities?.length)
57
+ return;
58
+ const store = this.getStore(modelClass);
59
+ store.addToGroundTruth(entities);
60
+ }
54
61
  }
55
62
  // Export singleton instance
56
63
  export const modelStoreRegistry = new ModelStoreRegistry();
@@ -9,6 +9,7 @@ export class ModelStore {
9
9
  modelCache: Cache;
10
10
  _lastRenderedData: Map<any, any>;
11
11
  renderCallbacks: Set<any>;
12
+ _unsubscribeTempPk: Function;
12
13
  registerRenderCallback(callback: any): () => boolean;
13
14
  /**
14
15
  * Load operations from data and add them to the operations map,
@@ -44,7 +45,9 @@ export class ModelStore {
44
45
  * @param {QuerysetStoreRegistry} querysetStoreRegistry - The registry to check for queryset references
45
46
  */
46
47
  pruneUnreferencedInstances(querysetStoreRegistry: QuerysetStoreRegistry): void;
47
- render(pks?: null, optimistic?: boolean): any[];
48
+ render(pks?: null, optimistic?: boolean, useCache?: boolean): any;
49
+ _renderFromCache(pks: any): any;
50
+ _renderFresh(pks?: null, optimistic?: boolean): any[];
48
51
  sync(pks?: null): Promise<void>;
49
52
  }
50
53
  import { Cache } from '../cache/cache.js';
@@ -2,52 +2,62 @@ import { Operation, Status, Type, operationRegistry } from './operation.js';
2
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
- import { replaceTempPks, containsTempPk } from '../../flavours/django/tempPk.js';
6
- const emitEvents = (store, events) => {
7
- if (!Array.isArray(events))
5
+ import { replaceTempPks, containsTempPk, resolveToRealPk, onTempPkResolved } from '../../flavours/django/tempPk.js';
6
+ const emitEvents = (store, event) => {
7
+ if (!event || !event.pks || event.pks.length === 0)
8
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
- store.renderCallbacks.forEach((callback) => {
20
- try {
21
- callback();
22
- }
23
- catch (error) {
24
- console.warn("Error in model store render callback:", error);
25
- }
26
- });
9
+ // Get prior values for these pks in one pass (before computing new)
10
+ const lastRenderedDataArray = event.pks.map(pk => store._lastRenderedData.get(pk) ?? null);
11
+ // Batch render all pks - useCache=false to get fresh data for comparison
12
+ const newRenderedDataArray = store.render(event.pks, true, false);
13
+ // Single equality check on the whole batch
14
+ if (!isEqual(newRenderedDataArray, lastRenderedDataArray)) {
15
+ // Update cache for all pks
16
+ const pkField = store.pkField;
17
+ for (const item of newRenderedDataArray) {
18
+ if (item && item[pkField] != null) {
19
+ store._lastRenderedData.set(item[pkField], item);
20
+ }
21
+ }
22
+ // Also mark any pks that are now null (deleted)
23
+ for (const pk of event.pks) {
24
+ if (!newRenderedDataArray.some(item => item && item[pkField] === pk)) {
25
+ store._lastRenderedData.set(pk, null);
26
+ }
27
27
  }
28
- });
28
+ modelEventEmitter.emit(`${store.modelClass.configKey}::${store.modelClass.modelName}::render`, event);
29
+ store.renderCallbacks.forEach((callback) => {
30
+ try {
31
+ callback();
32
+ }
33
+ catch (error) {
34
+ console.warn("Error in model store render callback:", error);
35
+ }
36
+ });
37
+ }
29
38
  };
30
39
  class EventData {
31
- constructor(ModelClass, pk) {
40
+ constructor(ModelClass, pks) {
32
41
  this.ModelClass = ModelClass;
33
- this.pk = pk;
42
+ this.pks = Array.isArray(pks) ? pks : [pks];
34
43
  }
35
44
  /**
36
- * One event per instance in a single operation
45
+ * Single event containing all PKs from an operation
37
46
  */
38
47
  static fromOperation(operation) {
39
48
  const ModelClass = operation.queryset.ModelClass;
40
49
  const pkField = ModelClass.primaryKeyField;
41
- return operation.instances
50
+ const pks = operation.instances
42
51
  .filter(instance => instance != null && typeof instance === 'object' && pkField in instance)
43
- .map(instance => new EventData(ModelClass, instance[pkField]));
52
+ .map(instance => instance[pkField]);
53
+ return pks.length > 0 ? new EventData(ModelClass, pks) : null;
44
54
  }
45
55
  /**
46
- * One event per unique PK across multiple operations
56
+ * Single event containing all unique PKs across multiple operations
47
57
  */
48
58
  static fromOperations(operations) {
49
59
  if (!operations.length)
50
- return [];
60
+ return null;
51
61
  const ModelClass = operations[0].queryset.ModelClass;
52
62
  const pkField = ModelClass.primaryKeyField;
53
63
  const uniquePks = new Set();
@@ -58,17 +68,17 @@ class EventData {
58
68
  }
59
69
  }
60
70
  }
61
- return Array.from(uniquePks).map(pk => new EventData(ModelClass, pk));
71
+ return uniquePks.size > 0 ? new EventData(ModelClass, Array.from(uniquePks)) : null;
62
72
  }
63
73
  /**
64
- * One event per unique PK in an array of instances
74
+ * Single event containing all unique PKs from an array of instances
65
75
  */
66
76
  static fromInstances(instances, ModelClass) {
67
77
  const pkField = ModelClass.primaryKeyField;
68
78
  const uniquePks = new Set(instances
69
79
  .filter(inst => inst && inst[pkField] != null)
70
80
  .map(inst => inst[pkField]));
71
- return Array.from(uniquePks).map(pk => new EventData(ModelClass, pk));
81
+ return uniquePks.size > 0 ? new EventData(ModelClass, Array.from(uniquePks)) : null;
72
82
  }
73
83
  }
74
84
  export class ModelStore {
@@ -86,6 +96,18 @@ export class ModelStore {
86
96
  this.modelCache = new Cache("model-cache", {}, this.onHydrated.bind(this));
87
97
  this._lastRenderedData = new Map();
88
98
  this.renderCallbacks = new Set();
99
+ // Migrate cache entries when temp pks are resolved to real pks
100
+ this._unsubscribeTempPk = onTempPkResolved((tempPk, realPk) => {
101
+ if (this._lastRenderedData.has(tempPk)) {
102
+ const data = this._lastRenderedData.get(tempPk);
103
+ // Update the pk field in the cached data
104
+ if (data && typeof data === 'object') {
105
+ data[this.pkField] = realPk;
106
+ }
107
+ this._lastRenderedData.set(realPk, data);
108
+ this._lastRenderedData.delete(tempPk);
109
+ }
110
+ });
89
111
  }
90
112
  registerRenderCallback(callback) {
91
113
  this.renderCallbacks.add(callback);
@@ -435,7 +457,60 @@ export class ModelStore {
435
457
  }
436
458
  }
437
459
  // Render methods
438
- render(pks = null, optimistic = true) {
460
+ render(pks = null, optimistic = true, useCache = true) {
461
+ // When rendering specific pks, check cache first
462
+ if (useCache && pks !== null) {
463
+ const pksArray = Array.isArray(pks) ? pks : [pks];
464
+ // Resolve temp pks to real pks for cache lookup
465
+ const resolvedPks = pksArray.map(pk => resolveToRealPk(pk));
466
+ const uncachedPks = [];
467
+ for (const pk of resolvedPks) {
468
+ if (!this._lastRenderedData.has(pk)) {
469
+ uncachedPks.push(pk);
470
+ }
471
+ }
472
+ // All pks were cached - return from cache
473
+ if (uncachedPks.length === 0) {
474
+ return this._renderFromCache(resolvedPks);
475
+ }
476
+ // Some or all pks need fresh render
477
+ const pkField = this.pkField;
478
+ if (uncachedPks.length < resolvedPks.length) {
479
+ // Partial cache hit - get cached and render uncached
480
+ const cachedPks = resolvedPks.filter(pk => this._lastRenderedData.has(pk));
481
+ const cached = this._renderFromCache(cachedPks);
482
+ const fresh = this._renderFresh(uncachedPks, optimistic);
483
+ // Update cache for freshly rendered items
484
+ for (const item of fresh) {
485
+ if (item && item[pkField] != null) {
486
+ this._lastRenderedData.set(item[pkField], item);
487
+ }
488
+ }
489
+ return [...cached, ...fresh];
490
+ }
491
+ else {
492
+ // All pks uncached - render fresh and update cache
493
+ const fresh = this._renderFresh(resolvedPks, optimistic);
494
+ // Update cache for freshly rendered items
495
+ for (const item of fresh) {
496
+ if (item && item[pkField] != null) {
497
+ this._lastRenderedData.set(item[pkField], item);
498
+ }
499
+ }
500
+ return fresh;
501
+ }
502
+ }
503
+ // Full render (no cache, useCache=false, or pks=null for all)
504
+ return this._renderFresh(pks, optimistic);
505
+ }
506
+ _renderFromCache(pks) {
507
+ // Returns cached rendered data for the given pks
508
+ // This method can be spied on in tests to track cache hits
509
+ return pks
510
+ .map(pk => this._lastRenderedData.get(pk))
511
+ .filter(item => item !== null && item !== undefined);
512
+ }
513
+ _renderFresh(pks = null, optimistic = true) {
439
514
  const pksSet = pks === null
440
515
  ? null
441
516
  : pks instanceof Set
@@ -14,6 +14,7 @@ import { MAX, v7 as uuidv7 } from "uuid";
14
14
  import { isNil } from 'lodash-es';
15
15
  import mitt from 'mitt';
16
16
  import { tempPkMap } from "../../flavours/django/tempPk";
17
+ import { modelStoreRegistry } from "../registries/modelStoreRegistry.js";
17
18
  export const operationEvents = mitt();
18
19
  export const Status = {
19
20
  CREATED: 'operation:created',
@@ -73,7 +74,11 @@ export class Operation {
73
74
  throw new Error(`All operation instances must be objects with the '${pkField}' field`);
74
75
  }
75
76
  __classPrivateFieldSet(this, _Operation__instances, instances, "f");
76
- __classPrivateFieldSet(this, _Operation__frozenInstances, instances.map(i => ModelClass.fromPk(i[ModelClass.primaryKeyField]).serialize()), "f");
77
+ // Batch render to warm the cache, then serialize each instance to get plain objects
78
+ const pks = instances.map(i => i[pkField]);
79
+ const store = modelStoreRegistry.getStore(ModelClass);
80
+ store.render(pks, true, false);
81
+ __classPrivateFieldSet(this, _Operation__frozenInstances, instances.map(i => ModelClass.fromPk(i[pkField]).serialize()), "f");
77
82
  this.timestamp = data.timestamp || Date.now();
78
83
  if (restore)
79
84
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",