UNPKG

@statezero/core

Version:

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

441 lines (440 loc) 17.9 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 } from '../../flavours/django/tempPk.js'; const emitEvents = (store, events) => { if (!Array.isArray(events)) return; events.forEach((event) => { const pk = event.pk; if (isNil(pk)) return; const newRenderedDataArray = store.render([pk], true); const newRenderedData = newRenderedDataArray.length > 0 ? newRenderedDataArray[0] : null; const lastRenderedData = store._lastRenderedData.get(pk); if (!isEqual(newRenderedData, lastRenderedData)) { store._lastRenderedData.set(pk, newRenderedData); 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, pk) { this.ModelClass = ModelClass; this.pk = pk; } /** * One event per instance in a single operation */ static fromOperation(operation) { const ModelClass = operation.queryset.ModelClass; const pkField = ModelClass.primaryKeyField; return operation.instances.map(instance => new EventData(ModelClass, instance[pkField])); } /** * One event per unique PK across multiple operations */ static fromOperations(operations) { if (!operations.length) return []; 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) { uniquePks.add(inst[pkField]); } } return Array.from(uniquePks).map(pk => new EventData(ModelClass, pk)); } /** * One event per unique PK in 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 Array.from(uniquePks).map(pk => new EventData(ModelClass, pk)); } } 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(); } 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; } } 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: 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); } // Render methods render(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; } } }