UNPKG

@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
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}`); } }