@statezero/core 0.2.10 → 0.2.12
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/dist/adaptors/vue/reactivity.d.ts +0 -26
- package/dist/adaptors/vue/reactivity.js +10 -152
- package/dist/filtering/localFiltering.js +5 -4
- package/dist/flavours/django/makeApiCall.js +3 -4
- package/dist/flavours/django/serializers.js +24 -1
- package/dist/flavours/django/tempPk.d.ts +12 -0
- package/dist/flavours/django/tempPk.js +43 -0
- package/dist/syncEngine/registries/modelStoreRegistry.d.ts +1 -0
- package/dist/syncEngine/registries/modelStoreRegistry.js +8 -1
- package/dist/syncEngine/stores/modelStore.d.ts +4 -1
- package/dist/syncEngine/stores/modelStore.js +108 -33
- package/dist/syncEngine/stores/operation.js +6 -1
- package/package.json +1 -1
|
@@ -16,29 +16,3 @@ export function ModelAdaptor(modelInstance: Object, reactivityFn?: Function): an
|
|
|
16
16
|
*/
|
|
17
17
|
export function QuerySetAdaptor(liveQuerySet: Object, reactivityFn?: Function): any | import("vue").Ref;
|
|
18
18
|
export function MetricAdaptor(metric: any): any;
|
|
19
|
-
export const modelEventBatcher: ModelEventBatcher;
|
|
20
|
-
export const BATCH_THRESHOLD: 50;
|
|
21
|
-
/**
|
|
22
|
-
* Batches model events and decides whether to use fine-grained (touch) or
|
|
23
|
-
* coarse-grained (queryset refresh) reactivity based on batch size.
|
|
24
|
-
*/
|
|
25
|
-
declare class ModelEventBatcher {
|
|
26
|
-
pendingTouches: Map<any, any>;
|
|
27
|
-
debounceTimers: Map<any, any>;
|
|
28
|
-
maxWaitTimers: Map<any, any>;
|
|
29
|
-
processQueue: PQueue<import("p-queue/dist/priority-queue.js").default, import("p-queue").QueueAddOptions>;
|
|
30
|
-
/**
|
|
31
|
-
* Queue a touch call for batching
|
|
32
|
-
*/
|
|
33
|
-
queueTouch(configKey: any, modelName: any, pk: any, wrapper: any): void;
|
|
34
|
-
_resetDebounce(modelKey: any): void;
|
|
35
|
-
_ensureMaxWait(modelKey: any): void;
|
|
36
|
-
_clearTimers(modelKey: any): void;
|
|
37
|
-
_flush(modelKey: any): void;
|
|
38
|
-
_processBatch(modelKey: any, touchBatch: any): void;
|
|
39
|
-
_touchModels(touchBatch: any): void;
|
|
40
|
-
_refreshQuerysets(modelKey: any): void;
|
|
41
|
-
clear(): void;
|
|
42
|
-
}
|
|
43
|
-
import PQueue from "p-queue";
|
|
44
|
-
export {};
|
|
@@ -4,154 +4,9 @@ import { initEventHandler } from "../../syncEngine/stores/operationEventHandlers
|
|
|
4
4
|
import { isEqual, isNil } from 'lodash-es';
|
|
5
5
|
import hash from 'object-hash';
|
|
6
6
|
import { registerAdapterReset } from "../../reset.js";
|
|
7
|
-
import PQueue from "p-queue";
|
|
8
7
|
initEventHandler();
|
|
9
8
|
const wrappedQuerysetCache = new Map();
|
|
10
9
|
const wrappedMetricCache = new Map();
|
|
11
|
-
// =============================================================================
|
|
12
|
-
// Model Event Batcher - Batches rapid model events to prevent watcher overload
|
|
13
|
-
// =============================================================================
|
|
14
|
-
const BATCH_THRESHOLD = 50; // Above this, use queryset refresh instead of individual touch
|
|
15
|
-
const DEBOUNCE_MS = 16; // ~1 frame, gather rapid events
|
|
16
|
-
const MAX_WAIT_MS = 100; // Don't wait longer than this to flush
|
|
17
|
-
/**
|
|
18
|
-
* Batches model events and decides whether to use fine-grained (touch) or
|
|
19
|
-
* coarse-grained (queryset refresh) reactivity based on batch size.
|
|
20
|
-
*/
|
|
21
|
-
class ModelEventBatcher {
|
|
22
|
-
constructor() {
|
|
23
|
-
// Queue of pending touch calls: Map<modelKey, Map<pk, Set<wrapper>>>
|
|
24
|
-
this.pendingTouches = new Map();
|
|
25
|
-
// Debounce timers per model
|
|
26
|
-
this.debounceTimers = new Map();
|
|
27
|
-
// Max wait timers per model
|
|
28
|
-
this.maxWaitTimers = new Map();
|
|
29
|
-
// Rate-limited queue for processing
|
|
30
|
-
this.processQueue = new PQueue({
|
|
31
|
-
concurrency: 1,
|
|
32
|
-
interval: 16, // ~1 frame
|
|
33
|
-
intervalCap: 10 // max 10 flushes per frame
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Queue a touch call for batching
|
|
38
|
-
*/
|
|
39
|
-
queueTouch(configKey, modelName, pk, wrapper) {
|
|
40
|
-
const modelKey = `${configKey}::${modelName}`;
|
|
41
|
-
// Initialize maps if needed
|
|
42
|
-
if (!this.pendingTouches.has(modelKey)) {
|
|
43
|
-
this.pendingTouches.set(modelKey, new Map());
|
|
44
|
-
}
|
|
45
|
-
const pkMap = this.pendingTouches.get(modelKey);
|
|
46
|
-
if (!pkMap.has(pk)) {
|
|
47
|
-
pkMap.set(pk, new Set());
|
|
48
|
-
}
|
|
49
|
-
pkMap.get(pk).add(wrapper);
|
|
50
|
-
// Reset debounce timer
|
|
51
|
-
this._resetDebounce(modelKey);
|
|
52
|
-
// Start max wait timer if not already running
|
|
53
|
-
this._ensureMaxWait(modelKey);
|
|
54
|
-
}
|
|
55
|
-
_resetDebounce(modelKey) {
|
|
56
|
-
// Clear existing debounce
|
|
57
|
-
if (this.debounceTimers.has(modelKey)) {
|
|
58
|
-
clearTimeout(this.debounceTimers.get(modelKey));
|
|
59
|
-
}
|
|
60
|
-
// Set new debounce
|
|
61
|
-
const timer = setTimeout(() => {
|
|
62
|
-
this._flush(modelKey);
|
|
63
|
-
}, DEBOUNCE_MS);
|
|
64
|
-
this.debounceTimers.set(modelKey, timer);
|
|
65
|
-
}
|
|
66
|
-
_ensureMaxWait(modelKey) {
|
|
67
|
-
if (this.maxWaitTimers.has(modelKey)) {
|
|
68
|
-
return; // Already waiting
|
|
69
|
-
}
|
|
70
|
-
const timer = setTimeout(() => {
|
|
71
|
-
this._flush(modelKey);
|
|
72
|
-
}, MAX_WAIT_MS);
|
|
73
|
-
this.maxWaitTimers.set(modelKey, timer);
|
|
74
|
-
}
|
|
75
|
-
_clearTimers(modelKey) {
|
|
76
|
-
if (this.debounceTimers.has(modelKey)) {
|
|
77
|
-
clearTimeout(this.debounceTimers.get(modelKey));
|
|
78
|
-
this.debounceTimers.delete(modelKey);
|
|
79
|
-
}
|
|
80
|
-
if (this.maxWaitTimers.has(modelKey)) {
|
|
81
|
-
clearTimeout(this.maxWaitTimers.get(modelKey));
|
|
82
|
-
this.maxWaitTimers.delete(modelKey);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
_flush(modelKey) {
|
|
86
|
-
this._clearTimers(modelKey);
|
|
87
|
-
const pkMap = this.pendingTouches.get(modelKey);
|
|
88
|
-
if (!pkMap || pkMap.size === 0) {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Take the pending touches and clear
|
|
92
|
-
const touchBatch = new Map(pkMap);
|
|
93
|
-
pkMap.clear();
|
|
94
|
-
// Queue for rate-limited processing
|
|
95
|
-
this.processQueue.add(() => this._processBatch(modelKey, touchBatch));
|
|
96
|
-
}
|
|
97
|
-
_processBatch(modelKey, touchBatch) {
|
|
98
|
-
const batchSize = touchBatch.size;
|
|
99
|
-
if (batchSize < BATCH_THRESHOLD) {
|
|
100
|
-
// Small batch: fine-grained touch for each model
|
|
101
|
-
this._touchModels(touchBatch);
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
// Large batch: refresh all querysets for this model instead
|
|
105
|
-
this._refreshQuerysets(modelKey);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
_touchModels(touchBatch) {
|
|
109
|
-
// Call touch() on each wrapper
|
|
110
|
-
for (const [pk, wrappers] of touchBatch) {
|
|
111
|
-
for (const wrapper of wrappers) {
|
|
112
|
-
try {
|
|
113
|
-
wrapper.touch();
|
|
114
|
-
}
|
|
115
|
-
catch (e) {
|
|
116
|
-
console.warn('[ModelEventBatcher] Error touching model:', e);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
_refreshQuerysets(modelKey) {
|
|
122
|
-
// Find all cached querysets for this model and refresh them
|
|
123
|
-
// modelKey is "configKey::modelName"
|
|
124
|
-
const [configKey, modelName] = modelKey.split('::');
|
|
125
|
-
for (const [semanticKey, wrapper] of wrappedQuerysetCache) {
|
|
126
|
-
const liveQuerySet = wrapper.original;
|
|
127
|
-
if (!liveQuerySet)
|
|
128
|
-
continue;
|
|
129
|
-
const qs = liveQuerySet.queryset;
|
|
130
|
-
if (qs?.ModelClass?.configKey === configKey &&
|
|
131
|
-
qs?.ModelClass?.modelName === modelName) {
|
|
132
|
-
// Refresh this queryset's reactive wrapper
|
|
133
|
-
try {
|
|
134
|
-
wrapper.splice(0, wrapper.length);
|
|
135
|
-
wrapper.push(...liveQuerySet);
|
|
136
|
-
}
|
|
137
|
-
catch (e) {
|
|
138
|
-
console.warn('[ModelEventBatcher] Error refreshing queryset:', e);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
clear() {
|
|
144
|
-
// Clear all pending state
|
|
145
|
-
for (const modelKey of this.pendingTouches.keys()) {
|
|
146
|
-
this._clearTimers(modelKey);
|
|
147
|
-
}
|
|
148
|
-
this.pendingTouches.clear();
|
|
149
|
-
this.processQueue.clear();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
const modelEventBatcher = new ModelEventBatcher();
|
|
153
|
-
// Export for testing
|
|
154
|
-
export { modelEventBatcher, BATCH_THRESHOLD };
|
|
155
10
|
/**
|
|
156
11
|
* Adapts a model instance to a Vue reactive object by directly wrapping
|
|
157
12
|
* the instance and incrementing an internal version on relevant events.
|
|
@@ -168,15 +23,19 @@ export function ModelAdaptor(modelInstance, reactivityFn = reactive) {
|
|
|
168
23
|
// Make the model instance reactive using the specified function
|
|
169
24
|
const wrapper = reactivityFn(modelInstance);
|
|
170
25
|
const eventName = `${configKey}::${modelName}::render`;
|
|
171
|
-
// Handler
|
|
26
|
+
// Handler bumps version to trigger Vue reactivity when this instance updates
|
|
172
27
|
const renderHandler = (eventData) => {
|
|
173
28
|
const isRef = reactivityFn === ref;
|
|
174
29
|
const model = isRef ? wrapper.value : wrapper;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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)) {
|
|
33
|
+
if (isRef) {
|
|
34
|
+
wrapper.value.touch();
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
wrapper.touch();
|
|
38
|
+
}
|
|
180
39
|
}
|
|
181
40
|
};
|
|
182
41
|
// Subscribe to model events indefinitely
|
|
@@ -270,5 +129,4 @@ export function MetricAdaptor(metric) {
|
|
|
270
129
|
registerAdapterReset(() => {
|
|
271
130
|
wrappedQuerysetCache.clear();
|
|
272
131
|
wrappedMetricCache.clear();
|
|
273
|
-
modelEventBatcher.clear();
|
|
274
132
|
});
|
|
@@ -283,16 +283,17 @@ function createOperatorFromLookup(field, lookup, value, isRelationship, ModelCla
|
|
|
283
283
|
return { field, operator: { $in: value } };
|
|
284
284
|
}
|
|
285
285
|
else if (lookup === 'gt') {
|
|
286
|
-
|
|
286
|
+
// Exclude null/undefined to match Django (NULL comparisons return no results)
|
|
287
|
+
return { field, operator: { $nin: [null, undefined], $gt: value } };
|
|
287
288
|
}
|
|
288
289
|
else if (lookup === 'gte') {
|
|
289
|
-
return { field, operator: { $gte: value } };
|
|
290
|
+
return { field, operator: { $nin: [null, undefined], $gte: value } };
|
|
290
291
|
}
|
|
291
292
|
else if (lookup === 'lt') {
|
|
292
|
-
return { field, operator: { $lt: value } };
|
|
293
|
+
return { field, operator: { $nin: [null, undefined], $lt: value } };
|
|
293
294
|
}
|
|
294
295
|
else if (lookup === 'lte') {
|
|
295
|
-
return { field, operator: { $lte: value } };
|
|
296
|
+
return { field, operator: { $nin: [null, undefined], $lte: value } };
|
|
296
297
|
}
|
|
297
298
|
else {
|
|
298
299
|
// Default to direct equality if lookup not recognized
|
|
@@ -44,10 +44,9 @@ export function processIncludedEntities(modelStoreRegistry, included, ModelClass
|
|
|
44
44
|
pksSet.add(Number(pk));
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
// Register
|
|
48
|
-
|
|
49
|
-
|
|
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) {
|
|
@@ -220,7 +220,30 @@ export class ModelSerializer {
|
|
|
220
220
|
...context,
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
|
-
//
|
|
223
|
+
// Django-style type coercion for basic types (mimics get_prep_value)
|
|
224
|
+
if (value !== null && value !== undefined) {
|
|
225
|
+
if (fieldType === 'integer') {
|
|
226
|
+
const num = Number(value);
|
|
227
|
+
return Number.isNaN(num) ? value : Math.trunc(num);
|
|
228
|
+
}
|
|
229
|
+
if (fieldType === 'number') {
|
|
230
|
+
const num = Number(value);
|
|
231
|
+
return Number.isNaN(num) ? value : num;
|
|
232
|
+
}
|
|
233
|
+
if (fieldType === 'boolean') {
|
|
234
|
+
if (typeof value === 'string') {
|
|
235
|
+
const lower = value.toLowerCase();
|
|
236
|
+
if (['true', '1', 't', 'yes'].includes(lower))
|
|
237
|
+
return true;
|
|
238
|
+
if (['false', '0', 'f', 'no', ''].includes(lower))
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return Boolean(value);
|
|
242
|
+
}
|
|
243
|
+
if (fieldType === 'string' && typeof value !== 'string') {
|
|
244
|
+
return String(value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
224
247
|
return value;
|
|
225
248
|
}
|
|
226
249
|
toLive(data) {
|
|
@@ -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,
|
|
7
|
-
if (!
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,
|
|
40
|
+
constructor(ModelClass, pks) {
|
|
32
41
|
this.ModelClass = ModelClass;
|
|
33
|
-
this.
|
|
42
|
+
this.pks = Array.isArray(pks) ? pks : [pks];
|
|
34
43
|
}
|
|
35
44
|
/**
|
|
36
|
-
*
|
|
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
|
-
|
|
50
|
+
const pks = operation.instances
|
|
42
51
|
.filter(instance => instance != null && typeof instance === 'object' && pkField in instance)
|
|
43
|
-
.map(instance =>
|
|
52
|
+
.map(instance => instance[pkField]);
|
|
53
|
+
return pks.length > 0 ? new EventData(ModelClass, pks) : null;
|
|
44
54
|
}
|
|
45
55
|
/**
|
|
46
|
-
*
|
|
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
|
|
71
|
+
return uniquePks.size > 0 ? new EventData(ModelClass, Array.from(uniquePks)) : null;
|
|
62
72
|
}
|
|
63
73
|
/**
|
|
64
|
-
*
|
|
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
|
|
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
|
-
|
|
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