UNPKG

@statezero/core

Version:

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

316 lines (315 loc) 12.8 kB
import { Operation, Status, Type, operationRegistry } from './operation.js'; import { querysetEventEmitter } from './reactivity.js'; import { isNil, isEmpty, trim, isEqual } from 'lodash-es'; import { replaceTempPks, containsTempPk } from '../../flavours/django/tempPk.js'; import { modelStoreRegistry } from '../registries/modelStoreRegistry.js'; import { processIncludedEntities } from '../../flavours/django/makeApiCall.js'; import hash from 'object-hash'; import { Cache } from '../cache/cache.js'; import { filter } from "../../filtering/localFiltering.js"; import { mod } from 'mathjs'; export class QuerysetStore { constructor(modelClass, fetchFn, queryset, initialGroundTruthPks = null, initialOperations = null, options = {}) { this.modelClass = modelClass; this.fetchFn = fetchFn; this.queryset = queryset; this.isSyncing = false; this.lastSync = null; this.needsSync = false; this.isTemp = options.isTemp || false; this.pruneThreshold = options.pruneThreshold || 10; this.getRootStore = options.getRootStore || null; this.groundTruthPks = initialGroundTruthPks || []; this.operationsMap = new Map(); if (Array.isArray(initialOperations)) { for (const opData of initialOperations) { const existing = operationRegistry.get(opData.operationId); const op = existing || new Operation(opData, true); this.operationsMap.set(op.operationId, op); } } this.qsCache = new Cache("queryset-cache", {}, this.onHydrated.bind(this)); this._lastRenderedPks = null; this.renderCallbacks = new Set(); this._rootUnregister = null; this._currentRootStore = null; this._ensureRootRegistration(); const modelStore = modelStoreRegistry.getStore(this.modelClass); this._modelStoreUnregister = modelStore.registerRenderCallback(() => { this._emitRenderEvent(); }); } // Caching get cacheKey() { return this.queryset.semanticKey; } onHydrated(hydratedData) { if (this.groundTruthPks.length === 0 && this.operationsMap.size === 0) { const cached = this.qsCache.get(this.cacheKey); if (!isNil(cached) && !isEmpty(cached)) { console.log(`[QuerysetStore] Hydrated ${this.modelClass.modelName} queryset with ${cached.length} items from cache`); this.setGroundTruth(cached); } } } setCache(result) { let nonTempPks = []; result.forEach((pk) => { if (typeof pk === "string" && containsTempPk(pk)) { pk = replaceTempPks(pk); if (isNil(pk) || isEmpty(trim(pk))) { return; } } nonTempPks.push(pk); }); this.qsCache.set(this.cacheKey, nonTempPks); } clearCache() { this.qsCache.delete(this.cacheKey); } // -- Core methods -- get operations() { return Array.from(this.operationsMap.values()); } get pkField() { return this.modelClass.primaryKeyField; } get groundTruthSet() { return new Set(this.groundTruthPks); } _emitRenderEvent() { const newPks = this.render(true, false); // 1. Always notify direct child stores to trigger their own re-evaluation. // They will perform their own check to see if their own results have changed. this.renderCallbacks.forEach((callback) => { try { callback(); } catch (error) { console.warn("Error in render callback:", error); } }); // 2. Only emit the global event for UI components if the final list of PKs has actually changed. if (!isEqual(newPks, this._lastRenderedPks)) { this._lastRenderedPks = newPks; // Update the cache with the new state querysetEventEmitter.emit(`${this.modelClass.configKey}::${this.modelClass.modelName}::queryset::render`, { ast: this.queryset.build(), ModelClass: this.modelClass }); } } async addOperation(operation) { this.operationsMap.set(operation.operationId, operation); if (this.operationsMap.size > this.pruneThreshold) { this.prune(); } this._emitRenderEvent(); } async updateOperation(operation) { if (!this.operationsMap.has(operation.operationId)) return; this.operationsMap.set(operation.operationId, operation); this._emitRenderEvent(); return true; } async confirm(operation) { if (!this.operationsMap.has(operation.operationId)) return; this.operationsMap.set(operation.operationId, operation); this._emitRenderEvent(); } async reject(operation) { if (!this.operationsMap.has(operation.operationId)) return; this.operationsMap.set(operation.operationId, operation); this._emitRenderEvent(); } async setGroundTruth(groundTruthPks) { this.groundTruthPks = Array.isArray(groundTruthPks) ? groundTruthPks : []; this._emitRenderEvent(); } async setOperations(operations) { this.operationsMap.clear(); if (Array.isArray(operations)) { for (const op of operations) { this.operationsMap.set(op.operationId, op); } } this._emitRenderEvent(); } getTrimmedOperations() { const cutoff = Date.now() - 1000 * 60 * 2; return this.operations.filter((op) => op.timestamp > cutoff); } getInflightOperations() { return this.operations.filter((operation) => operation.status != Status.CONFIRMED && operation.status != Status.REJECTED); } prune() { const renderedPks = this.render(false); this.setGroundTruth(renderedPks); this.setOperations(this.getInflightOperations()); } registerRenderCallback(callback) { this.renderCallbacks.add(callback); return () => this.renderCallbacks.delete(callback); } _ensureRootRegistration() { if (this.isTemp) return; const { isRoot, rootStore } = this.getRootStore(this.queryset); // If the root store hasn't changed, nothing to do if (this._currentRootStore === rootStore) { return; } // Root store changed - clean up old registration if it exists if (this._rootUnregister) { this._rootUnregister(); this._rootUnregister = null; } // Set up new registration if we're derived and have a root store if (!isRoot && rootStore) { this._rootUnregister = rootStore.registerRenderCallback(() => { this._emitRenderEvent(); }); } // Update current root store reference (could be null now) this._currentRootStore = rootStore; } /** * Helper to validate PKs against the model store and apply local filtering/sorting. * This is the core of the rendering logic. * @private */ _getValidatedAndFilteredPks(pks) { // 1. Convert PKs to instances, filtering out any that are null (deleted). const instances = Array.from(pks) .map((pk) => this.modelClass.fromPk(pk, this.queryset)) .filter((instance) => modelStoreRegistry.getEntity(this.modelClass, instance.pk) !== null); // 2. Apply the queryset's AST (filters, ordering) to the validated instances. const ast = this.queryset.build(); const finalPks = filter(instances, ast, this.modelClass, false); // false = return PKs return finalPks; } render(optimistic = true, fromCache = false) { this._ensureRootRegistration(); if (fromCache) { const cachedResult = this.qsCache.get(this.cacheKey); if (Array.isArray(cachedResult)) { return cachedResult; } } let pks; if (this.getRootStore && typeof this.getRootStore === "function" && !this.isTemp) { const { isRoot, rootStore } = this.getRootStore(this.queryset); if (!isRoot && rootStore) { pks = this.renderFromRoot(optimistic, rootStore); } } if (isNil(pks)) { pks = this.renderFromData(optimistic); } let result = this._getValidatedAndFilteredPks(pks); let limit = this.queryset.build().serializerOptions?.limit; if (limit) { result = result.slice(0, limit); } this.setCache(result); return result; } renderFromRoot(optimistic = true, rootStore) { let renderedPks = rootStore.render(optimistic); let renderedData = renderedPks.map((pk) => { return this.modelClass.fromPk(pk, this.queryset); }); let ast = this.queryset.build(); let result = filter(renderedData, ast, this.modelClass, false); return result; } renderFromData(optimistic = true) { const renderedPks = this.groundTruthSet; for (const op of this.operations) { if (op.status !== Status.REJECTED && (optimistic || op.status === Status.CONFIRMED)) { this.applyOperation(op, renderedPks); } } let result = Array.from(renderedPks); return result; } applyOperation(operation, currentPks) { const pkField = this.pkField; for (const instance of operation.instances) { if (!instance || typeof instance !== "object" || !(pkField in instance)) { console.warn(`[QuerysetStore ${this.modelClass.modelName}] Skipping instance in operation ${operation.operationId} due to missing PK '${String(pkField)}' or invalid format.`); continue; } let pk = instance[pkField]; switch (operation.type) { case Type.CREATE: currentPks.add(pk); break; case Type.CHECKPOINT: case Type.UPDATE: case Type.UPDATE_INSTANCE: break; case Type.DELETE: case Type.DELETE_INSTANCE: currentPks.delete(pk); break; default: console.error(`[QuerysetStore ${this.modelClass.modelName}] Unknown operation type: ${operation.type}`); } } return currentPks; } async sync() { const id = this.modelClass.modelName; if (this.isSyncing) { console.warn(`[QuerysetStore ${id}] Already syncing, request ignored.`); return; } // Check if we're delegating to a root store if (this.getRootStore && typeof this.getRootStore === "function" && !this.isTemp) { const { isRoot, rootStore } = this.getRootStore(this.queryset); if (!isRoot && rootStore) { // We're delegating to a root store - don't sync, just mark as needing sync console.log(`[${id}] Delegating to root store, marking sync needed.`); this.needsSync = true; this.lastSync = null; // Clear last sync since we're not actually syncing this.setOperations(this.getInflightOperations()); return; } } // We're in independent mode - proceed with normal sync this.isSyncing = true; console.log(`[${id}] Starting sync...`); try { const response = await this.fetchFn({ ast: this.queryset.build(), modelClass: this.modelClass, }); const { data, included } = response; if (isNil(data)) { return; } console.log(`[${id}] Sync fetch completed. Received: ${JSON.stringify(data)}.`); // Persists all the instances (including nested instances) to the model store processIncludedEntities(modelStoreRegistry, included, this.modelClass); this.setGroundTruth(data); this.setOperations(this.getInflightOperations()); this.lastSync = Date.now(); this.needsSync = false; console.log(`[${id}] Sync completed.`); } catch (e) { console.error(`[${id}] Failed to sync ground truth:`, e); this.needsSync = true; // Mark as needing sync on error } finally { this.isSyncing = false; } } }