@statezero/core 0.1.18 → 0.1.20

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.
@@ -48,8 +48,8 @@ export class MaxStrategy extends MetricCalculationStrategy {
48
48
  * Factory class for creating the appropriate strategy
49
49
  */
50
50
  export class MetricStrategyFactory {
51
- static "__#5@#customStrategies": Map<any, any>;
52
- static "__#5@#defaultStrategies": Map<string, () => CountStrategy>;
51
+ static "__#7@#customStrategies": Map<any, any>;
52
+ static "__#7@#defaultStrategies": Map<string, () => CountStrategy>;
53
53
  /**
54
54
  * Clear all custom strategy overrides
55
55
  */
@@ -61,7 +61,7 @@ export class MetricStrategyFactory {
61
61
  * @param {Function} ModelClass - The model class
62
62
  * @returns {string} A unique key
63
63
  */
64
- private static "__#5@#generateStrategyKey";
64
+ private static "__#7@#generateStrategyKey";
65
65
  /**
66
66
  * Override a strategy for a specific metric type and model class
67
67
  * @param {string} metricType - The type of metric (count, sum, min, max)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Simple graph for tracking queryset store ancestry
3
+ */
4
+ export class QuerysetStoreGraph {
5
+ constructor(hasStoreFn?: null);
6
+ graph: any;
7
+ hasStoreFn: () => boolean;
8
+ processedQuerysets: Set<any>;
9
+ setHasStoreFn(hasStoreFn: any): void;
10
+ /**
11
+ * Add a queryset and its parent relationship to the graph
12
+ */
13
+ addQueryset(queryset: any): void;
14
+ /**
15
+ * Find the root store for a queryset
16
+ * @param {Object} queryset - The queryset to analyze
17
+ * @returns {Object} { isRoot: boolean, root: semanticKey|null }
18
+ */
19
+ findRoot(queryset: Object): Object;
20
+ clear(): void;
21
+ }
@@ -0,0 +1,93 @@
1
+ import { Graph } from "graphlib";
2
+ /**
3
+ * Simple graph for tracking queryset store ancestry
4
+ */
5
+ export class QuerysetStoreGraph {
6
+ constructor(hasStoreFn = null) {
7
+ this.graph = new Graph({ directed: true });
8
+ this.hasStoreFn = hasStoreFn || (() => false);
9
+ this.processedQuerysets = new Set(); // Track UUIDs of processed querysets
10
+ }
11
+ setHasStoreFn(hasStoreFn) {
12
+ this.hasStoreFn = hasStoreFn;
13
+ }
14
+ /**
15
+ * Add a queryset and its parent relationship to the graph
16
+ */
17
+ addQueryset(queryset) {
18
+ if (!queryset)
19
+ return;
20
+ if (this.processedQuerysets.has(queryset.key)) {
21
+ return; // Already processed, skip
22
+ }
23
+ let current = queryset;
24
+ while (current && !this.processedQuerysets.has(current.key)) {
25
+ const currentKey = current.semanticKey;
26
+ const currentUuid = current.key;
27
+ this.processedQuerysets.add(currentUuid);
28
+ this.graph.setNode(currentKey);
29
+ if (current.__parent) {
30
+ const parentKey = current.__parent.semanticKey;
31
+ this.graph.setNode(parentKey);
32
+ this.graph.setEdge(currentKey, parentKey); // child -> parent
33
+ current = current.__parent;
34
+ }
35
+ else {
36
+ break;
37
+ }
38
+ }
39
+ }
40
+ /**
41
+ * Find the root store for a queryset
42
+ * @param {Object} queryset - The queryset to analyze
43
+ * @returns {Object} { isRoot: boolean, root: semanticKey|null }
44
+ */
45
+ findRoot(queryset) {
46
+ // Validate input - null/undefined is a programming error
47
+ if (!queryset) {
48
+ throw new Error("findRoot was called with a null object, instead of a queryset");
49
+ }
50
+ // Handle queryset without semanticKey
51
+ if (!queryset.semanticKey) {
52
+ throw new Error("findRoot was called on an object without a semanticKey, which means its not a queryset. findRoot only works on querysets");
53
+ }
54
+ const semanticKey = queryset.semanticKey;
55
+ if (!this.graph.hasNode(semanticKey)) {
56
+ this.addQueryset(queryset);
57
+ }
58
+ // Traverse ALL the way up to find the HIGHEST ancestor with a store
59
+ const visited = new Set();
60
+ let current = semanticKey;
61
+ let highestAncestorWithStore = null;
62
+ while (current && !visited.has(current)) {
63
+ visited.add(current);
64
+ // Check if current node has a store
65
+ if (this.hasStoreFn(current)) {
66
+ highestAncestorWithStore = current;
67
+ }
68
+ // Move to parent
69
+ const parents = this.graph.successors(current) || [];
70
+ if (parents.length > 0) {
71
+ current = parents[0]; // Follow the parent chain
72
+ }
73
+ else {
74
+ break; // No more parents
75
+ }
76
+ }
77
+ if (highestAncestorWithStore) {
78
+ if (highestAncestorWithStore === semanticKey) {
79
+ // This queryset itself is the highest with a store
80
+ return { isRoot: true, root: semanticKey };
81
+ }
82
+ else {
83
+ // Found a higher ancestor with a store
84
+ return { isRoot: false, root: highestAncestorWithStore };
85
+ }
86
+ }
87
+ // No stores found anywhere in the chain
88
+ return { isRoot: true, root: null };
89
+ }
90
+ clear() {
91
+ this.graph = new Graph({ directed: true });
92
+ }
93
+ }
@@ -23,6 +23,7 @@ declare class QuerysetStoreRegistry {
23
23
  _tempStores: WeakMap<object, any>;
24
24
  followingQuerysets: Map<any, any>;
25
25
  syncManager: () => void;
26
+ querysetStoreGraph: QuerysetStoreGraph;
26
27
  clear(): void;
27
28
  setSyncManager(syncManager: any): void;
28
29
  /**
@@ -30,6 +31,13 @@ declare class QuerysetStoreRegistry {
30
31
  */
31
32
  addFollowingQueryset(semanticKey: any, queryset: any): void;
32
33
  getStore(queryset: any, seed?: boolean): any;
34
+ /**
35
+ * Function to return the root store for a queryset
36
+ */
37
+ getRootStore(queryset: any): {
38
+ isRoot: boolean;
39
+ rootStore: any;
40
+ };
33
41
  /**
34
42
  * Get the current state of the queryset, wrapped in a LiveQueryset
35
43
  * @param {Object} queryset - The queryset
@@ -52,4 +60,5 @@ declare class QuerysetStoreRegistry {
52
60
  */
53
61
  getAllStoresForModel(ModelClass: any): any[];
54
62
  }
63
+ import { QuerysetStoreGraph } from './querysetStoreGraph.js';
55
64
  export {};
@@ -16,6 +16,7 @@ import { wrapReactiveQuerySet } from '../../reactiveAdaptor.js';
16
16
  import { processQuery, getRequiredFields, pickRequiredFields } from '../../filtering/localFiltering.js';
17
17
  import { filter } from '../../filtering/localFiltering.js';
18
18
  import { makeApiCall } from '../../flavours/django/makeApiCall.js';
19
+ import { QuerysetStoreGraph } from './querysetStoreGraph.js';
19
20
  import { isNil, pick } from 'lodash-es';
20
21
  import hash from 'object-hash';
21
22
  import { Operation } from '../stores/operation.js';
@@ -106,6 +107,9 @@ class QuerysetStoreRegistry {
106
107
  this._tempStores = new WeakMap(); // WeakMap<Queryset, Store>
107
108
  this.followingQuerysets = new Map(); // Map<semanticKey, Set<queryset>>
108
109
  this.syncManager = () => { console.warn("SyncManager not set for QuerysetStoreRegistry"); };
110
+ this.querysetStoreGraph = new QuerysetStoreGraph((semanticKey) => {
111
+ return this._stores.has(semanticKey);
112
+ });
109
113
  }
110
114
  clear() {
111
115
  this._stores.forEach((store) => {
@@ -113,6 +117,7 @@ class QuerysetStoreRegistry {
113
117
  });
114
118
  this._stores = new Map();
115
119
  this.followingQuerysets = new Map();
120
+ this.querysetStoreGraph.clear();
116
121
  }
117
122
  setSyncManager(syncManager) {
118
123
  this.syncManager = syncManager;
@@ -130,6 +135,7 @@ class QuerysetStoreRegistry {
130
135
  if (isNil(queryset) || isNil(queryset.ModelClass)) {
131
136
  throw new Error("QuerysetStoreRegistry.getStore requires a valid queryset");
132
137
  }
138
+ this.querysetStoreGraph.addQueryset(queryset);
133
139
  // Check if we already have a temporary store for this exact QuerySet instance
134
140
  if (this._tempStores.has(queryset)) {
135
141
  return this._tempStores.get(queryset);
@@ -156,17 +162,39 @@ class QuerysetStoreRegistry {
156
162
  let ast = queryset.build();
157
163
  let ModelClass = queryset.ModelClass;
158
164
  if (queryset.__parent && seed) {
159
- let parentLiveQuerySet = this.getEntity(queryset.__parent);
160
- initialGroundTruthPks = filter(parentLiveQuerySet, ast, ModelClass, false);
165
+ const parentKey = queryset.__parent.semanticKey;
166
+ if (this._stores.has(parentKey)) {
167
+ let parentLiveQuerySet = this.getEntity(queryset.__parent);
168
+ initialGroundTruthPks = filter(parentLiveQuerySet, ast, ModelClass, false);
169
+ }
161
170
  }
162
171
  // Get the parent registry
163
172
  const store = new QuerysetStore(ModelClass, fetchQueryset, queryset, initialGroundTruthPks, // Initial ground truth PKs
164
- null // Initial operations
165
- );
173
+ null, // Initial operations
174
+ {
175
+ getRootStore: this.getRootStore.bind(this),
176
+ isTemp: true,
177
+ });
166
178
  // Store it in the temp store map
167
179
  this._tempStores.set(queryset, store);
168
180
  return store;
169
181
  }
182
+ /**
183
+ * Function to return the root store for a queryset
184
+ */
185
+ getRootStore(queryset) {
186
+ if (isNil(queryset)) {
187
+ throw new Error("QuerysetStoreRegistry.getRootStore requires a valid queryset");
188
+ }
189
+ const { isRoot, root } = this.querysetStoreGraph.findRoot(queryset);
190
+ const rootStore = this._stores.get(root);
191
+ if (!isRoot && rootStore) {
192
+ return { isRoot: false, rootStore: rootStore };
193
+ }
194
+ else {
195
+ return { isRoot: true, rootStore: null };
196
+ }
197
+ }
170
198
  /**
171
199
  * Get the current state of the queryset, wrapped in a LiveQueryset
172
200
  * @param {Object} queryset - The queryset
@@ -183,12 +211,14 @@ class QuerysetStoreRegistry {
183
211
  // If we have a temporary store, promote it
184
212
  if (this._tempStores.has(queryset)) {
185
213
  store = this._tempStores.get(queryset);
214
+ store.isTemp = false; // Promote to permanent store
186
215
  this._stores.set(semanticKey, store);
187
216
  this.syncManager.followModel(this, queryset.ModelClass);
188
217
  }
189
218
  // Otherwise, ensure we have a permanent store
190
219
  else if (!this._stores.has(semanticKey)) {
191
220
  store = this.getStore(queryset, seed);
221
+ store.isTemp = false;
192
222
  this._stores.set(semanticKey, store);
193
223
  this.syncManager.followModel(this, queryset.ModelClass);
194
224
  }
@@ -219,11 +249,13 @@ class QuerysetStoreRegistry {
219
249
  // If we have a temp store, promote it
220
250
  if (this._tempStores.has(queryset)) {
221
251
  store = this._tempStores.get(queryset);
252
+ store.isTemp = false; // Promote to permanent store
222
253
  this._stores.set(semanticKey, store);
223
254
  }
224
255
  else {
225
256
  // Create a new permanent store
226
257
  store = this.getStore(queryset);
258
+ store.isTemp = false;
227
259
  this._stores.set(semanticKey, store);
228
260
  }
229
261
  }
@@ -6,7 +6,11 @@ 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;
11
15
  get cacheKey(): any;
12
16
  onHydrated(hydratedData: any): void;
@@ -26,6 +30,8 @@ export class QuerysetStore {
26
30
  getInflightOperations(): any[];
27
31
  prune(): void;
28
32
  render(optimistic?: boolean, fromCache?: boolean): any[];
33
+ renderFromRoot(optimistic: boolean | undefined, rootStore: any): any[];
34
+ renderFromData(optimistic?: boolean): any[];
29
35
  applyOperation(operation: any, currentPks: any): any;
30
36
  sync(): Promise<void>;
31
37
  }
@@ -6,13 +6,19 @@ 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,7 @@ 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));
26
32
  }
27
33
  // Caching
28
34
  get cacheKey() {
@@ -40,7 +46,7 @@ export class QuerysetStore {
40
46
  setCache(result) {
41
47
  let nonTempPks = [];
42
48
  result.forEach((pk) => {
43
- if (typeof pk === 'string' && containsTempPk(pk)) {
49
+ if (typeof pk === "string" && containsTempPk(pk)) {
44
50
  pk = replaceTempPks(pk);
45
51
  if (isNil(pk) || isEmpty(trim(pk))) {
46
52
  return;
@@ -93,9 +99,7 @@ export class QuerysetStore {
93
99
  this._emitRenderEvent();
94
100
  }
95
101
  async setGroundTruth(groundTruthPks) {
96
- this.groundTruthPks = Array.isArray(groundTruthPks)
97
- ? groundTruthPks
98
- : [];
102
+ this.groundTruthPks = Array.isArray(groundTruthPks) ? groundTruthPks : [];
99
103
  this._emitRenderEvent();
100
104
  }
101
105
  async setOperations(operations) {
@@ -109,10 +113,10 @@ export class QuerysetStore {
109
113
  }
110
114
  getTrimmedOperations() {
111
115
  const cutoff = Date.now() - 1000 * 60 * 2;
112
- return this.operations.filter(op => op.timestamp > cutoff);
116
+ return this.operations.filter((op) => op.timestamp > cutoff);
113
117
  }
114
118
  getInflightOperations() {
115
- return this.operations.filter(operation => operation.status != Status.CONFIRMED &&
119
+ return this.operations.filter((operation) => operation.status != Status.CONFIRMED &&
116
120
  operation.status != Status.REJECTED);
117
121
  }
118
122
  prune() {
@@ -127,13 +131,16 @@ export class QuerysetStore {
127
131
  return cachedResult;
128
132
  }
129
133
  }
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);
134
+ let result;
135
+ if (this.getRootStore && typeof this.getRootStore === "function" && !this.isTemp) {
136
+ const { isRoot, rootStore } = this.getRootStore(this.queryset);
137
+ if (!isRoot && rootStore) {
138
+ result = this.renderFromRoot(optimistic, rootStore);
134
139
  }
135
140
  }
136
- let result = Array.from(renderedPks);
141
+ if (isNil(result)) {
142
+ result = this.renderFromData(optimistic);
143
+ }
137
144
  let limit = this.queryset.build().serializerOptions?.limit;
138
145
  if (limit) {
139
146
  result = result.slice(0, limit);
@@ -141,12 +148,30 @@ export class QuerysetStore {
141
148
  this.setCache(result);
142
149
  return result;
143
150
  }
151
+ renderFromRoot(optimistic = true, rootStore) {
152
+ let renderedPks = rootStore.render(optimistic);
153
+ let renderedData = renderedPks.map((pk) => {
154
+ return this.modelClass.fromPk(pk, this.queryset);
155
+ });
156
+ let ast = this.queryset.build();
157
+ let result = filter(renderedData, ast, this.modelClass, false);
158
+ return result;
159
+ }
160
+ renderFromData(optimistic = true) {
161
+ const renderedPks = this.groundTruthSet;
162
+ for (const op of this.operations) {
163
+ if (op.status !== Status.REJECTED &&
164
+ (optimistic || op.status === Status.CONFIRMED)) {
165
+ this.applyOperation(op, renderedPks);
166
+ }
167
+ }
168
+ let result = Array.from(renderedPks);
169
+ return result;
170
+ }
144
171
  applyOperation(operation, currentPks) {
145
172
  const pkField = this.pkField;
146
173
  for (const instance of operation.instances) {
147
- if (!instance ||
148
- typeof instance !== 'object' ||
149
- !(pkField in instance)) {
174
+ if (!instance || typeof instance !== "object" || !(pkField in instance)) {
150
175
  console.warn(`[QuerysetStore ${this.modelClass.modelName}] Skipping instance in operation ${operation.operationId} due to missing PK '${String(pkField)}' or invalid format.`);
151
176
  continue;
152
177
  }
@@ -175,12 +200,25 @@ export class QuerysetStore {
175
200
  console.warn(`[QuerysetStore ${id}] Already syncing, request ignored.`);
176
201
  return;
177
202
  }
203
+ // Check if we're delegating to a root store
204
+ if (this.getRootStore && typeof this.getRootStore === "function" && !this.isTemp) {
205
+ const { isRoot, rootStore } = this.getRootStore(this.queryset);
206
+ if (!isRoot && rootStore) {
207
+ // We're delegating to a root store - don't sync, just mark as needing sync
208
+ console.log(`[${id}] Delegating to root store, marking sync needed.`);
209
+ this.needsSync = true;
210
+ this.lastSync = null; // Clear last sync since we're not actually syncing
211
+ this.setOperations(this.getInflightOperations());
212
+ return;
213
+ }
214
+ }
215
+ // We're in independent mode - proceed with normal sync
178
216
  this.isSyncing = true;
179
217
  console.log(`[${id}] Starting sync...`);
180
218
  try {
181
219
  const response = await this.fetchFn({
182
220
  ast: this.queryset.build(),
183
- modelClass: this.modelClass
221
+ modelClass: this.modelClass,
184
222
  });
185
223
  const { data, included } = response;
186
224
  console.log(`[${id}] Sync fetch completed. Received: ${JSON.stringify(data)}.`);
@@ -188,10 +226,13 @@ export class QuerysetStore {
188
226
  processIncludedEntities(modelStoreRegistry, included, this.modelClass);
189
227
  this.setGroundTruth(data);
190
228
  this.setOperations(this.getInflightOperations());
229
+ this.lastSync = Date.now();
230
+ this.needsSync = false;
191
231
  console.log(`[${id}] Sync completed.`);
192
232
  }
193
233
  catch (e) {
194
234
  console.error(`[${id}] Failed to sync ground truth:`, e);
235
+ this.needsSync = true; // Mark as needing sync on error
195
236
  }
196
237
  finally {
197
238
  this.isSyncing = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statezero/core",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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",