@beignet/core
Version:
Core framework primitives for Beignet
161 lines • 5.37 kB
JavaScript
/**
* Validate data using a Standard Schema validator synchronously if possible
*/
function validateSchemaSync(schema, data) {
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");
}
/**
* Builder class for creating Entities/Aggregates.
*/
class EntityBuilder {
cfg;
constructor(cfg) {
this.cfg = cfg;
}
/**
* Define the schema for this entity using any Standard Schema compatible validator.
*/
props(schema) {
return new EntityBuilder({
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,
});
}
/**
* Define methods to attach to entity instances.
* The method builder receives the typed base instance: validated props plus
* `with` and `toJSON`.
*/
methods(build) {
return new EntityBuilder({
name: this.cfg.name,
schema: this.cfg.schema,
methods: build,
});
}
/**
* Finalize and build the entity definition.
*/
build() {
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) => {
const validationResult = validateSchemaSync(schema, raw);
const buildInstance = (parsed) => {
// Cast parsed to a plain object for spreading
const parsedObj = parsed;
const base = {
...parsedObj,
// Immutable update - creates a new validated instance
// Note: Re-validates the merged props for data integrity
with(patch) {
const patchObj = patch;
return createInstance({
...parsedObj,
...patchObj,
});
},
toJSON() {
return parsed;
},
};
const methods = methodsBuilder?.(base) ?? {};
const instance = Object.freeze({
...base,
...methods,
});
return instance;
};
if (validationResult instanceof Promise) {
return validationResult.then(buildInstance);
}
return buildInstance(validationResult);
};
const def = {
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,
};
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) {
return new EntityBuilder({
name,
});
}
//# sourceMappingURL=entity.js.map