import { castTo, type Class, Util, AppError, hasFunction, JSONUtil } from '@travetto/runtime'; import { DataUtil, SchemaRegistryIndex, SchemaValidator, type ValidationError, ValidationResultError } from '@travetto/schema'; import { ModelRegistryIndex } from '../registry/registry-index.ts'; import type { ModelIdSource, ModelType, OptionalId } from '../types/model.ts'; import { NotFoundError } from '../error/not-found.ts'; import { ExistsError } from '../error/exists.ts'; import { SubTypeNotSupportedError } from '../error/invalid-sub-type.ts'; import type { DataHandler, PrePersistScope } from '../registry/types.ts'; import type { ModelCrudSupport } from '../types/crud.ts'; export type ModelCrudProvider = { idSource: ModelIdSource; }; /** * Crud utilities */ export class ModelCrudUtil { /** * Type guard for determining if service supports crud operations */ static isSupported = hasFunction('upsert'); /** * Build a uuid generator */ static uuidSource(length: number = 32): ModelIdSource { const create = (): string => Util.uuid(length); const valid = (id: string): boolean => id.length === length && /^[0-9a-f]+$/i.test(id); return { create, valid }; } /** * Load model * @param cls Class to load model for * @param input Input as string or plain object */ static async load(cls: Class, input: Buffer | string | object, onTypeMismatch: 'notfound' | 'exists' = 'notfound'): Promise { let resolvedInput: object; if (typeof input === 'string' || input instanceof Buffer) { resolvedInput = JSONUtil.parseSafe(input); } else { resolvedInput = input; } const result = SchemaRegistryIndex.getBaseClass(cls).from(resolvedInput); if (!(result instanceof cls || result.constructor.Ⲑid === cls.Ⲑid)) { if (onTypeMismatch === 'notfound') { throw new NotFoundError(cls, result.id); } else { throw new ExistsError(cls, result.id); } } return this.postLoad(cls, result); } /** * Prepares item for storage * * @param cls Type to store for * @param item Item to store */ static async preStore(cls: Class, item: Partial>, provider: ModelCrudProvider, scope: PrePersistScope = 'all'): Promise { if (!item.id) { item.id = provider.idSource.create(); } if (DataUtil.isPlainObject(item)) { item = cls.from(castTo(item)); } SchemaRegistryIndex.get(cls).ensureInstanceTypeField(item); item = await this.prePersist(cls, item, scope); let errors: ValidationError[] = []; try { await SchemaValidator.validate(cls, item); } catch (error) { if (error instanceof ValidationResultError) { errors = error.details.errors; } } if (!provider.idSource.valid(item.id!)) { errors.push({ kind: 'invalid', path: 'id', value: item.id!, type: 'string', message: `${item.id!} is an invalid value for \`id\`` }); } if (errors.length) { throw new ValidationResultError(errors); } return castTo(item); } /** * Ensure subtype is not supported */ static ensureNotSubType(cls: Class): void { const config = SchemaRegistryIndex.getConfig(cls); if (config.discriminatedType && !config.discriminatedBase) { throw new SubTypeNotSupportedError(cls); } } /** * Pre persist behavior */ static async prePersist(cls: Class, item: T, scope: PrePersistScope): Promise { const config = ModelRegistryIndex.getConfig(cls); for (const state of (config.prePersist ?? [])) { if (state.scope === scope || scope === 'all' || state.scope === 'all') { const handler: DataHandler = castTo(state.handler); item = await handler(item) ?? item; } } if (typeof item === 'object' && item && 'prePersist' in item && typeof item['prePersist'] === 'function') { item = await item.prePersist() ?? item; } return item; } /** * Post load behavior */ static async postLoad(cls: Class, item: T): Promise { const config = ModelRegistryIndex.getConfig(cls); for (const handler of castTo[]>(config.postLoad ?? [])) { item = await handler(item) ?? item; } if (typeof item === 'object' && item && 'postLoad' in item && typeof item['postLoad'] === 'function') { item = await item.postLoad() ?? item; } return item; } /** * Ensure everything is correct for a partial update */ static async prePartialUpdate(cls: Class, item: Partial, view?: string): Promise> { if (!DataUtil.isPlainObject(item)) { throw new AppError(`A partial update requires a plain object, not an instance of ${castTo(item).constructor.name}`, { category: 'data' }); } const keys = Object.keys(item); if ((keys.length === 1 && item.id) || keys.length === 0) { throw new AppError('No fields to update'); } else { item = { ...item }; delete item.id; } const result = await this.prePersist(cls, castTo(item), 'partial'); await SchemaValidator.validatePartial(cls, item, view); return result; } /** * Ensure everything is correct for a partial update */ static async naivePartialUpdate(cls: Class, get: () => Promise, item: Partial, view?: string): Promise { const prepared = await this.prePartialUpdate(cls, item, view); const full = await get(); return cls.from(castTo({ ...full, ...prepared })); } }