@statezero/core
Version:
The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate
361 lines (360 loc) • 14.4 kB
JavaScript
import { Manager } from "./manager.js";
import { ValidationError } from "./errors.js";
import { modelStoreRegistry } from "../../syncEngine/registries/modelStoreRegistry.js";
import { isNil } from "lodash-es";
import { QueryExecutor } from "./queryExecutor";
import { wrapReactiveModel } from "../../reactiveAdaptor.js";
import { DateParsingHelpers } from "./dates.js";
import { FileObject } from './files.js';
import { configInstance } from "../../config.js";
import { ModelSerializer } from "./serializers.js";
import { parseStateZeroError, MultipleObjectsReturned, DoesNotExist, } from "./errors.js";
import axios from "axios";
/**
* A constructor for a Model.
*
* @typedef {Function} ModelConstructor
* @param {any} data - Data to initialize the model.
* @returns {Model}
*
* @property {Manager} objects - The model's manager.
* @property {string} configKey - The configuration key.
* @property {string} modelName - The model name.
* @property {string} primaryKeyField - The primary key field (default 'id').
*/
/**
* Base Model class with integrated API implementation.
*
* @abstract
*/
export class Model {
constructor(data = {}) {
this.serializer = new ModelSerializer(this.constructor);
const serializedData = this.serializer.toInternal(data);
this._data = serializedData;
this._pk = serializedData[this.constructor.primaryKeyField] || undefined;
this.__version = 0;
return wrapReactiveModel(this);
}
touch() {
this.__version++;
}
/**
* Returns the primary key of the model instance.
*
* @returns {number|undefined} The primary key.
*/
get pk() {
return this._pk;
}
/**
* Sets the primary key of the model instance.
*
* @param {number|undefined} value - The new primary key value.
*/
set pk(value) {
this._pk = value;
this.touch();
}
/**
* Instantiate from pk using queryset scoped singletons
*/
static fromPk(pk, querySet) {
let qsId = querySet ? querySet.__uuid : "";
let key = `${qsId}__${this.configKey}__${this.modelName}__${pk}`;
if (!this.instanceCache.has(key)) {
const instance = new this();
instance.pk = pk;
this.instanceCache.set(key, instance);
}
return this.instanceCache.get(key);
}
/**
* Gets a field value from the internal data store
*
* @param {string} field - The field name
* @returns {any} The field value
*/
getField(field) {
// Access the reactive __version property to establish dependency for vue integration
const trackVersion = this.__version;
const ModelClass = this.constructor;
if (ModelClass.primaryKeyField === field)
return this._pk;
// check local overrides
let value = this._data[field];
// if its not been overridden, get it from the store
if (value === undefined && !isNil(this._pk)) {
let storedValue = modelStoreRegistry.getEntity(ModelClass, this._pk);
if (storedValue)
value = storedValue[field]; // if stops null -> undefined
}
// Use serializer to convert internal format to live format
return this.serializer.toLiveField(field, value);
}
/**
* Sets a field value in the internal data store
*
* @param {string} field - The field name
* @param {any} value - The field value to set
*/
setField(field, value) {
const ModelClass = this.constructor;
// Use serializer to convert live format to internal format
const internalValue = this.serializer.toInternalField(field, value);
if (ModelClass.primaryKeyField === field) {
this._pk = internalValue;
}
else {
this._data[field] = internalValue;
}
}
/**
* Validates that the provided data object only contains keys
* defined in the model's allowed fields. Supports nested fields
* using double underscore notation (e.g., author__name).
*
* @param {Object} data - The object to validate.
* @throws {ValidationError} If an unknown key is found.
*/
static validateFields(data) {
if (isNil(data))
return;
const allowedFields = this.fields;
for (const key of Object.keys(data)) {
if (key === "repr" || key === "type")
continue;
// Handle nested fields by splitting on double underscore
// and taking just the base field name
const baseField = key.split("__")[0];
if (!allowedFields.includes(baseField)) {
let errorMsg = `Invalid field: ${baseField}. Allowed fields are: ${allowedFields.join(", ")}`;
console.error(errorMsg);
throw new ValidationError(errorMsg);
}
}
}
/**
* Serializes the model instance.
*
* By default, it returns all enumerable own properties.
* Subclasses should override this to return specific keys.
*
* @param {boolean} includeRepr - Whether to include the repr field (for caching). Default: false.
* @returns {Object} The serialized model data.
*/
serialize(includeRepr = false) {
const ModelClass = this.constructor;
const data = {};
// Collect all field values (already in internal format)
for (const field of ModelClass.fields) {
if (field === ModelClass.primaryKeyField) {
data[field] = this._pk;
}
else {
let value = this._data[field];
// Get from store if not in local data
if (value === undefined && !isNil(this._pk)) {
const storedData = modelStoreRegistry.getEntity(ModelClass, this._pk);
if (storedData) {
value = storedData[field];
}
}
data[field] = value;
}
}
// Include repr field if requested (for caching purposes)
if (includeRepr && !isNil(this._pk)) {
const storedData = modelStoreRegistry.getEntity(ModelClass, this._pk);
if (storedData && storedData.repr) {
data.repr = storedData.repr;
}
}
// Data is already in internal format, so return as-is for API transmission
return data;
}
/**
* Saves the model instance by either creating a new record or updating an existing one.
*
* @returns {Promise<Model>} A promise that resolves to the updated model instance.
*/
async save() {
const ModelClass = this.constructor;
const pkField = ModelClass.primaryKeyField;
const querySet = !this.pk
? ModelClass.objects.newQuerySet()
: ModelClass.objects.filter({ [pkField]: this.pk });
const data = this.serialize();
let instance;
if (!this.pk) {
// Create new instance
instance = await QueryExecutor.execute(querySet, "create", { data });
}
else {
// Update existing instance
instance = await QueryExecutor.execute(querySet, "update_instance", {
data,
});
}
this._pk = instance.pk;
this._data = {};
return this;
}
/**
* Deletes the instance from the database.
*
* Returns a tuple with the number of objects deleted and an object mapping
* model names to the number of objects deleted, matching Django's behavior.
*
* @returns {Promise<[number, Object]>} A promise that resolves to the deletion result.
* @throws {Error} If the instance has not been saved (no primary key).
*/
async delete() {
if (!this.pk) {
throw new Error("Cannot delete unsaved instance");
}
const ModelClass = this.constructor;
const pkField = ModelClass.primaryKeyField;
const querySet = ModelClass.objects.filter({ [pkField]: this.pk });
// Pass the instance data with primary key as the args
const args = { [pkField]: this.pk };
const result = await QueryExecutor.execute(querySet, "delete_instance", args);
// result -> [deletedCount, { [modelName]: deletedCount }];
return result;
}
/**
* Refreshes the model instance with data from the database.
*
* @returns {Promise<void>} A promise that resolves when the instance has been refreshed.
* @throws {Error} If the instance has not been saved (no primary key).
*/
async refreshFromDb() {
if (!this.pk) {
throw new Error("Cannot refresh unsaved instance");
}
const ModelClass = this.constructor;
const fresh = await ModelClass.objects.get({
[ModelClass.primaryKeyField]: this.pk,
});
// clear the current data and fresh data will flow
this._data = {};
}
/**
* Validates the model instance using the same serialize behavior as save()
* @param {string} validateType - 'create' or 'update' (defaults to auto-detect)
* @param {boolean} partial - Whether to allow partial validation
* @returns {Promise<boolean>} Promise that resolves to true if valid, throws error if invalid
*/
async validate(validateType = null, partial = false) {
const ModelClass = this.constructor;
if (!validateType) {
validateType = this.pk ? "update" : "create";
}
// Validate the validateType parameter
if (!["update", "create"].includes(validateType)) {
throw new Error(`Validation type must be 'update' or 'create', not '${validateType}'`);
}
// Use the same serialize logic as save()
const data = this.serialize();
// Delegate to static method
return ModelClass.validate(data, validateType, partial);
}
/**
* Static method to validate data without creating an instance
* @param {Object} data - Data to validate
* @param {string} validateType - 'create' or 'update'
* @param {boolean} partial - Whether to allow partial validation
* @returns {Promise<boolean>} Promise that resolves to true if valid, throws error if invalid
*/
static async validate(data, validateType = "create", partial = false) {
const ModelClass = this;
// Validate the validateType parameter
if (!["update", "create"].includes(validateType)) {
throw new Error(`Validation type must be 'update' or 'create', not '${validateType}'`);
}
// Get backend config and check if it exists
const config = configInstance.getConfig();
const backend = config.backendConfigs[ModelClass.configKey];
if (!backend) {
throw new Error(`No backend configuration found for key: ${ModelClass.configKey}`);
}
// Build URL for validate endpoint
const baseUrl = backend.API_URL.replace(/\/+$/, "");
const url = `${baseUrl}/${ModelClass.modelName}/validate/`;
// Prepare headers
const headers = {
"Content-Type": "application/json",
...(backend.getAuthHeaders ? backend.getAuthHeaders() : {}),
};
// Make direct API call to validate endpoint
try {
const response = await axios.post(url, {
data: data,
validate_type: validateType,
partial: partial,
}, { headers });
// Backend returns {"valid": true} on success
return response.data.valid === true;
}
catch (error) {
if (error.response && error.response.data) {
const parsedError = parseStateZeroError(error.response.data);
if (Error.captureStackTrace) {
Error.captureStackTrace(parsedError, ModelClass.validate);
}
throw parsedError;
}
throw new Error(`Validation failed: ${error.message}`);
}
}
/**
* Get field permissions for the current user (cached on the class)
* @param {boolean} refresh - Force refresh the cached permissions
* @returns {Promise<{visible_fields: string[], creatable_fields: string[], editable_fields: string[]}>}
*/
static async getFieldPermissions(refresh = false) {
const ModelClass = this;
// Return cached permissions if available and not forcing refresh
if (!refresh && ModelClass._fieldPermissionsCache) {
return ModelClass._fieldPermissionsCache;
}
// Get backend config and check if it exists
const config = configInstance.getConfig();
const backend = config.backendConfigs[ModelClass.configKey];
if (!backend) {
throw new Error(`No backend configuration found for key: ${ModelClass.configKey}`);
}
// Build URL for field permissions endpoint
const baseUrl = backend.API_URL.replace(/\/+$/, "");
const url = `${baseUrl}/${ModelClass.modelName}/field-permissions/`;
// Prepare headers
const headers = {
"Content-Type": "application/json",
...(backend.getAuthHeaders ? backend.getAuthHeaders() : {}),
};
// Make direct API call to field permissions endpoint
try {
const response = await axios.get(url, { headers });
// Cache the permissions on the class
ModelClass._fieldPermissionsCache = response.data;
// Backend returns {visible_fields: [], creatable_fields: [], editable_fields: []}
return response.data;
}
catch (error) {
if (error.response && error.response.data) {
const parsedError = parseStateZeroError(error.response.data);
if (Error.captureStackTrace) {
Error.captureStackTrace(parsedError, ModelClass.getFieldPermissions);
}
throw parsedError;
}
throw new Error(`Failed to get field permissions: ${error.message}`);
}
}
}
/**
* Creates a new Model instance.
*
* @param {any} [data={}] - The data for initialization.
*/
Model.instanceCache = new Map();