@statezero/core 0.1.2 → 0.1.3-9.2
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.
- package/LICENSE +3 -2
- package/dist/cli/commands/sync.d.ts +6 -0
- package/dist/cli/commands/sync.js +30 -0
- package/dist/cli/commands/syncActions.d.ts +46 -0
- package/dist/cli/commands/syncActions.js +623 -0
- package/dist/cli/commands/syncModels.js +17 -35
- package/dist/cli/index.js +18 -10
- package/dist/config.d.ts +5 -0
- package/dist/config.js +40 -10
- package/dist/flavours/django/dates.js +3 -3
- package/dist/flavours/django/files.d.ts +8 -7
- package/dist/flavours/django/files.js +36 -2
- package/dist/flavours/django/model.d.ts +15 -0
- package/dist/flavours/django/model.js +143 -24
- package/dist/setup.js +11 -0
- package/dist/syncEngine/registries/metricRegistry.d.ts +5 -0
- package/dist/syncEngine/registries/metricRegistry.js +8 -0
- package/dist/syncEngine/registries/querysetStoreGraph.d.ts +21 -0
- package/dist/syncEngine/registries/querysetStoreGraph.js +95 -0
- package/dist/syncEngine/registries/querysetStoreRegistry.d.ts +14 -0
- package/dist/syncEngine/registries/querysetStoreRegistry.js +64 -16
- package/dist/syncEngine/stores/modelStore.d.ts +3 -0
- package/dist/syncEngine/stores/modelStore.js +76 -41
- package/dist/syncEngine/stores/querysetStore.d.ts +19 -0
- package/dist/syncEngine/stores/querysetStore.js +133 -18
- package/dist/syncEngine/sync.d.ts +5 -0
- package/dist/syncEngine/sync.js +61 -5
- package/package.json +126 -123
- package/readme.md +1 -1
|
@@ -1,12 +1,30 @@
|
|
|
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 = (
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
store.renderCallbacks.forEach((callback) => {
|
|
20
|
+
try {
|
|
21
|
+
callback();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.warn("Error in model store render callback:", error);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
10
28
|
});
|
|
11
29
|
};
|
|
12
30
|
class EventData {
|
|
@@ -61,14 +79,20 @@ export class ModelStore {
|
|
|
61
79
|
if (initialOperations && initialOperations.length > 0) {
|
|
62
80
|
this._loadOperations(initialOperations);
|
|
63
81
|
}
|
|
64
|
-
this.modelCache = new Cache(
|
|
82
|
+
this.modelCache = new Cache("model-cache", {}, this.onHydrated.bind(this));
|
|
83
|
+
this._lastRenderedData = new Map();
|
|
84
|
+
this.renderCallbacks = new Set();
|
|
85
|
+
}
|
|
86
|
+
registerRenderCallback(callback) {
|
|
87
|
+
this.renderCallbacks.add(callback);
|
|
88
|
+
return () => this.renderCallbacks.delete(callback);
|
|
65
89
|
}
|
|
66
90
|
/**
|
|
67
91
|
* Load operations from data and add them to the operations map,
|
|
68
92
|
* reusing existing operations from the registry if they exist
|
|
69
93
|
*/
|
|
70
94
|
_loadOperations(operationsData) {
|
|
71
|
-
operationsData.forEach(opData => {
|
|
95
|
+
operationsData.forEach((opData) => {
|
|
72
96
|
const existingOp = operationRegistry.get(opData.operationId);
|
|
73
97
|
if (existingOp) {
|
|
74
98
|
// If the operation exists in the registry, use it
|
|
@@ -98,7 +122,7 @@ export class ModelStore {
|
|
|
98
122
|
let nonTempPkItems = [];
|
|
99
123
|
result.forEach((item) => {
|
|
100
124
|
let pk = item[pkField];
|
|
101
|
-
if (typeof pk ===
|
|
125
|
+
if (typeof pk === "string" && containsTempPk(pk)) {
|
|
102
126
|
pk = replaceTempPks(item[pkField]);
|
|
103
127
|
if (isNil(pk) || isEmpty(trim(pk))) {
|
|
104
128
|
return;
|
|
@@ -117,7 +141,7 @@ export class ModelStore {
|
|
|
117
141
|
let nonTempPkItems = [];
|
|
118
142
|
items.forEach((item) => {
|
|
119
143
|
let pk = item[pkField];
|
|
120
|
-
if (typeof pk ===
|
|
144
|
+
if (typeof pk === "string" && containsTempPk(pk)) {
|
|
121
145
|
pk = replaceTempPks(item[pkField]);
|
|
122
146
|
if (isNil(pk) || isEmpty(trim(pk))) {
|
|
123
147
|
return;
|
|
@@ -134,16 +158,16 @@ export class ModelStore {
|
|
|
134
158
|
// Otherwise, we're rendering specific items - update only those items
|
|
135
159
|
const currentCache = this.modelCache.get(this.cacheKey) || [];
|
|
136
160
|
// Filter out items that were requested but not in the result (they were deleted)
|
|
137
|
-
const filteredCache = currentCache.filter(item => item &&
|
|
138
|
-
typeof item ===
|
|
161
|
+
const filteredCache = currentCache.filter((item) => item &&
|
|
162
|
+
typeof item === "object" &&
|
|
139
163
|
pkField in item &&
|
|
140
164
|
(!requestedPks.has(item[pkField]) ||
|
|
141
|
-
nonTempPkItems.some(newItem => newItem[pkField] === item[pkField])));
|
|
165
|
+
nonTempPkItems.some((newItem) => newItem[pkField] === item[pkField])));
|
|
142
166
|
// Create a map for faster lookups
|
|
143
|
-
const cacheMap = new Map(filteredCache.map(item => [item[pkField], item]));
|
|
167
|
+
const cacheMap = new Map(filteredCache.map((item) => [item[pkField], item]));
|
|
144
168
|
// Add or update items from the result
|
|
145
169
|
for (const item of nonTempPkItems) {
|
|
146
|
-
if (item && typeof item ===
|
|
170
|
+
if (item && typeof item === "object" && pkField in item) {
|
|
147
171
|
cacheMap.set(item[pkField], item);
|
|
148
172
|
}
|
|
149
173
|
}
|
|
@@ -164,35 +188,35 @@ export class ModelStore {
|
|
|
164
188
|
if (this.operationsMap.size > this.pruneThreshold) {
|
|
165
189
|
this.prune();
|
|
166
190
|
}
|
|
167
|
-
emitEvents(this
|
|
191
|
+
emitEvents(this, EventData.fromOperation(operation));
|
|
168
192
|
}
|
|
169
193
|
updateOperation(operation) {
|
|
170
194
|
if (!this.operationsMap.has(operation.operationId))
|
|
171
195
|
return false;
|
|
172
196
|
this.operationsMap.set(operation.operationId, operation);
|
|
173
|
-
emitEvents(this
|
|
197
|
+
emitEvents(this, EventData.fromOperation(operation));
|
|
174
198
|
return true;
|
|
175
199
|
}
|
|
176
200
|
confirm(operation) {
|
|
177
201
|
if (!this.operationsMap.has(operation.operationId))
|
|
178
202
|
return;
|
|
179
203
|
this.operationsMap.set(operation.operationId, operation);
|
|
180
|
-
emitEvents(this
|
|
204
|
+
emitEvents(this, EventData.fromOperation(operation));
|
|
181
205
|
}
|
|
182
206
|
reject(operation) {
|
|
183
207
|
if (!this.operationsMap.has(operation.operationId))
|
|
184
208
|
return;
|
|
185
209
|
this.operationsMap.set(operation.operationId, operation);
|
|
186
|
-
emitEvents(this
|
|
210
|
+
emitEvents(this, EventData.fromOperation(operation));
|
|
187
211
|
}
|
|
188
212
|
setOperations(operations = []) {
|
|
189
213
|
const prevOps = this.operations;
|
|
190
214
|
this.operationsMap.clear();
|
|
191
|
-
operations.forEach(op => {
|
|
215
|
+
operations.forEach((op) => {
|
|
192
216
|
this.operationsMap.set(op.operationId, op);
|
|
193
217
|
});
|
|
194
218
|
const allOps = [...prevOps, ...this.operations];
|
|
195
|
-
emitEvents(this
|
|
219
|
+
emitEvents(this, EventData.fromOperations(allOps));
|
|
196
220
|
}
|
|
197
221
|
// Ground truth data methods
|
|
198
222
|
setGroundTruth(groundTruth) {
|
|
@@ -200,7 +224,7 @@ export class ModelStore {
|
|
|
200
224
|
this.groundTruthArray = Array.isArray(groundTruth) ? groundTruth : [];
|
|
201
225
|
// reactivity - gather all ops
|
|
202
226
|
const allOps = [...prevGroundTruth, ...this.groundTruthArray];
|
|
203
|
-
emitEvents(this
|
|
227
|
+
emitEvents(this, EventData.fromInstances(allOps, this.modelClass));
|
|
204
228
|
}
|
|
205
229
|
getGroundTruth() {
|
|
206
230
|
return this.groundTruthArray;
|
|
@@ -208,16 +232,16 @@ export class ModelStore {
|
|
|
208
232
|
get groundTruthPks() {
|
|
209
233
|
const pk = this.pkField;
|
|
210
234
|
return this.groundTruthArray
|
|
211
|
-
.filter(instance => instance && typeof instance ===
|
|
212
|
-
.map(instance => instance[pk]);
|
|
235
|
+
.filter((instance) => instance && typeof instance === "object" && pk in instance)
|
|
236
|
+
.map((instance) => instance[pk]);
|
|
213
237
|
}
|
|
214
238
|
addToGroundTruth(instances) {
|
|
215
239
|
if (!Array.isArray(instances) || instances.length === 0)
|
|
216
240
|
return;
|
|
217
241
|
const pkField = this.pkField;
|
|
218
242
|
const pkMap = new Map();
|
|
219
|
-
instances.forEach(inst => {
|
|
220
|
-
if (inst && typeof inst ===
|
|
243
|
+
instances.forEach((inst) => {
|
|
244
|
+
if (inst && typeof inst === "object" && pkField in inst) {
|
|
221
245
|
pkMap.set(inst[pkField], inst);
|
|
222
246
|
}
|
|
223
247
|
else {
|
|
@@ -230,7 +254,9 @@ export class ModelStore {
|
|
|
230
254
|
const processedPks = new Set();
|
|
231
255
|
const checkpointInstances = []; // Track instances that need CHECKPOINT operations
|
|
232
256
|
for (const existingItem of this.groundTruthArray) {
|
|
233
|
-
if (!existingItem ||
|
|
257
|
+
if (!existingItem ||
|
|
258
|
+
typeof existingItem !== "object" ||
|
|
259
|
+
!(pkField in existingItem)) {
|
|
234
260
|
continue;
|
|
235
261
|
}
|
|
236
262
|
const pk = existingItem[pkField];
|
|
@@ -252,18 +278,20 @@ export class ModelStore {
|
|
|
252
278
|
// Create CHECKPOINT operation for instances that already existed
|
|
253
279
|
if (checkpointInstances.length > 0) {
|
|
254
280
|
const checkpointOperation = new Operation({
|
|
255
|
-
operationId: `checkpoint_${Date.now()}_${Math.random()
|
|
281
|
+
operationId: `checkpoint_${Date.now()}_${Math.random()
|
|
282
|
+
.toString(36)
|
|
283
|
+
.substr(2, 9)}`,
|
|
256
284
|
type: Type.CHECKPOINT,
|
|
257
285
|
instances: checkpointInstances,
|
|
258
286
|
status: Status.CONFIRMED,
|
|
259
287
|
timestamp: Date.now(),
|
|
260
|
-
queryset: this.modelClass.objects.all()
|
|
288
|
+
queryset: this.modelClass.objects.all(),
|
|
261
289
|
});
|
|
262
290
|
this.operationsMap.set(checkpointOperation.operationId, checkpointOperation);
|
|
263
291
|
console.log(`[ModelStore ${this.modelClass.modelName}] Created CHECKPOINT operation for ${checkpointInstances.length} existing instances`);
|
|
264
292
|
}
|
|
265
293
|
// reactivity - use all the newly added instances (both new and updated)
|
|
266
|
-
emitEvents(this
|
|
294
|
+
emitEvents(this, EventData.fromInstances([...checkpointInstances, ...Array.from(pkMap.values())], this.modelClass));
|
|
267
295
|
}
|
|
268
296
|
_filteredOperations(pks, operations) {
|
|
269
297
|
if (!pks)
|
|
@@ -271,7 +299,7 @@ export class ModelStore {
|
|
|
271
299
|
const pkField = this.pkField;
|
|
272
300
|
let filteredOps = [];
|
|
273
301
|
for (const op of operations) {
|
|
274
|
-
let relevantInstances = op.instances.filter(instance => pks.has(instance[pkField] || instance));
|
|
302
|
+
let relevantInstances = op.instances.filter((instance) => pks.has(instance[pkField] || instance));
|
|
275
303
|
if (relevantInstances.length > 0) {
|
|
276
304
|
filteredOps.push({
|
|
277
305
|
operationId: op.operationId,
|
|
@@ -280,7 +308,7 @@ export class ModelStore {
|
|
|
280
308
|
queryset: op.queryset,
|
|
281
309
|
type: op.type,
|
|
282
310
|
status: op.status,
|
|
283
|
-
args: op.args
|
|
311
|
+
args: op.args,
|
|
284
312
|
});
|
|
285
313
|
}
|
|
286
314
|
}
|
|
@@ -290,7 +318,7 @@ export class ModelStore {
|
|
|
290
318
|
const pkField = this.pkField;
|
|
291
319
|
let groundTruthMap = new Map();
|
|
292
320
|
for (const instance of groundTruthArray) {
|
|
293
|
-
if (!instance || typeof instance !==
|
|
321
|
+
if (!instance || typeof instance !== "object" || !(pkField in instance)) {
|
|
294
322
|
continue;
|
|
295
323
|
}
|
|
296
324
|
const pk = instance[pkField];
|
|
@@ -303,7 +331,7 @@ export class ModelStore {
|
|
|
303
331
|
applyOperation(operation, currentInstances) {
|
|
304
332
|
const pkField = this.pkField;
|
|
305
333
|
for (const instance of operation.instances) {
|
|
306
|
-
if (!instance || typeof instance !==
|
|
334
|
+
if (!instance || typeof instance !== "object" || !(pkField in instance)) {
|
|
307
335
|
console.warn(`[ModelStore ${this.modelClass.modelName}] Skipping instance ${instance} in operation ${operation.operationId} during applyOperation due to missing PK field '${String(pkField)}' or invalid format.`);
|
|
308
336
|
continue;
|
|
309
337
|
}
|
|
@@ -322,9 +350,9 @@ export class ModelStore {
|
|
|
322
350
|
currentInstances.set(pk, { ...existing, ...instance });
|
|
323
351
|
}
|
|
324
352
|
else {
|
|
325
|
-
const wasDeletedLocally = this.operations.some(op => op.type === Type.DELETE &&
|
|
353
|
+
const wasDeletedLocally = this.operations.some((op) => op.type === Type.DELETE &&
|
|
326
354
|
op.status !== Status.REJECTED &&
|
|
327
|
-
op.instances.some(inst => inst && inst[pkField] === pk));
|
|
355
|
+
op.instances.some((inst) => inst && inst[pkField] === pk));
|
|
328
356
|
if (!wasDeletedLocally) {
|
|
329
357
|
currentInstances.set(pk, instance);
|
|
330
358
|
}
|
|
@@ -343,10 +371,10 @@ export class ModelStore {
|
|
|
343
371
|
}
|
|
344
372
|
getTrimmedOperations() {
|
|
345
373
|
const twoMinutesAgo = Date.now() - 1000 * 60 * 2;
|
|
346
|
-
return this.operations.filter(operation => operation.timestamp > twoMinutesAgo);
|
|
374
|
+
return this.operations.filter((operation) => operation.timestamp > twoMinutesAgo);
|
|
347
375
|
}
|
|
348
376
|
getInflightOperations() {
|
|
349
|
-
return this.operations.filter(operation => operation.status != Status.CONFIRMED &&
|
|
377
|
+
return this.operations.filter((operation) => operation.status != Status.CONFIRMED &&
|
|
350
378
|
operation.status != Status.REJECTED);
|
|
351
379
|
}
|
|
352
380
|
// Pruning
|
|
@@ -358,12 +386,16 @@ export class ModelStore {
|
|
|
358
386
|
}
|
|
359
387
|
// Render methods
|
|
360
388
|
render(pks = null, optimistic = true) {
|
|
361
|
-
const pksSet = pks === null
|
|
362
|
-
|
|
389
|
+
const pksSet = pks === null
|
|
390
|
+
? null
|
|
391
|
+
: pks instanceof Set
|
|
392
|
+
? pks
|
|
393
|
+
: new Set(Array.isArray(pks) ? pks : [pks]);
|
|
363
394
|
const renderedInstancesMap = this._filteredGroundTruth(pksSet, this.groundTruthArray);
|
|
364
395
|
const relevantOperations = this._filteredOperations(pksSet, this.operations);
|
|
365
396
|
for (const op of relevantOperations) {
|
|
366
|
-
if (op.status !== Status.REJECTED &&
|
|
397
|
+
if (op.status !== Status.REJECTED &&
|
|
398
|
+
(optimistic || op.status === Status.CONFIRMED)) {
|
|
367
399
|
this.applyOperation(op, renderedInstancesMap);
|
|
368
400
|
}
|
|
369
401
|
}
|
|
@@ -386,7 +418,10 @@ export class ModelStore {
|
|
|
386
418
|
this.setOperations(trimmedOps);
|
|
387
419
|
return;
|
|
388
420
|
}
|
|
389
|
-
const newGroundTruth = await this.fetchFn({
|
|
421
|
+
const newGroundTruth = await this.fetchFn({
|
|
422
|
+
pks: currentPks,
|
|
423
|
+
modelClass: this.modelClass,
|
|
424
|
+
});
|
|
390
425
|
if (pks) {
|
|
391
426
|
this.addToGroundTruth(newGroundTruth);
|
|
392
427
|
return;
|
|
@@ -6,8 +6,17 @@ 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;
|
|
19
|
+
_modelStoreUnregister: any;
|
|
11
20
|
get cacheKey(): any;
|
|
12
21
|
onHydrated(hydratedData: any): void;
|
|
13
22
|
setCache(result: any): void;
|
|
@@ -25,7 +34,17 @@ export class QuerysetStore {
|
|
|
25
34
|
getTrimmedOperations(): any[];
|
|
26
35
|
getInflightOperations(): any[];
|
|
27
36
|
prune(): void;
|
|
37
|
+
registerRenderCallback(callback: any): () => boolean;
|
|
38
|
+
_ensureRootRegistration(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Helper to validate PKs against the model store and apply local filtering/sorting.
|
|
41
|
+
* This is the core of the rendering logic.
|
|
42
|
+
* @private
|
|
43
|
+
*/
|
|
44
|
+
private _getValidatedAndFilteredPks;
|
|
28
45
|
render(optimistic?: boolean, fromCache?: boolean): any[];
|
|
46
|
+
renderFromRoot(optimistic: boolean | undefined, rootStore: any): any[];
|
|
47
|
+
renderFromData(optimistic?: boolean): any[];
|
|
29
48
|
applyOperation(operation: any, currentPks: any): any;
|
|
30
49
|
sync(): Promise<void>;
|
|
31
50
|
}
|
|
@@ -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,16 @@ export class QuerysetStore {
|
|
|
22
28
|
this.operationsMap.set(op.operationId, op);
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
|
-
this.qsCache = new Cache(
|
|
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();
|
|
37
|
+
const modelStore = modelStoreRegistry.getStore(this.modelClass);
|
|
38
|
+
this._modelStoreUnregister = modelStore.registerRenderCallback(() => {
|
|
39
|
+
this._emitRenderEvent();
|
|
40
|
+
});
|
|
26
41
|
}
|
|
27
42
|
// Caching
|
|
28
43
|
get cacheKey() {
|
|
@@ -40,7 +55,7 @@ export class QuerysetStore {
|
|
|
40
55
|
setCache(result) {
|
|
41
56
|
let nonTempPks = [];
|
|
42
57
|
result.forEach((pk) => {
|
|
43
|
-
if (typeof pk ===
|
|
58
|
+
if (typeof pk === "string" && containsTempPk(pk)) {
|
|
44
59
|
pk = replaceTempPks(pk);
|
|
45
60
|
if (isNil(pk) || isEmpty(trim(pk))) {
|
|
46
61
|
return;
|
|
@@ -64,7 +79,22 @@ export class QuerysetStore {
|
|
|
64
79
|
return new Set(this.groundTruthPks);
|
|
65
80
|
}
|
|
66
81
|
_emitRenderEvent() {
|
|
67
|
-
|
|
82
|
+
const newPks = this.render(true, false);
|
|
83
|
+
// 1. Always notify direct child stores to trigger their own re-evaluation.
|
|
84
|
+
// They will perform their own check to see if their own results have changed.
|
|
85
|
+
this.renderCallbacks.forEach((callback) => {
|
|
86
|
+
try {
|
|
87
|
+
callback();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.warn("Error in render callback:", error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
// 2. Only emit the global event for UI components if the final list of PKs has actually changed.
|
|
94
|
+
if (!isEqual(newPks, this._lastRenderedPks)) {
|
|
95
|
+
this._lastRenderedPks = newPks; // Update the cache with the new state
|
|
96
|
+
querysetEventEmitter.emit(`${this.modelClass.configKey}::${this.modelClass.modelName}::queryset::render`, { ast: this.queryset.build(), ModelClass: this.modelClass });
|
|
97
|
+
}
|
|
68
98
|
}
|
|
69
99
|
async addOperation(operation) {
|
|
70
100
|
this.operationsMap.set(operation.operationId, operation);
|
|
@@ -93,9 +123,7 @@ export class QuerysetStore {
|
|
|
93
123
|
this._emitRenderEvent();
|
|
94
124
|
}
|
|
95
125
|
async setGroundTruth(groundTruthPks) {
|
|
96
|
-
this.groundTruthPks = Array.isArray(groundTruthPks)
|
|
97
|
-
? groundTruthPks
|
|
98
|
-
: [];
|
|
126
|
+
this.groundTruthPks = Array.isArray(groundTruthPks) ? groundTruthPks : [];
|
|
99
127
|
this._emitRenderEvent();
|
|
100
128
|
}
|
|
101
129
|
async setOperations(operations) {
|
|
@@ -109,10 +137,10 @@ export class QuerysetStore {
|
|
|
109
137
|
}
|
|
110
138
|
getTrimmedOperations() {
|
|
111
139
|
const cutoff = Date.now() - 1000 * 60 * 2;
|
|
112
|
-
return this.operations.filter(op => op.timestamp > cutoff);
|
|
140
|
+
return this.operations.filter((op) => op.timestamp > cutoff);
|
|
113
141
|
}
|
|
114
142
|
getInflightOperations() {
|
|
115
|
-
return this.operations.filter(operation => operation.status != Status.CONFIRMED &&
|
|
143
|
+
return this.operations.filter((operation) => operation.status != Status.CONFIRMED &&
|
|
116
144
|
operation.status != Status.REJECTED);
|
|
117
145
|
}
|
|
118
146
|
prune() {
|
|
@@ -120,20 +148,68 @@ export class QuerysetStore {
|
|
|
120
148
|
this.setGroundTruth(renderedPks);
|
|
121
149
|
this.setOperations(this.getInflightOperations());
|
|
122
150
|
}
|
|
151
|
+
registerRenderCallback(callback) {
|
|
152
|
+
this.renderCallbacks.add(callback);
|
|
153
|
+
return () => this.renderCallbacks.delete(callback);
|
|
154
|
+
}
|
|
155
|
+
_ensureRootRegistration() {
|
|
156
|
+
if (this.isTemp)
|
|
157
|
+
return;
|
|
158
|
+
const { isRoot, rootStore } = this.getRootStore(this.queryset);
|
|
159
|
+
// If the root store hasn't changed, nothing to do
|
|
160
|
+
if (this._currentRootStore === rootStore) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Root store changed - clean up old registration if it exists
|
|
164
|
+
if (this._rootUnregister) {
|
|
165
|
+
this._rootUnregister();
|
|
166
|
+
this._rootUnregister = null;
|
|
167
|
+
}
|
|
168
|
+
// Set up new registration if we're derived and have a root store
|
|
169
|
+
if (!isRoot && rootStore) {
|
|
170
|
+
this._rootUnregister = rootStore.registerRenderCallback(() => {
|
|
171
|
+
this._emitRenderEvent();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// Update current root store reference (could be null now)
|
|
175
|
+
this._currentRootStore = rootStore;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Helper to validate PKs against the model store and apply local filtering/sorting.
|
|
179
|
+
* This is the core of the rendering logic.
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
_getValidatedAndFilteredPks(pks) {
|
|
183
|
+
// 1. Convert PKs to instances, filtering out any that are null (deleted).
|
|
184
|
+
const instances = Array.from(pks)
|
|
185
|
+
.map((pk) => this.modelClass.fromPk(pk, this.queryset))
|
|
186
|
+
.filter((instance) => modelStoreRegistry.getEntity(this.modelClass, instance.pk) !== null);
|
|
187
|
+
// 2. Apply the queryset's AST (filters, ordering) to the validated instances.
|
|
188
|
+
const ast = this.queryset.build();
|
|
189
|
+
const finalPks = filter(instances, ast, this.modelClass, false); // false = return PKs
|
|
190
|
+
return finalPks;
|
|
191
|
+
}
|
|
123
192
|
render(optimistic = true, fromCache = false) {
|
|
193
|
+
this._ensureRootRegistration();
|
|
124
194
|
if (fromCache) {
|
|
125
195
|
const cachedResult = this.qsCache.get(this.cacheKey);
|
|
126
196
|
if (Array.isArray(cachedResult)) {
|
|
127
197
|
return cachedResult;
|
|
128
198
|
}
|
|
129
199
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
200
|
+
let pks;
|
|
201
|
+
if (this.getRootStore &&
|
|
202
|
+
typeof this.getRootStore === "function" &&
|
|
203
|
+
!this.isTemp) {
|
|
204
|
+
const { isRoot, rootStore } = this.getRootStore(this.queryset);
|
|
205
|
+
if (!isRoot && rootStore) {
|
|
206
|
+
pks = this.renderFromRoot(optimistic, rootStore);
|
|
134
207
|
}
|
|
135
208
|
}
|
|
136
|
-
|
|
209
|
+
if (isNil(pks)) {
|
|
210
|
+
pks = this.renderFromData(optimistic);
|
|
211
|
+
}
|
|
212
|
+
let result = this._getValidatedAndFilteredPks(pks);
|
|
137
213
|
let limit = this.queryset.build().serializerOptions?.limit;
|
|
138
214
|
if (limit) {
|
|
139
215
|
result = result.slice(0, limit);
|
|
@@ -141,12 +217,30 @@ export class QuerysetStore {
|
|
|
141
217
|
this.setCache(result);
|
|
142
218
|
return result;
|
|
143
219
|
}
|
|
220
|
+
renderFromRoot(optimistic = true, rootStore) {
|
|
221
|
+
let renderedPks = rootStore.render(optimistic);
|
|
222
|
+
let renderedData = renderedPks.map((pk) => {
|
|
223
|
+
return this.modelClass.fromPk(pk, this.queryset);
|
|
224
|
+
});
|
|
225
|
+
let ast = this.queryset.build();
|
|
226
|
+
let result = filter(renderedData, ast, this.modelClass, false);
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
renderFromData(optimistic = true) {
|
|
230
|
+
const renderedPks = this.groundTruthSet;
|
|
231
|
+
for (const op of this.operations) {
|
|
232
|
+
if (op.status !== Status.REJECTED &&
|
|
233
|
+
(optimistic || op.status === Status.CONFIRMED)) {
|
|
234
|
+
this.applyOperation(op, renderedPks);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let result = Array.from(renderedPks);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
144
240
|
applyOperation(operation, currentPks) {
|
|
145
241
|
const pkField = this.pkField;
|
|
146
242
|
for (const instance of operation.instances) {
|
|
147
|
-
if (!instance ||
|
|
148
|
-
typeof instance !== 'object' ||
|
|
149
|
-
!(pkField in instance)) {
|
|
243
|
+
if (!instance || typeof instance !== "object" || !(pkField in instance)) {
|
|
150
244
|
console.warn(`[QuerysetStore ${this.modelClass.modelName}] Skipping instance in operation ${operation.operationId} due to missing PK '${String(pkField)}' or invalid format.`);
|
|
151
245
|
continue;
|
|
152
246
|
}
|
|
@@ -175,23 +269,44 @@ export class QuerysetStore {
|
|
|
175
269
|
console.warn(`[QuerysetStore ${id}] Already syncing, request ignored.`);
|
|
176
270
|
return;
|
|
177
271
|
}
|
|
272
|
+
// Check if we're delegating to a root store
|
|
273
|
+
if (this.getRootStore &&
|
|
274
|
+
typeof this.getRootStore === "function" &&
|
|
275
|
+
!this.isTemp) {
|
|
276
|
+
const { isRoot, rootStore } = this.getRootStore(this.queryset);
|
|
277
|
+
if (!isRoot && rootStore) {
|
|
278
|
+
// We're delegating to a root store - don't sync, just mark as needing sync
|
|
279
|
+
console.log(`[${id}] Delegating to root store, marking sync needed.`);
|
|
280
|
+
this.needsSync = true;
|
|
281
|
+
this.lastSync = null; // Clear last sync since we're not actually syncing
|
|
282
|
+
this.setOperations(this.getInflightOperations());
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// We're in independent mode - proceed with normal sync
|
|
178
287
|
this.isSyncing = true;
|
|
179
288
|
console.log(`[${id}] Starting sync...`);
|
|
180
289
|
try {
|
|
181
290
|
const response = await this.fetchFn({
|
|
182
291
|
ast: this.queryset.build(),
|
|
183
|
-
modelClass: this.modelClass
|
|
292
|
+
modelClass: this.modelClass,
|
|
184
293
|
});
|
|
185
294
|
const { data, included } = response;
|
|
295
|
+
if (isNil(data)) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
186
298
|
console.log(`[${id}] Sync fetch completed. Received: ${JSON.stringify(data)}.`);
|
|
187
299
|
// Persists all the instances (including nested instances) to the model store
|
|
188
300
|
processIncludedEntities(modelStoreRegistry, included, this.modelClass);
|
|
189
301
|
this.setGroundTruth(data);
|
|
190
302
|
this.setOperations(this.getInflightOperations());
|
|
303
|
+
this.lastSync = Date.now();
|
|
304
|
+
this.needsSync = false;
|
|
191
305
|
console.log(`[${id}] Sync completed.`);
|
|
192
306
|
}
|
|
193
307
|
catch (e) {
|
|
194
308
|
console.error(`[${id}] Failed to sync ground truth:`, e);
|
|
309
|
+
this.needsSync = true; // Mark as needing sync on error
|
|
195
310
|
}
|
|
196
311
|
finally {
|
|
197
312
|
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;
|