@statezero/core
Version:
The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate
345 lines (344 loc) • 14.3 kB
JavaScript
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.isTemp = options.isTemp || false;
this.pruneThreshold = options.pruneThreshold || 10;
this.getRootStore = options.getRootStore || null;
this.groundTruthPks = initialGroundTruthPks || [];
this.operationsMap = new Map();
// Track which model PKs are in this queryset's included data
// Map<modelName, Set<pk>>
this.includedPks = 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.lastSync = Date.now();
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);
// Only render from root if the root has been synced at least once
// This prevents child stores from getting empty data on first render
if (!isRoot && rootStore && rootStore.lastSync !== null) {
pks = this.renderFromRoot(optimistic, rootStore);
}
}
// For temp stores with no ground truth (e.g., chained optimistic filters),
// render from the model store instead of empty ground truth
if (isNil(pks) && this.isTemp && this.groundTruthPks.length === 0) {
pks = this.renderFromModelStore(optimistic);
}
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;
}
/**
* Render by getting ALL instances from the model store and applying
* the queryset's filters locally. Used for temp stores (e.g., optimistic
* chained filters) that don't have their own ground truth.
*/
renderFromModelStore(optimistic = true) {
const modelStore = modelStoreRegistry.getStore(this.modelClass);
// Get all PKs from the model store
const allPks = modelStore.groundTruthPks;
// Convert to model instances (like renderFromRoot does)
const allInstances = allPks.map((pk) => {
return this.modelClass.fromPk(pk, this.queryset);
});
// Apply the queryset's AST filters locally
const ast = this.queryset.build();
const result = filter(allInstances, 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:
case Type.BULK_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(forceFromDb = false) {
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 (!forceFromDb &&
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.`);
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)}.`);
// Clear previous included PKs tracking before processing new data
this.includedPks.clear();
// Persists all the instances (including nested instances) to the model store
// Pass this queryset to track which PKs are in the included data
processIncludedEntities(modelStoreRegistry, included, this.modelClass, this.queryset);
this.setGroundTruth(data);
this.setOperations(this.getInflightOperations());
this.lastSync = Date.now();
console.log(`[${id}] Sync completed.`);
}
catch (e) {
console.error(`[${id}] Failed to sync ground truth:`, e);
}
finally {
this.isSyncing = false;
}
}
}