@beignet/core
Version:
Core framework primitives for Beignet
316 lines (291 loc) • 9.56 kB
text/typescript
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,
});
}