Files
bknd/app/src/data/entities/mutation/Mutator.ts
T
dswbx a298b65abf Release 0.16 (#196)
* initial refactor

* fixes

* test secrets extraction

* updated lock

* fix secret schema

* updated schemas, fixed tests, skipping flow tests for now

* added validator for rjsf, hook form via standard schema

* removed @sinclair/typebox

* remove unneeded vite dep

* fix jsonv literal on Field.tsx

* fix schema import path

* fix schema modals

* fix schema modals

* fix json field form, replaced auth form

* initial waku

* finalize waku example

* fix jsonv-ts version

* fix schema updates with falsy values

* fix media api to respect options' init, improve types

* checking media controller test

* checking media controller test

* checking media controller test

* clean up mediacontroller test

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214)

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials`

* fix server test

* fix data api (updated jsonv-ts)

* enhance cloudflare image optimization plugin with new options and explain endpoint (#215)

* feat: add ability to serve static by using dynamic imports (#197)

* feat: add ability to serve static by using dynamic imports

* serveStaticViaImport: make manifest optional

* serveStaticViaImport: add error log

* refactor/imports (#217)

* refactored core and core/utils imports

* refactored core and core/utils imports

* refactored media imports

* refactored auth imports

* refactored data imports

* updated package json exports, fixed mm config

* fix tests

* feat/deno (#219)

* update bun version

* fix module manager's em reference

* add basic deno example

* finalize

* docs: fumadocs migration (#185)

* feat(docs): initialize documentation structure with Fumadocs

* feat(docs): remove home route and move /docs route to /route

* feat(docs): add redirect to /start page

* feat(docs): migrate Getting Started chapters

* feat(docs): migrate Usage and Extending chapters

* feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive

* feat(layout): add Discord and GitHub links to documentation layout

* feat(docs): add integration chapters draft

* feat(docs): add modules chapters draft

* refactor(mdx-components): remove unused Icon import

* refactor(StackBlitz): enhance type safety by using unknown instead of any

* refactor(layout): update navigation mode to 'top' in layout configuration

* feat(docs): add @iconify/react package

* docs(mdx-components): add Icon component to MDX components list

* feat(docs): update Next.js integration guide

* feat(docs): update React Router integration guide

* feat(docs): update Astro integration guide

* feat(docs): update Vite integration guide

* fix(docs): update package manager initialization commands

* feat(docs): migrate Modules chapters

* chore(docs): update package.json with new devDependencies

* feat(docs): migrate Integration Runtimes chapters

* feat(docs): update Database usage chapter

* feat(docs): restructure documentation paths

* chore(docs): clean up unused imports and files in documentation

* style(layout): revert navigation mode to previous state

* fix(docs): routing for documentation structure

* feat(openapi): add API documentation generation from OpenAPI schema

* feat(docs): add icons to documentation pages

* chore(dependencies): remove unused content-collections packages

* fix(types): fix type error for attachFile in source.ts

* feat(redirects): update root redirect destination to '/start'

* feat(search): add static search functionality

* chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions

* feat(search): add Powered by Orama link

* feat(generate-openapi): add error handling for missing OpenAPI schema

* feat(scripts): add OpenAPI generation to build process

* feat(config): enable dynamic redirects and rewrites in development mode

* feat(layout): add GitHub token support for improved API rate limits

* feat(redirects): add 301 redirects for cloudflare pages

* feat(docs): add Vercel redirects configuration

* feat(config): enable standalone output for development environment

* chore(layout): adjust layout settings

* refactor(package): clean up ajv dependency versions

* feat(docs): add twoslash support

* refactor(layout): update DocsLayout import and navigation configuration

* chore(layout): clean up layout.tsx by commenting out GithubInfo

* fix(Search): add locale to search initialization

* chore(package): update fumadocs and orama to latest versions

* docs: add menu items descriptions

* feat(layout): add GitHub URL to the layout component

* feat(docs): add AutoTypeTable component to MDX components

* feat(app): implement AutoTypeTable rendering for AppEvents type

* docs(layout): switch callouts back to default components

* fix(config): use __filename and __dirname for module paths

* docs: add note about node.js 22 requirement

* feat(styles): add custom color variables for light and dark themes

* docs: add S3 setup instructions for media module

* docs: fix typos and indentation in media module docs

* docs: add local media adapter example for Node.js

* docs(media): add S3/R2 URL format examples and fix typo

* docs: add cross-links to initial config and seeding sections

* indent numbered lists content, clarified media serve locations

* fix mediacontroller tests

* feat(layout): add AnimatedGridPattern component for dynamic background

* style(layout): configure fancy ToC style ('clerk')

* fix(AnimatedGridPattern): correct strokeDasharray type

* docs: actualize docs

* feat: add favicon

* style(cloudflare): format code examples

* feat(layout): add Github and Discord footer icons

* feat(footer): add SVG social media icons for GitHub and Discord

* docs: adjusted auto type table, added llm functions

* added static deployment to cloudflare workers

* docs: change cf redirects to proxy *.mdx instead of redirecting

---------

Co-authored-by: dswbx <dennis.senn@gmx.ch>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>

* build: improve build script

* add missing exports, fix EntityTypescript imports

* media: Dropzone: add programmatic upload, additional events, loading state

* schema object: disable extended defaults to allow empty config values

* Feat/new docs deploy (#224)

* test

* try fixing pm

* try fixing pm

* fix docs on imports, export events correctly

---------

Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
2025-08-01 15:55:59 +02:00

342 lines
11 KiB
TypeScript

import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import type { TActionContext } from "data/fields";
import { WhereBuilder } from "../query/WhereBuilder";
import type { Entity, EntityData, EntityManager } from "../../entities";
import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents } from "../../events";
import { RelationMutator } from "../../relations";
import type { RepoQuery } from "../../server/query";
import { MutatorResult, type MutatorResultOptions } from "./MutatorResult";
type MutatorQB =
| InsertQueryBuilder<any, any, any>
| UpdateQueryBuilder<any, any, any, any>
| DeleteQueryBuilder<any, any, any>;
type MutatorUpdateOrDelete =
| UpdateQueryBuilder<any, any, any, any>
| DeleteQueryBuilder<any, any, any>;
export class Mutator<
TBD extends object = DefaultDB,
TB extends keyof TBD = any,
Output = TBD[TB],
Input = Omit<Output, "id">,
> implements EmitsEvents
{
static readonly Events = MutatorEvents;
emgr: EventManager<typeof MutatorEvents>;
// @todo: current hacky workaround to disable creation of system entities
__unstable_disable_system_entity_creation = true;
__unstable_toggleSystemEntityCreation(value: boolean) {
this.__unstable_disable_system_entity_creation = value;
}
constructor(
public em: EntityManager<TBD>,
public entity: Entity,
protected options?: { emgr?: EventManager<any> },
) {
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
}
private get conn() {
return this.em.connection.kysely;
}
async getValidatedData<Given = any>(data: Given, context: TActionContext): Promise<Given> {
const entity = this.entity;
if (!context) {
throw new Error("Context must be provided for validation");
}
const keys = Object.keys(data as any);
const validatedData: EntityData = {};
// get relational references/keys
const relationMutator = new RelationMutator(entity, this.em);
const relational_keys = relationMutator.getRelationalKeys();
for (const key of keys) {
if (relational_keys.includes(key)) {
const result = await relationMutator.persistRelation(key, data[key]);
// if relation field (include key and value in validatedData)
if (Array.isArray(result)) {
const [relation_key, relation_value] = result;
validatedData[relation_key] = relation_value;
}
continue;
}
const field = entity.getField(key);
if (!field) {
throw new Error(
`Field "${key}" not found on entity "${entity.name}". Fields: ${entity
.getFillableFields()
.map((f) => f.name)
.join(", ")}`,
);
}
// we should never get here, but just to be sure (why?)
if (!field.isFillable(context)) {
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
}
// transform from field
validatedData[key] = await field.transformPersist(data[key], this.em, context);
// transform to driver
validatedData[key] = this.em.connection.toDriver(validatedData[key], field);
}
if (Object.keys(validatedData).length === 0) {
throw new Error(`No data left to update "${entity.name}"`);
}
return validatedData as Given;
}
protected async performQuery<T = EntityData[]>(
qb: MutatorQB,
opts?: MutatorResultOptions,
): Promise<MutatorResult<T>> {
const result = new MutatorResult(this.em, this.entity, {
silent: false,
...opts,
});
return (await result.execute(qb)) as any;
}
async insertOne(data: Input): Promise<MutatorResult<Output>> {
const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
}
const result = await this.emgr.emit(
new Mutator.Events.MutatorInsertBefore({ entity, data: data as any }),
);
// if listener returned, take what's returned
const _data = result.returned ? result.params.data : data;
let validatedData = await this.getValidatedData(
{
...entity.getDefaultObject(),
..._data,
},
"create",
);
// check if required fields are present
const required = entity.getRequiredFields();
for (const field of required) {
if (
typeof validatedData[field.name] === "undefined" ||
validatedData[field.name] === null
) {
throw new Error(`Field "${field.name}" is required`);
}
}
// primary
const primary = entity.getPrimaryField();
const primary_value = primary.getNewValue();
if (primary_value) {
validatedData = {
[primary.name]: primary_value,
...validatedData,
};
}
const query = this.conn
.insertInto(entity.name)
.values(validatedData)
.returning(entity.getSelect());
const res = await this.performQuery(query, { single: true });
await this.emgr.emit(
new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }),
);
return res as any;
}
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResult<Output>> {
const entity = this.entity;
if (!id) {
throw new Error("ID must be provided for update");
}
const result = await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({
entity,
entityId: id,
data,
}),
);
const _data = result.returned ? result.params.data : data;
const validatedData = await this.getValidatedData(_data, "update");
const query = this.conn
.updateTable(entity.name)
.set(validatedData as any)
.where(entity.id().name, "=", id)
.returning(entity.getSelect());
const res = await this.performQuery(query, { single: true });
await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({
entity,
entityId: id,
data: res.data,
changed: validatedData,
}),
);
return res as any;
}
async deleteOne(id: PrimaryFieldType): Promise<MutatorResult<Output>> {
const entity = this.entity;
if (!id) {
throw new Error("ID must be provided for deletion");
}
await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
const query = this.conn
.deleteFrom(entity.name)
.where(entity.id().name, "=", id)
.returning(entity.getSelect());
const res = await this.performQuery(query, { single: true });
await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }),
);
return res as any;
}
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
const entity = this.entity;
const validated: Partial<RepoQuery> = {};
if (options?.where) {
// @todo: add tests for aliased fields in where
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
return typeof entity.getField(field) === "undefined";
});
if (invalid.length > 0) {
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
}
validated.where = options.where;
}
return validated;
}
private appendWhere<QB extends MutatorUpdateOrDelete>(qb: QB, _where?: RepoQuery["where"]): QB {
const entity = this.entity;
const alias = entity.name;
const aliased = (field: string) => `${alias}.${field}`;
// add where if present
if (_where) {
// @todo: add tests for aliased fields in where
const invalid = WhereBuilder.getPropertyNames(_where).filter((field) => {
return typeof entity.getField(field) === "undefined";
});
if (invalid.length > 0) {
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
}
return WhereBuilder.addClause(qb, _where);
}
return qb;
}
// @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteWhere(where: RepoQuery["where"]): Promise<MutatorResult<Output[]>> {
const entity = this.entity;
// @todo: add a way to delete all by adding force?
if (!where || typeof where !== "object" || Object.keys(where).length === 0) {
throw new Error("Where clause must be provided for mass deletion");
}
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
entity.getSelect(),
);
return await this.performQuery(qb);
}
async updateWhere(
data: Partial<Input>,
where: RepoQuery["where"],
): Promise<MutatorResult<Output[]>> {
const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update");
// @todo: add a way to delete all by adding force?
if (!where || typeof where !== "object" || Object.keys(where).length === 0) {
throw new Error("Where clause must be provided for mass update");
}
const query = this.appendWhere(this.conn.updateTable(entity.name), where)
.set(validatedData as any)
.returning(entity.getSelect());
return await this.performQuery(query);
}
async insertMany(data: Input[]): Promise<MutatorResult<Output[]>> {
const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
}
const validated: any[] = [];
for (const row of data) {
const validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(row, "create")),
};
// check if required fields are present
const required = entity.getRequiredFields();
for (const field of required) {
if (
typeof validatedData[field.name] === "undefined" ||
validatedData[field.name] === null
) {
throw new Error(`Field "${field.name}" is required`);
}
}
validated.push(validatedData);
}
const query = this.conn
.insertInto(entity.name)
.values(validated)
.returning(entity.getSelect());
return await this.performQuery(query);
}
}