UNPKG

@statezero/core

Version:

The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate

566 lines (565 loc) 24 kB
import { Operation, Status, Type, operationRegistry } from './operation.js'; import { isNil, isEmpty, trim, isEqual } from 'lodash-es'; import { modelEventEmitter } from './reactivity.js'; import { Cache } from '../cache/cache.js'; import { replaceTempPks, containsTempPk, resolveToRealPk, onTempPkResolved } from '../../flavours/django/tempPk.js'; const emitEvents = (store, event) => { if (!event || !event.pks || event.pks.length === 0) return; // Get prior values for these pks in one pass (before computing new) const lastRenderedDataArray = event.pks.map(pk => store._lastRenderedData.get(pk) ?? null); // Batch render all pks - useCache=false to get fresh data for comparison const newRenderedDataArray = store.render(event.pks, true, false); // Single equality check on the whole batch if (!isEqual(newRenderedDataArray, lastRenderedDataArray)) { // Update cache for all pks const pkField = store.pkField; for (const item of newRenderedDataArray) { if (item && item[pkField] != null) { store._lastRenderedData.set(item[pkField], item); } } // Also mark any pks that are now null (deleted) for (const pk of event.pks) { if (!newRenderedDataArray.some(item => item && item[pkField] === pk)) { store._lastRenderedData.set(pk, null); } } modelEventEmitter.emit(`${store.modelClass.configKey}::${store.modelClass.modelName}::render`, event); store.renderCallbacks.forEach((callback) => { try { callback(); } catch (error) { console.warn("Error in model store render callback:", error); } }); } }; class EventData { constructor(ModelClass, pks) { this.ModelClass = ModelClass; this.pks = Array.isArray(pks) ? pks : [pks]; } /** * Single event containing all PKs from an operation */ static fromOperation(operation) { const ModelClass = operation.queryset.ModelClass; const pkField = ModelClass.primaryKeyField; const pks = operation.instances .filter(instance => instance != null && typeof instance === 'object' && pkField in instance) .map(instance => instance[pkField]); return pks.length > 0 ? new EventData(ModelClass, pks) : null; } /** * Single event containing all unique PKs across multiple operations */ static fromOperations(operations) { if (!operations.length) return null; const ModelClass = operations[0].queryset.ModelClass; const pkField = ModelClass.primaryKeyField; const uniquePks = new Set(); for (const op of operations) { for (const inst of op.instances) { if (inst != null && typeof inst === 'object' && pkField in inst) { uniquePks.add(inst[pkField]); } } } return uniquePks.size > 0 ? new EventData(ModelClass, Array.from(uniquePks)) : null; } /** * Single event containing all unique PKs from an array of instances */ static fromInstances(instances, ModelClass) { const pkField = ModelClass.primaryKeyField; const uniquePks = new Set(instances .filter(inst => inst && inst[pkField] != null) .map(inst => inst[pkField])); return uniquePks.size > 0 ? new EventData(ModelClass, Array.from(uniquePks)) : null; } } export class ModelStore { constructor(modelClass, fetchFn, initialGroundTruth = null, initialOperations = null, options = {}) { this.modelClass = modelClass; this.fetchFn = fetchFn; this.isSyncing = false; this.pruneThreshold = options.pruneThreshold || 10; this.groundTruthArray = initialGroundTruth || []; this.operationsMap = new Map(); // Handle initial operations if provided if (initialOperations && initialOperations.length > 0) { this._loadOperations(initialOperations); } this.modelCache = new Cache("model-cache", {}, this.onHydrated.bind(this)); this._lastRenderedData = new Map(); this.renderCallbacks = new Set(); // Migrate cache entries when temp pks are resolved to real pks this._unsubscribeTempPk = onTempPkResolved((tempPk, realPk) => { if (this._lastRenderedData.has(tempPk)) { const data = this._lastRenderedData.get(tempPk); // Update the pk field in the cached data if (data && typeof data === 'object') { data[this.pkField] = realPk; } this._lastRenderedData.set(realPk, data); this._lastRenderedData.delete(tempPk); } }); } registerRenderCallback(callback) { this.renderCallbacks.add(callback); return () => this.renderCallbacks.delete(callback); } /** * Load operations from data and add them to the operations map, * reusing existing operations from the registry if they exist */ _loadOperations(operationsData) { operationsData.forEach((opData) => { const existingOp = operationRegistry.get(opData.operationId); if (existingOp) { // If the operation exists in the registry, use it this.operationsMap.set(existingOp.operationId, existingOp); } else { // Otherwise just use the plain object data this.operationsMap.set(opData.operationId, new Operation(opData, true)); } }); } // Caching get cacheKey() { return `${this.modelClass.configKey}::${this.modelClass.modelName}`; } onHydrated() { if (this.groundTruthArray.length === 0 && this.operationsMap.size === 0) { let cached = this.modelCache.get(this.cacheKey); console.log(`[ModelStore] Hydrated ${this.modelClass.modelName} with ${(cached || []).length} items from cache`); if (!isNil(cached) && !isEmpty(cached)) { this.setGroundTruth(cached); } } } setCache(result) { const pkField = this.pkField; let nonTempPkItems = []; result.forEach((item) => { let pk = item[pkField]; if (typeof pk === "string" && containsTempPk(pk)) { pk = replaceTempPks(item[pkField]); if (isNil(pk) || isEmpty(trim(pk))) { return; } } if (item && typeof item.serialize === "function") { // Pass includeRepr=true to preserve repr field in cache const serializedItem = item.serialize(true); serializedItem[pkField] = pk; nonTempPkItems.push(serializedItem); } else { item[pkField] = pk; nonTempPkItems.push(item); } }); this.modelCache.set(this.cacheKey, nonTempPkItems); } clearCache() { this.modelCache.delete(this.cacheKey); } updateCache(items, requestedPks) { const pkField = this.pkField; let nonTempPkItems = []; items.forEach((item) => { let pk = item[pkField]; if (typeof pk === "string" && containsTempPk(pk)) { pk = replaceTempPks(item[pkField]); if (isNil(pk) || isEmpty(trim(pk))) { return; } } item[pkField] = pk; nonTempPkItems.push(item); }); // If rendering ALL items (requestedPks is null), simply replace the cache if (requestedPks === null) { this.setCache(nonTempPkItems); return; } // Otherwise, we're rendering specific items - update only those items const currentCache = this.modelCache.get(this.cacheKey) || []; // Filter out items that were requested but not in the result (they were deleted) const filteredCache = currentCache.filter((item) => item && typeof item === "object" && pkField in item && (!requestedPks.has(item[pkField]) || nonTempPkItems.some((newItem) => newItem[pkField] === item[pkField]))); // Create a map for faster lookups const cacheMap = new Map(filteredCache.map((item) => [item[pkField], item])); // Add or update items from the result for (const item of nonTempPkItems) { if (item && typeof item === "object" && pkField in item) { cacheMap.set(item[pkField], item); } } // Update the cache const updatedCache = Array.from(cacheMap.values()); this.setCache(updatedCache); } // Main modelStore methods get operations() { return Array.from(this.operationsMap.values()); } get pkField() { return this.modelClass.primaryKeyField; } // Commit optimistic updates addOperation(operation) { this.operationsMap.set(operation.operationId, operation); if (this.operationsMap.size > this.pruneThreshold) { this.prune(); } emitEvents(this, EventData.fromOperation(operation)); } updateOperation(operation) { if (!this.operationsMap.has(operation.operationId)) return false; this.operationsMap.set(operation.operationId, operation); emitEvents(this, EventData.fromOperation(operation)); return true; } confirm(operation) { if (!this.operationsMap.has(operation.operationId)) return; this.operationsMap.set(operation.operationId, operation); emitEvents(this, EventData.fromOperation(operation)); } reject(operation) { if (!this.operationsMap.has(operation.operationId)) return; this.operationsMap.set(operation.operationId, operation); emitEvents(this, EventData.fromOperation(operation)); } setOperations(operations = []) { const prevOps = this.operations; this.operationsMap.clear(); operations.forEach((op) => { this.operationsMap.set(op.operationId, op); }); const allOps = [...prevOps, ...this.operations]; emitEvents(this, EventData.fromOperations(allOps)); } // Ground truth data methods setGroundTruth(groundTruth) { let prevGroundTruth = this.groundTruthArray; this.groundTruthArray = Array.isArray(groundTruth) ? groundTruth : []; // reactivity - gather all ops const allOps = [...prevGroundTruth, ...this.groundTruthArray]; emitEvents(this, EventData.fromInstances(allOps, this.modelClass)); } getGroundTruth() { return this.groundTruthArray; } get groundTruthPks() { const pk = this.pkField; return this.groundTruthArray .filter((instance) => instance && typeof instance === "object" && pk in instance) .map((instance) => instance[pk]); } addToGroundTruth(instances) { if (!Array.isArray(instances) || instances.length === 0) return; const pkField = this.pkField; const pkMap = new Map(); instances.forEach((inst) => { if (inst && typeof inst === "object" && pkField in inst) { pkMap.set(inst[pkField], inst); } else { console.warn(`[ModelStore ${this.modelClass.modelName}] Skipping invalid instance in addToGroundTruth:`, inst); } }); if (pkMap.size === 0) return; const updatedGroundTruth = []; const processedPks = new Set(); const checkpointInstances = []; // Track instances that need CHECKPOINT operations for (const existingItem of this.groundTruthArray) { if (!existingItem || typeof existingItem !== "object" || !(pkField in existingItem)) { continue; } const pk = existingItem[pkField]; if (pkMap.has(pk)) { const updatedInstance = { ...existingItem, ...pkMap.get(pk) }; updatedGroundTruth.push(updatedInstance); processedPks.add(pk); // This instance already existed - add it to checkpoint list checkpointInstances.push(updatedInstance); pkMap.delete(pk); } else { updatedGroundTruth.push(existingItem); } } // Add completely new instances (these don't need checkpoint operations) updatedGroundTruth.push(...Array.from(pkMap.values())); this.groundTruthArray = updatedGroundTruth; // Create CHECKPOINT operation for instances that already existed if (checkpointInstances.length > 0) { const checkpointOperation = new Operation({ operationId: `checkpoint_${Date.now()}_${Math.random() .toString(36) .substr(2, 9)}`, type: Type.CHECKPOINT, instances: checkpointInstances, status: Status.CONFIRMED, timestamp: Date.now(), queryset: this.modelClass.objects.all(), }); this.operationsMap.set(checkpointOperation.operationId, checkpointOperation); console.log(`[ModelStore ${this.modelClass.modelName}] Created CHECKPOINT operation for ${checkpointInstances.length} existing instances`); } // reactivity - use all the newly added instances (both new and updated) emitEvents(this, EventData.fromInstances([...checkpointInstances, ...Array.from(pkMap.values())], this.modelClass)); } _filteredOperations(pks, operations) { if (!pks) return operations; const pkField = this.pkField; let filteredOps = []; for (const op of operations) { let relevantInstances = op.instances.filter((instance) => pks.has(instance[pkField] || instance)); if (relevantInstances.length > 0) { filteredOps.push({ operationId: op.operationId, instances: relevantInstances, timestamp: op.timestamp, queryset: op.queryset, type: op.type, status: op.status, args: op.args, }); } } return filteredOps; } _filteredGroundTruth(pks, groundTruthArray) { const pkField = this.pkField; let groundTruthMap = new Map(); for (const instance of groundTruthArray) { if (!instance || typeof instance !== "object" || !(pkField in instance)) { continue; } const pk = instance[pkField]; if (!pks || pks.has(pk)) { groundTruthMap.set(pk, instance); } } return groundTruthMap; } applyOperation(operation, currentInstances) { const pkField = this.pkField; for (const instance of operation.instances) { if (!instance || typeof instance !== "object" || !(pkField in instance)) { 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.`); continue; } let pk = instance[pkField]; switch (operation.type) { case Type.CREATE: case Type.BULK_CREATE: if (!currentInstances.has(pk)) { currentInstances.set(pk, instance); } break; case Type.CHECKPOINT: case Type.UPDATE_INSTANCE: case Type.UPDATE: { const existing = currentInstances.get(pk); if (existing) { currentInstances.set(pk, { ...existing, ...instance }); } else { const wasDeletedLocally = this.operations.some((op) => op.type === Type.DELETE && op.status !== Status.REJECTED && op.instances.some((inst) => inst && inst[pkField] === pk)); if (!wasDeletedLocally) { currentInstances.set(pk, instance); } } break; } case Type.DELETE_INSTANCE: case Type.DELETE: currentInstances.delete(pk); break; default: console.error(`[ModelStore ${this.modelClass.modelName}] Unknown operation type: ${operation.type}`); } } return currentInstances; } getTrimmedOperations() { const twoMinutesAgo = Date.now() - 1000 * 60 * 2; return this.operations.filter((operation) => operation.timestamp > twoMinutesAgo); } getInflightOperations() { return this.operations.filter((operation) => operation.status != Status.CONFIRMED && operation.status != Status.REJECTED); } // Pruning prune() { let renderedPks = this.render(null, false); this.setGroundTruth(renderedPks); this.setOperations(this.getInflightOperations()); this.setCache(renderedPks); } /** * Prune model instances that aren't referenced by any queryset store. * This prevents unbounded cache growth from included nested models. * * @param {QuerysetStoreRegistry} querysetStoreRegistry - The registry to check for queryset references */ pruneUnreferencedInstances(querysetStoreRegistry) { const pkField = this.pkField; const modelName = this.modelClass.modelName; // Collect all PKs that are needed by ANY queryset (permanent or temporary) const neededPks = new Set(); for (const [semanticKey, store] of querysetStoreRegistry._stores.entries()) { // Check top-level PKs (groundTruthPks) if (store.modelClass.modelName === modelName) { store.groundTruthPks.forEach(pk => neededPks.add(pk)); } // Check included PKs (nested models) if (store.includedPks.has(modelName)) { const includedPkSet = store.includedPks.get(modelName); includedPkSet.forEach(pk => neededPks.add(pk)); } } // Filter ground truth to only keep needed PKs const filteredGroundTruth = this.groundTruthArray.filter(instance => { if (!instance || typeof instance !== 'object' || !(pkField in instance)) { return false; } return neededPks.has(instance[pkField]); }); const removedCount = this.groundTruthArray.length - filteredGroundTruth.length; if (removedCount > 0) { console.log(`[ModelStore ${modelName}] Pruned ${removedCount} unreferenced instances (${filteredGroundTruth.length} remaining)`); this.groundTruthArray = filteredGroundTruth; // Update the cache to reflect the pruned data this.setCache(filteredGroundTruth); } } // Render methods render(pks = null, optimistic = true, useCache = true) { // When rendering specific pks, check cache first if (useCache && pks !== null) { const pksArray = Array.isArray(pks) ? pks : [pks]; // Resolve temp pks to real pks for cache lookup const resolvedPks = pksArray.map(pk => resolveToRealPk(pk)); const uncachedPks = []; for (const pk of resolvedPks) { if (!this._lastRenderedData.has(pk)) { uncachedPks.push(pk); } } // All pks were cached - return from cache if (uncachedPks.length === 0) { return this._renderFromCache(resolvedPks); } // Some or all pks need fresh render const pkField = this.pkField; if (uncachedPks.length < resolvedPks.length) { // Partial cache hit - get cached and render uncached const cachedPks = resolvedPks.filter(pk => this._lastRenderedData.has(pk)); const cached = this._renderFromCache(cachedPks); const fresh = this._renderFresh(uncachedPks, optimistic); // Update cache for freshly rendered items for (const item of fresh) { if (item && item[pkField] != null) { this._lastRenderedData.set(item[pkField], item); } } return [...cached, ...fresh]; } else { // All pks uncached - render fresh and update cache const fresh = this._renderFresh(resolvedPks, optimistic); // Update cache for freshly rendered items for (const item of fresh) { if (item && item[pkField] != null) { this._lastRenderedData.set(item[pkField], item); } } return fresh; } } // Full render (no cache, useCache=false, or pks=null for all) return this._renderFresh(pks, optimistic); } _renderFromCache(pks) { // Returns cached rendered data for the given pks // This method can be spied on in tests to track cache hits return pks .map(pk => this._lastRenderedData.get(pk)) .filter(item => item !== null && item !== undefined); } _renderFresh(pks = null, optimistic = true) { const pksSet = pks === null ? null : pks instanceof Set ? pks : new Set(Array.isArray(pks) ? pks : [pks]); const renderedInstancesMap = this._filteredGroundTruth(pksSet, this.groundTruthArray); const relevantOperations = this._filteredOperations(pksSet, this.operations); for (const op of relevantOperations) { if (op.status !== Status.REJECTED && (optimistic || op.status === Status.CONFIRMED)) { this.applyOperation(op, renderedInstancesMap); } } let result = Array.from(renderedInstancesMap.values()); if (pks) this.updateCache(result, pksSet); if (isNil(pks)) this.setCache(result); return result; } async sync(pks = null) { const storeIdForLog = this.modelClass.modelName; if (this.isSyncing) return; this.isSyncing = true; try { const currentPks = pks || this.groundTruthPks; if (currentPks.length === 0) { const trimmedOps = this.getTrimmedOperations(); this.setOperations(trimmedOps); return; } const newGroundTruth = await this.fetchFn({ pks: currentPks, modelClass: this.modelClass, }); if (pks) { this.addToGroundTruth(newGroundTruth); return; } this.setGroundTruth(newGroundTruth); const trimmedOps = this.getTrimmedOperations(); this.setOperations(trimmedOps); } catch (error) { console.error(`[ModelStore ${storeIdForLog}] Failed to sync ground truth:`, error); } finally { this.isSyncing = false; } } }