Merge pull request #32 from bknd-io/feat/optimize-seeding

Feat: optimize seeding, adding api typing support
This commit is contained in:
dswbx
2024-12-23 11:45:15 +01:00
committed by GitHub
54 changed files with 883 additions and 443 deletions

View File

@@ -16,7 +16,7 @@ describe("Mutator simple", async () => {
new TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 })
]);
const em = new EntityManager([items], connection);
const em = new EntityManager<any>([items], connection);
await em.connection.kysely.schema
.createTable("items")
@@ -175,4 +175,18 @@ describe("Mutator simple", async () => {
{ id: 8, label: "keep", count: 0 }
]);
});
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
expect(data2).toEqual(data);
});
});

View File

@@ -3,6 +3,8 @@ import {
BooleanField,
DateField,
Entity,
EntityIndex,
EntityManager,
EnumField,
JsonField,
ManyToManyRelation,
@@ -12,6 +14,7 @@ import {
PolymorphicRelation,
TextField
} from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import {
FieldPrototype,
type FieldSchema,
@@ -20,6 +23,7 @@ import {
boolean,
date,
datetime,
em,
entity,
enumm,
json,
@@ -46,12 +50,17 @@ describe("prototype", () => {
});
test("...2", async () => {
const user = entity("users", {
name: text().required(),
const users = entity("users", {
name: text(),
bio: text(),
age: number(),
some: number().required()
some: number()
});
type db = {
users: Schema<typeof users>;
};
const obj: Schema<typeof users> = {} as any;
//console.log("user", user.toJSON());
});
@@ -266,4 +275,38 @@ describe("prototype", () => {
const obj: Schema<typeof test> = {} as any;
});
test("schema", async () => {
const _em = em(
{
posts: entity("posts", { name: text(), slug: text().required() }),
comments: entity("comments", { some: text() }),
users: entity("users", { email: text() })
},
({ relation, index }, { posts, comments, users }) => {
relation(posts).manyToOne(comments).manyToOne(users);
index(posts).on(["name"]).on(["slug"], true);
}
);
type LocalDb = (typeof _em)["DB"];
const es = [
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
new Entity("comments", [new TextField("some")]),
new Entity("users", [new TextField("email")])
];
const _em2 = new EntityManager(
es,
new DummyConnection(),
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
[
new EntityIndex(es[0], [es[0].field("name")!]),
new EntityIndex(es[0], [es[0].field("slug")!], true)
]
);
// @ts-ignore
expect(_em2.toJSON()).toEqual(_em.toJSON());
});
});

View File

@@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => {
new TextField("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false })
]);
const em = new EntityManager([entity], dummyConnection);
const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const payload = { label: "item 1", count: 1 };
@@ -61,7 +61,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const users = new Entity("users", [new TextField("username")]);
const relations = [new ManyToOneRelation(posts, users)];
const em = new EntityManager([posts, users], dummyConnection, relations);
const em = new EntityManager<any>([posts, users], dummyConnection, relations);
await em.schema().sync({ force: true });
test("RelationMutator", async () => {
@@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => {
const users = new Entity("users", [new TextField("username")]);
const settings = new Entity("settings", [new TextField("theme")]);
const relations = [new OneToOneRelation(users, settings)];
const em = new EntityManager([users, settings], dummyConnection, relations);
const em = new EntityManager<any>([users, settings], dummyConnection, relations);
await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => {
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
describe("[data] Mutator (Events)", async () => {
const entity = new Entity("test", [new TextField("label")]);
const em = new EntityManager([entity], dummyConnection);
const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const events = new Map<string, any>();

View File

@@ -9,14 +9,42 @@ const watch = args.includes("--watch");
const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
if (clean) {
console.log("Cleaning dist");
await $`rm -rf dist`;
}
let types_running = false;
function buildTypes() {
if (types_running) return;
types_running = true;
await $`rm -rf dist`;
if (types) {
Bun.spawn(["bun", "build:types"], {
onExit: () => {
console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], {
onExit: () => {
console.log("Types aliased");
types_running = false;
}
});
}
});
}
let watcher_timeout: any;
function delayTypes() {
if (!watch) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
}
if (types && !watch) {
buildTypes();
}
/**
@@ -46,7 +74,8 @@ const result = await esbuild.build({
__isDev: "0",
"process.env.NODE_ENV": '"production"'
},
chunkNames: "chunks/[name]-[hash]"
chunkNames: "chunks/[name]-[hash]",
logLevel: "error"
});
// Write manifest
@@ -96,6 +125,9 @@ await tsup.build({
treeshake: true,
loader: {
".svg": "dataurl"
},
onSuccess: async () => {
delayTypes();
}
});
@@ -117,11 +149,12 @@ await tsup.build({
loader: {
".svg": "dataurl"
},
onSuccess: async () => {
console.log("--- ui built");
},
esbuildOptions: (options) => {
options.logLevel = "silent";
options.chunkNames = "chunks/[name]-[hash]";
},
onSuccess: async () => {
delayTypes();
}
});
@@ -148,7 +181,10 @@ function baseConfig(adapter: string): tsup.Options {
],
metafile: true,
splitting: false,
treeshake: true
treeshake: true,
onSuccess: async () => {
delayTypes();
}
};
}

View File

@@ -5,14 +5,14 @@
"bin": "./dist/cli/index.js",
"version": "0.3.4-alpha1",
"scripts": {
"build:all": "bun run build && bun run build:cli",
"build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly",
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"updater": "bun x npm-check-updates -ui",
@@ -75,6 +75,7 @@
"tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.5",
"vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0",
@@ -90,75 +91,75 @@
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs"
},
"./client": {
"types": "./dist/ui/client/index.d.ts",
"types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs"
},
"./data": {
"types": "./dist/data/index.d.ts",
"types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.cjs"
},
"./core": {
"types": "./dist/core/index.d.ts",
"types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.cjs"
},
"./utils": {
"types": "./dist/core/utils/index.d.ts",
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs"
},
"./adapter/cloudflare": {
"types": "./dist/adapter/cloudflare/index.d.ts",
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs"
},
"./adapter/vite": {
"types": "./dist/adapter/vite/index.d.ts",
"types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs"
},
"./adapter/nextjs": {
"types": "./dist/adapter/nextjs/index.d.ts",
"types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs"
},
"./adapter/remix": {
"types": "./dist/adapter/remix/index.d.ts",
"types": "./dist/types/adapter/remix/index.d.ts",
"import": "./dist/adapter/remix/index.js",
"require": "./dist/adapter/remix/index.cjs"
},
"./adapter/bun": {
"types": "./dist/adapter/bun/index.d.ts",
"types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs"
},
"./adapter/node": {
"types": "./dist/adapter/node/index.d.ts",
"types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
},
"./adapter/astro": {
"types": "./dist/adapter/astro/index.d.ts",
"types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs"
},

View File

@@ -38,7 +38,7 @@ export class Api {
private token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi;
public data!: DataApi;
public data!: DataApi<DB>;
public auth!: AuthApi;
public media!: MediaApi;

View File

@@ -12,13 +12,17 @@ import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void;
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends Event<{ app: App }> {
export class AppBuiltEvent extends AppEvent {
static override slug = "app-built";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type CreateAppConfig = {
connection?:
@@ -37,6 +41,8 @@ export type AppConfig = InitialModuleConfigs;
export class App<DB = any> {
modules: ModuleManager;
static readonly Events = AppEvents;
adminController?: AdminController;
private trigger_first_boot = false;
constructor(
private connection: Connection,
@@ -48,9 +54,20 @@ export class App<DB = any> {
...moduleManagerOptions,
initial: _initialConfig,
onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config);
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
if (!this.emgr.enabled) {
console.warn("[APP] config updated, but event manager is disabled, skip.");
return;
}
console.log("[APP] config updated", key);
await this.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
console.log("[APP] first boot");
this.trigger_first_boot = true;
}
});
this.modules.ctx().emgr.registerEvents(AppEvents);
@@ -88,14 +105,24 @@ export class App<DB = any> {
if (options?.save) {
await this.modules.save();
}
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
this.trigger_first_boot = false;
await this.emgr.emit(new AppFirstBoot({ app: this }));
}
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema();
}
get server() {
return this.modules.server;
}
get fetch(): any {
return this.modules.server.fetch;
return this.server.fetch;
}
get module() {
@@ -119,7 +146,8 @@ export class App<DB = any> {
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.modules.server.route("/", new AdminController(this, config).getController());
this.adminController = new AdminController(this, config);
this.modules.server.route("/", this.adminController.getController());
return this;
}

View File

@@ -197,7 +197,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Exception("User already exists");
}
const payload = {
const payload: any = {
...profile,
strategy: strategy.getName(),
strategy_value: identifier

View File

@@ -15,6 +15,7 @@ export class EventManager<
> {
protected events: EventClass[] = [];
protected listeners: EventListener[] = [];
enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
if (events) {
@@ -28,6 +29,16 @@ export class EventManager<
}
}
enable() {
this.enabled = true;
return this;
}
disable() {
this.enabled = false;
return this;
}
clearEvents() {
this.events = [];
return this;
@@ -39,6 +50,10 @@ export class EventManager<
return this;
}
getListeners(): EventListener[] {
return [...this.listeners];
}
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
// proxy class to access events
return new Proxy(this, {
@@ -133,6 +148,11 @@ export class EventManager<
async emit(event: Event) {
// @ts-expect-error slug is static
const slug = event.constructor.slug;
if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug);
return;
}
if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`);
}

View File

@@ -9,10 +9,25 @@ export async function withDisabledConsole<R>(
fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log"]
): Promise<R> {
const enable = disableConsoleLog(severities);
const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error
};
disableConsoleLog(severities);
const enable = () => {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
};
try {
const result = await fn();
enable();
return result;
} catch (e) {
enable();
throw e;
}
}
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {

View File

@@ -1,52 +1,20 @@
import { transformObject } from "core/utils";
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data";
import {
DataPermissions,
type Entity,
EntityIndex,
type EntityManager,
constructEntity,
constructRelation
} from "data";
import { Module } from "modules/Module";
import { DataController } from "./api/DataController";
import {
type AppDataConfig,
FIELDS,
RELATIONS,
type TAppDataEntity,
type TAppDataRelation,
dataConfigSchema
} from "./data-schema";
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
export class AppData<DB> extends Module<typeof dataConfigSchema> {
static constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
const { type } = fieldConfig;
if (!(type in FIELDS)) {
throw new Error(`Field type "${type}" not found`);
}
const { field } = FIELDS[type as any];
const returnal = new field(name, fieldConfig.config) as Field;
return returnal;
});
// @todo: entity must be migrated to typebox
return new Entity(
name,
Object.values(fields),
entityConfig.config as any,
entityConfig.type as any
);
}
static constructRelation(
relationConfig: TAppDataRelation,
resolver: (name: Entity | string) => Entity
) {
return new RELATIONS[relationConfig.type].cls(
resolver(relationConfig.source),
resolver(relationConfig.target),
relationConfig.config
);
}
override async build() {
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
return AppData.constructEntity(name, entityConfig);
return constructEntity(name, entityConfig);
});
const _entity = (_e: Entity | string): Entity => {
@@ -57,7 +25,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
};
const relations = transformObject(this.config.relations ?? {}, (relation) =>
AppData.constructRelation(relation, _entity)
constructRelation(relation, _entity)
);
const indices = transformObject(this.config.indices ?? {}, (index, name) => {

View File

@@ -5,7 +5,7 @@ export type DataApiOptions = BaseModuleApiOptions & {
defaultQuery?: Partial<RepoQuery>;
};
export class DataApi extends ModuleApi<DataApiOptions> {
export class DataApi<DB> extends ModuleApi<DataApiOptions> {
protected override getDefaultOptions(): Partial<DataApiOptions> {
return {
basepath: "/api/data",
@@ -15,48 +15,60 @@ export class DataApi extends ModuleApi<DataApiOptions> {
};
}
readOne(
entity: string,
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
) {
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
}
readMany(entity: string, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity],
query ?? this.options.defaultQuery
);
}
readManyByReference(
entity: string,
id: PrimaryFieldType,
reference: string,
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: Partial<RepoQuery> = {}
) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity, id, reference],
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any],
query ?? this.options.defaultQuery
);
}
createOne(entity: string, input: EntityData) {
return this.post<RepositoryResponse<EntityData>>([entity], input);
readManyByReference<
E extends keyof DB | string,
R extends keyof DB | string,
Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference],
query ?? this.options.defaultQuery
);
}
updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">
) {
return this.post<RepositoryResponse<Data>>([entity as any], input);
}
deleteOne(entity: string, id: PrimaryFieldType) {
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>
) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
}
count(entity: string, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
[entity, "fn", "count"],
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType
) {
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
}
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"],
where
);
}

View File

@@ -1,5 +1,5 @@
import { type ClassController, isDebug, tbValidator as tb } from "core";
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils";
import {
DataPermissions,
type EntityData,
@@ -165,13 +165,12 @@ export class DataController implements ClassController {
// read entity schema
.get("/schema.json", async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const url = new URL(c.req.url);
const $id = `${url.origin}${this.config.basepath}/schema.json`;
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `schemas/${e.name}`
$ref: `${this.config.basepath}/schemas/${e.name}`
}
])
);
@@ -183,22 +182,28 @@ export class DataController implements ClassController {
})
// read schema
.get(
"/schemas/:entity",
tb("param", Type.Object({ entity: Type.String() })),
"/schemas/:entity/:context?",
tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw);
const { entity } = c.req.param();
const { entity, context } = c.req.param();
if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities);
return c.notFound();
}
const _entity = this.em.entity(entity);
const schema = _entity.toSchema();
const schema = _entity.toSchema({ context } as any);
const url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`;
const $id = `${base}/schemas/${entity}`;
const $id = `${this.config.basepath}/schemas/${entity}`;
return c.json({
$schema: `${base}/schema.json`,
$id,

View File

@@ -0,0 +1,7 @@
import { Connection } from "./Connection";
export class DummyConnection extends Connection {
constructor() {
super(undefined as any);
}
}

View File

@@ -158,7 +158,7 @@ export class Entity<
}
get label(): string {
return snakeToPascalWithSpaces(this.config.name || this.name);
return this.config.name ?? snakeToPascalWithSpaces(this.name);
}
field(name: string): Field | undefined {
@@ -210,20 +210,34 @@ export class Entity<
return true;
}
toSchema(clean?: boolean): object {
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
let fields: Field[];
switch (options?.context) {
case "create":
case "update":
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
const schema = Type.Object(
transformObject(fields, (field) => ({
transformObject(_fields, (field) => {
//const hidden = field.isHidden(options?.context);
const fillable = field.isFillable(options?.context);
return {
title: field.config.label,
$comment: field.config.description,
$field: field.type,
readOnly: !field.isFillable("update") ? true : undefined,
writeOnly: !field.isFillable("create") ? true : undefined,
readOnly: !fillable ? true : undefined,
...field.toJsonSchema()
}))
};
}),
{ additionalProperties: false }
);
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
}
toJSON() {

View File

@@ -14,6 +14,14 @@ import { SchemaManager } from "../schema/SchemaManager";
import { Entity } from "./Entity";
import { type EntityData, Mutator, Repository } from "./index";
type EntitySchema<E extends Entity | string, DB = any> = E extends Entity<infer Name>
? Name extends keyof DB
? Name
: never
: E extends keyof DB
? E
: never;
export class EntityManager<DB> {
connection: Connection;
@@ -87,10 +95,16 @@ export class EntityManager<DB> {
this.entities.push(entity);
}
entity(name: string): Entity {
const entity = this.entities.find((e) => e.name === name);
entity(e: Entity | string): Entity {
let entity: Entity | undefined;
if (typeof e === "string") {
entity = this.entities.find((entity) => entity.name === e);
} else {
entity = e;
}
if (!entity) {
throw new EntityNotDefinedException(name);
throw new EntityNotDefinedException(typeof e === "string" ? e : e.name);
}
return entity;
@@ -162,28 +176,16 @@ export class EntityManager<DB> {
return this.relations.relationReferencesOf(this.entity(entity_name));
}
repository(_entity: Entity | string) {
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
return new Repository(this, entity, this.emgr);
repository<E extends Entity | string>(entity: E): Repository<DB, EntitySchema<E, DB>> {
return this.repo(entity);
}
repo<E extends Entity>(
_entity: E
): Repository<
DB,
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
> {
return new Repository(this, _entity, this.emgr);
repo<E extends Entity | string>(entity: E): Repository<DB, EntitySchema<E, DB>> {
return new Repository(this, this.entity(entity), this.emgr);
}
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
const entity = this.entity(_entity as any);
return new Repository(this, entity, this.emgr);
}
mutator(_entity: Entity | string) {
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
return new Mutator(this, entity, this.emgr);
mutator<E extends Entity | string>(entity: E): Mutator<DB, EntitySchema<E, DB>> {
return new Mutator(this, this.entity(entity), this.emgr);
}
addIndex(index: EntityIndex, force = false) {

View File

@@ -25,7 +25,13 @@ export type MutatorResponse<T = EntityData[]> = {
data: T;
};
export class Mutator<DB> implements EmitsEvents {
export class Mutator<
DB = any,
TB extends keyof DB = any,
Output = DB[TB],
Input = Omit<Output, "id">
> implements EmitsEvents
{
em: EntityManager<DB>;
entity: Entity;
static readonly Events = MutatorEvents;
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
return this.em.connection.kysely;
}
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
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);
const keys = Object.keys(data as any);
const validatedData: EntityData = {};
// get relational references/keys
@@ -95,7 +101,7 @@ export class Mutator<DB> implements EmitsEvents {
throw new Error(`No data left to update "${entity.name}"`);
}
return validatedData;
return validatedData as Given;
}
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
return { ...response, data: data[0]! };
}
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
async insertOne(data: Input): Promise<MutatorResponse<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`);
@@ -154,10 +160,10 @@ export class Mutator<DB> implements EmitsEvents {
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
return res;
return res as any;
}
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
async updateOne(id: PrimaryFieldType, data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update");
@@ -166,12 +172,16 @@ export class Mutator<DB> implements EmitsEvents {
const validatedData = await this.getValidatedData(data, "update");
await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
new Mutator.Events.MutatorUpdateBefore({
entity,
entityId: id,
data: validatedData as any
})
);
const query = this.conn
.updateTable(entity.name)
.set(validatedData)
.set(validatedData as any)
.where(entity.id().name, "=", id)
.returning(entity.getSelect());
@@ -181,10 +191,10 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
);
return res;
return res as any;
}
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion");
@@ -203,7 +213,7 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);
return res;
return res as any;
}
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
@@ -250,47 +260,59 @@ export class Mutator<DB> implements EmitsEvents {
}
// @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
const entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
entity.getSelect()
);
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
const res = await this.many(qb);
/*await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);*/
return res;
return (await this.many(qb)) as any;
}
async updateWhere(
data: EntityData,
where?: RepoQuery["where"]
): Promise<MutatorResponse<EntityData>> {
async updateWhere(data: Partial<Input>, where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update");
/*await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
);*/
const query = this.appendWhere(this.conn.updateTable(entity.name), where)
.set(validatedData)
//.where(entity.id().name, "=", id)
.set(validatedData as any)
.returning(entity.getSelect());
const res = await this.many(query);
return (await this.many(query)) as any;
}
/*await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
);*/
async insertMany(data: Input[]): Promise<MutatorResponse<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`);
}
return res;
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.many(query)) as any;
}
}

View File

@@ -272,7 +272,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findId(
id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB]>> {
): Promise<RepositoryResponse<DB[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,

View File

@@ -104,6 +104,12 @@ export class TextField<Required extends true | false = false> extends Field<
);
}
if (this.config.pattern && value && !new RegExp(this.config.pattern).test(value)) {
throw new TransformPersistFailedException(
`Field "${this.name}" must match the pattern ${this.config.pattern}`
);
}
return value;
}

View File

@@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon
export { SqliteConnection } from "./connection/SqliteConnection";
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
export { constructEntity, constructRelation } from "./schema/constructor";
export const DatabaseEvents = {
...MutatorEvents,
...RepositoryEvents

View File

@@ -1,3 +1,8 @@
import { DummyConnection } from "data/connection/DummyConnection";
import { EntityManager } from "data/entities/EntityManager";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
import type { ModuleConfigs } from "modules";
import {
BooleanField,
type BooleanFieldConfig,
@@ -5,6 +10,8 @@ import {
type DateFieldConfig,
Entity,
type EntityConfig,
EntityIndex,
type EntityRelation,
EnumField,
type EnumFieldConfig,
type Field,
@@ -25,15 +32,14 @@ import {
type TEntityType,
TextField,
type TextFieldConfig
} from "data";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
} from "../index";
type Options<Config = any> = {
entity: { name: string; fields: Record<string, Field<any, any, any>> };
field_name: string;
config: Config;
is_required: boolean;
another?: string;
};
const FieldMap = {
@@ -239,7 +245,89 @@ export function relation<Local extends Entity>(local: Local) {
};
}
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
export function index<E extends Entity>(entity: E) {
return {
on: (fields: (keyof InsertSchema<E>)[], unique?: boolean) => {
const _fields = fields.map((f) => {
const field = entity.field(f as any);
if (!field) {
throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`);
}
return field;
});
return new EntityIndex(entity, _fields, unique);
}
};
}
class EntityManagerPrototype<Entities extends Record<string, Entity>> extends EntityManager<
Schema<Entities>
> {
constructor(
public __entities: Entities,
relations: EntityRelation[] = [],
indices: EntityIndex[] = []
) {
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
}
type Chained<Fn extends (...args: any[]) => any, Rt = ReturnType<Fn>> = <E extends Entity>(
e: E
) => {
[K in keyof Rt]: Rt[K] extends (...args: any[]) => any
? (...args: Parameters<Rt[K]>) => Rt
: never;
};
export function em<Entities extends Record<string, Entity>>(
entities: Entities,
schema?: (
fns: { relation: Chained<typeof relation>; index: Chained<typeof index> },
entities: Entities
) => void
) {
const relations: EntityRelation[] = [];
const indices: EntityIndex[] = [];
const relationProxy = (e: Entity) => {
return new Proxy(relation(e), {
get(target, prop) {
return (...args: any[]) => {
relations.push(target[prop](...args));
return relationProxy(e);
};
}
}) as any;
};
const indexProxy = (e: Entity) => {
return new Proxy(index(e), {
get(target, prop) {
return (...args: any[]) => {
indices.push(target[prop](...args));
return indexProxy(e);
};
}
}) as any;
};
if (schema) {
schema({ relation: relationProxy, index: indexProxy }, entities);
}
const e = new EntityManagerPrototype(entities, relations, indices);
return {
DB: e.__entities as unknown as Schemas<Entities>,
entities: e.__entities,
relations,
indices,
toJSON: () =>
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">
};
}
export type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
? {
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
? Required extends true
@@ -284,12 +372,16 @@ type OptionalUndefined<
}
>;
type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
export type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
? Required extends true
? Type
: Type | undefined
: never;
export type Schemas<T extends Record<string, Entity>> = {
[K in keyof T]: Schema<T[K]>;
};
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>;
export type Schema<T> = Simplify<{ id: Generated<number> } & InsertSchema<T>>;
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;

View File

@@ -0,0 +1,34 @@
import { transformObject } from "core/utils";
import { Entity, type Field } from "data";
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
const { type } = fieldConfig;
if (!(type in FIELDS)) {
throw new Error(`Field type "${type}" not found`);
}
const { field } = FIELDS[type as any];
const returnal = new field(name, fieldConfig.config) as Field;
return returnal;
});
return new Entity(
name,
Object.values(fields),
entityConfig.config as any,
entityConfig.type as any
);
}
export function constructRelation(
relationConfig: TAppDataRelation,
resolver: (name: Entity | string) => Entity
) {
return new RELATIONS[relationConfig.type].cls(
resolver(relationConfig.source),
resolver(relationConfig.target),
relationConfig.config
);
}

View File

@@ -4,8 +4,10 @@ export {
getDefaultConfig,
getDefaultSchema,
type ModuleConfigs,
type ModuleSchemas
} from "modules/ModuleManager";
type ModuleSchemas,
type ModuleManagerOptions,
type ModuleBuildContext
} from "./modules/ModuleManager";
export { registries } from "modules/registries";

View File

@@ -174,7 +174,7 @@ export class MediaController implements ClassController {
const result = await mutator.insertOne({
...this.media.uploadedEventDataToMediaPayload(info),
...mediaRef
});
} as any);
mutator.__unstable_toggleSystemEntityCreation(true);
// delete items if needed

View File

@@ -5,10 +5,10 @@ import type { Static, TSchema } from "core/utils";
import type { Connection, EntityManager } from "data";
import type { Hono } from "hono";
export type ModuleBuildContext = {
export type ModuleBuildContext<DB = any> = {
connection: Connection;
server: Hono<any>;
em: EntityManager<any>;
em: EntityManager<DB>;
emgr: EventManager<any>;
guard: Guard;
};

View File

@@ -35,6 +35,8 @@ import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia";
import type { Module, ModuleBuildContext } from "./Module";
export type { ModuleBuildContext };
export const MODULES = {
server: AppServer,
data: AppData<any>,
@@ -73,9 +75,14 @@ export type ModuleManagerOptions = {
module: Module,
config: ModuleConfigs[Module]
) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// base path for the hono instance
basePath?: string;
// doesn't perform validity checks for given/fetched config
trustFetched?: boolean;
// runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>;
};
type ConfigTable<Json = ModuleConfigs> = {
@@ -294,7 +301,7 @@ export class ModuleManager {
version,
json: configs,
updated_at: new Date()
},
} as any,
{
type: "config",
version
@@ -448,6 +455,9 @@ export class ModuleManager {
await this.buildModules();
await this.save();
// run initial setup
await this.setupInitial();
this.logger.clear();
return this;
}
@@ -462,6 +472,21 @@ export class ModuleManager {
return this;
}
protected async setupInitial() {
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork()
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
}
get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);

View File

@@ -5,14 +5,14 @@ import { useApi } from "ui/client";
export const useApiQuery = <
Data,
RefineFn extends (data: ResponseObject<Data>) => any = (data: ResponseObject<Data>) => Data
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data
>(
fn: (api: Api) => FetchPromise<Data>,
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
) => {
const api = useApi();
const promise = fn(api);
const refine = options?.refine ?? ((data: ResponseObject<Data>) => data);
const refine = options?.refine ?? ((data: any) => data);
const fetcher = () => promise.execute().then(refine);
const key = promise.key();

View File

@@ -10,7 +10,7 @@ type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => any
* the first argument "entity" for convenience
* @param entity
*/
export const useData = <T extends keyof DataApi>(entity: string) => {
export const useData = <T extends keyof DataApi<DB>>(entity: string) => {
const api = useApi().data;
const methods = [
"readOne",

View File

@@ -1,23 +1,40 @@
import type { PrimaryFieldType } from "core";
import { objectTransform } from "core/utils";
import { encodeSearch, objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data";
import type { ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration } from "swr";
import { useApi } from "ui/client";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, mutate } from "swr";
import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error {
constructor(
public payload: Payload,
public response: Response,
message?: string
public response: ResponseObject<Payload>,
fallback?: string
) {
let message = fallback;
if ("error" in response) {
message = response.error as string;
if (fallback) {
message = `${fallback}: ${message}`;
}
}
super(message ?? "UseEntityApiError");
}
}
function Test() {
const { read } = useEntity("users");
async () => {
const data = await read();
};
return null;
}
export const useEntity = <
Entity extends string,
Id extends PrimaryFieldType | undefined = undefined
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData
>(
entity: Entity,
id?: Id
@@ -25,27 +42,30 @@ export const useEntity = <
const api = useApi().data;
return {
create: async (input: EntityData) => {
create: async (input: Omit<Data, "id">) => {
const res = await api.createOne(entity, input);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to create entity");
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
}
return res;
},
read: async (query: Partial<RepoQuery> = {}) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to read entity");
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
}
return res;
// must be manually typed
return res as unknown as Id extends undefined
? ResponseObject<Data[]>
: ResponseObject<Data>;
},
update: async (input: Partial<EntityData>, _id: PrimaryFieldType | undefined = id) => {
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
if (!_id) {
throw new Error("id is required");
}
const res = await api.updateOne(entity, _id, input);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to update entity");
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
}
return res;
},
@@ -56,44 +76,67 @@ export const useEntity = <
const res = await api.deleteOne(entity, _id);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to delete entity");
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
}
return res;
}
};
};
// @todo: try to get from ModuleApi directly
export function makeKey(
api: ModuleApi,
entity: string,
id?: PrimaryFieldType,
query?: Partial<RepoQuery>
) {
return (
"/" +
[...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])]
.filter(Boolean)
.join("/") +
(query ? "?" + encodeSearch(query) : "")
);
}
export const useEntityQuery = <
Entity extends string,
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined
>(
entity: Entity,
id?: Id,
query?: Partial<RepoQuery>,
options?: SWRConfiguration & { enabled?: boolean }
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => {
const api = useApi().data;
const key =
options?.enabled !== false
? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter(
Boolean
)
: null;
const { read, ...actions } = useEntity(entity, id) as any;
const key = makeKey(api, entity, id, query);
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query);
type T = Awaited<ReturnType<(typeof api)[Id extends undefined ? "readMany" : "readOne"]>>;
const swr = useSWR<T>(key, fetcher, {
type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
revalidateOnFocus: false,
keepPreviousData: false,
keepPreviousData: true,
...options
});
const mapped = objectTransform(actions, (action) => {
if (action === "read") return;
const mutateAll = async () => {
const entityKey = makeKey(api, entity);
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
revalidate: true
});
};
return async (...args) => {
return swr.mutate(action(...args)) as any;
const mapped = objectTransform(actions, (action) => {
return async (...args: any) => {
// @ts-ignore
const res = await action(...args);
// mutate all keys of entity by default
if (options?.revalidateOnMutate !== false) {
await mutateAll();
}
return res;
};
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
@@ -105,17 +148,62 @@ export const useEntityQuery = <
};
};
export async function mutateEntityCache<
Entity extends keyof DB | string,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
function update(prev: any, partialNext: any) {
if (
typeof prev !== "undefined" &&
typeof partialNext !== "undefined" &&
"id" in prev &&
prev.id === id
) {
return { ...prev, ...partialNext };
}
return prev;
}
const entityKey = makeKey(api, entity);
return mutate(
(key) => typeof key === "string" && key.startsWith(entityKey),
async (data) => {
if (typeof data === "undefined") return;
if (Array.isArray(data)) {
return data.map((item) => update(item, partialData));
}
return update(data, partialData);
},
{
revalidate: false
}
);
}
export const useEntityMutate = <
Entity extends string,
Id extends PrimaryFieldType | undefined = undefined
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>(
entity: Entity,
id?: Id,
options?: SWRConfiguration
) => {
const { data, ...$q } = useEntityQuery(entity, id, undefined, {
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
...options,
enabled: false
});
return $q;
const _mutate = id
? (data) => mutateEntityCache($q.api, entity, id, data)
: (id, data) => mutateEntityCache($q.api, entity, id, data);
return {
...$q,
mutate: _mutate as unknown as Id extends undefined
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
: (data: Partial<Data>) => Promise<void>
};
};

View File

@@ -1,6 +1,5 @@
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import type { Entity } from "data";
import { AppData } from "data/AppData";
import { constructEntity } from "data";
import {
type TAppDataEntity,
type TAppDataEntityFields,
@@ -19,7 +18,7 @@ export function useBkndData() {
// @todo: potentially store in ref, so it doesn't get recomputed? or use memo?
const entities = transformObject(config.data.entities ?? {}, (entity, name) => {
return AppData.constructEntity(name, entity);
return constructEntity(name, entity);
});
const actions = {

View File

@@ -1,6 +1,5 @@
import type { App } from "App";
import type { Entity, EntityRelation } from "data";
import { AppData } from "data/AppData";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows";
@@ -20,11 +19,11 @@ export class AppReduced {
//console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return AppData.constructEntity(name, entity);
return constructEntity(name, entity);
});
this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => {
return AppData.constructRelation(relation, this.entity.bind(this));
return constructRelation(relation, this.entity.bind(this));
});
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {

View File

@@ -1,7 +1,6 @@
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useBknd } from "ui/client/bknd";
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
const b = useBknd();
@@ -15,13 +14,11 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode
: basicSetup;
return (
<Suspense>
<CodeMirror
theme={theme === "dark" ? "dark" : "light"}
editable={editable}
basicSetup={_basicSetup}
{...props}
/>
</Suspense>
);
}

View File

@@ -1,15 +1,12 @@
import type { Schema } from "@cfworker/json-schema";
import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { cloneDeep } from "lodash-es";
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
import { fields as Fields } from "./fields";
import { templates as Templates } from "./templates";
import { widgets as Widgets } from "./widgets";
import "./styles.css";
import { filterKeys } from "core/utils";
import { cloneDeep } from "lodash-es";
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
import { widgets as Widgets } from "./widgets";
const validator = new RJSFTypeboxValidator();

View File

@@ -0,0 +1,18 @@
import { Suspense, forwardRef, lazy } from "react";
import type { JsonSchemaFormProps, JsonSchemaFormRef } from "./JsonSchemaForm";
export type { JsonSchemaFormProps, JsonSchemaFormRef };
const Module = lazy(() =>
import("./JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>((props, ref) => {
return (
<Suspense>
<Module ref={ref} {...props} />
</Suspense>
);
});

View File

@@ -4,7 +4,7 @@ import {
JsonSchemaForm,
type JsonSchemaFormProps,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
} from "ui/components/form/json-schema";
import type { ContextModalProps } from "@mantine/modals";

View File

@@ -1,14 +1,8 @@
import type { FieldApi } from "@tanstack/react-form";
import type { EntityData, JsonSchemaField } from "data";
import { Suspense, lazy } from "react";
import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
import { JsonSchemaForm } from "ui/components/form/json-schema";
export function EntityJsonSchemaFormField({
fieldApi,
@@ -34,7 +28,6 @@ export function EntityJsonSchemaFormField({
return (
<Formy.Group>
<FieldLabel htmlFor={fieldApi.name} field={field} />
<Suspense fallback={<div>Loading...</div>}>
<div
data-disabled={disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
@@ -50,7 +43,6 @@ export function EntityJsonSchemaFormField({
}}
/>
</div>
</Suspense>
</Formy.Group>
);
}

View File

@@ -1,14 +1,9 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { Const, Type, transformObject } from "core/utils";
import { type TaskRenderProps, type Trigger, TriggerMap } from "flows";
import { Suspense, lazy } from "react";
import { type Trigger, TriggerMap } from "flows";
import type { IconType } from "react-icons";
import { TbCircleLetterT } from "react-icons/tb";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
import { JsonSchemaForm } from "ui/components/form/json-schema";
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
Icon?: IconType;
@@ -48,7 +43,6 @@ export function TriggerComponent({
</div>
<div className="w-full h-px bg-primary/10" />
<div className="flex flex-col gap-2 px-3 py-2">
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm
className="legacy"
schema={Type.Union(triggerSchemas)}
@@ -58,7 +52,6 @@ export function TriggerComponent({
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</Suspense>
</div>
</div>
<Handle

View File

@@ -1,12 +1,5 @@
import type { Task } from "flows";
import { Suspense, lazy } from "react";
import { TemplateField } from "./TemplateField";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
import { JsonSchemaForm } from "ui/components/form/json-schema";
export type TaskFormProps = {
task: Task;
@@ -26,7 +19,6 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
//console.log("uiSchema", uiSchema);
return (
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm
className="legacy"
schema={schema}
@@ -36,6 +28,5 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</Suspense>
);
}

View File

@@ -5,10 +5,7 @@ import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema";

View File

@@ -1,9 +1,7 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { extractSchema } from "../settings/utils/schema";
export function AuthStrategiesList() {
useBknd({ withSecrets: true });

View File

@@ -101,7 +101,7 @@ export function DataEntityUpdate({ params }) {
data: {
data: data as any,
entity: entity.toJSON(),
schema: entity.toSchema(true),
schema: entity.toSchema({ clean: true }),
form: Form.state.values,
state: Form.state
}

View File

@@ -13,10 +13,7 @@ import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";

View File

@@ -22,7 +22,7 @@ import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
import { Popover } from "ui/components/overlay/Popover";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";

View File

@@ -1,14 +1,19 @@
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd";
import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth";
import { AuthLogin } from "./auth/auth.login";
import DataRoutes from "./data";
import FlowRoutes from "./flows";
import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root";
import SettingsRoutes from "./settings";
const DataRoutes = lazy(() => import("./data"));
/*const DataRoutes = lazy(() => import("./data"));
const AuthRoutes = lazy(() => import("./auth"));
const MediaRoutes = lazy(() => import("./media"));
const FlowRoutes = lazy(() => import("./flows"));
const SettingsRoutes = lazy(() => import("./settings"));
const SettingsRoutes = lazy(() => import("./settings"));*/
// @ts-ignore
const TestRoutes = lazy(() => import("./test"));

View File

@@ -8,10 +8,7 @@ import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Alert } from "ui/components/display/Alert";
import { Empty } from "ui/components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { DataTable } from "ui/components/table/DataTable";
import { useEvent } from "ui/hooks/use-event";

View File

@@ -3,16 +3,13 @@ import type { TObject } from "core/utils";
import { omit } from "lodash-es";
import { useRef, useState } from "react";
import { TbCirclePlus, TbVariable } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { Modal } from "ui/components/overlay/Modal";
import { useLocation } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Button } from "../../../components/buttons/Button";
import * as Formy from "../../../components/form/Formy";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "../../../components/overlay/Dropdown";
import { Modal } from "../../../components/overlay/Modal";
export type SettingsNewModalProps = {
schema: TObject;

View File

@@ -2,7 +2,7 @@ import { parse } from "core/utils";
import { AppFlows } from "flows/AppFlows";
import { useState } from "react";
import { JsonViewer } from "../../../components/code/JsonViewer";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm } from "../../../components/form/json-schema";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
export default function FlowCreateSchemaTest() {

View File

@@ -2,12 +2,9 @@ import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { useRef } from "react";
import { TbPlus, TbTrash } from "react-icons/tb";
import { Button } from "../../../../components/buttons/Button";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import * as Formy from "../../../../components/form/Formy";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../../components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../../../layouts/AppShell/AppShell";
class CfJsonSchemaValidator {}

View File

@@ -1,7 +1,7 @@
import type { Schema } from "@cfworker/json-schema";
import { useState } from "react";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
const schema: Schema = {
definitions: {
@@ -9,52 +9,52 @@ const schema: Schema = {
anyOf: [
{
title: "String",
type: "string",
type: "string"
},
{
title: "Number",
type: "number",
type: "number"
},
{
title: "Boolean",
type: "boolean",
},
],
type: "boolean"
}
]
},
numeric: {
anyOf: [
{
title: "Number",
type: "number",
type: "number"
},
{
title: "Datetime",
type: "string",
format: "date-time",
format: "date-time"
},
{
title: "Date",
type: "string",
format: "date",
format: "date"
},
{
title: "Time",
type: "string",
format: "time",
},
],
format: "time"
}
]
},
boolean: {
title: "Boolean",
type: "boolean",
},
type: "boolean"
}
},
type: "object",
properties: {
operand: {
enum: ["$and", "$or"],
default: "$and",
type: "string",
type: "string"
},
conditions: {
type: "array",
@@ -64,10 +64,10 @@ const schema: Schema = {
operand: {
enum: ["$and", "$or"],
default: "$and",
type: "string",
type: "string"
},
key: {
type: "string",
type: "string"
},
operator: {
type: "array",
@@ -78,30 +78,30 @@ const schema: Schema = {
type: "object",
properties: {
$eq: {
$ref: "#/definitions/primitive",
$ref: "#/definitions/primitive"
}
},
},
required: ["$eq"],
required: ["$eq"]
},
{
title: "Lower than",
type: "object",
properties: {
$lt: {
$ref: "#/definitions/numeric",
$ref: "#/definitions/numeric"
}
},
},
required: ["$lt"],
required: ["$lt"]
},
{
title: "Greather than",
type: "object",
properties: {
$gt: {
$ref: "#/definitions/numeric",
$ref: "#/definitions/numeric"
}
},
},
required: ["$gt"],
required: ["$gt"]
},
{
title: "Between",
@@ -110,13 +110,13 @@ const schema: Schema = {
$between: {
type: "array",
items: {
$ref: "#/definitions/numeric",
$ref: "#/definitions/numeric"
},
minItems: 2,
maxItems: 2,
maxItems: 2
}
},
},
required: ["$between"],
required: ["$between"]
},
{
title: "In",
@@ -125,23 +125,23 @@ const schema: Schema = {
$in: {
type: "array",
items: {
$ref: "#/definitions/primitive",
$ref: "#/definitions/primitive"
},
minItems: 1,
minItems: 1
}
}
}
]
},
minItems: 1
}
},
required: ["key", "operator"]
},
],
minItems: 1
}
},
minItems: 1,
},
},
required: ["key", "operator"],
},
minItems: 1,
},
},
required: ["operand", "conditions"],
required: ["operand", "conditions"]
};
export default function QueryJsonFormTest() {

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useBknd } from "../../../client/BkndProvider";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
import { useBknd } from "ui/client/BkndProvider";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function useSchema() {
const [schema, setSchema] = useState<any>();

View File

@@ -1,7 +1,20 @@
import { useEffect, useState } from "react";
import { useApiQuery } from "ui/client";
import { useApi, useApiQuery } from "ui/client";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function Bla() {
const api = useApi();
useEffect(() => {
(async () => {
const one = await api.data.readOne("users", 1);
const many = await api.data.readMany("users");
})();
}, []);
return null;
}
export default function SWRAndAPI() {
const [text, setText] = useState("");
const { data, ...r } = useApiQuery((api) => api.data.readOne("comments", 1), {
@@ -16,7 +29,7 @@ export default function SWRAndAPI() {
return (
<Scrollable>
<pre>{JSON.stringify(r.promise.keyArray({ search: false }))}</pre>
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
@@ -26,12 +39,12 @@ export default function SWRAndAPI() {
e.preventDefault();
if (!comment) return;
await r.mutate(async () => {
/*await r.mutate(async () => {
const res = await r.api.data.updateOne("comments", comment.id, {
content: text
});
return res.data;
});
});*/
return false;
}}

View File

@@ -1,54 +1,72 @@
import { useEffect, useState } from "react";
import { useEntity, useEntityQuery } from "ui/client/api/use-entity";
import { useEntity, useEntityMutate, useEntityQuery } from "ui/client/api/use-entity";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
export default function SwrAndDataApi() {
return (
<div>
<Scrollable>
asdf
<DirectDataApi />
<QueryDataApi />
</div>
<QueryMutateDataApi />
</Scrollable>
);
}
function QueryDataApi() {
const [text, setText] = useState("");
const { data, update, ...r } = useEntityQuery("comments", 1, {});
const comment = data ? data : null;
useEffect(() => {
setText(comment?.content ?? "");
}, [comment]);
function QueryMutateDataApi() {
const { mutate } = useEntityMutate("comments");
const { data, ...r } = useEntityQuery("comments", undefined, {
limit: 2
});
return (
<Scrollable>
<div>
bla
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
{data && (
<form
onSubmit={async (e) => {
e.preventDefault();
if (!comment) return;
await update({ content: text });
return false;
<div>
{data.map((comment) => (
<input
key={String(comment.id)}
type="text"
value={comment.content}
onChange={async (e) => {
await mutate(comment.id, { content: e.target.value });
}}
>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">submit</button>
</form>
className="border border-black"
/>
))}
</div>
)}
</Scrollable>
</div>
);
}
function QueryDataApi() {
const { data, update, ...r } = useEntityQuery("comments", undefined, {
sort: { by: "id", dir: "asc" },
limit: 3
});
return (
<div>
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
}
function DirectDataApi() {
const [data, setData] = useState<any>();
const { create, read, update, _delete } = useEntity("comments", 1);
const { create, read, update, _delete } = useEntity("comments");
useEffect(() => {
read().then(setData);
read().then((data) => setData(data));
}, []);
return <pre>{JSON.stringify(data, null, 2)}</pre>;

View File

@@ -26,14 +26,13 @@
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"outDir": "./dist/types",
"baseUrl": ".",
"paths": {
"*": ["./src/*"],
"bknd": ["./src/*"]
}
},
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"],
"exclude": ["node_modules", "dist/**/*", "../examples/bun"]
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"]
}

BIN
bun.lockb

Binary file not shown.