mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
a298b65abf
* 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>
342 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|