@statezero/core
Version:
The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate
469 lines (468 loc) • 22 kB
JavaScript
import axios from 'axios';
import { configInstance } from '../../config.js';
import { parseStateZeroError, MultipleObjectsReturned, DoesNotExist } from './errors.js';
import { modelStoreRegistry } from '../../syncEngine/registries/modelStoreRegistry.js';
import { querysetStoreRegistry } from '../../syncEngine/registries/querysetStoreRegistry.js';
import { metricRegistry } from '../../syncEngine/registries/metricRegistry.js';
import { Status } from '../../syncEngine/stores/operation.js';
import { breakThenable, makeLiveThenable } from './utils.js';
import { setRealPk } from './tempPk.js';
import { isNil } from 'lodash-es';
import { Model } from './model.js';
import { v7 as uuid7 } from 'uuid';
import { makeApiCall, processIncludedEntities } from './makeApiCall.js';
import { OperationFactory } from './operationFactory.js';
const getModelClass = configInstance.getModelClass;
/**
* A custom data structure that behaves as an augmented array.
* It stores [instance, created] and also provides named properties for clarity.
*
* @class ResultTuple
* @extends {Array}
*/
export class ResultTuple extends Array {
/**
* Creates a new ResultTuple.
*
* @param {*} instance - The model instance.
* @param {boolean} created - Whether the instance was created.
*/
constructor(instance, created) {
// Create an array with length 2.
super(2);
// Set array indices directly instead of using push.
this[0] = instance;
this[1] = created;
// Set named properties.
this.instance = instance;
this.created = created;
}
}
/**
* Handles query execution against the backend, and parsing the response into the correct format.
*/
export class QueryExecutor {
/**
* Executes a get operation (get, first, last) with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The specific get operation type.
* @param {Object} args - Additional arguments for the operation.
* @returns {Promise<Object>} The model instance.
*/
static executeGet(querySet, operationType, args = {}) {
const ModelClass = querySet.ModelClass;
const store = querysetStoreRegistry.getStore(querySet);
const existing = store.render();
const tempPk = Array.isArray(existing) && existing.length === 1 ? existing[0] : null;
// always return a Model instance (pk might be null)
const live = ModelClass.fromPk(tempPk, querySet);
const promise = makeApiCall(querySet, operationType, args).then((resp) => {
const { data, included, model_name } = resp.data;
if (isNil(data) || (Array.isArray(data) && data.length === 0)) {
return null;
}
processIncludedEntities(modelStoreRegistry, included, ModelClass);
const realPk = Array.isArray(data) ? data[0] : data;
// swap in the real PK on the same instance, will trigger reactivity
live.pk = realPk;
breakThenable(live);
return live;
});
return makeLiveThenable(live, promise);
}
/**
* Execute a list-style API call for the given QuerySet, update the in‑memory store with the returned primary keys,
* process any included entities, and return a live‑thenable that keeps the local "live" collection in sync.
*
* @template T The model type of the QuerySet
* @param {QuerySet<T>} qs
* The QuerySet to execute.
* @param {string} [op="list"]
* The operation to perform. Defaults to `"list"`, but could be overridden for other list‑style endpoints.
* @param {Object} [args={}]
* Additional arguments to pass through to the underlying API call (e.g. filters, pagination).
* @returns {LiveThenable<import('./makeLiveThenable').Result<T[]>>}
* A live‑thenable wrapping an array of primary‑key values for the fetched models. The live part remains
* synchronized with the in‑memory store.
*/
static executeList(qs, op = "list", args = {}) {
const live = querysetStoreRegistry.getEntity(qs);
const promise = makeApiCall(qs, op, args).then((resp) => {
const { data, included } = resp.data;
processIncludedEntities(modelStoreRegistry, included, qs.ModelClass);
const pks = Array.isArray(data) ? data : [];
querysetStoreRegistry.setEntity(qs, pks);
return querysetStoreRegistry.getEntity(qs);
});
return makeLiveThenable(live, promise);
}
/**
* Executes a get_or_create or update_or_create operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The specific operation type ('get_or_create' or 'update_or_create').
* @param {Object} args - Additional arguments for the operation.
* @returns {Promise<ResultTuple>} Tuple with instance and created flag.
*/
static executeOrCreate(querySet, operationType, args = {}) {
const ModelClass = querySet.ModelClass;
const primaryKeyField = ModelClass.primaryKeyField;
const apiCallArgs = {
lookup: args.lookup || {},
defaults: args.defaults || {},
};
// Use the factory to create the operation (which includes local filtering logic)
const operation = operationType === 'get_or_create'
? OperationFactory.createGetOrCreateOperation(querySet, apiCallArgs.lookup, apiCallArgs.defaults)
: OperationFactory.createUpdateOrCreateOperation(querySet, apiCallArgs.lookup, apiCallArgs.defaults);
// Determine if we're creating new based on the operation type
const isCreatingNew = operation.type === 'create';
// Create optimistic instance and result
const live = isCreatingNew
? ModelClass.fromPk(operation.instances[0][primaryKeyField], querySet)
: ModelClass.fromPk(operation.instances[0][primaryKeyField], querySet);
let liveResult = new ResultTuple(live, isCreatingNew);
// Make API call with original operation type for backend
const promise = makeApiCall(querySet, operationType, apiCallArgs, operation.operationId)
.then((response) => {
const { data, included, model_name } = response.data;
const created = response.metadata.created;
// Process included entities
processIncludedEntities(modelStoreRegistry, included, ModelClass);
// Get the real PK
const pk = Array.isArray(data) ? data[0] : data;
// Update PK if we created a new instance
if (isCreatingNew) {
live.pk = pk;
}
// Confirm operation
const entityMap = included[model_name] || {};
const entityData = entityMap[pk];
if (entityData) {
operation.mutate({
instances: [entityData],
status: Status.CONFIRMED,
});
}
// Update result with actual created flag
liveResult = new ResultTuple(live, created);
breakThenable(liveResult);
return liveResult;
})
.catch((error) => {
operation.updateStatus(Status.REJECTED);
throw error;
});
return makeLiveThenable(liveResult, promise);
}
/**
* Executes an aggregation operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The specific aggregation operation type.
* @param {Object} args - Additional arguments for the operation.
* @returns {LiveMetric} The LiveMetric instance that updates optimistically.
*/
static executeAgg(querySet, operationType, args = {}) {
const ModelClass = querySet.ModelClass;
const field = operationType === 'count'
? (args.field || ModelClass.primaryKeyField)
: args.field;
// Only include defined properties
if (operationType !== 'exists' && operationType !== 'count' && field === undefined) {
throw new Error(`Field parameter is required for ${operationType} operation`);
}
// Get the live metric from the registry
const liveMetric = metricRegistry.getEntity(operationType, querySet, field);
// Create the API call args
const apiCallArgs = {};
if (operationType !== 'exists') {
apiCallArgs.field = field;
}
// Perform the async request
const promise = makeApiCall(querySet, operationType, apiCallArgs).then((response) => {
// The aggregation result should be directly in data
const value = response.data;
// Update the metric store with the ground truth value and current queryset
const dataSlice = querysetStoreRegistry.getEntity(querySet);
metricRegistry.setEntity(operationType, querySet, field, value, dataSlice);
// Return the value for the promise resolution
return value;
});
// For consistency with other methods, make the liveMetric thenable
return makeLiveThenable(liveMetric, promise);
}
/**
* Executes an exists operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type (always 'exists' for this method).
* @param {Object} args - Additional arguments for the operation.
* @returns {Promise<boolean>} Whether records exist.
*/
static async executeExists(querySet, operationType = "exists", args = {}) {
// exists
const apiCallArgs = {};
const response = await makeApiCall(querySet, operationType, apiCallArgs);
return response.data || false;
}
/**
* Executes an update operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type (always 'update' for this method).
* @param {Object} args - Additional arguments for the operation.
* @returns {Promise<Array>} Tuple with count and model counts map.
*/
static executeUpdate(querySet, operationType = "update", args = {}) {
const ModelClass = querySet.ModelClass;
const modelName = ModelClass.modelName;
const apiCallArgs = {
filter: args.filter,
data: args.data || {},
};
// Use factory to create operation (includes F expression evaluation)
const operation = OperationFactory.createUpdateOperation(querySet, apiCallArgs.data, apiCallArgs.filter);
const estimatedCount = operation.instances.length;
let liveResult = [estimatedCount, { [modelName]: estimatedCount }];
const promise = makeApiCall(querySet, operationType, apiCallArgs, operation.operationId)
.then((response) => {
const { data, included } = response.data || {};
const fullData = included[modelName] || {};
const updatedObjects = Array.isArray(data)
? data.map((pk) => (fullData[`${pk}`]))
: [];
operation.updateStatus(Status.CONFIRMED, updatedObjects);
const updatedCount = response.metadata?.updated_count ?? 0;
liveResult = [updatedCount, { [modelName]: updatedCount }];
breakThenable(liveResult);
return liveResult;
})
.catch((err) => {
operation.updateStatus(Status.REJECTED);
throw err;
});
return makeLiveThenable(liveResult, promise);
}
/**
* Executes a delete operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type (always 'delete' for this method).
* @param {Object} args - Additional arguments for the operation.
* @returns {Promise<Array>} Tuple with count and model counts map.
*/
static executeDelete(querySet, operationType = "delete", args = {}) {
const ModelClass = querySet.ModelClass;
const modelName = ModelClass.modelName;
// Use factory to create operation
const operation = OperationFactory.createDeleteOperation(querySet);
// live placeholder: assume we delete all existing pks
const estimatedCount = operation.instances.length;
let liveResult = [estimatedCount, { [modelName]: estimatedCount }];
const promise = makeApiCall(querySet, operationType, {}, operation.operationId)
.then((response) => {
const deletedCount = response.metadata.deleted_count;
const deletedInstances = response.metadata.rows_deleted;
operation.updateStatus(Status.CONFIRMED, deletedInstances);
liveResult = [deletedCount, { [modelName]: deletedCount }];
breakThenable(liveResult);
return liveResult;
})
.catch((err) => {
operation.updateStatus(Status.REJECTED);
throw err;
});
return makeLiveThenable(liveResult, promise);
}
/**
* Executes a create operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type (always 'create' for this method).
* @param {Object} args - Additional arguments for the operation.
* @returns {LiveThenable<Object>} The live Model instance which resolves to the created model.
*/
static executeCreate(querySet, operationType = "create", args = {}) {
const ModelClass = querySet.ModelClass;
const operationId = `${uuid7()}`;
const apiCallArgs = {
data: args.data || {},
};
// set the data so the operationId matches
if (isNil(args.data)) {
console.warn(`executeCreate was called with null data`);
args.data = {};
}
// Use factory to create operation
const operation = OperationFactory.createCreateOperation(querySet, apiCallArgs.data, operationId);
const tempPk = operation.instances[0][ModelClass.primaryKeyField];
// 1) placeholder instance
const live = ModelClass.fromPk(tempPk, querySet);
// 2) kick off the async call
const promise = makeApiCall(querySet, operationType, apiCallArgs, operationId, async (response) => {
const { data } = response.data;
const pk = Array.isArray(data) ? data[0] : data;
setRealPk(operationId, pk);
})
.then((response) => {
const { data, included, model_name } = response.data;
// Process included entities
processIncludedEntities(modelStoreRegistry, included, ModelClass);
// Get the real PK
const pk = Array.isArray(data) ? data[0] : data;
live.pk = pk;
// Two‑line lookup
const entityMap = included[model_name] || {};
const entityData = entityMap[pk];
if (!entityData) {
throw new Error(`Entity data not found for ${model_name} with pk ${pk}`);
}
// Confirm operation with full entity data
operation.mutate({
instances: [entityData],
status: Status.CONFIRMED,
});
// Freeze the live instance
breakThenable(live);
return live;
})
.catch((error) => {
operation.updateStatus(Status.REJECTED);
throw error;
});
// 3) return the live‑thenable
return makeLiveThenable(live, promise);
}
/**
* Executes an update_instance operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type (always 'update_instance' for this method).
* @param {Object} args - Additional arguments for the operation.
* @returns {LiveThenable<Object>} The live Model instance which resolves to the updated model.
*/
static executeUpdateInstance(querySet, operationType = "update_instance", args = {}) {
const ModelClass = querySet.ModelClass;
const primaryKeyField = ModelClass.primaryKeyField;
const store = querysetStoreRegistry.getStore(querySet);
const querysetPks = store.render();
const data = args.data || {};
// Use factory to create operation
const operation = OperationFactory.createUpdateInstanceOperation(querySet, data);
// 1) placeholder instance
const initialPk = Array.isArray(querysetPks) ? querysetPks[0] : null;
const live = ModelClass.fromPk(initialPk, querySet);
// 2) async call
const promise = makeApiCall(querySet, operationType, { data }, operation.operationId)
.then((response) => {
const { data: raw, included, model_name } = response.data;
// Process included entities
processIncludedEntities(modelStoreRegistry, included, ModelClass);
// Swap in the real PK
const pk = Array.isArray(raw) ? raw[0] : raw;
live.pk = pk;
// Two‑line lookup
const entityMap = included[model_name] || {};
const entityData = entityMap[pk];
if (!entityData) {
throw new Error(`Entity data not found for ${model_name} with pk ${pk}`);
}
// Confirm operation with full entity data
operation.updateStatus(Status.CONFIRMED, [entityData]);
// Freeze the live instance
breakThenable(live);
return live;
})
.catch((error) => {
operation.updateStatus(Status.REJECTED);
throw error;
});
// 3) return the live‑thenable
return makeLiveThenable(live, promise);
}
/**
* Executes a delete_instance operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type (always 'delete_instance' for this method).
* @param {Object} args - Additional arguments for the operation, including the primary key.
* @returns {LiveThenable<Array>} A live‑thenable resolving to [deletedCount, { modelName: deletedCount }].
*/
static executeDeleteInstance(querySet, operationType = "delete_instance", args = {}) {
const ModelClass = querySet.ModelClass;
const modelName = ModelClass.modelName;
const primaryKeyField = ModelClass.primaryKeyField;
// Validate that the primary key is provided
if (args[primaryKeyField] === undefined) {
throw new Error(`Primary key '${primaryKeyField}' must be provided in args for delete_instance operation`);
}
// Use factory to create operation
const operation = OperationFactory.createDeleteInstanceOperation(querySet, args);
// 1) placeholder result
let liveResult = [1, { [modelName]: 1 }];
// 2) async call
const promise = makeApiCall(querySet, operationType, args, operation.operationId)
.then((response) => {
// response.data is the count for delete_instance
let deletedCount = 1;
if (typeof response.data === "number") {
deletedCount = response.data;
}
// Confirm operation
operation.updateStatus(Status.CONFIRMED, [args]);
// Swap in real result and freeze
liveResult = [deletedCount, { [modelName]: deletedCount }];
breakThenable(liveResult);
return liveResult;
})
.catch((err) => {
operation.updateStatus(Status.REJECTED);
throw err;
});
// 3) return live‑thenable
return makeLiveThenable(liveResult, promise);
}
/**
* Executes a query operation with the QuerySet.
*
* @param {QuerySet} querySet - The QuerySet to execute.
* @param {string} operationType - The operation type to perform.
* @param {Object} args - Additional arguments for the operation.
* @returns {Promise<any>} The operation result.
*/
static execute(querySet, operationType = "list", args = {}) {
// execute the query and return the result
switch (operationType) {
case "get":
case "first":
case "last":
return this.executeGet(querySet, operationType, args);
case "update_instance":
return this.executeUpdateInstance(querySet, operationType, args);
case "delete_instance":
return this.executeDeleteInstance(querySet, operationType, args);
case "update":
return this.executeUpdate(querySet, operationType, args);
case "delete":
return this.executeDelete(querySet, operationType, args);
case "create":
return this.executeCreate(querySet, operationType, args);
case "get_or_create":
case "update_or_create":
return this.executeOrCreate(querySet, operationType, args);
case "min":
case "max":
case "avg":
case "sum":
case "count":
return this.executeAgg(querySet, operationType, args);
case "exists":
return this.executeExists(querySet, operationType, args);
case "list":
return this.executeList(querySet, operationType, args);
}
throw new Error(`Invalid operation type: ${operationType}`);
}
}