UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

316 lines (291 loc) 9.56 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; /** * Any Standard Schema compatible validator. */ export type StandardSchema = StandardSchemaV1<unknown, unknown>; /** * Infer the parsed output type from a Standard Schema. */ export type InferOutput<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>; /** * Validate data using a Standard Schema validator synchronously if possible */ function validateSchemaSync<T>( schema: StandardSchemaV1<unknown, T>, data: unknown, ): T | Promise<T> { const result = schema["~standard"].validate(data); if (result instanceof Promise) { return result.then((resolved) => { if (resolved.issues?.length) { const message = resolved.issues .map((issue) => { const path = issue.path !== undefined ? Array.isArray(issue.path) ? issue.path.join(".") : String(issue.path) : ""; return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); throw new Error(`Validation failed: ${message}`); } if ("value" in resolved) { return resolved.value; } throw new Error("Invalid Standard Schema result: missing value"); }); } if (result.issues?.length) { const message = result.issues .map((issue) => { const path = issue.path !== undefined ? Array.isArray(issue.path) ? issue.path.join(".") : String(issue.path) : ""; return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); throw new Error(`Validation failed: ${message}`); } if ("value" in result) { return result.value; } throw new Error("Invalid Standard Schema result: missing value"); } // biome-ignore lint/suspicious/noExplicitAny: Base methods type for flexibility type AnyMethods = Record<string, any>; /** * Base entity instance passed to the `.methods(...)` builder: validated props * plus the `with` and `toJSON` helpers, before domain methods attach. * * `with(...)` is typed as returning the base instance here because the final * methods object is still being defined while the builder runs. At runtime * the returned instance carries the entity's methods, and `EntityInstance` * maps base-instance returns back to the full instance type so consumers keep * typed method chaining. */ export type EntityBaseInstance<Schema extends StandardSchema> = InferOutput<Schema> & { /** * Create a new validated entity instance with patched properties. */ with( patch: Partial<InferOutput<Schema>>, ): EntityBaseInstance<Schema> | Promise<EntityBaseInstance<Schema>>; /** * Convert the entity to a plain JSON object for persistence. */ toJSON(): InferOutput<Schema>; }; /** * Replace base-instance returns with the full entity instance type, unwrapping * promises, so methods that return `self` or `self.with(...)` chain with the * entity's methods attached. */ type WithEntityInstance<R, Instance, Schema extends StandardSchema> = R extends Promise<infer P> ? Promise<WithEntityInstance<P, Instance, Schema>> : R extends EntityBaseInstance<Schema> ? Instance : R; /** * Entity instance with validated props, attached methods, and immutable update * helpers. Method returns that are typed as the base instance (`self` or * `self.with(...)`) are mapped to the full instance type so chaining keeps * the entity's methods. */ export type EntityInstance< Name extends string, Schema extends StandardSchema, Methods extends AnyMethods, > = InferOutput<Schema> & { /** * Create a new validated entity instance with patched properties. */ with( patch: Partial<InferOutput<Schema>>, ): | EntityInstance<Name, Schema, Methods> | Promise<EntityInstance<Name, Schema, Methods>>; /** * Convert the entity to a plain JSON object for persistence. */ toJSON(): InferOutput<Schema>; } & { [K in keyof Methods]: Methods[K] extends (...args: infer A) => infer R ? ( ...args: A ) => WithEntityInstance<R, EntityInstance<Name, Schema, Methods>, Schema> : Methods[K]; }; /** * Entity definition returned by `defineEntity(...).build()`. */ export interface EntityDef< Name extends string, Schema extends StandardSchema, Methods extends AnyMethods, > { /** Name used for debugging and introspection. */ name: Name; /** Standard Schema used to validate entity props. */ schema: Schema; /** * Create a new frozen entity instance from props. * * Returns a promise when the underlying Standard Schema validates async. */ create( props: InferOutput<Schema>, ): | EntityInstance<Name, Schema, Methods> | Promise<EntityInstance<Name, Schema, Methods>>; /** * Reconstruct an entity instance from JSON, usually from persistence. */ fromJSON( json: InferOutput<Schema>, ): | EntityInstance<Name, Schema, Methods> | Promise<EntityInstance<Name, Schema, Methods>>; /** Type-only alias for the entity instance type. Undefined at runtime. */ Type: EntityInstance<Name, Schema, Methods>; } /** * Builder class for creating Entities/Aggregates. */ class EntityBuilder< Name extends string, Schema extends StandardSchema, Methods extends AnyMethods, > { constructor( private readonly cfg: { name: Name; schema?: Schema; methods?: (self: EntityBaseInstance<Schema>) => Methods; }, ) {} /** * Define the schema for this entity using any Standard Schema compatible validator. */ props<S extends StandardSchema>(schema: S): EntityBuilder<Name, S, Methods> { return new EntityBuilder<Name, S, Methods>({ name: this.cfg.name, schema, // Methods declared before props were typed against the previous schema; // canonical builder order is props-then-methods, so this widening only // affects the discouraged reverse order. methods: this.cfg.methods as unknown as | ((self: EntityBaseInstance<S>) => Methods) | undefined, }); } /** * Define methods to attach to entity instances. * The method builder receives the typed base instance: validated props plus * `with` and `toJSON`. */ methods<M extends AnyMethods>( build: (self: EntityBaseInstance<Schema>) => M, ): EntityBuilder<Name, Schema, M> { return new EntityBuilder<Name, Schema, M>({ name: this.cfg.name, schema: this.cfg.schema, methods: build, }); } /** * Finalize and build the entity definition. */ build(): EntityDef<Name, Schema, Methods> { if (!this.cfg.schema) { throw new Error(`Entity "${this.cfg.name}" is missing props schema`); } const schema = this.cfg.schema; const methodsBuilder = this.cfg.methods; const createInstance = ( raw: InferOutput<Schema>, ): | EntityInstance<Name, Schema, Methods> | Promise<EntityInstance<Name, Schema, Methods>> => { const validationResult = validateSchemaSync(schema, raw); const buildInstance = ( parsed: InferOutput<Schema>, ): EntityInstance<Name, Schema, Methods> => { // Cast parsed to a plain object for spreading const parsedObj = parsed as Record<string, unknown>; const base = { ...parsedObj, // Immutable update - creates a new validated instance // Note: Re-validates the merged props for data integrity with(patch: Partial<InferOutput<Schema>>) { const patchObj = patch as Record<string, unknown>; return createInstance({ ...parsedObj, ...patchObj, } as InferOutput<Schema>); }, toJSON() { return parsed; }, } as EntityBaseInstance<Schema>; const methods = methodsBuilder?.(base) ?? {}; const instance = Object.freeze({ ...base, ...methods, }) as EntityInstance<Name, Schema, Methods>; return instance; }; if (validationResult instanceof Promise) { return validationResult.then(buildInstance); } return buildInstance(validationResult); }; const def: EntityDef<Name, Schema, Methods> = { name: this.cfg.name, schema, create: createInstance, fromJSON: createInstance, // Type is undefined at runtime - used only for TypeScript type inference via `typeof Entity.Type` Type: undefined as unknown as EntityInstance<Name, Schema, Methods>, }; return def; } } /** * Create a new entity builder. * * Entities validate props, attach domain methods, and return frozen immutable * instances. `with(...)` revalidates the merged props. `.Type` is type-only and * should be used with `typeof Entity.Type`. * * @example * ```ts * const Todo = defineEntity("Todo") * .props(z.object({ * id: z.string(), * title: z.string(), * assigneeIds: z.array(z.string()).default([]), * })) * .methods((self) => ({ * addAssignee(id: string) { * if (self.assigneeIds.includes(id)) return self; * return self.with({ assigneeIds: [...self.assigneeIds, id] }); * }, * })) * .build(); * * type Todo = typeof Todo.Type; * ``` */ export function defineEntity<Name extends string>(name: Name) { return new EntityBuilder<Name, StandardSchema, Record<string, never>>({ name, }); }