public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

95
app/src/Api.ts Normal file
View File

@@ -0,0 +1,95 @@
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decodeJwt } from "jose";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
export type ApiOptions = {
host: string;
token?: string;
tokenStorage?: "localStorage";
localStorage?: {
key?: string;
};
};
export class Api {
private token?: string;
private user?: object;
private verified = false;
public system!: SystemApi;
public data!: DataApi;
public auth!: AuthApi;
public media!: MediaApi;
constructor(private readonly options: ApiOptions) {
if (options.token) {
this.updateToken(options.token);
} else {
this.extractToken();
}
this.buildApis();
}
private extractToken() {
if (this.options.tokenStorage === "localStorage") {
const key = this.options.localStorage?.key ?? "auth";
const raw = localStorage.getItem(key);
if (raw) {
const { token } = JSON.parse(raw);
this.token = token;
this.user = decodeJwt(token) as any;
}
}
}
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.user = token ? (decodeJwt(token) as any) : undefined;
if (this.options.tokenStorage === "localStorage") {
const key = this.options.localStorage?.key ?? "auth";
if (token) {
localStorage.setItem(key, JSON.stringify({ token }));
} else {
localStorage.removeItem(key);
}
}
if (rebuild) this.buildApis();
}
markAuthVerified(verfied: boolean) {
this.verified = verfied;
return this;
}
getAuthState() {
if (!this.token) return;
return {
token: this.token,
user: this.user,
verified: this.verified
};
}
private buildApis() {
const baseParams = {
host: this.options.host,
token: this.token
};
this.system = new SystemApi(baseParams);
this.data = new DataApi(baseParams);
this.auth = new AuthApi({
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
});
this.media = new MediaApi(baseParams);
}
}

142
app/src/App.ts Normal file
View File

@@ -0,0 +1,142 @@
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import {
type InitialModuleConfigs,
ModuleManager,
type ModuleManagerOptions,
type Modules
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void;
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends Event<{ app: App }> {
static override slug = "app-built";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export type CreateAppConfig = {
connection:
| Connection
| {
type: "libsql";
config: LibSqlCredentials;
};
initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[];
options?: ModuleManagerOptions;
};
export type AppConfig = InitialModuleConfigs;
export class App<DB = any> {
modules: ModuleManager;
static readonly Events = AppEvents;
constructor(
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin<DB>[] = [],
moduleManagerOptions?: ModuleManagerOptions
) {
this.modules = new ModuleManager(connection, {
...moduleManagerOptions,
initial: _initialConfig,
onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config);
await this.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
static create(config: CreateAppConfig) {
let connection: Connection | undefined = undefined;
if (config.connection instanceof Connection) {
connection = config.connection;
} else if (typeof config.connection === "object") {
switch (config.connection.type) {
case "libsql":
connection = new LibsqlConnection(config.connection.config);
break;
}
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
get emgr() {
return this.modules.ctx().emgr;
}
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
//console.log("building");
await this.modules.build();
if (options?.sync) {
const syncResult = await this.module.data.em
.schema()
.sync({ force: true, drop: options.drop });
//console.log("syncing", syncResult);
}
// load system controller
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
this.modules.server.route("/api/system", new SystemController(this).getController());
// load plugins
if (this.plugins.length > 0) {
this.plugins.forEach((plugin) => plugin(this));
}
//console.log("emitting built", options);
await this.emgr.emit(new AppBuiltEvent({ app: this }));
// not found on any not registered api route
this.modules.server.all("/api/*", async (c) => c.notFound());
if (options?.save) {
await this.modules.save();
}
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema();
}
get fetch(): any {
return this.modules.server.fetch;
}
get module() {
return new Proxy(
{},
{
get: (_, module: keyof Modules) => {
return this.modules.get(module);
}
}
) as Modules;
}
getSchema() {
return this.modules.getSchema();
}
version() {
return this.modules.version();
}
toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets);
}
}

View File

@@ -0,0 +1,33 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { App, type CreateAppConfig } from "bknd";
import { serveStatic } from "hono/bun";
let app: App;
export function serve(config: CreateAppConfig, distPath?: string) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
return async (req: Request) => {
if (!app) {
app = App.create(config);
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/assets/*",
serveStatic({
root
})
);
app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8"));
},
"sync"
);
await app.build();
}
return app.modules.server.fetch(req);
};
}

View File

@@ -0,0 +1 @@
export * from "./bun.adapter";

View File

@@ -0,0 +1,267 @@
import { DurableObject } from "cloudflare:workers";
import { App, type CreateAppConfig } from "bknd";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { BkndConfig, CfBkndModeCache } from "../index";
// @ts-ignore
//import manifest from "__STATIC_CONTENT_MANIFEST";
import _html from "../../static/index.html";
type Context = {
request: Request;
env: any;
ctx: ExecutionContext;
manifest: any;
html: string;
};
export function serve(_config: BkndConfig, manifest?: string, overrideHtml?: string) {
const html = overrideHtml ?? _html;
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const url = new URL(request.url);
if (manifest) {
const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(manifest);
if (pathname && pathname in assetManifest) {
const hono = new Hono();
hono.all("*", async (c, next) => {
const res = await serveStatic({
path: `./${pathname}`,
manifest,
onNotFound: (path) => console.log("not found", path)
})(c as any, next);
if (res instanceof Response) {
const ttl = pathname.startsWith("assets/")
? 60 * 60 * 24 * 365 // 1 year
: 60 * 5; // 5 minutes
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
return res;
}
return c.notFound();
});
return hono.fetch(request, env);
}
}
const config = {
..._config,
setAdminHtml: _config.setAdminHtml ?? !!manifest
};
const context = { request, env, ctx, manifest, html };
const mode = config.cloudflare?.mode?.(env);
if (!mode) {
console.log("serving fresh...");
const app = await getFresh(config, context);
return app.fetch(request, env);
} else if ("cache" in mode) {
console.log("serving cached...");
const app = await getCached(config as any, context);
return app.fetch(request, env);
} else if ("durableObject" in mode) {
console.log("serving durable...");
if (config.onBuilt) {
console.log("onBuilt() is not supported with DurableObject mode");
}
const start = performance.now();
const durable = mode.durableObject;
const id = durable.idFromName(mode.key);
const stub = durable.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const res = await stub.fire(request, {
config: create_config,
html,
keepAliveSeconds: mode.keepAliveSeconds,
setAdminHtml: config.setAdminHtml
});
const headers = new Headers(res.headers);
headers.set("X-TTDO", String(performance.now() - start));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
}
};
}
async function getFresh(config: BkndConfig, { env, html }: Context) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const app = App.create(create_config);
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
config.onBuilt!(app);
},
"sync"
);
}
await app.build();
if (config?.setAdminHtml !== false) {
app.module.server.setAdminHtml(html);
}
return app;
}
async function getCached(
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
{ env, html, ctx }: Context
) {
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const cachedConfig = await cache.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
const app = App.create({ ...create_config, initialConfig });
async function saveConfig(__config: any) {
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
}
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
await cache.delete(key);
return c.json({ message: "Cache cleared" });
});
config.onBuilt!(app);
},
"sync"
);
}
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync"
);
await app.build();
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
//addAssetsRoute(app, manifest);
if (config?.setAdminHtml !== false) {
app.module.server.setAdminHtml(html);
}
return app;
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
}
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if ("type" in config.connection && config.connection.type === "libsql") {
config.connection.config.protocol = "wss";
}
this.app = App.create(config);
this.app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
app.modules.server.get("/__do", async (c) => {
// @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({
id: this.id,
keepAlive: options?.keepAliveSeconds,
colo: context.colo
});
});
if (options?.setAdminHtml !== false) {
app.module.server.setAdminHtml(options.html);
}
},
"sync"
);
await this.app.build();
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
console.log("id", this.id);
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
headers.set("X-DO-ID", this.id);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
//console.log("keep-alive", i);
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
// ping every 30 seconds
} else if (i % 30 === 0) {
console.log("ping");
this.app?.modules.ctx().connection.ping();
}
}, 1000);
}
}

View File

@@ -0,0 +1 @@
export * from "./cloudflare-workers.adapter";

36
app/src/adapter/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { App, CreateAppConfig } from "bknd";
export type CfBkndModeCache<Env = any> = (env: Env) => {
cache: KVNamespace;
key: string;
};
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
durableObject: DurableObjectNamespace;
key: string;
keepAliveSeconds?: number;
};
export type CloudflareBkndConfig<Env = any> = {
mode?: CfBkndModeCache | CfBkndModeDurableObject;
forceHttps?: boolean;
};
export type BkndConfig<Env = any> = {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
cloudflare?: CloudflareBkndConfig<Env>;
onBuilt?: (app: App) => Promise<void>;
};
export type BkndConfigJson = {
app: CreateAppConfig;
setAdminHtml?: boolean;
server?: {
port?: number;
};
};

View File

@@ -0,0 +1 @@
export * from "./nextjs.adapter";

View File

@@ -0,0 +1,25 @@
import { App, type CreateAppConfig } from "bknd";
import { isDebug } from "bknd/core";
function getCleanRequest(req: Request) {
// clean search params from "route" attribute
const url = new URL(req.url);
url.searchParams.delete("route");
return new Request(url.toString(), {
method: req.method,
headers: req.headers,
body: req.body
});
}
let app: App;
export function serve(config: CreateAppConfig) {
return async (req: Request) => {
if (!app || isDebug()) {
app = App.create(config);
await app.build();
}
const request = getCleanRequest(req);
return app.fetch(request, process.env);
};
}

View File

@@ -0,0 +1 @@
export * from "./remix.adapter";

View File

@@ -0,0 +1,12 @@
import { App, type CreateAppConfig } from "../../App";
let app: App;
export function serve(config: CreateAppConfig) {
return async (args: { request: Request }) => {
if (!app) {
app = App.create(config);
await app.build();
}
return app.fetch(args.request);
};
}

View File

@@ -0,0 +1 @@
export * from "./vite.adapter";

View File

@@ -0,0 +1,82 @@
import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static";
import { App } from "../../App";
import type { BkndConfig } from "../index";
async function getHtml() {
return readFile("index.html", "utf8");
}
function addViteScripts(html: string) {
return html.replace(
"<head>",
`<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/@vite/client"></script>
`
);
}
function createApp(config: BkndConfig, env: any) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
return App.create(create_config);
}
function setAppBuildListener(app: App, config: BkndConfig, html: string) {
app.emgr.on(
"app-built",
async () => {
await config.onBuilt?.(app);
app.module.server.setAdminHtml(html);
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
},
"sync"
);
}
export async function serveFresh(config: BkndConfig, _html?: string) {
let html = _html;
if (!html) {
html = await getHtml();
}
html = addViteScripts(html);
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = createApp(config, env);
setAppBuildListener(app, config, html);
await app.build();
//console.log("routes", app.module.server.client.routes);
return app.fetch(request, env, ctx);
}
};
}
let app: App;
export async function serveCached(config: BkndConfig, _html?: string) {
let html = _html;
if (!html) {
html = await getHtml();
}
html = addViteScripts(html);
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) {
app = createApp(config, env);
setAppBuildListener(app, config, html);
await app.build();
}
return app.fetch(request, env, ctx);
}
};
}

269
app/src/auth/AppAuth.ts Normal file
View File

@@ -0,0 +1,269 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import { Exception } from "core";
import { Const, StringRecord, Type, transformObject } from "core/utils";
import {
type Entity,
EntityIndex,
type EntityManager,
EnumField,
type Field,
type Mutator
} from "data";
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
import { cloneDeep, mergeWith, omit, pick } from "lodash-es";
import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare global {
interface DB {
users: UserFieldSchema;
}
}
export class AppAuth extends Module<typeof authConfigSchema> {
private _authenticator?: Authenticator;
cache: Record<string, any> = {};
override async build() {
if (!this.config.enabled) {
this.setBuilt();
return;
}
// register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
//console.log("role", role, name);
return Role.create({ name, ...role });
});
this.ctx.guard.setRoles(Object.values(roles));
this.ctx.guard.setConfig(this.config.guard ?? {});
// build strategies
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
try {
return new STRATEGIES[strategy.type].cls(strategy.config as any);
} catch (e) {
throw new Error(
`Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}`
);
}
});
const { fields, ...jwt } = this.config.jwt;
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
jwt
});
this.registerEntities();
super.setBuilt();
const controller = new AuthController(this);
//this.ctx.server.use(controller.getMiddleware);
this.ctx.server.route(this.config.basepath, controller.getController());
}
getMiddleware() {
if (!this.config.enabled) {
return;
}
return new AuthController(this).getMiddleware;
}
getSchema() {
return authConfigSchema;
}
get authenticator(): Authenticator {
this.throwIfNotBuilt();
return this._authenticator!;
}
get em(): EntityManager<DB> {
return this.ctx.em as any;
}
private async resolveUser(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange
): Promise<any> {
console.log("***** AppAuth:resolveUser", {
action,
strategy: strategy.getName(),
identifier,
profile
});
const fields = this.getUsersEntity()
.getFillableFields("create")
.map((f) => f.name);
const filteredProfile = Object.fromEntries(
Object.entries(profile).filter(([key]) => fields.includes(key))
);
switch (action) {
case "login":
return this.login(strategy, identifier, filteredProfile);
case "register":
return this.register(strategy, identifier, filteredProfile);
}
}
private filterUserData(user: any) {
console.log(
"--filterUserData",
user,
this.config.jwt.fields,
pick(user, this.config.jwt.fields)
);
return pick(user, this.config.jwt.fields);
}
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile });
if (!("email" in profile)) {
throw new Exception("Profile must have email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(users).findOne({ email: profile.email! });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
throw new Exception("User not found", 404);
}
console.log("---login data", result.data, result);
// compare strategy and identifier
console.log("strategy comparison", result.data.strategy, strategy.getName());
if (result.data.strategy !== strategy.getName()) {
console.log("!!! User registered with different strategy");
throw new Exception("User registered with different strategy");
}
console.log("identifier comparison", result.data.strategy_value, identifier);
if (result.data.strategy_value !== identifier) {
console.log("!!! Invalid credentials");
throw new Exception("Invalid credentials");
}
return this.filterUserData(result.data);
}
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
if (!("email" in profile)) {
throw new Exception("Profile must have an email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
if (data) {
throw new Exception("User already exists");
}
const payload = {
...profile,
strategy: strategy.getName(),
strategy_value: identifier
};
const mutator = this.em.mutator(users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new Error("Could not create user");
}
return this.filterUserData(createResult.data);
}
private toggleStrategyValueVisibility(visible: boolean) {
const field = this.getUsersEntity().field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
// @todo: think about a PasswordField that automatically hashes on save?
}
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
const entity_name = this.config.entity_name;
if (forceCreate || !this.em.hasEntity(entity_name)) {
return entity(entity_name as "users", AppAuth.usersFields, undefined, "system");
}
return this.em.entity(entity_name) as any;
}
static usersFields = {
email: text().required(),
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"]
}).required(),
role: text()
};
registerEntities() {
const users = this.getUsersEntity();
if (!this.em.hasEntity(users.name)) {
this.em.addEntity(users);
} else {
// if exists, check all fields required are there
// @todo: add to context: "needs sync" flag
const _entity = this.getUsersEntity(true);
for (const field of _entity.fields) {
const _field = users.field(field.name);
if (!_field) {
users.addField(field);
}
}
}
const indices = [
new EntityIndex(users, [users.field("email")!], true),
new EntityIndex(users, [users.field("strategy")!]),
new EntityIndex(users, [users.field("strategy_value")!])
];
indices.forEach((index) => {
if (!this.em.hasIndex(index)) {
this.em.addIndex(index);
}
});
try {
const roles = Object.keys(this.config.roles ?? {});
const field = make("role", enumm({ enum: roles }));
this.em.entity(users.name).__experimental_replaceField("role", field);
} catch (e) {}
try {
const strategies = Object.keys(this.config.strategies ?? {});
const field = make("strategy", enumm({ enum: strategies }));
this.em.entity(users.name).__experimental_replaceField("strategy", field);
} catch (e) {}
}
override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) {
return this.configDefault;
}
// fixes freezed config object
return mergeWith({ ...this.config }, this.authenticator.toJSON(secrets));
}
}

View File

@@ -0,0 +1,41 @@
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token: string) => void | Promise<void>;
};
export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> {
return {
basepath: "/api/auth"
};
}
async loginWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input);
if (res.res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async registerWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input);
if (res.res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async me() {
return this.get<{ user: SafeUser | null }>(["me"]);
}
async strategies() {
return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]);
}
async logout() {}
}

View File

@@ -0,0 +1,57 @@
import type { AppAuth } from "auth";
import type { ClassController } from "core";
import { Hono, type MiddlewareHandler } from "hono";
export class AuthController implements ClassController {
constructor(private auth: AppAuth) {}
getMiddleware: MiddlewareHandler = async (c, next) => {
// @todo: consider adding app name to the payload, because user is not refetched
//try {
if (c.req.raw.headers.has("Authorization")) {
const bearerHeader = String(c.req.header("Authorization"));
const token = bearerHeader.replace("Bearer ", "");
const verified = await this.auth.authenticator.verify(token);
// @todo: don't extract user from token, but from the database or cache
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
/*console.log("jwt verified?", {
verified,
auth: this.auth.authenticator.isUserLoggedIn()
});*/
} else {
this.auth.authenticator.__setUserNull();
}
/* } catch (e) {
this.auth.authenticator.__setUserNull();
}*/
await next();
};
getController(): Hono<any> {
const hono = new Hono();
const strategies = this.auth.authenticator.getStrategies();
//console.log("strategies", strategies);
for (const [name, strategy] of Object.entries(strategies)) {
//console.log("registering", name, "at", `/${name}`);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
}
hono.get("/me", async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() });
}
return c.json({ user: null }, 403);
});
hono.get("/strategies", async (c) => {
return c.json({ strategies: this.auth.toJSON(false).strategies });
});
return hono;
}
}

View File

@@ -0,0 +1,85 @@
import { jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
export const Strategies = {
password: {
cls: PasswordStrategy,
schema: PasswordStrategy.prototype.getSchema()
},
oauth: {
cls: OAuthStrategy,
schema: OAuthStrategy.prototype.getSchema()
},
custom_oauth: {
cls: CustomOAuthStrategy,
schema: CustomOAuthStrategy.prototype.getSchema()
}
} as const;
export const STRATEGIES = Strategies;
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
return Type.Object(
{
type: Type.Const(name, { default: name, readOnly: true }),
config: strategy.schema
},
{
title: name,
additionalProperties: false
}
);
});
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
export type AppAuthStrategies = Static<typeof strategiesSchema>;
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
const guardConfigSchema = Type.Object({
enabled: Type.Optional(Type.Boolean({ default: false }))
});
export const guardRoleSchema = Type.Object(
{
permissions: Type.Optional(Type.Array(Type.String())),
is_default: Type.Optional(Type.Boolean()),
implicit_allow: Type.Optional(Type.Boolean())
},
{ additionalProperties: false }
);
export const authConfigSchema = Type.Object(
{
enabled: Type.Boolean({ default: false }),
basepath: Type.String({ default: "/api/auth" }),
entity_name: Type.String({ default: "users" }),
jwt: Type.Composite(
[
jwtConfig,
Type.Object({
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
})
],
{ default: {}, additionalProperties: false }
),
strategies: Type.Optional(
StringRecord(strategiesSchema, {
title: "Strategies",
default: {
password: {
type: "password",
config: {
hashing: "sha256"
}
}
}
})
),
guard: Type.Optional(guardConfigSchema),
roles: Type.Optional(StringRecord(guardRoleSchema, { default: {} }))
},
{
title: "Authentication",
additionalProperties: false
}
);
export type AppAuthSchema = Static<typeof authConfigSchema>;

View File

@@ -0,0 +1,190 @@
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
import type { Hono } from "hono";
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
type Input = any; // workaround
// @todo: add schema to interface to ensure proper inference
export interface Strategy {
getController: (auth: Authenticator) => Hono<any>;
getType: () => string;
getMode: () => "form" | "external";
getName: () => string;
toJSON: (secrets?: boolean) => any;
}
export type User = {
id: number;
email: string;
username: string;
password: string;
role: string;
};
export type ProfileExchange = {
email?: string;
username?: string;
sub?: string;
password?: string;
[key: string]: any;
};
export type SafeUser = Omit<User, "password">;
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
export type AuthResponse = { user: SafeUser; token: string };
export interface UserPool<Fields = "id" | "email" | "username"> {
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
create: (user: CreateUser) => Promise<User | undefined>;
}
export const jwtConfig = Type.Object(
{
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
secret: Type.String({ default: "secret" }),
alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })),
expiresIn: Type.Optional(Type.String()),
issuer: Type.Optional(Type.String())
},
{
default: {},
additionalProperties: false
}
);
export const authenticatorConfig = Type.Object({
jwt: jwtConfig
});
type AuthConfig = Static<typeof authenticatorConfig>;
export type AuthAction = "login" | "register";
export type AuthUserResolver = (
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange
) => Promise<SafeUser | undefined>;
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
private _user: SafeUser | undefined;
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
this.strategies = strategies as Strategies;
this.config = parse(authenticatorConfig, config ?? {});
/*const secret = String(this.config.jwt.secret);
if (secret === "secret" || secret.length === 0) {
this.config.jwt.secret = randomString(64, true);
}*/
}
async resolve(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange
) {
//console.log("resolve", { action, strategy: strategy.getName(), profile });
const user = await this.userResolver(action, strategy, identifier, profile);
if (user) {
return {
user,
token: await this.jwt(user)
};
}
throw new Error("User could not be resolved");
}
getStrategies(): Strategies {
return this.strategies;
}
isUserLoggedIn(): boolean {
return this._user !== undefined;
}
getUser() {
return this._user;
}
// @todo: determine what to do exactly
__setUserNull() {
this._user = undefined;
}
strategy<
StrategyName extends keyof Strategies,
Strat extends Strategy = Strategies[StrategyName]
>(strategy: StrategyName): Strat {
try {
return this.strategies[strategy] as unknown as Strat;
} catch (e) {
throw new Error(`Strategy "${String(strategy)}" not found`);
}
}
async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"];
for (const prop of prohibited) {
if (prop in user) {
throw new Error(`Property "${prop}" is prohibited`);
}
}
const jwt = new SignJWT(user)
.setProtectedHeader({ alg: this.config.jwt?.alg ?? "HS256" })
.setIssuedAt();
if (this.config.jwt?.issuer) {
jwt.setIssuer(this.config.jwt.issuer);
}
if (this.config.jwt?.expiresIn) {
jwt.setExpirationTime(this.config.jwt.expiresIn);
}
return jwt.sign(new TextEncoder().encode(this.config.jwt?.secret ?? ""));
}
async verify(jwt: string): Promise<boolean> {
const options: JWTVerifyOptions = {
algorithms: [this.config.jwt?.alg ?? "HS256"]
};
if (this.config.jwt?.issuer) {
options.issuer = this.config.jwt.issuer;
}
if (this.config.jwt?.expiresIn) {
options.maxTokenAge = this.config.jwt.expiresIn;
}
try {
const { payload } = await jwtVerify<User>(
jwt,
new TextEncoder().encode(this.config.jwt?.secret ?? ""),
options
);
this._user = payload;
return true;
} catch (e) {
this._user = undefined;
//console.error(e);
}
return false;
}
toJSON(secrets?: boolean) {
return {
...this.config,
jwt: secrets ? this.config.jwt : undefined,
strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets))
};
}
}

View File

@@ -0,0 +1,98 @@
import type { Authenticator, Strategy } from "auth";
import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils";
import { Hono } from "hono";
type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any };
const schema = Type.Object({
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" })
});
export type PasswordStrategyOptions = Static<typeof schema>;
/*export type PasswordStrategyOptions2 = {
hashing?: "plain" | "bcrypt" | "sha256";
};*/
export class PasswordStrategy implements Strategy {
private options: PasswordStrategyOptions;
constructor(options: Partial<PasswordStrategyOptions> = {}) {
this.options = parse(schema, options);
}
async hash(password: string) {
switch (this.options.hashing) {
case "sha256":
return hash.sha256(password);
default:
return password;
}
}
async login(input: LoginSchema) {
if (!("email" in input) || !("password" in input)) {
throw new Error("Invalid input: Email and password must be provided");
}
const hashedPassword = await this.hash(input.password);
return { ...input, password: hashedPassword };
}
async register(input: RegisterSchema) {
if (!input.email || !input.password) {
throw new Error("Invalid input: Email and password must be provided");
}
return {
...input,
password: await this.hash(input.password)
};
}
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
return hono
.post("/login", async (c) => {
const body = (await c.req.json()) ?? {};
const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload);
return c.json(data);
})
.post("/register", async (c) => {
const body = (await c.req.json()) ?? {};
const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload);
return c.json(data);
});
}
getSchema() {
return schema;
}
getType() {
return "password";
}
getMode() {
return "form" as const;
}
getName() {
return "password" as const;
}
toJSON(secrets?: boolean) {
return {
type: this.getType(),
config: secrets ? this.options : undefined
};
}
}

View File

@@ -0,0 +1,13 @@
import { CustomOAuthStrategy } from "auth/authenticate/strategies/oauth/CustomOAuthStrategy";
import { PasswordStrategy, type PasswordStrategyOptions } from "./PasswordStrategy";
import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
export * as issuers from "./oauth/issuers";
export {
PasswordStrategy,
type PasswordStrategyOptions,
OAuthStrategy,
OAuthCallbackException,
CustomOAuthStrategy
};

View File

@@ -0,0 +1,77 @@
import { type Static, StringEnum, Type } from "core/utils";
import type * as oauth from "oauth4webapi";
import { OAuthStrategy } from "./OAuthStrategy";
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = Type.Object(
{
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: Type.String(),
client: Type.Object(
{
client_id: Type.String(),
client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"])
},
{
additionalProperties: false
}
),
as: Type.Object(
{
issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())),
scope_separator: Type.Optional(Type.String({ default: " " })),
authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString)
},
{
additionalProperties: false
}
)
// @todo: profile mapping
},
{ title: "Custom OAuth", additionalProperties: false }
);
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
export type UserProfile = {
sub: string;
email: string;
[key: string]: any;
};
export type IssuerConfig<UserInfo = any> = {
type: SupportedTypes;
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
as: oauth.AuthorizationServer & {
scope_separator?: string;
};
profile: (
info: UserInfo,
config: Omit<IssuerConfig, "profile">,
tokenResponse: any
) => Promise<UserProfile>;
};
export class CustomOAuthStrategy extends OAuthStrategy {
override getIssuerConfig(): IssuerConfig {
return { ...this.config, profile: async (info) => info } as any;
}
// @ts-ignore
override getSchema() {
return oauthSchemaCustom;
}
override getType() {
return "custom_oauth";
}
}

View File

@@ -0,0 +1,431 @@
import type { AuthAction, Authenticator, Strategy } from "auth";
import { Exception } from "core";
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi";
import * as issuers from "./issuers";
type ConfiguredIssuers = keyof typeof issuers;
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const schemaProvided = Type.Object(
{
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
client: Type.Object(
{
client_id: Type.String(),
client_secret: Type.String()
},
{
additionalProperties: false
}
)
},
{ title: "OAuth" }
);
type ProvidedOAuthConfig = Static<typeof schemaProvided>;
export type CustomOAuthConfig = {
type: SupportedTypes;
name: string;
} & IssuerConfig & {
client: RequireKeys<
oauth.Client,
"client_id" | "client_secret" | "token_endpoint_auth_method"
>;
};
type OAuthConfig = ProvidedOAuthConfig | CustomOAuthConfig;
export type UserProfile = {
sub: string;
email: string;
[key: string]: any;
};
export type IssuerConfig<UserInfo = any> = {
type: SupportedTypes;
client: RequireKeys<oauth.Client, "token_endpoint_auth_method">;
as: oauth.AuthorizationServer & {
scope_separator?: string;
};
profile: (
info: UserInfo,
config: Omit<IssuerConfig, "profile">,
tokenResponse: any
) => Promise<UserProfile>;
};
export class OAuthCallbackException extends Exception {
override name = "OAuthCallbackException";
constructor(
public error: any,
public step: string
) {
super("OAuthCallbackException on " + step);
}
}
export class OAuthStrategy implements Strategy {
constructor(private _config: OAuthConfig) {}
get config() {
return this._config;
}
getIssuerConfig(): IssuerConfig {
return issuers[this.config.name];
}
async getConfig(): Promise<
IssuerConfig & {
client: {
client_id: string;
client_secret: string;
};
}
> {
const info = this.getIssuerConfig();
if (info.type === "oidc") {
const issuer = new URL(info.as.issuer);
const request = await oauth.discoveryRequest(issuer);
info.as = await oauth.processDiscoveryResponse(issuer, request);
}
return {
...info,
type: info.type,
client: {
...info.client,
...this._config.client
}
};
}
async getCodeChallenge(as: oauth.AuthorizationServer, state: string, method: "S256" = "S256") {
const challenge_supported = as.code_challenge_methods_supported?.includes(method);
let challenge: string | undefined;
let challenge_method: string | undefined;
if (challenge_supported) {
challenge = await oauth.calculatePKCECodeChallenge(state);
challenge_method = method;
}
return { challenge_supported, challenge, challenge_method };
}
async request(options: { redirect_uri: string; state: string; scopes?: string[] }): Promise<{
url: string;
endpoint: string;
params: Record<string, string>;
}> {
const { client, as } = await this.getConfig();
const { challenge_supported, challenge, challenge_method } = await this.getCodeChallenge(
as,
options.state
);
if (!as.authorization_endpoint) {
throw new Error("authorization_endpoint is not provided");
}
const scopes = options.scopes ?? as.scopes_supported;
if (!Array.isArray(scopes) || scopes.length === 0) {
throw new Error("No scopes provided");
}
if (scopes.every((scope) => !as.scopes_supported?.includes(scope))) {
throw new Error("Invalid scopes provided");
}
const endpoint = as.authorization_endpoint!;
const params: any = {
client_id: client.client_id,
redirect_uri: options.redirect_uri,
response_type: "code",
scope: scopes.join(as.scope_separator ?? " ")
};
if (challenge_supported) {
params.code_challenge = challenge;
params.code_challenge_method = challenge_method;
} else {
params.nonce = options.state;
}
return {
url: new URL(endpoint) + "?" + new URLSearchParams(params).toString(),
endpoint,
params
};
}
private async oidc(
callbackParams: URL | URLSearchParams,
options: { redirect_uri: string; state: string; scopes?: string[] }
) {
const config = await this.getConfig();
const { client, as, type } = config;
//console.log("config", config);
//console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
callbackParams,
oauth.expectNoState
);
if (oauth.isOAuth2Error(parameters)) {
//console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
/*console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);*/
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
parameters,
options.redirect_uri,
options.state
);
//console.log("callback.response", response);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
const { challenge_supported, challenge } = await this.getCodeChallenge(as, options.state);
const expectedNonce = challenge_supported ? undefined : challenge;
const result = await oauth.processAuthorizationCodeOpenIDResponse(
as,
client,
response,
expectedNonce
);
if (oauth.isOAuth2Error(result)) {
//console.log("callback.error", result);
// @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
}
//console.log("callback.result", result);
const claims = oauth.getValidatedIdTokenClaims(result);
//console.log("callback.IDTokenClaims", claims);
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
//console.log("callback.resultUser", resultUser);
return await config.profile(resultUser, config, claims); // @todo: check claims
}
private async oauth2(
callbackParams: URL | URLSearchParams,
options: { redirect_uri: string; state: string; scopes?: string[] }
) {
const config = await this.getConfig();
const { client, type, as, profile } = config;
console.log("config", { client, as, type });
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
callbackParams,
oauth.expectNoState
);
if (oauth.isOAuth2Error(parameters)) {
console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2)
);
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
parameters,
options.redirect_uri,
options.state
);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
// slack does not return valid "token_type"...
const copy = response.clone();
let result: any = {};
try {
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) {
console.log("error", result);
throw new Error(); // Handle OAuth 2.0 response body error
}
} catch (e) {
result = (await copy.json()) as any;
console.log("failed", result);
}
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
const user = await res2.json();
console.log("res2", res2, user);
console.log("result", result);
return await config.profile(user, config, result);
}
async callback(
callbackParams: URL | URLSearchParams,
options: { redirect_uri: string; state: string; scopes?: string[] }
): Promise<UserProfile> {
const type = this.getIssuerConfig().type;
console.log("type", type);
switch (type) {
case "oidc":
return await this.oidc(callbackParams, options);
case "oauth2":
return await this.oauth2(callbackParams, options);
default:
throw new Error("Unsupported type");
}
}
getController(auth: Authenticator): Hono<any> {
const hono = new Hono();
const secret = "secret";
const cookie_name = "_challenge";
const setState = async (
c: Context,
config: { state: string; action: AuthAction; redirect?: string }
): Promise<void> => {
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
secure: true,
httpOnly: true,
sameSite: "Lax",
maxAge: 60 * 5 // 5 minutes
});
};
const getState = async (
c: Context
): Promise<{ state: string; action: AuthAction; redirect?: string }> => {
const state = await getSignedCookie(c, secret, cookie_name);
try {
return JSON.parse(state as string);
} catch (e) {
throw new Error("Invalid state");
}
};
hono.get("/callback", async (c) => {
const url = new URL(c.req.url);
const params = new URLSearchParams(url.search);
const state = await getState(c);
console.log("url", url);
const profile = await this.callback(params, {
redirect_uri: url.origin + url.pathname,
state: state.state
});
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
console.log("******** RESOLVED ********", { user, token });
if (state.redirect) {
console.log("redirect to", state.redirect + "?token=" + token);
return c.redirect(state.redirect + "?token=" + token);
}
return c.json({ user, token });
});
hono.get("/:action", async (c) => {
const action = c.req.param("action") as AuthAction;
if (!["login", "register"].includes(action)) {
return c.notFound();
}
const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/callback";
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
const state = await oauth.generateRandomCodeVerifier();
const response = await this.request({
redirect_uri,
state
});
//console.log("_state", state);
await setState(c, { state, action, redirect: q_redirect });
if (c.req.header("Accept") === "application/json") {
return c.json({
url: response.url,
redirect_uri,
challenge: state,
params: response.params
});
}
//return c.text(response.url);
console.log("--redirecting to", response.url);
return c.redirect(response.url);
});
return hono;
}
getType() {
return "oauth";
}
getMode() {
return "external" as const;
}
getName() {
return this.config.name;
}
getSchema() {
return schemaProvided;
}
toJSON(secrets?: boolean) {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return {
type: this.getType(),
config: {
type: this.getIssuerConfig().type,
...config
}
};
}
}

View File

@@ -0,0 +1,63 @@
import type { IssuerConfig } from "../OAuthStrategy";
type GithubUserInfo = {
id: number;
sub: string;
name: string;
email: null;
avatar_url: string;
};
type GithubUserEmailResponse = {
email: string;
primary: boolean;
verified: boolean;
visibility: string;
}[];
export const github: IssuerConfig<GithubUserInfo> = {
type: "oauth2",
client: {
token_endpoint_auth_method: "client_secret_basic",
},
as: {
code_challenge_methods_supported: ["S256"],
issuer: "https://github.com",
scopes_supported: ["read:user", "user:email"],
scope_separator: " ",
authorization_endpoint: "https://github.com/login/oauth/authorize",
token_endpoint: "https://github.com/login/oauth/access_token",
userinfo_endpoint: "https://api.github.com/user",
},
profile: async (
info: GithubUserInfo,
config: Omit<IssuerConfig, "profile">,
tokenResponse: any,
) => {
console.log("github info", info, config, tokenResponse);
try {
const res = await fetch("https://api.github.com/user/emails", {
headers: {
"User-Agent": "bknd", // this is mandatory... *smh*
Accept: "application/json",
Authorization: `Bearer ${tokenResponse.access_token}`,
},
});
const data = (await res.json()) as GithubUserEmailResponse;
console.log("data", data);
const email = data.find((e: any) => e.primary)?.email;
if (!email) {
throw new Error("No primary email found");
}
return {
...info,
sub: String(info.id),
email: email,
};
} catch (e) {
throw new Error("Couldn't retrive github email");
}
},
};

View File

@@ -0,0 +1,29 @@
import type { IssuerConfig } from "../OAuthStrategy";
type GoogleUserInfo = {
sub: string;
name: string;
given_name: string;
family_name: string;
picture: string;
email: string;
email_verified: boolean;
locale: string;
};
export const google: IssuerConfig<GoogleUserInfo> = {
type: "oidc",
client: {
token_endpoint_auth_method: "client_secret_basic",
},
as: {
issuer: "https://accounts.google.com",
},
profile: async (info) => {
return {
...info,
sub: info.sub,
email: info.email,
};
},
};

View File

@@ -0,0 +1,2 @@
export { google } from "./google";
export { github } from "./github";

View File

@@ -0,0 +1,160 @@
import { Exception, Permission } from "core";
import { type Static, Type, objectTransform } from "core/utils";
import { Role } from "./Role";
export type GuardUserContext = {
role: string | null | undefined;
[key: string]: any;
};
export type GuardConfig = {
enabled?: boolean;
};
export class Guard {
permissions: Permission[];
user?: GuardUserContext;
roles?: Role[];
config?: GuardConfig;
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
this.permissions = permissions;
this.roles = roles;
this.config = config;
}
static create(
permissionNames: string[],
roles?: Record<
string,
{
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}
>,
config?: GuardConfig
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
getPermissionNames(): string[] {
return this.permissions.map((permission) => permission.name);
}
getPermissions(): Permission[] {
return this.permissions;
}
permissionExists(permissionName: string): boolean {
return !!this.permissions.find((p) => p.name === permissionName);
}
setRoles(roles: Role[]) {
this.roles = roles;
return this;
}
getRoles() {
return this.roles;
}
setConfig(config: Partial<GuardConfig>) {
this.config = { ...this.config, ...config };
return this;
}
registerPermission(permission: Permission) {
if (this.permissions.find((p) => p.name === permission.name)) {
throw new Error(`Permission ${permission.name} already exists`);
}
this.permissions.push(permission);
return this;
}
registerPermissions(permissions: Permission[]) {
for (const permission of permissions) {
this.registerPermission(permission);
}
return this;
}
setUserContext(user: GuardUserContext | undefined) {
this.user = user;
return this;
}
getUserRole(): Role | undefined {
if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) {
console.log("guard: role found", this.user.role);
return role;
}
}
console.log("guard: role not found", this.user, this.user?.role);
return this.getDefaultRole();
}
getDefaultRole(): Role | undefined {
return this.roles?.find((role) => role.is_default);
}
hasPermission(permission: Permission): boolean;
hasPermission(name: string): boolean;
hasPermission(permissionOrName: Permission | string): boolean {
if (this.config?.enabled !== true) {
//console.log("guard not enabled, allowing");
return true;
}
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
const exists = this.permissionExists(name);
if (!exists) {
throw new Error(`Permission ${name} does not exist`);
}
const role = this.getUserRole();
if (!role) {
console.log("guard: role not found, denying");
return false;
} else if (role.implicit_allow === true) {
console.log("guard: role implicit allow, allowing");
return true;
}
const rolePermission = role.permissions.find(
(rolePermission) => rolePermission.permission.name === name
);
console.log("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission
});
return !!rolePermission;
}
granted(permission: Permission | string): boolean {
return this.hasPermission(permission as any);
}
throwUnlessGranted(permission: Permission | string) {
if (!this.granted(permission)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403
);
}
}
}

View File

@@ -0,0 +1,45 @@
import { Permission } from "core";
export class RolePermission {
constructor(
public permission: Permission,
public config?: any
) {}
}
export class Role {
constructor(
public name: string,
public permissions: RolePermission[] = [],
public is_default: boolean = false,
public implicit_allow: boolean = false
) {}
static createWithPermissionNames(
name: string,
permissionNames: string[],
is_default: boolean = false,
implicit_allow: boolean = false
) {
return new Role(
name,
permissionNames.map((name) => new RolePermission(new Permission(name))),
is_default,
implicit_allow
);
}
static create(config: {
name: string;
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}) {
return new Role(
config.name,
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
config.is_default,
config.implicit_allow
);
}
}

28
app/src/auth/errors.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Exception } from "core";
export class UserExistsException extends Exception {
override name = "UserExistsException";
override code = 422;
constructor() {
super("User already exists");
}
}
export class UserNotFoundException extends Exception {
override name = "UserNotFoundException";
override code = 404;
constructor() {
super("User not found");
}
}
export class InvalidCredentialsException extends Exception {
override name = "InvalidCredentialsException";
override code = 401;
constructor() {
super("Invalid credentials");
}
}

21
app/src/auth/index.ts Normal file
View File

@@ -0,0 +1,21 @@
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
export { sha256 } from "./utils/hash";
export {
type ProfileExchange,
type Strategy,
type User,
type SafeUser,
type CreateUser,
type AuthResponse,
type UserPool,
type AuthAction,
type AuthUserResolver,
Authenticator,
authenticatorConfig,
jwtConfig
} from "./authenticate/Authenticator";
export { AppAuth, type UserFieldSchema } from "./AppAuth";
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
export { Role } from "./authorize/Role";

View File

@@ -0,0 +1,13 @@
// @deprecated: moved to @bknd/core
export async function sha256(password: string, salt?: string) {
// 1. Convert password to Uint8Array
const encoder = new TextEncoder();
const data = encoder.encode((salt ?? "") + password);
// 2. Hash the data using SHA-256
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
// 3. Convert hash to hex string for easier display
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

View File

@@ -0,0 +1,12 @@
import { getDefaultConfig } from "modules/ModuleManager";
import type { CliCommand } from "../types";
export const config: CliCommand = (program) => {
program
.command("config")
.description("get default config")
.option("--pretty", "pretty print")
.action((options) => {
console.log(getDefaultConfig(options.pretty));
});
};

View File

@@ -0,0 +1,20 @@
import path from "node:path";
import url from "node:url";
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
import type { CliCommand } from "../types";
export const debug: CliCommand = (program) => {
program
.command("debug")
.description("debug path resolution")
.action(() => {
console.log("paths", {
rootpath: getRootPath(),
distPath: getDistPath(),
relativeDistPath: getRelativeDistPath(),
cwd: process.cwd(),
dir: path.dirname(url.fileURLToPath(import.meta.url)),
resolvedPkg: path.resolve(getRootPath(), "package.json")
});
});
};

View File

@@ -0,0 +1,5 @@
export { config } from "./config";
export { schema } from "./schema";
export { run } from "./run";
export { debug } from "./debug";
export { user } from "./user";

View File

@@ -0,0 +1 @@
export * from "./run";

View File

@@ -0,0 +1,96 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ServeStaticOptions } from "@hono/node-server/serve-static";
import { type Config, createClient } from "@libsql/client/node";
import { Connection, LibsqlConnection, SqliteLocalConnection } from "data";
import type { MiddlewareHandler } from "hono";
import { fileExists, getDistPath, getRelativeDistPath } from "../../utils/sys";
export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number];
export async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
switch (server) {
case "node": {
const m = await import("@hono/node-server/serve-static");
return m.serveStatic({
// somehow different for node
root: getRelativeDistPath() + "/static"
});
}
case "bun": {
const m = await import("hono/bun");
return m.serveStatic({
root: path.resolve(getRelativeDistPath(), "static")
});
}
}
}
export async function attachServeStatic(app: any, platform: Platform) {
app.module.server.client.get("/assets/*", await serveStatic(platform));
}
export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port;
console.log("running on", server, port);
switch (server) {
case "node": {
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88
const serve = await import("@hono/node-server").then((m) => m.serve);
serve({
fetch: (req) => app.fetch(req),
port
});
break;
}
case "bun": {
Bun.serve({
fetch: (req) => app.fetch(req),
port
});
break;
}
}
console.log("Server listening on", "http://localhost:" + port);
}
export async function getHtml() {
return await readFile(path.resolve(getDistPath(), "static/index.html"), "utf-8");
}
export function getConnection(connectionOrConfig?: Connection | Config): Connection {
if (connectionOrConfig) {
if (connectionOrConfig instanceof Connection) {
return connectionOrConfig;
}
if ("url" in connectionOrConfig) {
return new LibsqlConnection(createClient(connectionOrConfig));
}
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
//return new SqliteLocalConnection(new Database(":memory:"));
}
export async function getConfigPath(filePath?: string) {
if (filePath) {
const config_path = path.resolve(process.cwd(), filePath);
if (await fileExists(config_path)) {
return config_path;
}
}
const paths = ["./bknd.config", "./bknd.config.ts", "./bknd.config.js"];
for (const p of paths) {
const _p = path.resolve(process.cwd(), p);
if (await fileExists(_p)) {
return _p;
}
}
return;
}

View File

@@ -0,0 +1,115 @@
import type { Config } from "@libsql/client/node";
import { App } from "App";
import type { BkndConfig } from "adapter";
import { Option } from "commander";
import type { Connection } from "data";
import type { CliCommand } from "../../types";
import {
PLATFORMS,
type Platform,
attachServeStatic,
getConfigPath,
getConnection,
getHtml,
startServer
} from "./platform";
const isBun = typeof Bun !== "undefined";
export const run: CliCommand = (program) => {
program
.command("run")
.addOption(
new Option("-p, --port <port>", "port to run on")
.env("PORT")
.default(1337)
.argParser((v) => Number.parseInt(v))
)
.addOption(new Option("-c, --config <config>", "config file"))
.addOption(
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
"config"
)
)
.addOption(new Option("--db-token <db>", "database token").conflicts("config"))
.addOption(
new Option("--server <server>", "server type")
.choices(PLATFORMS)
.default(isBun ? "bun" : "node")
)
.action(action);
};
type MakeAppConfig = {
connection: Connection;
server?: { platform?: Platform };
setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>;
};
async function makeApp(config: MakeAppConfig) {
const html = await getHtml();
const app = new App(config.connection);
app.emgr.on(
"app-built",
async () => {
await attachServeStatic(app, config.server?.platform ?? "node");
app.module.server.setAdminHtml(html);
if (config.onBuilt) {
await config.onBuilt(app);
}
},
"sync"
);
await app.build();
return app;
}
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
const html = await getHtml();
const app = App.create(appConfig);
app.emgr.on(
"app-built",
async () => {
await attachServeStatic(app, platform ?? "node");
app.module.server.setAdminHtml(html);
if (config.onBuilt) {
await config.onBuilt(app);
}
},
"sync"
);
await app.build();
return app;
}
async function action(options: {
port: number;
config?: string;
dbUrl?: string;
dbToken?: string;
server: Platform;
}) {
const configFilePath = await getConfigPath(options.config);
let app: App;
if (options.dbUrl || !configFilePath) {
const connection = getConnection(
options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined
);
app = await makeApp({ connection, server: { platform: options.server } });
} else {
console.log("Using config from:", configFilePath);
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
app = await makeConfigApp(config, options.server);
}
await startServer(options.server, app, { port: options.port });
}

View File

@@ -0,0 +1,12 @@
import { getDefaultSchema } from "modules/ModuleManager";
import type { CliCommand } from "../types";
export const schema: CliCommand = (program) => {
program
.command("schema")
.description("get schema")
.option("--pretty", "pretty print")
.action((options) => {
console.log(getDefaultSchema(options.pretty));
});
};

View File

@@ -0,0 +1,144 @@
import { password as $password, text as $text } from "@clack/prompts";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import type { App, BkndConfig } from "bknd";
import { makeConfigApp } from "cli/commands/run";
import { getConfigPath } from "cli/commands/run/platform";
import type { CliCommand } from "cli/types";
import { Argument } from "commander";
export const user: CliCommand = (program) => {
program
.command("user")
.description("create and update user (auth)")
.addArgument(new Argument("<action>", "action to perform").choices(["create", "update"]))
.action(action);
};
async function action(action: "create" | "update", options: any) {
const configFilePath = await getConfigPath();
if (!configFilePath) {
console.error("config file not found");
return;
}
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
const app = await makeConfigApp(config, options.server);
switch (action) {
case "create":
await create(app, options);
break;
case "update":
await update(app, options);
break;
}
}
async function create(app: App, options: any) {
const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name;
const email = await $text({
message: "Enter email",
validate: (v) => {
if (!v.includes("@")) {
return "Invalid email";
}
return;
}
});
const password = await $password({
message: "Enter password",
validate: (v) => {
if (v.length < 3) {
return "Invalid password";
}
return;
}
});
if (typeof email !== "string" || typeof password !== "string") {
console.log("Cancelled");
process.exit(0);
}
try {
const mutator = app.modules.ctx().em.mutator(users_entity);
mutator.__unstable_toggleSystemEntityCreation(true);
const res = await mutator.insertOne({
email,
strategy: "password",
strategy_value: await strategy.hash(password as string)
});
mutator.__unstable_toggleSystemEntityCreation(false);
console.log("Created:", res.data);
} catch (e) {
console.error("Error");
}
}
async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name;
const em = app.modules.ctx().em;
const email = (await $text({
message: "Which user? Enter email",
validate: (v) => {
if (!v.includes("@")) {
return "Invalid email";
}
return;
}
})) as string;
if (typeof email !== "string") {
console.log("Cancelled");
process.exit(0);
}
const { data: user } = await em.repository(users_entity).findOne({ email });
if (!user) {
console.log("User not found");
process.exit(0);
}
console.log("User found:", user);
const password = await $password({
message: "New Password?",
validate: (v) => {
if (v.length < 3) {
return "Invalid password";
}
return;
}
});
if (typeof password !== "string") {
console.log("Cancelled");
process.exit(0);
}
try {
function togglePw(visible: boolean) {
const field = em.entity(users_entity).field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
}
togglePw(true);
await app.modules
.ctx()
.em.mutator(users_entity)
.updateOne(user.id, {
strategy_value: await strategy.hash(password as string)
});
togglePw(false);
console.log("Updated:", user);
} catch (e) {
console.error("Error", e);
}
}

22
app/src/cli/index.ts Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { Command } from "commander";
import * as commands from "./commands";
import { getVersion } from "./utils/sys";
const program = new Command();
export async function main() {
program
.name("bknd")
.description("bknd cli")
.version(await getVersion());
// register commands
for (const command of Object.values(commands)) {
command(program);
}
program.parse();
}
main().then(null).catch(console.error);

3
app/src/cli/types.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import type { Command } from "commander";
export type CliCommand = (program: Command) => void;

40
app/src/cli/utils/sys.ts Normal file
View File

@@ -0,0 +1,40 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import url from "node:url";
export function getRootPath() {
const _path = path.dirname(url.fileURLToPath(import.meta.url));
// because of "src", local needs one more level up
return path.resolve(_path, process.env.LOCAL ? "../../../" : "../../");
}
export function getDistPath() {
return path.resolve(getRootPath(), "dist");
}
export function getRelativeDistPath() {
return path.relative(process.cwd(), getDistPath());
}
export async function getVersion() {
try {
const resolved = path.resolve(getRootPath(), "package.json");
const pkg = await readFile(resolved, "utf-8");
if (pkg) {
return JSON.parse(pkg).version ?? "preview";
}
} catch (e) {
console.error("Failed to resolve version");
}
return "unknown";
}
export async function fileExists(filePath: string) {
try {
await readFile(path.resolve(process.cwd(), filePath));
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,127 @@
import type { ICacheItem, ICachePool } from "../cache-interface";
export class CloudflareKVCachePool<Data = any> implements ICachePool<Data> {
constructor(private namespace: KVNamespace) {}
supports = () => ({
metadata: true,
clear: false,
});
async get(key: string): Promise<ICacheItem<Data>> {
const result = await this.namespace.getWithMetadata<any>(key);
const hit = result.value !== null && typeof result.value !== "undefined";
// Assuming metadata is not supported directly;
// you may adjust if Cloudflare KV supports it in future.
return new CloudflareKVCacheItem(key, result.value ?? undefined, hit, result.metadata) as any;
}
async getMany(keys: string[] = []): Promise<Map<string, ICacheItem<Data>>> {
const items = new Map<string, ICacheItem<Data>>();
await Promise.all(
keys.map(async (key) => {
const item = await this.get(key);
items.set(key, item);
}),
);
return items;
}
async has(key: string): Promise<boolean> {
const data = await this.namespace.get(key);
return data !== null;
}
async clear(): Promise<boolean> {
// Cloudflare KV does not support clearing all keys in one operation
return false;
}
async delete(key: string): Promise<boolean> {
await this.namespace.delete(key);
return true;
}
async deleteMany(keys: string[]): Promise<boolean> {
const results = await Promise.all(keys.map((key) => this.delete(key)));
return results.every((result) => result);
}
async save(item: CloudflareKVCacheItem<Data>): Promise<boolean> {
await this.namespace.put(item.key(), (await item.value()) as string, {
expirationTtl: item._expirationTtl,
metadata: item.metadata(),
});
return true;
}
async put(
key: string,
value: any,
options?: { ttl?: number; expiresAt?: Date; metadata?: Record<string, string> },
): Promise<boolean> {
const item = new CloudflareKVCacheItem(key, value, true, options?.metadata);
if (options?.expiresAt) item.expiresAt(options.expiresAt);
if (options?.ttl) item.expiresAfter(options.ttl);
return await this.save(item);
}
}
export class CloudflareKVCacheItem<Data = any> implements ICacheItem<Data> {
_expirationTtl: number | undefined;
constructor(
private _key: string,
private data: Data | undefined,
private _hit: boolean = false,
private _metadata: Record<string, string> = {},
) {}
key(): string {
return this._key;
}
value(): Data | undefined {
if (this.data) {
try {
return JSON.parse(this.data as string);
} catch (e) {}
}
return this.data ?? undefined;
}
metadata(): Record<string, string> {
return this._metadata;
}
hit(): boolean {
return this._hit;
}
set(value: Data, metadata: Record<string, string> = {}): this {
this.data = value;
this._metadata = metadata;
return this;
}
expiresAt(expiration: Date | null): this {
// Cloudflare KV does not support specific date expiration; calculate ttl instead.
if (expiration) {
const now = new Date();
const ttl = (expiration.getTime() - now.getTime()) / 1000;
return this.expiresAfter(Math.max(0, Math.floor(ttl)));
}
return this.expiresAfter(null);
}
expiresAfter(time: number | null): this {
// Dummy implementation as Cloudflare KV requires setting expiration during PUT operation.
// This method will be effectively implemented in the Cache Pool save methods.
this._expirationTtl = time ?? undefined;
return this;
}
}

View File

@@ -0,0 +1,139 @@
import type { ICacheItem, ICachePool } from "../cache-interface";
export class MemoryCache<Data = any> implements ICachePool<Data> {
private cache: Map<string, MemoryCacheItem<Data>> = new Map();
private maxSize?: number;
constructor(options?: { maxSize?: number }) {
this.maxSize = options?.maxSize;
}
supports = () => ({
metadata: true,
clear: true
});
async get(key: string): Promise<MemoryCacheItem<Data>> {
if (!this.cache.has(key)) {
// use undefined to denote a miss initially
return new MemoryCacheItem<Data>(key, undefined!);
}
return this.cache.get(key)!;
}
async getMany(keys: string[] = []): Promise<Map<string, MemoryCacheItem<Data>>> {
const items = new Map<string, MemoryCacheItem<Data>>();
for (const key of keys) {
items.set(key, await this.get(key));
}
return items;
}
async has(key: string): Promise<boolean> {
return this.cache.has(key) && this.cache.get(key)!.hit();
}
async clear(): Promise<boolean> {
this.cache.clear();
return true;
}
async delete(key: string): Promise<boolean> {
return this.cache.delete(key);
}
async deleteMany(keys: string[]): Promise<boolean> {
let success = true;
for (const key of keys) {
if (!this.delete(key)) {
success = false;
}
}
return success;
}
async save(item: MemoryCacheItem<Data>): Promise<boolean> {
this.checkSizeAndPurge();
this.cache.set(item.key(), item);
return true;
}
async put(
key: string,
value: Data,
options: { expiresAt?: Date; ttl?: number; metadata?: Record<string, string> } = {}
): Promise<boolean> {
const item = await this.get(key);
item.set(value, options.metadata || {});
if (options.expiresAt) {
item.expiresAt(options.expiresAt);
} else if (typeof options.ttl === "number") {
item.expiresAfter(options.ttl);
}
return this.save(item);
}
private checkSizeAndPurge(): void {
if (!this.maxSize) return;
if (this.cache.size >= this.maxSize) {
// Implement logic to purge items, e.g., LRU (Least Recently Used)
// For simplicity, clear the oldest item inserted
const keyToDelete = this.cache.keys().next().value;
this.cache.delete(keyToDelete!);
}
}
}
export class MemoryCacheItem<Data = any> implements ICacheItem<Data> {
private _key: string;
private _value: Data | undefined;
private expiration: Date | null = null;
private _metadata: Record<string, string> = {};
constructor(key: string, value: Data, metadata: Record<string, string> = {}) {
this._key = key;
this.set(value, metadata);
}
key(): string {
return this._key;
}
metadata(): Record<string, string> {
return this._metadata;
}
value(): Data | undefined {
return this._value;
}
hit(): boolean {
if (this.expiration !== null && new Date() > this.expiration) {
return false;
}
return this.value() !== undefined;
}
set(value: Data, metadata: Record<string, string> = {}): this {
this._value = value;
this._metadata = metadata;
return this;
}
expiresAt(expiration: Date | null): this {
this.expiration = expiration;
return this;
}
expiresAfter(time: number | null): this {
if (typeof time === "number") {
const expirationDate = new Date();
expirationDate.setSeconds(expirationDate.getSeconds() + time);
this.expiration = expirationDate;
} else {
this.expiration = null;
}
return this;
}
}

178
app/src/core/cache/cache-interface.ts vendored Normal file
View File

@@ -0,0 +1,178 @@
/**
* CacheItem defines an interface for interacting with objects inside a cache.
* based on https://www.php-fig.org/psr/psr-6/
*/
export interface ICacheItem<Data = any> {
/**
* Returns the key for the current cache item.
*
* The key is loaded by the Implementing Library, but should be available to
* the higher level callers when needed.
*
* @returns The key string for this cache item.
*/
key(): string;
/**
* Retrieves the value of the item from the cache associated with this object's key.
*
* The value returned must be identical to the value originally stored by set().
*
* If isHit() returns false, this method MUST return null. Note that null
* is a legitimate cached value, so the isHit() method SHOULD be used to
* differentiate between "null value was found" and "no value was found."
*
* @returns The value corresponding to this cache item's key, or undefined if not found.
*/
value(): Data | undefined;
/**
* Retrieves the metadata of the item from the cache associated with this object's key.
*/
metadata(): Record<string, string>;
/**
* Confirms if the cache item lookup resulted in a cache hit.
*
* Note: This method MUST NOT have a race condition between calling isHit()
* and calling get().
*
* @returns True if the request resulted in a cache hit. False otherwise.
*/
hit(): boolean;
/**
* Sets the value represented by this cache item.
*
* The value argument may be any item that can be serialized by PHP,
* although the method of serialization is left up to the Implementing
* Library.
*
* @param value The serializable value to be stored.
* @param metadata The metadata to be associated with the item.
* @returns The invoked object.
*/
set(value: Data, metadata?: Record<string, string>): this;
/**
* Sets the expiration time for this cache item.
*
* @param expiration The point in time after which the item MUST be considered expired.
* If null is passed explicitly, a default value MAY be used. If none is set,
* the value should be stored permanently or for as long as the
* implementation allows.
* @returns The called object.
*/
expiresAt(expiration: Date | null): this;
/**
* Sets the expiration time for this cache item.
*
* @param time The period of time from the present after which the item MUST be considered
* expired. An integer parameter is understood to be the time in seconds until
* expiration. If null is passed explicitly, a default value MAY be used.
* If none is set, the value should be stored permanently or for as long as the
* implementation allows.
* @returns The called object.
*/
expiresAfter(time: number | null): this;
}
/**
* CachePool generates CacheItem objects.
* based on https://www.php-fig.org/psr/psr-6/
*/
export interface ICachePool<Data = any> {
supports(): {
metadata: boolean;
clear: boolean;
};
/**
* Returns a Cache Item representing the specified key.
* This method must always return a CacheItemInterface object, even in case of
* a cache miss. It MUST NOT return null.
*
* @param key The key for which to return the corresponding Cache Item.
* @throws Error If the key string is not a legal value an Error MUST be thrown.
* @returns The corresponding Cache Item.
*/
get(key: string): Promise<ICacheItem<Data>>;
/**
* Returns a traversable set of cache items.
*
* @param keys An indexed array of keys of items to retrieve.
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
* @returns A traversable collection of Cache Items keyed by the cache keys of
* each item. A Cache item will be returned for each key, even if that
* key is not found. However, if no keys are specified then an empty
* traversable MUST be returned instead.
*/
getMany(keys?: string[]): Promise<Map<string, ICacheItem<Data>>>;
/**
* Confirms if the cache contains specified cache item.
*
* Note: This method MAY avoid retrieving the cached value for performance reasons.
* This could result in a race condition with CacheItemInterface.get(). To avoid
* such situation use CacheItemInterface.isHit() instead.
*
* @param key The key for which to check existence.
* @throws Error If the key string is not a legal value an Error MUST be thrown.
* @returns True if item exists in the cache, false otherwise.
*/
has(key: string): Promise<boolean>;
/**
* Deletes all items in the pool.
* @returns True if the pool was successfully cleared. False if there was an error.
*/
clear(): Promise<boolean>;
/**
* Removes the item from the pool.
*
* @param key The key to delete.
* @throws Error If the key string is not a legal value an Error MUST be thrown.
* @returns True if the item was successfully removed. False if there was an error.
*/
delete(key: string): Promise<boolean>;
/**
* Removes multiple items from the pool.
*
* @param keys An array of keys that should be removed from the pool.
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
* @returns True if the items were successfully removed. False if there was an error.
*/
deleteMany(keys: string[]): Promise<boolean>;
/**
* Persists a cache item immediately.
*
* @param item The cache item to save.
* @returns True if the item was successfully persisted. False if there was an error.
*/
save(item: ICacheItem<Data>): Promise<boolean>;
/**
* Persists any deferred cache items.
* @returns True if all not-yet-saved items were successfully saved or there were none. False otherwise.
*/
put(
key: string,
value: any,
options?: { expiresAt?: Date; metadata?: Record<string, string> },
): Promise<boolean>;
put(
key: string,
value: any,
options?: { ttl?: number; metadata?: Record<string, string> },
): Promise<boolean>;
put(
key: string,
value: any,
options?: ({ ttl?: number } | { expiresAt?: Date }) & { metadata?: Record<string, string> },
): Promise<boolean>;
}

View File

@@ -0,0 +1,96 @@
import { AwsClient as Aws4fetchClient } from "aws4fetch";
import { objectKeysPascalToKebab } from "../../utils/objects";
import { xmlToObject } from "../../utils/xml";
type Aws4fetchClientConfig = ConstructorParameters<typeof Aws4fetchClient>[0];
type AwsClientConfig = {
responseType?: "xml" | "json";
responseKeysToUpper?: boolean;
convertParams?: "pascalToKebab";
};
export class AwsClient extends Aws4fetchClient {
readonly #options: AwsClientConfig;
constructor(aws4fetchConfig: Aws4fetchClientConfig, options?: AwsClientConfig) {
super(aws4fetchConfig);
this.#options = options ?? {
responseType: "json",
};
}
protected convertParams(params: Record<string, any>): Record<string, any> {
switch (this.#options.convertParams) {
case "pascalToKebab":
return objectKeysPascalToKebab(params);
default:
return params;
}
}
getUrl(path: string = "/", searchParamsObj: Record<string, any> = {}): string {
//console.log("super:getUrl", path, searchParamsObj);
const url = new URL(path);
const converted = this.convertParams(searchParamsObj);
Object.entries(converted).forEach(([key, value]) => {
url.searchParams.append(key, value as any);
});
return url.toString();
}
protected updateKeysRecursively(obj: any, direction: "toUpperCase" | "toLowerCase") {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) {
return obj.map((item) => this.updateKeysRecursively(item, direction));
}
if (typeof obj === "object") {
return Object.keys(obj).reduce(
(acc, key) => {
// only if key doesn't have any whitespaces
let newKey = key;
if (key.indexOf(" ") === -1) {
newKey = key.charAt(0)[direction]() + key.slice(1);
}
acc[newKey] = this.updateKeysRecursively(obj[key], direction);
return acc;
},
{} as { [key: string]: any },
);
}
return obj;
}
async fetchJson<T extends Record<string, any>>(
input: RequestInfo,
init?: RequestInit,
): Promise<T> {
const response = await this.fetch(input, init);
if (this.#options.responseType === "xml") {
if (!response.ok) {
const body = await response.text();
throw new Error(body);
}
const raw = await response.text();
//console.log("raw", raw);
//console.log(JSON.stringify(xmlToObject(raw), null, 2));
return xmlToObject(raw) as T;
}
if (!response.ok) {
const body = await response.json<{ message: string }>();
throw new Error(body.message);
}
const raw = (await response.json()) as T;
if (this.#options.responseKeysToUpper) {
return this.updateKeysRecursively(raw, "toUpperCase");
}
return raw;
}
}

12
app/src/core/config.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* These are package global defaults.
*/
import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>;
export const config = {
data: {
default_primary_field: "id"
}
} as const;

27
app/src/core/env.ts Normal file
View File

@@ -0,0 +1,27 @@
type TURSO_DB = {
url: string;
authToken: string;
};
export type Env = {
__STATIC_CONTENT: Fetcher;
ENVIRONMENT: string;
CACHE: KVNamespace;
// db
DB_DATA: TURSO_DB;
DB_SCHEMA: TURSO_DB;
// storage
STORAGE: { access_key: string; secret_access_key: string; url: string };
BUCKET: R2Bucket;
};
export function isDebug(): boolean {
try {
// @ts-expect-error - this is a global variable in dev
return __isDev === "1" || __isDev === 1;
} catch (e) {
return false;
}
}

37
app/src/core/errors.ts Normal file
View File

@@ -0,0 +1,37 @@
export class Exception extends Error {
code = 400;
override name = "Exception";
constructor(message: string, code?: number) {
super(message);
if (code) {
this.code = code;
}
}
toJSON() {
return {
error: this.message,
type: this.name
//message: this.message
};
}
}
export class BkndError extends Error {
constructor(
message: string,
public details?: Record<string, any>,
public type?: string
) {
super(message);
}
toJSON() {
return {
type: this.type ?? "unknown",
message: this.message,
details: this.details
};
}
}

View File

@@ -0,0 +1,21 @@
export abstract class Event<Params = any> {
/**
* Unique event slug
* Must be static, because registering events is done by class
*/
static slug: string = "untitled-event";
params: Params;
constructor(params: Params) {
this.params = params;
}
}
// @todo: current workaround: potentially there is none and that's the way
export class NoParamEvent extends Event<null> {
static override slug: string = "noparam-event";
constructor() {
super(null);
}
}

View File

@@ -0,0 +1,22 @@
import type { Event } from "./Event";
import type { EventClass } from "./EventManager";
export const ListenerModes = ["sync", "async"] as const;
export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event = Event> = (
event: E,
slug: string,
) => Promise<void> | void;
export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async";
event: EventClass;
handler: ListenerHandler<E>;
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
this.event = event;
this.handler = handler;
this.mode = mode;
}
}

View File

@@ -0,0 +1,151 @@
import type { Event } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
export interface EmitsEvents {
emgr: EventManager;
}
export type EventClass = {
new (params: any): Event;
slug: string;
};
export class EventManager<
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>
> {
protected events: EventClass[] = [];
protected listeners: EventListener[] = [];
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
if (events) {
this.registerEvents(events);
}
if (listeners) {
for (const listener of listeners) {
this.addListener(listener);
}
}
}
clearEvents() {
this.events = [];
return this;
}
clearAll() {
this.clearEvents();
this.listeners = [];
return this;
}
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
// proxy class to access events
return new Proxy(this, {
get: (_, prop: string) => {
return this.events.find((e) => e.slug === prop);
}
}) as any;
}
eventExists(slug: string): boolean;
eventExists(event: EventClass | Event): boolean;
eventExists(eventOrSlug: EventClass | Event | string): boolean {
let slug: string;
if (typeof eventOrSlug === "string") {
slug = eventOrSlug;
} else {
// @ts-expect-error
slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug;
/*eventOrSlug instanceof Event
? // @ts-expect-error slug is static
eventOrSlug.constructor.slug
: eventOrSlug.slug;*/
}
return !!this.events.find((e) => slug === e.slug);
}
protected throwIfEventNotRegistered(event: EventClass) {
if (!this.eventExists(event)) {
throw new Error(`Event "${event.slug}" not registered`);
}
}
registerEvent(event: EventClass, silent: boolean = false) {
if (this.eventExists(event)) {
if (silent) {
return this;
}
throw new Error(`Event "${event.name}" already registered.`);
}
this.events.push(event);
return this;
}
registerEvents(eventObjects: Record<string, EventClass>): this;
registerEvents(eventArray: EventClass[]): this;
registerEvents(objectOrArray: Record<string, EventClass> | EventClass[]): this {
const events =
typeof objectOrArray === "object" ? Object.values(objectOrArray) : objectOrArray;
events.forEach((event) => this.registerEvent(event, true));
return this;
}
addListener(listener: EventListener) {
this.throwIfEventNotRegistered(listener.event);
this.listeners.push(listener);
return this;
}
onEvent<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>(
event: ActualEvent,
handler: ListenerHandler<Instance>,
mode: ListenerMode = "async"
) {
this.throwIfEventNotRegistered(event);
const listener = new EventListener(event, handler, mode);
this.addListener(listener as any);
}
on<Params = any>(
slug: string,
handler: ListenerHandler<Event<Params>>,
mode: ListenerMode = "async"
) {
const event = this.events.find((e) => e.slug === slug);
if (!event) {
throw new Error(`Event "${slug}" not registered`);
}
this.onEvent(event, handler, mode);
}
onAny(handler: ListenerHandler<Event<unknown>>, mode: ListenerMode = "async") {
this.events.forEach((event) => this.onEvent(event, handler, mode));
}
async emit(event: Event) {
// @ts-expect-error slug is static
const slug = event.constructor.slug;
if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`);
}
const listeners = this.listeners.filter((listener) => listener.event.slug === slug);
//console.log("---!-- emitting", slug, listeners.length);
for (const listener of listeners) {
if (listener.mode === "sync") {
await listener.handler(event, listener.event.slug);
} else {
listener.handler(event, listener.event.slug);
}
}
}
}

View File

@@ -0,0 +1,8 @@
export { Event, NoParamEvent } from "./Event";
export {
EventListener,
ListenerModes,
type ListenerMode,
type ListenerHandler,
} from "./EventListener";
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";

28
app/src/core/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint";
export { zValidator } from "./server/lib/zValidator";
export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors";
export { isDebug } from "./env";
export { type PrimaryFieldType, config } from "./config";
export { AwsClient } from "./clients/aws/AwsClient";
export {
SimpleRenderer,
type TemplateObject,
type TemplateTypes,
type SimpleRendererOptions
} from "./template/SimpleRenderer";
export { Controller, type ClassController } from "./server/Controller";
export { SchemaObject } from "./object/SchemaObject";
export { DebugLogger } from "./utils/DebugLogger";
export { Permission } from "./security/Permission";
export {
exp,
makeValidator,
type FilterQuery,
type Primitive,
isPrimitive,
type TExpression,
type BooleanLike,
isBooleanLike
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";

View File

@@ -0,0 +1,199 @@
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
import {
Default,
type Static,
type TObject,
getFullPathKeys,
mark,
parse,
stripMark
} from "../utils";
export type SchemaObjectOptions<Schema extends TObject> = {
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
restrictPaths?: string[];
overwritePaths?: (RegExp | string)[];
forceParse?: boolean;
};
export class SchemaObject<Schema extends TObject> {
private readonly _default: Partial<Static<Schema>>;
private _value: Static<Schema>;
private _config: Static<Schema>;
private _restriction_bypass: boolean = false;
constructor(
private _schema: Schema,
initial?: Partial<Static<Schema>>,
private options?: SchemaObjectOptions<Schema>
) {
this._default = Default(_schema, {} as any) as any;
this._value = initial
? parse(_schema, cloneDeep(initial as any), {
forceParse: this.isForceParse(),
skipMark: this.isForceParse()
})
: this._default;
this._config = Object.freeze(this._value);
}
protected isForceParse(): boolean {
return this.options?.forceParse ?? true;
}
default(): Static<Schema> {
return this._default;
}
get(options?: { stripMark?: boolean }): Static<Schema> {
if (options?.stripMark) {
return stripMark(this._config);
}
return this._config;
}
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
const valid = parse(this._schema, cloneDeep(config) as any, {
forceParse: true,
skipMark: this.isForceParse()
});
this._value = valid;
this._config = Object.freeze(valid);
if (noEmit !== true) {
await this.options?.onUpdate?.(this._config);
}
return this._config;
}
bypass() {
this._restriction_bypass = true;
return this;
}
throwIfRestricted(object: object): void;
throwIfRestricted(path: string): void;
throwIfRestricted(pathOrObject: string | object): void {
// only bypass once
if (this._restriction_bypass) {
this._restriction_bypass = false;
return;
}
const paths = this.options?.restrictPaths ?? [];
if (Array.isArray(paths) && paths.length > 0) {
for (const path of paths) {
const restricted =
typeof pathOrObject === "string"
? pathOrObject.startsWith(path)
: has(pathOrObject, path);
if (restricted) {
throw new Error(`Path "${path}" is restricted`);
}
}
}
return;
}
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config);
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = mergeWith(current, partial, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue;
}
});
//console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) {
const keys = getFullPathKeys(value).map((k) => path + "." + k);
const overwritePaths = keys.filter((k) => {
return this.options?.overwritePaths?.some((p) => {
if (typeof p === "string") {
return k === p;
} else {
return p.test(k);
}
});
});
//console.log("overwritePaths", keys, overwritePaths);
if (overwritePaths.length > 0) {
// filter out less specific paths (but only if more than 1)
const specific =
overwritePaths.length > 1
? overwritePaths.filter((k) =>
overwritePaths.some((k2) => {
console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
return k2 !== k && k2.startsWith(k);
})
)
: overwritePaths;
//console.log("specific", specific);
for (const p of specific) {
set(config, p, get(partial, p));
}
}
}
//console.log("patch", { path, value, partial, config, current });
const newConfig = await this.set(config);
return [partial, newConfig];
}
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config);
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = set(current, path, value);
//console.log("overwrite", { path, value, partial, config, current });
const newConfig = await this.set(config);
return [partial, newConfig];
}
has(path: string): boolean {
const p = path.split(".");
if (p.length > 1) {
const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) {
console.log("parent", parent, JSON.stringify(this._config, null, 2));
throw new Error(`Parent path "${parent}" does not exist`);
}
}
return has(this._config, path);
}
async remove(path: string): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
this.throwIfRestricted(path);
if (!this.has(path)) {
throw new Error(`Path "${path}" does not exist`);
}
const current = cloneDeep(this._config);
const removed = get(current, path) as Partial<Static<Schema>>;
const config = omit(current, path);
const newConfig = await this.set(config);
return [removed, newConfig];
}
}

View File

@@ -0,0 +1,96 @@
import { type FilterQuery, type Primitive, exp, isPrimitive, makeValidator } from "./query";
const expressions = [
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(e, a) => e === a
),
exp(
"$ne",
(v: Primitive) => isPrimitive(v),
(e, a) => e !== a
),
exp(
"$like",
(v: Primitive) => isPrimitive(v),
(e, a) => {
switch (typeof a) {
case "string":
return (a as string).includes(e as string);
case "number":
return (a as number) === Number(e);
case "boolean":
return (a as boolean) === Boolean(e);
default:
return false;
}
}
),
exp(
"$regex",
(v: RegExp | string) => (v instanceof RegExp ? true : typeof v === "string"),
(e: any, a: any) => {
if (e instanceof RegExp) {
return e.test(a);
}
if (typeof e === "string") {
const regex = new RegExp(e);
return regex.test(a);
}
return false;
}
),
exp(
"$isnull",
(v: boolean | 1 | 0) => true,
(e, a) => (e ? a === null : a !== null)
),
exp(
"$notnull",
(v: boolean | 1 | 0) => true,
(e, a) => (e ? a !== null : a === null)
),
exp(
"$in",
(v: (string | number)[]) => Array.isArray(v),
(e: any, a: any) => e.includes(a)
),
exp(
"$notin",
(v: (string | number)[]) => Array.isArray(v),
(e: any, a: any) => !e.includes(a)
),
exp(
"$gt",
(v: number) => typeof v === "number",
(e: any, a: any) => a > e
),
exp(
"$gte",
(v: number) => typeof v === "number",
(e: any, a: any) => a >= e
),
exp(
"$lt",
(v: number) => typeof v === "number",
(e: any, a: any) => a < e
),
exp(
"$lte",
(v: number) => typeof v === "number",
(e: any, a: any) => a <= e
),
exp(
"$between",
(v: [number, number]) =>
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === "number"),
(e: any, a: any) => e[0] <= a && a <= e[1]
)
];
export type ObjectQuery = FilterQuery<typeof expressions>;
const validator = makeValidator(expressions);
export const convert = (query: ObjectQuery) => validator.convert(query);
export const validate = (query: ObjectQuery, object: Record<string, any>) =>
validator.validate(query, { object, convert: true });

View File

@@ -0,0 +1,209 @@
export type Primitive = string | number | boolean;
export function isPrimitive(value: any): value is Primitive {
return ["string", "number", "boolean"].includes(typeof value);
}
export type BooleanLike = boolean | 0 | 1;
export function isBooleanLike(value: any): value is boolean {
return [true, false, 0, 1].includes(value);
}
export class Expression<Key, Expect = unknown, CTX = any> {
expect!: Expect;
constructor(
public key: Key,
public valid: (v: Expect) => boolean,
public validate: (e: any, a: any, ctx: CTX) => any
) {}
}
export type TExpression<Key, Expect = unknown, CTX = any> = Expression<Key, Expect, CTX>;
export function exp<const Key, const Expect, CTX = any>(
key: Key,
valid: (v: Expect) => boolean,
validate: (e: Expect, a: unknown, ctx: CTX) => any
): Expression<Key, Expect, CTX> {
return new Expression(key, valid, validate);
}
type Expressions = Expression<any, any>[];
type ExpressionMap<Exps extends Expressions> = {
[K in Exps[number]["key"]]: Extract<Exps[number], { key: K }> extends Expression<K, infer E>
? E
: never;
};
type ExpressionCondition<Exps extends Expressions> = {
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
}[keyof ExpressionMap<Exps>];
function getExpression<Exps extends Expressions>(
expressions: Exps,
key: string
): Expression<any, any> {
const exp = expressions.find((e) => e.key === key);
if (!exp) throw new Error(`Expression does not exist: "${key}"`);
return exp as any;
}
type LiteralExpressionCondition<Exps extends Expressions> = {
[key: string]: Primitive | ExpressionCondition<Exps>;
};
const OperandOr = "$or";
type OperandCondition<Exps extends Expressions> = {
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
};
export type FilterQuery<Exps extends Expressions> =
| LiteralExpressionCondition<Exps>
| OperandCondition<Exps>;
function _convert<Exps extends Expressions>(
$query: FilterQuery<Exps>,
expressions: Exps,
path: string[] = []
): FilterQuery<Exps> {
//console.log("-----------------");
const ExpressionConditionKeys = expressions.map((e) => e.key);
const keys = Object.keys($query);
const operands = [OperandOr] as const;
const newQuery: FilterQuery<Exps> = {};
if (keys.some((k) => k.startsWith("$") && !operands.includes(k as any))) {
throw new Error(`Invalid key '${keys}'. Keys must not start with '$'.`);
}
if (path.length > 0 && keys.some((k) => operands.includes(k as any))) {
throw new Error(`Operand ${OperandOr} can only appear at the top level.`);
}
function validate(key: string, value: any, path: string[] = []) {
const exp = getExpression(expressions, key as any);
if (exp.valid(value) === false) {
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
}
}
for (const [key, value] of Object.entries($query)) {
// if $or, convert each value
if (key === "$or") {
newQuery.$or = _convert(value, expressions, [...path, key]);
// if primitive, assume $eq
} else if (isPrimitive(value)) {
validate("$eq", value, path);
newQuery[key] = { $eq: value };
// if object, check for expressions
} else if (typeof value === "object") {
// when object is given, check if all keys are expressions
const invalid = Object.keys(value).filter(
(f) => !ExpressionConditionKeys.includes(f as any)
);
if (invalid.length === 0) {
newQuery[key] = {};
// validate each expression
for (const [k, v] of Object.entries(value)) {
validate(k, v, [...path, key]);
newQuery[key][k] = v;
}
} else {
throw new Error(
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`
);
}
}
}
return newQuery;
}
type ValidationResults = { $and: any[]; $or: any[]; keys: Set<string> };
type BuildOptions = {
object?: any;
exp_ctx?: any;
convert?: boolean;
value_is_kv?: boolean;
};
function _build<Exps extends Expressions>(
_query: FilterQuery<Exps>,
expressions: Exps,
options: BuildOptions
): ValidationResults {
const $query = options.convert ? _convert<Exps>(_query, expressions) : _query;
//console.log("-----------------", { $query });
//const keys = Object.keys($query);
const result: ValidationResults = {
$and: [],
$or: [],
keys: new Set<string>()
};
const { $or, ...$and } = $query;
function __validate($op: string, expected: any, actual: any, path: string[] = []) {
const exp = getExpression(expressions, $op as any);
if (!exp) {
throw new Error(`Expression does not exist: "${$op}"`);
}
if (!exp.valid(expected)) {
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
}
//console.log("found exp", { key: exp.key, expected, actual });
return exp.validate(expected, actual, options.exp_ctx);
}
// check $and
//console.log("$and entries", Object.entries($and));
for (const [key, value] of Object.entries($and)) {
//console.log("$op/$v", Object.entries(value));
for (const [$op, $v] of Object.entries(value)) {
const objValue = options.value_is_kv ? key : options.object[key];
//console.log("--check $and", { key, value, objValue, v_i_kv: options.value_is_kv });
//console.log("validate", { $op, $v, objValue, key });
result.$and.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
//console.log("-", { key, value });
}
// check $or
for (const [key, value] of Object.entries($or ?? {})) {
const objValue = options.value_is_kv ? key : options.object[key];
for (const [$op, $v] of Object.entries(value)) {
//console.log("validate", { $op, $v, objValue });
result.$or.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
//console.log("-", { key, value });
}
//console.log("matches", matches);
return result;
}
function _validate(results: ValidationResults): boolean {
const matches: { $and?: boolean; $or?: boolean } = {
$and: undefined,
$or: undefined
};
matches.$and = results.$and.every((r) => Boolean(r));
matches.$or = results.$or.some((r) => Boolean(r));
return !!matches.$and || !!matches.$or;
}
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
return {
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
_build(query, expressions, options),
validate: (query: FilterQuery<Exps>, options: BuildOptions) => {
const fns = _build(query, expressions, options);
return _validate(fns);
}
};
}

View File

@@ -0,0 +1,30 @@
export type Constructor<T> = new (...args: any[]) => T;
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
private is_set: boolean = false;
private items: Items = {} as Items;
set<Actual extends Record<string, object>>(items: Actual) {
if (this.is_set) {
throw new Error("Registry is already set");
}
// @ts-ignore
this.items = items;
this.is_set = true;
return this as unknown as Registry<Item, Actual>;
}
add(name: string, item: Item) {
// @ts-ignore
this.items[name] = item;
return this;
}
get<Name extends keyof Items>(name: Name): Items[Name] {
return this.items[name];
}
all() {
return this.items;
}
}

View File

@@ -0,0 +1,11 @@
export class Permission<Name extends string = string> {
constructor(public name: Name) {
this.name = name;
}
toJSON() {
return {
name: this.name
};
}
}

View File

@@ -0,0 +1,29 @@
import type { Context } from "hono";
export class ContextHelper {
constructor(protected c: Context) {}
contentTypeMime(): string {
const contentType = this.c.res.headers.get("Content-Type");
if (contentType) {
return String(contentType.split(";")[0]);
}
return "";
}
isHtml(): boolean {
return this.contentTypeMime() === "text/html";
}
url(): URL {
return new URL(this.c.req.url);
}
headersObject() {
const headers = {};
for (const [k, v] of this.c.res.headers.entries()) {
headers[k] = v;
}
return headers;
}
}

View File

@@ -0,0 +1,155 @@
import { Hono, type MiddlewareHandler, type ValidationTargets } from "hono";
import type { H } from "hono/types";
import { safelyParseObjectValues } from "../utils";
import type { Endpoint, Middleware } from "./Endpoint";
import { zValidator } from "./lib/zValidator";
type RouteProxy<Endpoints> = {
[K in keyof Endpoints]: Endpoints[K];
};
export interface ClassController {
getController: () => Hono<any, any, any>;
getMiddleware?: MiddlewareHandler<any, any, any>;
}
/**
* @deprecated
*/
export class Controller<
Endpoints extends Record<string, Endpoint> = Record<string, Endpoint>,
Middlewares extends Record<string, Middleware> = Record<string, Middleware>
> {
protected endpoints: Endpoints = {} as Endpoints;
protected middlewares: Middlewares = {} as Middlewares;
public prefix: string = "/";
public routes: RouteProxy<Endpoints>;
constructor(
prefix: string = "/",
endpoints: Endpoints = {} as Endpoints,
middlewares: Middlewares = {} as Middlewares
) {
this.prefix = prefix;
this.endpoints = endpoints;
this.middlewares = middlewares;
this.routes = new Proxy(
{},
{
get: (_, name: string) => {
return this.endpoints[name];
}
}
) as RouteProxy<Endpoints>;
}
add<Name extends string, E extends Endpoint>(
this: Controller<Endpoints>,
name: Name,
endpoint: E
): Controller<Endpoints & Record<Name, E>> {
const newEndpoints = {
...this.endpoints,
[name]: endpoint
} as Endpoints & Record<Name, E>;
const newController: Controller<Endpoints & Record<Name, E>> = new Controller<
Endpoints & Record<Name, E>
>();
newController.endpoints = newEndpoints;
newController.middlewares = this.middlewares;
return newController;
}
get<Name extends keyof Endpoints>(name: Name): Endpoints[Name] {
return this.endpoints[name];
}
honoify(_hono: Hono = new Hono()) {
const hono = _hono.basePath(this.prefix);
// apply middlewares
for (const m_name in this.middlewares) {
const middleware = this.middlewares[m_name];
if (typeof middleware === "function") {
//if (isDebug()) console.log("+++ appyling middleware", m_name, middleware);
hono.use(middleware);
}
}
// apply endpoints
for (const name in this.endpoints) {
const endpoint = this.endpoints[name];
if (!endpoint) continue;
const handlers: H[] = [];
const supportedValidations: Array<keyof ValidationTargets> = ["param", "query", "json"];
// if validations are present, add them to the handlers
for (const validation of supportedValidations) {
if (endpoint.validation[validation]) {
handlers.push(async (c, next) => {
// @todo: potentially add "strict" to all schemas?
const res = await zValidator(
validation,
endpoint.validation[validation] as any,
(target, value, c) => {
if (["query", "param"].includes(target)) {
return safelyParseObjectValues(value);
}
//console.log("preprocess", target, value, c.req.raw.url);
return value;
}
)(c, next);
if (res instanceof Response && res.status === 400) {
const error = await res.json();
return c.json(
{
error: "Validation error",
target: validation,
message: error
},
400
);
}
return res;
});
}
}
// add actual handler
handlers.push(endpoint.toHandler());
const method = endpoint.method.toLowerCase() as
| "get"
| "post"
| "put"
| "delete"
| "patch";
//if (isDebug()) console.log("--- adding", method, endpoint.path);
hono[method](endpoint.path, ...handlers);
}
return hono;
}
toJSON() {
const endpoints: any = {};
for (const name in this.endpoints) {
const endpoint = this.endpoints[name];
if (!endpoint) continue;
endpoints[name] = {
method: endpoint.method,
path: (this.prefix + endpoint.path).replace("//", "/")
};
}
return endpoints;
}
}

View File

@@ -0,0 +1,147 @@
import type { Context, MiddlewareHandler, Next, ValidationTargets } from "hono";
import type { Handler } from "hono/types";
import { encodeSearch, replaceUrlParam } from "../utils";
import type { Prettify } from "../utils";
type ZodSchema = { [key: string]: any };
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type Validation<P, Q, B> = {
[K in keyof ValidationTargets]?: any;
} & {
param?: P extends ZodSchema ? P : undefined;
query?: Q extends ZodSchema ? Q : undefined;
json?: B extends ZodSchema ? B : undefined;
};
type ValidationInput<P, Q, B> = {
param?: P extends ZodSchema ? P["_input"] : undefined;
query?: Q extends ZodSchema ? Q["_input"] : undefined;
json?: B extends ZodSchema ? B["_input"] : undefined;
};
type HonoEnv = any;
export type Middleware = MiddlewareHandler<any, any, any>;
type HandlerFunction<P extends string, R> = (c: Context<HonoEnv, P, any>, next: Next) => R;
export type RequestResponse<R> = {
status: number;
ok: boolean;
response: Awaited<R>;
};
/**
* @deprecated
*/
export class Endpoint<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
> {
constructor(
readonly method: Method,
readonly path: Path,
readonly handler: HandlerFunction<Path, R>,
readonly validation: Validation<P, Q, B> = {}
) {}
// @todo: typing is not ideal
async $request(
args?: ValidationInput<P, Q, B>,
baseUrl: string = "http://localhost:28623"
): Promise<Prettify<RequestResponse<R>>> {
let path = this.path as string;
if (args?.param) {
path = replaceUrlParam(path, args.param);
}
if (args?.query) {
path += "?" + encodeSearch(args.query);
}
const url = [baseUrl, path].join("").replace(/\/$/, "");
const options: RequestInit = {
method: this.method,
headers: {} as any
};
if (!["GET", "HEAD"].includes(this.method)) {
if (args?.json) {
options.body = JSON.stringify(args.json);
options.headers!["Content-Type"] = "application/json";
}
}
const res = await fetch(url, options);
return {
status: res.status,
ok: res.ok,
response: (await res.json()) as any
};
}
toHandler(): Handler {
return async (c, next) => {
const res = await this.handler(c, next);
//console.log("toHandler:isResponse", res instanceof Response);
//return res;
if (res instanceof Response) {
return res;
}
return c.json(res as any) as unknown as Handler;
};
}
static get<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("GET", path, handler, validation);
}
static post<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("POST", path, handler, validation);
}
static patch<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("PATCH", path, handler, validation);
}
static put<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("PUT", path, handler, validation);
}
static delete<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("DELETE", path, handler, validation);
}
}

View File

@@ -0,0 +1,37 @@
import type { StaticDecode, TSchema } from "@sinclair/typebox";
import { Value, type ValueError } from "@sinclair/typebox/value";
import type { Context, Env, MiddlewareHandler, ValidationTargets } from "hono";
import { validator } from "hono/validator";
type Hook<T, E extends Env, P extends string> = (
result: { success: true; data: T } | { success: false; errors: ValueError[] },
c: Context<E, P>
) => Response | Promise<Response> | void;
export function tbValidator<
T extends TSchema,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
V extends { in: { [K in Target]: StaticDecode<T> }; out: { [K in Target]: StaticDecode<T> } }
>(target: Target, schema: T, hook?: Hook<StaticDecode<T>, E, P>): MiddlewareHandler<E, P, V> {
// Compile the provided schema once rather than per validation. This could be optimized further using a shared schema
// compilation pool similar to the Fastify implementation.
// @ts-expect-error not typed well
return validator(target, (data, c) => {
if (Value.Check(schema, data)) {
// always decode
const decoded = Value.Decode(schema, data);
if (hook) {
const hookResult = hook({ success: true, data: decoded }, c);
if (hookResult instanceof Response || hookResult instanceof Promise) {
return hookResult;
}
}
return decoded;
}
return c.json({ success: false, errors: [...Value.Errors(schema, data)] }, 400);
});
}

View File

@@ -0,0 +1,75 @@
import type {
Context,
Env,
Input,
MiddlewareHandler,
TypedResponse,
ValidationTargets,
} from "hono";
import { validator } from "hono/validator";
import type { ZodError, ZodSchema, z } from "zod";
export type Hook<T, E extends Env, P extends string, O = {}> = (
result: { success: true; data: T } | { success: false; error: ZodError; data: T },
c: Context<E, P>,
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
type HasUndefined<T> = undefined extends T ? true : false;
export const zValidator = <
T extends ZodSchema,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = z.input<T>,
Out = z.output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
[K in Target]?: K extends "json"
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] };
}
: {
[K in Target]: K extends "json"
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] };
};
out: { [K in Target]: Out };
},
V extends I = I,
>(
target: Target,
schema: T,
preprocess?: (target: string, value: In, c: Context<E, P>) => V, // <-- added
hook?: Hook<z.infer<T>, E, P>,
): MiddlewareHandler<E, P, V> =>
// @ts-expect-error not typed well
validator(target, async (value, c) => {
// added: preprocess value first if given
const _value = preprocess ? preprocess(target, value, c) : (value as any);
const result = await schema.safeParseAsync(_value);
if (hook) {
const hookResult = await hook({ data: value, ...result }, c);
if (hookResult) {
if (hookResult instanceof Response) {
return hookResult;
}
if ("response" in hookResult) {
return hookResult.response;
}
}
}
if (!result.success) {
return c.json(result, 400);
}
return result.data as z.infer<T>;
});

View File

@@ -0,0 +1,96 @@
import { Liquid, LiquidError } from "liquidjs";
import type { RenderOptions } from "liquidjs/dist/liquid-options";
import { BkndError } from "../errors";
export type TemplateObject = Record<string, string | Record<string, string>>;
export type TemplateTypes = string | TemplateObject;
export type SimpleRendererOptions = RenderOptions & {
renderKeys?: boolean;
};
export class SimpleRenderer {
private engine = new Liquid();
constructor(
private variables: Record<string, any> = {},
private options: SimpleRendererOptions = {}
) {}
another() {
return 1;
}
static hasMarkup(template: string | object): boolean {
//console.log("has markup?", template);
let flat: string = "";
if (Array.isArray(template) || typeof template === "object") {
// only plain arrays and objects
if (!["Array", "Object"].includes(template.constructor.name)) return false;
flat = JSON.stringify(template);
} else {
flat = String(template);
}
//console.log("** flat", flat);
const checks = ["{{", "{%", "{#", "{:"];
const hasMarkup = checks.some((check) => flat.includes(check));
//console.log("--has markup?", hasMarkup);
return hasMarkup;
}
async render<Given extends TemplateTypes>(template: Given): Promise<Given> {
try {
if (typeof template === "string") {
return (await this.renderString(template)) as unknown as Given;
} else if (Array.isArray(template)) {
return (await Promise.all(
template.map((item) => this.render(item))
)) as unknown as Given;
} else if (typeof template === "object") {
return (await this.renderObject(template)) as unknown as Given;
}
} catch (e) {
if (e instanceof LiquidError) {
const details = {
name: e.name,
token: {
kind: e.token.kind,
input: e.token.input,
begin: e.token.begin,
end: e.token.end
}
};
throw new BkndError(e.message, details, "liquid");
}
throw e;
}
throw new Error("Invalid template type");
}
async renderString(template: string): Promise<string> {
//console.log("*** renderString", template, this.variables);
return this.engine.parseAndRender(template, this.variables, this.options);
}
async renderObject(template: TemplateObject): Promise<TemplateObject> {
const result: TemplateObject = {};
for (const [key, value] of Object.entries(template)) {
let resultKey = key;
if (this.options.renderKeys) {
resultKey = await this.renderString(key);
}
result[resultKey] = await this.render(value);
}
return result;
}
}

4
app/src/core/types.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Serializable<Class, Json extends object = object> {
toJSON(): Json;
fromJSON(json: Json): Class;
}

View File

@@ -0,0 +1,36 @@
export class DebugLogger {
public _context: string[] = [];
_enabled: boolean = true;
private readonly id = Math.random().toString(36).substr(2, 9);
private last: number = 0;
constructor(enabled: boolean = true) {
this._enabled = enabled;
}
context(context: string) {
//console.log("[ settings context ]", context, this._context);
this._context.push(context);
return this;
}
clear() {
//console.log("[ clear context ]", this._context.pop(), this._context);
this._context.pop();
return this;
}
log(...args: any[]) {
if (!this._enabled) return this;
const now = performance.now();
const time = Number.parseInt(String(now - this.last));
const indents = " ".repeat(this._context.length);
const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
console.log(indents, context, time, ...args);
this.last = now;
return this;
}
}

View File

@@ -0,0 +1,20 @@
export type TBrowser = "Opera" | "Edge" | "Chrome" | "Safari" | "Firefox" | "IE" | "unknown";
export function getBrowser(): TBrowser {
if ((navigator.userAgent.indexOf("Opera") || navigator.userAgent.indexOf("OPR")) !== -1) {
return "Opera";
} else if (navigator.userAgent.indexOf("Edg") !== -1) {
return "Edge";
} else if (navigator.userAgent.indexOf("Chrome") !== -1) {
return "Chrome";
} else if (navigator.userAgent.indexOf("Safari") !== -1) {
return "Safari";
} else if (navigator.userAgent.indexOf("Firefox") !== -1) {
return "Firefox";
// @ts-ignore
} else if (navigator.userAgent.indexOf("MSIE") !== -1 || !!document.documentMode === true) {
//IF IE > 10
return "IE";
} else {
return "unknown";
}
}

View File

@@ -0,0 +1,29 @@
export const HashAlgorithms = ["SHA-1", "SHA-256", "SHA-384", "SHA-512"] as const;
export type HashAlgorithm = (typeof HashAlgorithms)[number];
export async function digest(alg: HashAlgorithm, input: string, salt?: string, pepper?: string) {
if (!HashAlgorithms.includes(alg)) {
throw new Error(`Invalid hash algorithm: ${alg}`);
}
// convert to Uint8Array
const data = new TextEncoder().encode((salt ?? "") + input + (pepper ?? ""));
// hash to alg
const hashBuffer = await crypto.subtle.digest(alg, data);
// convert hash to hex string
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
export const hash = {
sha256: async (input: string, salt?: string, pepper?: string) =>
digest("SHA-256", input, salt, pepper),
sha1: async (input: string, salt?: string, pepper?: string) =>
digest("SHA-1", input, salt, pepper)
};
export async function checksum(s: any) {
const o = typeof s === "string" ? s : JSON.stringify(s);
return await digest("SHA-1", o);
}

View File

@@ -0,0 +1,14 @@
import dayjs from "dayjs";
import weekOfYear from "dayjs/plugin/weekOfYear.js";
declare module "dayjs" {
interface Dayjs {
week(): number;
week(value: number): dayjs.Dayjs;
}
}
dayjs.extend(weekOfYear);
export { dayjs };

View File

@@ -0,0 +1,13 @@
export * from "./browser";
export * from "./objects";
export * from "./strings";
export * from "./perf";
export * from "./reqres";
export * from "./xml";
export type { Prettify, PrettifyRec } from "./types";
export * from "./typebox";
export * from "./dates";
export * from "./crypto";
export * from "./uuid";
export { FromSchema } from "./typebox/from-schema";
export * from "./test";

View File

@@ -0,0 +1,198 @@
import { pascalToKebab } from "./strings";
export function _jsonp(obj: any, space = 2): string {
return JSON.stringify(obj, null, space);
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => {
try {
// @ts-ignore
acc[key] = JSON.parse(value);
} catch (error) {
// @ts-ignore
acc[key] = value;
}
return acc;
}, {} as T);
}
export function keepChanged<T extends object>(origin: T, updated: T): Partial<T> {
return Object.keys(updated).reduce(
(acc, key) => {
if (updated[key] !== origin[key]) {
acc[key] = updated[key];
}
return acc;
},
{} as Partial<T>
);
}
export function objectKeysPascalToKebab(obj: any, ignoreKeys: string[] = []): any {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => objectKeysPascalToKebab(item, ignoreKeys));
}
return Object.keys(obj).reduce(
(acc, key) => {
const kebabKey = ignoreKeys.includes(key) ? key : pascalToKebab(key);
acc[kebabKey] = objectKeysPascalToKebab(obj[key], ignoreKeys);
return acc;
},
{} as Record<string, any>
);
}
export function filterKeys<Object extends { [key: string]: any }>(
obj: Object,
keysToFilter: string[]
): Object {
const result = {} as Object;
for (const key in obj) {
const shouldFilter = keysToFilter.some((filterKey) => key.includes(filterKey));
if (!shouldFilter) {
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
result[key] = filterKeys(obj[key], keysToFilter);
} else {
result[key] = obj[key];
}
}
}
return result;
}
export function transformObject<T extends Record<string, any>, U>(
object: T,
transform: (value: T[keyof T], key: keyof T) => U | undefined
): { [K in keyof T]: U } {
return Object.entries(object).reduce(
(acc, [key, value]) => {
const t = transform(value, key as keyof T);
if (typeof t !== "undefined") {
acc[key as keyof T] = t;
}
return acc;
},
{} as { [K in keyof T]: U }
);
}
export const objectTransform = transformObject;
export function objectEach<T extends Record<string, any>, U>(
object: T,
each: (value: T[keyof T], key: keyof T) => U
): void {
Object.entries(object).forEach(
([key, value]) => {
each(value, key);
},
{} as { [K in keyof T]: U }
);
}
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item) {
return item && typeof item === "object" && !Array.isArray(item);
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export function mergeDeep(target, ...sources) {
if (!sources.length) return target;
const source = sources.shift();
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) Object.assign(target, { [key]: {} });
mergeDeep(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
}
}
return mergeDeep(target, ...sources);
}
export function getFullPathKeys(obj: any, parentPath: string = ""): string[] {
let keys: string[] = [];
for (const key in obj) {
const fullPath = parentPath ? `${parentPath}.${key}` : key;
keys.push(fullPath);
if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(getFullPathKeys(obj[key], fullPath));
}
}
return keys;
}
export function flattenObject(obj: any, parentKey = "", result: any = {}): any {
for (const key in obj) {
if (key in obj) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) {
flattenObject(obj[key], newKey, result);
} else if (Array.isArray(obj[key])) {
obj[key].forEach((item, index) => {
const arrayKey = `${newKey}.${index}`;
if (typeof item === "object" && item !== null) {
flattenObject(item, arrayKey, result);
} else {
result[arrayKey] = item;
}
});
} else {
result[newKey] = obj[key];
}
}
}
return result;
}
export function objectDepth(object: object): number {
let level = 1;
for (const key in object) {
if (typeof object[key] === "object") {
const depth = objectDepth(object[key]) + 1;
level = Math.max(depth, level);
}
}
return level;
}
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) {
const nested = value.map(objectCleanEmpty);
if (nested.length > 0) {
acc[key] = nested;
}
} else if (value && typeof value === "object" && !Array.isArray(value)) {
const nested = objectCleanEmpty(value);
if (Object.keys(nested).length > 0) {
acc[key] = nested;
}
} else if (value !== "" && value !== null && value !== undefined) {
acc[key] = value;
}
return acc;
}, {} as any);
}

View File

@@ -0,0 +1,60 @@
export class Perf {
private marks: { mark: string; time: number }[] = [];
private startTime: number;
private endTime: number | null = null;
private constructor() {
this.startTime = performance.now();
}
static start(): Perf {
return new Perf();
}
mark(markName: string): void {
if (this.endTime !== null) {
throw new Error("Cannot add marks after perf measurement has been closed.");
}
const currentTime = performance.now();
const lastMarkTime =
this.marks.length > 0 ? this.marks[this.marks.length - 1]!.time : this.startTime;
const elapsedTimeSinceLastMark = currentTime - lastMarkTime;
this.marks.push({ mark: markName, time: elapsedTimeSinceLastMark });
}
close(): void {
if (this.endTime !== null) {
throw new Error("Perf measurement has already been closed.");
}
this.endTime = performance.now();
}
result(): { total: number; marks: { mark: string; time: number }[] } {
if (this.endTime === null) {
throw new Error("Perf measurement has not been closed yet.");
}
const totalTime = this.endTime - this.startTime;
return {
total: Number.parseFloat(totalTime.toFixed(2)),
marks: this.marks.map((mark) => ({
mark: mark.mark,
time: Number.parseFloat(mark.time.toFixed(2)),
})),
};
}
static async execute(fn: () => Promise<any>, times: number = 1): Promise<any> {
const perf = Perf.start();
for (let i = 0; i < times; i++) {
await fn();
perf.mark(`iteration-${i}`);
}
perf.close();
return perf.result();
}
}

View File

@@ -0,0 +1,84 @@
export function headersToObject(headers: Headers): Record<string, string> {
if (!headers) return {};
return { ...Object.fromEntries(headers.entries()) };
}
export function pickHeaders(headers: Headers, keys: string[]): Record<string, string> {
const obj = headersToObject(headers);
const res = {};
for (const key of keys) {
if (obj[key]) {
res[key] = obj[key];
}
}
return res;
}
export const replaceUrlParam = (urlString: string, params: Record<string, string>) => {
let newString = urlString;
for (const [k, v] of Object.entries(params)) {
const reg = new RegExp(`/:${k}(?:{[^/]+})?`);
newString = newString.replace(reg, `/${v}`);
}
return newString;
};
export function encodeSearch(obj, options?: { prefix?: string; encode?: boolean }) {
let str = "";
function _encode(str) {
return options?.encode ? encodeURIComponent(str) : str;
}
for (const k in obj) {
let tmp = obj[k];
if (tmp !== void 0) {
if (Array.isArray(tmp)) {
for (let i = 0; i < tmp.length; i++) {
if (str.length > 0) str += "&";
str += `${_encode(k)}=${_encode(tmp[i])}`;
}
} else {
if (typeof tmp === "object") {
tmp = JSON.stringify(tmp);
}
if (str.length > 0) str += "&";
str += `${_encode(k)}=${_encode(tmp)}`;
}
}
}
return (options?.prefix || "") + str;
}
export function decodeSearch(str) {
function toValue(mix) {
if (!mix) return "";
const str = decodeURIComponent(mix);
if (str === "false") return false;
if (str === "true") return true;
try {
return JSON.parse(str);
} catch (e) {
return +str * 0 === 0 ? +str : str;
}
}
let tmp: any;
let k: string;
const out = {};
const arr = str.split("&");
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
while ((tmp = arr.shift())) {
tmp = tmp.split("=");
k = tmp.shift();
if (out[k] !== void 0) {
out[k] = [].concat(out[k], toValue(tmp.shift()));
} else {
out[k] = toValue(tmp.shift());
}
}
return out;
}

View File

@@ -0,0 +1,9 @@
import { isDebug } from "../env";
export async function formatSql(sql: string): Promise<string> {
if (isDebug()) {
const { format } = await import("sql-formatter");
return format(sql);
}
return "";
}

View File

@@ -0,0 +1,62 @@
export function objectToKeyValueArray<T extends Record<string, any>>(obj: T) {
return Object.keys(obj).map((key) => ({ key, value: obj[key as keyof T] }));
}
export function ucFirst(str: string): string {
if (!str || str.length === 0) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function ucFirstAll(str: string, split: string = " "): string {
if (!str || str.length === 0) return str;
return str
.split(split)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(split);
}
export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string {
return ucFirstAll(snakeToPascalWithSpaces(str), split);
}
export function randomString(length: number, includeSpecial = false): string {
const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const special = "!@#$%^&*()_+{}:\"<>?|[];',./`~";
const chars = base + (includeSpecial ? special : "");
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
/**
* Convert a string from snake_case to PascalCase with spaces
* Example: `snake_to_pascal` -> `Snake To Pascal`
*
* @param str
*/
export function snakeToPascalWithSpaces(str: string): string {
if (!str || str.length === 0) return str;
return str
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
export function pascalToKebab(pascalStr: string): string {
return pascalStr.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
}
/**
* Replace simple mustache like {placeholders} in a string
*
* @param str
* @param vars
*/
export function replaceSimplePlaceholders(str: string, vars: Record<string, any>): string {
return str.replace(/\{\$(\w+)\}/g, (match, key) => {
return key in vars ? vars[key] : match;
});
}

View File

@@ -0,0 +1,18 @@
type ConsoleSeverity = "log" | "warn" | "error";
const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}

View File

@@ -0,0 +1,268 @@
/*--------------------------------------------------------------------------
@sinclair/typebox/prototypes
The MIT License (MIT)
Copyright (c) 2017-2024 Haydn Paterson (sinclair) <haydn.developer@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---------------------------------------------------------------------------*/
import * as Type from "@sinclair/typebox";
// ------------------------------------------------------------------
// Schematics
// ------------------------------------------------------------------
const IsExact = (value: unknown, expect: unknown) => value === expect;
const IsSValue = (value: unknown): value is SValue =>
Type.ValueGuard.IsString(value) ||
Type.ValueGuard.IsNumber(value) ||
Type.ValueGuard.IsBoolean(value);
const IsSEnum = (value: unknown): value is SEnum =>
Type.ValueGuard.IsObject(value) &&
Type.ValueGuard.IsArray(value.enum) &&
value.enum.every((value) => IsSValue(value));
const IsSAllOf = (value: unknown): value is SAllOf =>
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.allOf);
const IsSAnyOf = (value: unknown): value is SAnyOf =>
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.anyOf);
const IsSOneOf = (value: unknown): value is SOneOf =>
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsArray(value.oneOf);
const IsSTuple = (value: unknown): value is STuple =>
Type.ValueGuard.IsObject(value) &&
IsExact(value.type, "array") &&
Type.ValueGuard.IsArray(value.items);
const IsSArray = (value: unknown): value is SArray =>
Type.ValueGuard.IsObject(value) &&
IsExact(value.type, "array") &&
!Type.ValueGuard.IsArray(value.items) &&
Type.ValueGuard.IsObject(value.items);
const IsSConst = (value: unknown): value is SConst =>
Type.ValueGuard.IsObject(value) && Type.ValueGuard.IsObject(value["const"]);
const IsSString = (value: unknown): value is SString =>
Type.ValueGuard.IsObject(value) && IsExact(value.type, "string");
const IsSNumber = (value: unknown): value is SNumber =>
Type.ValueGuard.IsObject(value) && IsExact(value.type, "number");
const IsSInteger = (value: unknown): value is SInteger =>
Type.ValueGuard.IsObject(value) && IsExact(value.type, "integer");
const IsSBoolean = (value: unknown): value is SBoolean =>
Type.ValueGuard.IsObject(value) && IsExact(value.type, "boolean");
const IsSNull = (value: unknown): value is SBoolean =>
Type.ValueGuard.IsObject(value) && IsExact(value.type, "null");
const IsSProperties = (value: unknown): value is SProperties => Type.ValueGuard.IsObject(value);
// prettier-ignore
const IsSObject = (value: unknown): value is SObject => Type.ValueGuard.IsObject(value) && IsExact(value.type, 'object') && IsSProperties(value.properties) && (value.required === undefined || Type.ValueGuard.IsArray(value.required) && value.required.every((value: unknown) => Type.ValueGuard.IsString(value)))
type SValue = string | number | boolean;
type SEnum = Readonly<{ enum: readonly SValue[] }>;
type SAllOf = Readonly<{ allOf: readonly unknown[] }>;
type SAnyOf = Readonly<{ anyOf: readonly unknown[] }>;
type SOneOf = Readonly<{ oneOf: readonly unknown[] }>;
type SProperties = Record<PropertyKey, unknown>;
type SObject = Readonly<{ type: "object"; properties: SProperties; required?: readonly string[] }>;
type STuple = Readonly<{ type: "array"; items: readonly unknown[] }>;
type SArray = Readonly<{ type: "array"; items: unknown }>;
type SConst = Readonly<{ const: SValue }>;
type SString = Readonly<{ type: "string" }>;
type SNumber = Readonly<{ type: "number" }>;
type SInteger = Readonly<{ type: "integer" }>;
type SBoolean = Readonly<{ type: "boolean" }>;
type SNull = Readonly<{ type: "null" }>;
// ------------------------------------------------------------------
// FromRest
// ------------------------------------------------------------------
// prettier-ignore
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
T extends readonly [infer L extends unknown, ...infer R extends unknown[]]
? TFromSchema<L> extends infer S extends Type.TSchema
? TFromRest<R, [...Acc, S]>
: TFromRest<R, [...Acc]>
: Acc
)
function FromRest<T extends readonly unknown[]>(T: T): TFromRest<T> {
return T.map((L) => FromSchema(L)) as never;
}
// ------------------------------------------------------------------
// FromEnumRest
// ------------------------------------------------------------------
// prettier-ignore
type TFromEnumRest<T extends readonly SValue[], Acc extends Type.TSchema[] = []> = (
T extends readonly [infer L extends SValue, ...infer R extends SValue[]]
? TFromEnumRest<R, [...Acc, Type.TLiteral<L>]>
: Acc
)
function FromEnumRest<T extends readonly SValue[]>(T: T): TFromEnumRest<T> {
return T.map((L) => Type.Literal(L)) as never;
}
// ------------------------------------------------------------------
// AllOf
// ------------------------------------------------------------------
// prettier-ignore
type TFromAllOf<T extends SAllOf> = (
TFromRest<T['allOf']> extends infer Rest extends Type.TSchema[]
? Type.TIntersectEvaluated<Rest>
: Type.TNever
)
function FromAllOf<T extends SAllOf>(T: T): TFromAllOf<T> {
return Type.IntersectEvaluated(FromRest(T.allOf), T);
}
// ------------------------------------------------------------------
// AnyOf
// ------------------------------------------------------------------
// prettier-ignore
type TFromAnyOf<T extends SAnyOf> = (
TFromRest<T['anyOf']> extends infer Rest extends Type.TSchema[]
? Type.TUnionEvaluated<Rest>
: Type.TNever
)
function FromAnyOf<T extends SAnyOf>(T: T): TFromAnyOf<T> {
return Type.UnionEvaluated(FromRest(T.anyOf), T);
}
// ------------------------------------------------------------------
// OneOf
// ------------------------------------------------------------------
// prettier-ignore
type TFromOneOf<T extends SOneOf> = (
TFromRest<T['oneOf']> extends infer Rest extends Type.TSchema[]
? Type.TUnionEvaluated<Rest>
: Type.TNever
)
function FromOneOf<T extends SOneOf>(T: T): TFromOneOf<T> {
return Type.UnionEvaluated(FromRest(T.oneOf), T);
}
// ------------------------------------------------------------------
// Enum
// ------------------------------------------------------------------
// prettier-ignore
type TFromEnum<T extends SEnum> = (
TFromEnumRest<T['enum']> extends infer Elements extends Type.TSchema[]
? Type.TUnionEvaluated<Elements>
: Type.TNever
)
function FromEnum<T extends SEnum>(T: T): TFromEnum<T> {
return Type.UnionEvaluated(FromEnumRest(T.enum));
}
// ------------------------------------------------------------------
// Tuple
// ------------------------------------------------------------------
// prettier-ignore
type TFromTuple<T extends STuple> = (
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
? Type.TTuple<Elements>
: Type.TTuple<[]>
)
// prettier-ignore
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
return Type.Tuple(FromRest(T.items), T) as never
}
// ------------------------------------------------------------------
// Array
// ------------------------------------------------------------------
// prettier-ignore
type TFromArray<T extends SArray> = (
TFromSchema<T['items']> extends infer Items extends Type.TSchema
? Type.TArray<Items>
: Type.TArray<Type.TUnknown>
)
// prettier-ignore
function FromArray<T extends SArray>(T: T): TFromArray<T> {
return Type.Array(FromSchema(T.items), T) as never
}
// ------------------------------------------------------------------
// Const
// ------------------------------------------------------------------
// prettier-ignore
type TFromConst<T extends SConst> = (
Type.Ensure<Type.TLiteral<T['const']>>
)
function FromConst<T extends SConst>(T: T) {
return Type.Literal(T.const, T);
}
// ------------------------------------------------------------------
// Object
// ------------------------------------------------------------------
type TFromPropertiesIsOptional<
K extends PropertyKey,
R extends string | unknown,
> = unknown extends R ? true : K extends R ? false : true;
// prettier-ignore
type TFromProperties<T extends SProperties, R extends string | unknown> = Type.Evaluate<{
-readonly [K in keyof T]: TFromPropertiesIsOptional<K, R> extends true
? Type.TOptional<TFromSchema<T[K]>>
: TFromSchema<T[K]>
}>
// prettier-ignore
type TFromObject<T extends SObject> = (
TFromProperties<T['properties'], Exclude<T['required'], undefined>[number]> extends infer Properties extends Type.TProperties
? Type.TObject<Properties>
: Type.TObject<{}>
)
function FromObject<T extends SObject>(T: T): TFromObject<T> {
const properties = globalThis.Object.getOwnPropertyNames(T.properties).reduce((Acc, K) => {
return {
...Acc,
[K]:
T.required && T.required.includes(K)
? FromSchema(T.properties[K])
: Type.Optional(FromSchema(T.properties[K])),
};
}, {} as Type.TProperties);
return Type.Object(properties, T) as never;
}
// ------------------------------------------------------------------
// FromSchema
// ------------------------------------------------------------------
// prettier-ignore
export type TFromSchema<T> = (
T extends SAllOf ? TFromAllOf<T> :
T extends SAnyOf ? TFromAnyOf<T> :
T extends SOneOf ? TFromOneOf<T> :
T extends SEnum ? TFromEnum<T> :
T extends SObject ? TFromObject<T> :
T extends STuple ? TFromTuple<T> :
T extends SArray ? TFromArray<T> :
T extends SConst ? TFromConst<T> :
T extends SString ? Type.TString :
T extends SNumber ? Type.TNumber :
T extends SInteger ? Type.TInteger :
T extends SBoolean ? Type.TBoolean :
T extends SNull ? Type.TNull :
Type.TUnknown
)
/** Parses a TypeBox type from raw JsonSchema */
export function FromSchema<T>(T: T): TFromSchema<T> {
// prettier-ignore
return (
IsSAllOf(T) ? FromAllOf(T) :
IsSAnyOf(T) ? FromAnyOf(T) :
IsSOneOf(T) ? FromOneOf(T) :
IsSEnum(T) ? FromEnum(T) :
IsSObject(T) ? FromObject(T) :
IsSTuple(T) ? FromTuple(T) :
IsSArray(T) ? FromArray(T) :
IsSConst(T) ? FromConst(T) :
IsSString(T) ? Type.String(T) :
IsSNumber(T) ? Type.Number(T) :
IsSInteger(T) ? Type.Integer(T) :
IsSBoolean(T) ? Type.Boolean(T) :
IsSNull(T) ? Type.Null(T) :
Type.Unknown(T || {})
) as never
}

View File

@@ -0,0 +1,206 @@
import {
Kind,
type ObjectOptions,
type SchemaOptions,
type Static,
type StaticDecode,
type StringOptions,
type TLiteral,
type TLiteralValue,
type TObject,
type TRecord,
type TSchema,
type TString,
Type,
TypeRegistry
} from "@sinclair/typebox";
import {
DefaultErrorFunction,
Errors,
SetErrorFunction,
type ValueErrorIterator
} from "@sinclair/typebox/errors";
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
import { cloneDeep } from "lodash-es";
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object | undefined
? RecursivePartial<T[P]>
: T[P];
};
type ParseOptions = {
useDefaults?: boolean;
decode?: boolean;
onError?: (errors: ValueErrorIterator) => void;
forceParse?: boolean;
skipMark?: boolean;
};
const validationSymbol = Symbol("tb-parse-validation");
export class TypeInvalidError extends Error {
errors: ValueError[];
constructor(
public schema: TSchema,
public data: unknown,
message?: string
) {
//console.warn("errored schema", JSON.stringify(schema, null, 2));
super(message ?? `Invalid: ${JSON.stringify(data)}`);
this.errors = [...Errors(schema, data)];
}
first() {
return this.errors[0]!;
}
firstToString() {
const first = this.first();
return `${first.message} at "${first.path}"`;
}
toJSON() {
return {
message: this.message,
schema: this.schema,
data: this.data,
errors: this.errors
};
}
}
export function stripMark(obj: any) {
const newObj = cloneDeep(obj);
mark(newObj, false);
return newObj;
}
export function mark(obj: any, validated = true) {
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
if (validated) {
obj[validationSymbol] = true;
} else {
delete obj[validationSymbol];
}
for (const key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
mark(obj[key], validated);
}
}
}
}
export function parse<Schema extends TSchema = TSchema>(
schema: Schema,
data: RecursivePartial<Static<Schema>>,
options?: ParseOptions
): Static<Schema> {
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
if (options?.useDefaults === false) {
return data as Static<typeof schema>;
}
// this is important as defaults are expected
return Default(schema, data as any) as Static<Schema>;
}
const parsed = options?.useDefaults === false ? data : Default(schema, data);
if (Check(schema, parsed)) {
options?.skipMark !== true && mark(parsed, true);
return parsed as Static<typeof schema>;
} else if (options?.onError) {
options.onError(Errors(schema, data));
} else {
throw new TypeInvalidError(schema, data);
}
// @todo: check this
return undefined as any;
}
export function parseDecode<Schema extends TSchema = TSchema>(
schema: Schema,
data: RecursivePartial<StaticDecode<Schema>>
): StaticDecode<Schema> {
//console.log("parseDecode", schema, data);
const parsed = Default(schema, data);
if (Check(schema, parsed)) {
return parsed as StaticDecode<typeof schema>;
}
//console.log("errors", ...Errors(schema, data));
throw new TypeInvalidError(schema, data);
}
export function strictParse<Schema extends TSchema = TSchema>(
schema: Schema,
data: Static<Schema>,
options?: ParseOptions
): Static<Schema> {
return parse(schema, data as any, options);
}
export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) {
registry.Set("StringEnum", (schema: any, value: any) => {
return typeof value === "string" && schema.enum.includes(value);
});
}
registerCustomTypeboxKinds(TypeRegistry);
export const StringEnum = <const T extends readonly string[]>(values: T, options?: StringOptions) =>
Type.Unsafe<T[number]>({
[Kind]: "StringEnum",
type: "string",
enum: values,
...options
});
// key value record compatible with RJSF and typebox inference
// acting like a Record, but using an Object with additionalProperties
export const StringRecord = <T extends TSchema>(properties: T, options?: ObjectOptions) =>
Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord<
TString,
typeof properties
>;
// fixed value that only be what is given + prefilled
export const Const = <T extends TLiteralValue = TLiteralValue>(value: T, options?: SchemaOptions) =>
Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral<T>;
export const StringIdentifier = Type.String({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
minLength: 2,
maxLength: 150
});
SetErrorFunction((error) => {
if (error?.schema?.errorMessage) {
return error.schema.errorMessage;
}
if (error?.schema?.[Kind] === "StringEnum") {
return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`;
}
return DefaultErrorFunction(error);
});
export {
Type,
type Static,
type StaticDecode,
type TSchema,
Kind,
type TObject,
type ValueError,
type SchemaOptions,
Value,
Default,
Errors,
Check
};

8
app/src/core/utils/types.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
export type Prettify<T> = {
[K in keyof T]: T[K];
} & NonNullable<unknown>;
// prettify recursively
export type PrettifyRec<T> = {
[K in keyof T]: T[K] extends object ? Prettify<T[K]> : T[K];
} & NonNullable<unknown>;

View File

@@ -0,0 +1,4 @@
// generates v4
export function uuid(): string {
return crypto.randomUUID();
}

View File

@@ -0,0 +1,6 @@
import { XMLParser } from "fast-xml-parser";
export function xmlToObject(xml: string) {
const parser = new XMLParser();
return parser.parse(xml);
}

122
app/src/data/AppData.ts Normal file
View File

@@ -0,0 +1,122 @@
import { transformObject } from "core/utils";
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } 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";
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);
});
const _entity = (_e: Entity | string): Entity => {
const name = typeof _e === "string" ? _e : _e.name;
const entity = entities[name];
if (entity) return entity;
throw new Error(`Entity "${name}" not found`);
};
const relations = transformObject(this.config.relations ?? {}, (relation) =>
AppData.constructRelation(relation, _entity)
);
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
const entity = _entity(index.entity)!;
const fields = index.fields.map((f) => entity.field(f)!);
return new EntityIndex(entity, fields, index.unique, name);
});
for (const entity of Object.values(entities)) {
this.ctx.em.addEntity(entity);
}
for (const relation of Object.values(relations)) {
this.ctx.em.addRelation(relation);
}
for (const index of Object.values(indices)) {
this.ctx.em.addIndex(index);
}
this.ctx.server.route(
this.basepath,
new DataController(this.ctx, this.config).getController()
);
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.setBuilt();
}
getSchema() {
return dataConfigSchema;
}
get em(): EntityManager<DB> {
this.throwIfNotBuilt();
return this.ctx.em;
}
private get basepath() {
return this.config.basepath ?? "/api/data";
}
override getOverwritePaths() {
return [
/^entities\..*\.config$/,
/^entities\..*\.fields\..*\.config$/
///^entities\..*\.fields\..*\.config\.schema$/
];
}
/*registerController(server: AppServer) {
console.log("adding data controller to", this.basepath);
server.add(this.basepath, new DataController(this.em));
}*/
override toJSON(secrets?: boolean): AppDataConfig {
return {
...this.config,
...this.em.toJSON()
};
}
}

View File

@@ -0,0 +1,63 @@
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
export type DataApiOptions = BaseModuleApiOptions & {
defaultQuery?: Partial<RepoQuery>;
};
export class DataApi extends ModuleApi<DataApiOptions> {
protected override getDefaultOptions(): Partial<DataApiOptions> {
return {
basepath: "/api/data",
defaultQuery: {
limit: 10
}
};
}
async readOne(
entity: string,
id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
) {
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
}
async readMany(entity: string, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity],
query ?? this.options.defaultQuery
);
}
async readManyByReference(
entity: string,
id: PrimaryFieldType,
reference: string,
query: Partial<RepoQuery> = {}
) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity, id, reference],
query ?? this.options.defaultQuery
);
}
async createOne(entity: string, input: EntityData) {
return this.post<RepositoryResponse<EntityData>>([entity], input);
}
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
}
async deleteOne(entity: string, id: PrimaryFieldType) {
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
}
async count(entity: string, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
[entity, "fn", "count"],
where
);
}
}

View File

@@ -0,0 +1,384 @@
import { type ClassController, isDebug, tbValidator as tb } from "core";
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
import {
DataPermissions,
type EntityData,
type EntityManager,
FieldClassMap,
type MutatorResponse,
PrimaryField,
type RepoQuery,
type RepositoryResponse,
TextField,
querySchema
} from "data";
import { Hono } from "hono";
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { AppData } from "../AppData";
import { type AppDataConfig, FIELDS } from "../data-schema";
export class DataController implements ClassController {
constructor(
private readonly ctx: ModuleBuildContext,
private readonly config: AppDataConfig
) {
/*console.log(
"data controller",
this.em.entities.map((e) => e.name)
);*/
}
get em(): EntityManager<any> {
return this.ctx.em;
}
get guard() {
return this.ctx.guard;
}
repoResult<T extends RepositoryResponse<any> = RepositoryResponse>(
res: T
): Pick<T, "meta" | "data"> {
let meta: Partial<RepositoryResponse["meta"]> = {};
if ("meta" in res) {
const { query, ...rest } = res.meta;
meta = rest;
if (isDebug()) meta.query = query;
}
const template = { data: res.data, meta };
// @todo: this works but it breaks in FE (need to improve DataTable)
//return objectCleanEmpty(template) as any;
// filter empty
return Object.fromEntries(
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null)
) as any;
}
mutatorResult(res: MutatorResponse | MutatorResponse<EntityData>) {
const template = { data: res.data };
// filter empty
//return objectCleanEmpty(template);
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
}
entityExists(entity: string) {
try {
return !!this.em.entity(entity);
} catch (e) {
return false;
}
}
getController(): Hono<any> {
const hono = new Hono();
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
.Encode(String);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
const func = h;
// @ts-ignore
func.description = name;
return func;
}
// add timing
/*hono.use("*", async (c, next) => {
startTime(c, "data");
await next();
endTime(c, "data");
});*/
// info
hono.get(
"/",
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
})
);
// sync endpoint
hono.get("/sync", async (c) => {
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
const force = c.req.query("force") === "1";
const drop = c.req.query("drop") === "1";
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop
});
return c.json({ tables: tables.map((t) => t.name), changes });
});
/**
* Function endpoints
*/
hono
// fn: count
.post(
"/:entity/fn/count",
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.json() as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
}
)
// fn: exists
.post(
"/:entity/fn/exists",
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.json() as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists });
}
);
/**
* Read endpoints
*/
hono
// 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 schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `schemas/${e.name}`
}
])
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas
});
})
// read schema
.get(
"/schemas/:entity",
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw);
const { entity } = 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 url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`;
const $id = `${base}/schemas/${entity}`;
return c.json({
$schema: `${base}/schema.json`,
$id,
title: _entity.label,
$comment: _entity.config.description,
...schema
});
}
)
// read many
.get(
"/:entity",
tb("param", Type.Object({ entity: Type.String() })),
tb("query", querySchema),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities);
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery;
//console.log("before", this.ctx.emgr.Events);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}
)
// read one
.get(
"/:entity/:id",
tb(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber
})
),
tb("query", querySchema),
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
zValidator("query", repoQuerySchema),*/
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(Number(id), options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}
)
// read many by reference
.get(
"/:entity/:id/:reference",
tb(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber,
reference: Type.String()
})
),
tb("query", querySchema),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em
.repository(entity)
.findManyByReference(Number(id), reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}
)
// func query
.post(
"/:entity/query",
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const options = (await c.req.valid("json")) as RepoQuery;
console.log("options", options);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
}
);
/**
* Mutation endpoints
*/
// insert one
hono
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityCreate);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).insertOne(body);
return c.json(this.mutatorResult(result), 201);
})
// update one
.patch(
"/:entity/:id",
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(Number(id), body);
return c.json(this.mutatorResult(result));
}
)
// delete one
.delete(
"/:entity/:id",
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const result = await this.em.mutator(entity).deleteOne(Number(id));
return c.json(this.mutatorResult(result));
}
)
// delete many
.delete(
"/:entity",
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
//console.log("request", c.req.raw);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.valid("json") as RepoQuery["where"];
console.log("where", where);
const result = await this.em.mutator(entity).deleteMany(where);
return c.json(this.mutatorResult(result));
}
);
return hono;
}
}

View File

@@ -0,0 +1,97 @@
import {
type AliasableExpression,
type DatabaseIntrospector,
type Expression,
type Kysely,
type KyselyPlugin,
type RawBuilder,
type SelectQueryBuilder,
type SelectQueryNode,
type Simplify,
sql
} from "kysely";
export type QB = SelectQueryBuilder<any, any, any>;
export type IndexMetadata = {
name: string;
table: string;
isUnique: boolean;
columns: { name: string; order: number }[];
};
export interface ConnectionIntrospector extends DatabaseIntrospector {
getIndices(tbl_name?: string): Promise<IndexMetadata[]>;
}
export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O> {
get isSelectQueryBuilder(): true;
toOperationNode(): SelectQueryNode;
}
export type DbFunctions = {
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>;
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>;
jsonBuildObject<O extends Record<string, Expression<unknown>>>(
obj: O
): RawBuilder<
Simplify<{
[K in keyof O]: O[K] extends Expression<infer V> ? V : never;
}>
>;
};
export abstract class Connection {
kysely: Kysely<any>;
constructor(
kysely: Kysely<any>,
public fn: Partial<DbFunctions> = {},
protected plugins: KyselyPlugin[] = []
) {
this.kysely = kysely;
}
getIntrospector(): ConnectionIntrospector {
return this.kysely.introspection as ConnectionIntrospector;
}
supportsBatching(): boolean {
return false;
}
supportsIndices(): boolean {
return false;
}
async ping(): Promise<boolean> {
const res = await sql`SELECT 1`.execute(this.kysely);
return res.rows.length > 0;
}
protected async batch<Queries extends QB[]>(
queries: [...Queries]
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
throw new Error("Batching not supported");
}
async batchQuery<Queries extends QB[]>(
queries: [...Queries]
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
// bypass if no client support
if (!this.supportsBatching()) {
const data: any = [];
for (const q of queries) {
const result = await q.execute();
data.push(result);
}
return data;
}
return await this.batch(queries);
}
}

View File

@@ -0,0 +1,100 @@
import { type Client, type InStatement, createClient } from "@libsql/client/web";
import { LibsqlDialect } from "@libsql/kysely-libsql";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
import type { QB } from "./Connection";
import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
export type LibSqlCredentials = {
url: string;
authToken?: string;
protocol?: (typeof LIBSQL_PROTOCOLS)[number];
};
class CustomLibsqlDialect extends LibsqlDialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["libsql_wasm_func_table"]
});
}
}
export class LibsqlConnection extends SqliteConnection {
private client: Client;
constructor(client: Client);
constructor(credentials: LibSqlCredentials);
constructor(clientOrCredentials: Client | LibSqlCredentials) {
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
let client: Client;
if ("url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
console.log("changing protocol to", protocol);
const [, rest] = url.split("://");
url = `${protocol}://${rest}`;
}
//console.log("using", url, { protocol });
client = createClient({ url, authToken });
} else {
//console.log("-- client provided");
client = clientOrCredentials;
}
const kysely = new Kysely({
// @ts-expect-error libsql has type issues
dialect: new CustomLibsqlDialect({ client }),
plugins
//log: ["query"],
});
super(kysely, {}, plugins);
this.client = client;
}
override supportsBatching(): boolean {
return true;
}
override supportsIndices(): boolean {
return true;
}
getClient(): Client {
return this.client;
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries]
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const stms: InStatement[] = queries.map((q) => {
const compiled = q.compile();
//console.log("compiled", compiled.sql, compiled.parameters);
return {
sql: compiled.sql,
args: compiled.parameters as any[]
};
});
const res = await this.client.batch(stms);
// let it run through plugins
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.rows);
data.push(rows);
}
//console.log("data", data);
return data;
}
}

View File

@@ -0,0 +1,22 @@
import type { Kysely, KyselyPlugin } from "kysely";
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
import { Connection, type DbFunctions } from "./Connection";
export class SqliteConnection extends Connection {
constructor(kysely: Kysely<any>, fn: Partial<DbFunctions> = {}, plugins: KyselyPlugin[] = []) {
super(
kysely,
{
...fn,
jsonArrayFrom,
jsonObjectFrom,
jsonBuildObject
},
plugins
);
}
override supportsIndices(): boolean {
return true;
}
}

View File

@@ -0,0 +1,164 @@
import type {
DatabaseIntrospector,
DatabaseMetadata,
DatabaseMetadataOptions,
ExpressionBuilder,
Kysely,
SchemaMetadata,
TableMetadata,
} from "kysely";
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
export type SqliteIntrospectorConfig = {
excludeTables?: string[];
};
export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntrospector {
readonly #db: Kysely<any>;
readonly _excludeTables: string[] = [];
constructor(db: Kysely<any>, config: SqliteIntrospectorConfig = {}) {
this.#db = db;
this._excludeTables = config.excludeTables ?? [];
}
async getSchemas(): Promise<SchemaMetadata[]> {
// Sqlite doesn't support schemas.
return [];
}
async getIndices(tbl_name?: string): Promise<IndexMetadata[]> {
const indices = await this.#db
.selectFrom("sqlite_master")
.where("type", "=", "index")
.$if(!!tbl_name, (eb) => eb.where("tbl_name", "=", tbl_name))
.select("name")
.$castTo<{ name: string }>()
.execute();
return Promise.all(indices.map(({ name }) => this.#getIndexMetadata(name)));
}
async #getIndexMetadata(index: string): Promise<IndexMetadata> {
const db = this.#db;
// Get the SQL that was used to create the index.
const indexDefinition = await db
.selectFrom("sqlite_master")
.where("name", "=", index)
.select(["sql", "tbl_name", "type"])
.$castTo<{ sql: string | undefined; tbl_name: string; type: string }>()
.executeTakeFirstOrThrow();
//console.log("--indexDefinition--", indexDefinition, index);
// check unique by looking for the word "unique" in the sql
const isUnique = indexDefinition.sql?.match(/unique/i) != null;
const columns = await db
.selectFrom(
sql<{
seqno: number;
cid: number;
name: string;
}>`pragma_index_info(${index})`.as("index_info"),
)
.select(["seqno", "cid", "name"])
.orderBy("cid")
.execute();
return {
name: index,
table: indexDefinition.tbl_name,
isUnique: isUnique,
columns: columns.map((col) => ({
name: col.name,
order: col.seqno,
})),
};
}
private excludeTables(tables: string[] = []) {
return (eb: ExpressionBuilder<any, any>) => {
const and = tables.map((t) => eb("name", "!=", t));
return eb.and(and);
};
}
async getTables(
options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
): Promise<TableMetadata[]> {
let query = this.#db
.selectFrom("sqlite_master")
.where("type", "in", ["table", "view"])
.where("name", "not like", "sqlite_%")
.select("name")
.orderBy("name")
.$castTo<{ name: string }>();
if (!options.withInternalKyselyTables) {
query = query.where(
this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]),
);
}
if (this._excludeTables.length > 0) {
query = query.where(this.excludeTables(this._excludeTables));
}
const tables = await query.execute();
return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name)));
}
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
return {
tables: await this.getTables(options),
};
}
async #getTableMetadata(table: string): Promise<TableMetadata> {
const db = this.#db;
// Get the SQL that was used to create the table.
const tableDefinition = await db
.selectFrom("sqlite_master")
.where("name", "=", table)
.select(["sql", "type"])
.$castTo<{ sql: string | undefined; type: string }>()
.executeTakeFirstOrThrow();
// Try to find the name of the column that has `autoincrement` 🤦
const autoIncrementCol = tableDefinition.sql
?.split(/[\(\),]/)
?.find((it) => it.toLowerCase().includes("autoincrement"))
?.trimStart()
?.split(/\s+/)?.[0]
?.replace(/["`]/g, "");
const columns = await db
.selectFrom(
sql<{
name: string;
type: string;
notnull: 0 | 1;
dflt_value: any;
}>`pragma_table_info(${table})`.as("table_info"),
)
.select(["name", "type", "notnull", "dflt_value"])
.orderBy("cid")
.execute();
return {
name: table,
isView: tableDefinition.type === "view",
columns: columns.map((col) => ({
name: col.name,
dataType: col.type,
isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.dflt_value != null,
comment: undefined,
})),
};
}
}

View File

@@ -0,0 +1,31 @@
import type { DatabaseIntrospector, SqliteDatabase } from "kysely";
import { Kysely, SqliteDialect } from "kysely";
import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin";
import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
class CustomSqliteDialect extends SqliteDialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["test_table"]
});
}
}
export class SqliteLocalConnection extends SqliteConnection {
constructor(private database: SqliteDatabase) {
const plugins = [new DeserializeJsonValuesPlugin()];
const kysely = new Kysely({
dialect: new CustomSqliteDialect({ database }),
plugins
//log: ["query"],
});
super(kysely);
this.plugins = plugins;
}
override supportsIndices(): boolean {
return true;
}
}

View File

@@ -0,0 +1,83 @@
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
import {
FieldClassMap,
RelationClassMap,
RelationFieldClassMap,
entityConfigSchema,
entityTypes
} from "data";
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
export const FIELDS = {
...FieldClassMap,
...RelationFieldClassMap,
media: { schema: mediaFieldConfigSchema, field: MediaField }
};
export type FieldType = keyof typeof FIELDS;
export const RELATIONS = RelationClassMap;
export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
return Type.Object(
{
type: Type.Const(name, { default: name, readOnly: true }),
config: Type.Optional(field.schema)
},
{
title: name
}
);
});
export const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject));
export const entityFields = StringRecord(fieldsSchema);
export type TAppDataField = Static<typeof fieldsSchema>;
export type TAppDataEntityFields = Static<typeof entityFields>;
export const entitiesSchema = Type.Object({
//name: Type.String(),
type: Type.Optional(Type.String({ enum: entityTypes, default: "regular", readOnly: true })),
config: Type.Optional(entityConfigSchema),
fields: Type.Optional(entityFields)
});
export type TAppDataEntity = Static<typeof entitiesSchema>;
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
return Type.Object(
{
type: Type.Const(name, { default: name, readOnly: true }),
source: Type.String(),
target: Type.String(),
config: Type.Optional(relationClass.schema)
},
{
title: name
}
);
});
export type TAppDataRelation = Static<(typeof relationsSchema)[number]>;
export const indicesSchema = Type.Object(
{
entity: Type.String(),
fields: Type.Array(Type.String(), { minItems: 1 }),
//name: Type.Optional(Type.String()),
unique: Type.Optional(Type.Boolean({ default: false }))
},
{
additionalProperties: false
}
);
export const dataConfigSchema = Type.Object(
{
basepath: Type.Optional(Type.String({ default: "/api/data" })),
entities: Type.Optional(StringRecord(entitiesSchema, { default: {} })),
relations: Type.Optional(StringRecord(Type.Union(relationsSchema), { default: {} })),
indices: Type.Optional(StringRecord(indicesSchema, { default: {} }))
},
{
additionalProperties: false
}
);
export type AppDataConfig = Static<typeof dataConfigSchema>;

View File

@@ -0,0 +1,238 @@
import { config } from "core";
import {
type Static,
StringEnum,
Type,
parse,
snakeToPascalWithSpaces,
transformObject
} from "core/utils";
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
// @todo: entity must be migrated to typebox
export const entityConfigSchema = Type.Object(
{
name: Type.Optional(Type.String()),
name_singular: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" }))
},
{
additionalProperties: false
}
);
export type EntityConfig = Static<typeof entityConfigSchema>;
export type EntityData = Record<string, any>;
export type EntityJSON = ReturnType<Entity["toJSON"]>;
/**
* regular: normal defined entity
* system: generated by the system, e.g. "users" from auth
* generated: result of a relation, e.g. many-to-many relation's connection entity
*/
export const entityTypes = ["regular", "system", "generated"] as const;
export type TEntityType = (typeof entityTypes)[number];
/**
* @todo: add check for adding fields (primary and relation not allowed)
* @todo: add option to disallow api deletes (or api actions in general)
*/
export class Entity<
EntityName extends string = string,
Fields extends Record<string, Field<any, any, any>> = Record<string, Field<any, any, any>>
> {
readonly #_name!: EntityName;
readonly #_fields!: Fields; // only for types
readonly name: string;
readonly fields: Field[];
readonly config: EntityConfig;
protected data: EntityData[] | undefined;
readonly type: TEntityType = "regular";
constructor(name: string, fields?: Field[], config?: EntityConfig, type?: TEntityType) {
if (typeof name !== "string" || name.length === 0) {
throw new Error("Entity name must be a non-empty string");
}
this.name = name;
this.config = parse(entityConfigSchema, config || {}) as EntityConfig;
// add id field if not given
// @todo: add test
const primary_count = fields?.filter((field) => field instanceof PrimaryField).length ?? 0;
if (primary_count > 1) {
throw new Error(`Entity "${name}" has more than one primary field`);
}
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
if (fields) {
fields.forEach((field) => this.addField(field));
}
if (type) this.type = type;
}
static create(args: {
name: string;
fields?: Field[];
config?: EntityConfig;
type?: TEntityType;
}) {
return new Entity(args.name, args.fields, args.config, args.type);
}
// @todo: add test
getType(): TEntityType {
return this.type;
}
getSelect(alias?: string, context?: TActionContext | TRenderContext): string[] {
return this.getFields()
.filter((field) => !field.isHidden(context ?? "read"))
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
}
getDefaultSort() {
return {
by: this.config.sort_field,
dir: this.config.sort_dir
};
}
getAliasedSelectFrom(
select: string[],
_alias?: string,
context?: TActionContext | TRenderContext
): string[] {
const alias = _alias ?? this.name;
return this.getFields()
.filter(
(field) =>
!field.isVirtual() &&
!field.isHidden(context ?? "read") &&
select.includes(field.name)
)
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
}
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
}
getRequiredFields(): Field[] {
return this.getFields().filter((field) => field.isRequired());
}
getDefaultObject(): EntityData {
return this.getFields().reduce((acc, field) => {
if (field.hasDefault()) {
acc[field.name] = field.getDefault();
}
return acc;
}, {} as EntityData);
}
getField(name: string): Field | undefined {
return this.fields.find((field) => field.name === name);
}
__experimental_replaceField(name: string, field: Field) {
const index = this.fields.findIndex((f) => f.name === name);
if (index === -1) {
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
}
this.fields[index] = field;
}
getPrimaryField(): PrimaryField {
return this.fields[0] as PrimaryField;
}
id(): PrimaryField {
return this.getPrimaryField();
}
get label(): string {
return snakeToPascalWithSpaces(this.config.name ?? this.name);
}
field(name: string): Field | undefined {
return this.getField(name);
}
getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual());
}
addField(field: Field) {
const existing = this.getField(field.name);
// make unique name check
if (existing) {
// @todo: for now adding a graceful method
if (JSON.stringify(existing) === JSON.stringify(field)) {
/*console.warn(
`Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`,
);*/
return;
}
throw new Error(`Field "${field.name}" already exists on entity "${this.name}"`);
}
this.fields.push(field);
}
__setData(data: EntityData[]) {
this.data = data;
}
isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean {
const fields = this.getFillableFields(context, false);
//const fields = this.fields;
//console.log("data", data);
for (const field of fields) {
if (!field.isValid(data[field.name], context)) {
console.log("Entity.isValidData:invalid", context, field.name, data[field.name]);
if (explain) {
throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
}
return false;
}
}
return true;
}
toSchema(clean?: boolean): object {
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
const schema = Type.Object(
transformObject(fields, (field) => ({
title: field.config.label,
$comment: field.config.description,
$field: field.type,
readOnly: !field.isFillable("update") ? true : undefined,
writeOnly: !field.isFillable("create") ? true : undefined,
...field.toJsonSchema()
}))
);
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
}
toJSON() {
return {
//name: this.name,
type: this.type,
//fields: transformObject(this.fields, (field) => field.toJSON()),
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
config: this.config
};
}
}

View File

@@ -0,0 +1,266 @@
import { EventManager } from "core/events";
import { sql } from "kysely";
import { Connection } from "../connection/Connection";
import {
EntityNotDefinedException,
TransformRetrieveFailedException,
UnableToConnectException
} from "../errors";
import { MutatorEvents, RepositoryEvents } from "../events";
import type { EntityIndex } from "../fields/indices/EntityIndex";
import type { EntityRelation } from "../relations";
import { RelationAccessor } from "../relations/RelationAccessor";
import { SchemaManager } from "../schema/SchemaManager";
import { Entity } from "./Entity";
import { type EntityData, Mutator, Repository } from "./index";
export class EntityManager<DB> {
connection: Connection;
private _entities: Entity[] = [];
private _relations: EntityRelation[] = [];
private _indices: EntityIndex[] = [];
private _schema?: SchemaManager;
readonly emgr: EventManager<typeof EntityManager.Events>;
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
constructor(
entities: Entity[],
connection: Connection,
relations: EntityRelation[] = [],
indices: EntityIndex[] = [],
emgr?: EventManager<any>
) {
// add entities & relations
entities.forEach((entity) => this.addEntity(entity));
relations.forEach((relation) => this.addRelation(relation));
indices.forEach((index) => this.addIndex(index));
if (!(connection instanceof Connection)) {
throw new UnableToConnectException("");
}
this.connection = connection;
this.emgr = emgr ?? new EventManager();
//console.log("registering events", EntityManager.Events);
this.emgr.registerEvents(EntityManager.Events);
}
/**
* Forks the EntityManager without the EventManager.
* This is useful when used inside an event handler.
*/
fork(): EntityManager<DB> {
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
}
get entities(): Entity[] {
return this._entities;
}
get relations(): RelationAccessor {
return new RelationAccessor(this._relations);
}
get indices(): EntityIndex[] {
return this._indices;
}
async ping(): Promise<boolean> {
const res = await sql`SELECT 1`.execute(this.connection.kysely);
return res.rows.length > 0;
}
addEntity(entity: Entity) {
const existing = this.entities.find((e) => e.name === entity.name);
// check if already exists by name
if (existing) {
// @todo: for now adding a graceful method
if (JSON.stringify(existing) === JSON.stringify(entity)) {
//console.warn(`Entity "${entity.name}" already exists, but it's the same, so skipping.`);
return;
}
throw new Error(`Entity "${entity.name}" already exists`);
}
this.entities.push(entity);
}
entity(name: string): Entity {
const entity = this.entities.find((e) => e.name === name);
if (!entity) {
throw new EntityNotDefinedException(name);
}
return entity;
}
hasEntity(entity: string): boolean;
hasEntity(entity: Entity): boolean;
hasEntity(nameOrEntity: string | Entity): boolean {
const name = typeof nameOrEntity === "string" ? nameOrEntity : nameOrEntity.name;
return this.entities.some((e) => e.name === name);
}
hasIndex(index: string): boolean;
hasIndex(index: EntityIndex): boolean;
hasIndex(nameOrIndex: string | EntityIndex): boolean {
const name = typeof nameOrIndex === "string" ? nameOrIndex : nameOrIndex.name;
return this.indices.some((e) => e.name === name);
}
addRelation(relation: EntityRelation) {
// check if entities are registered
if (!this.entity(relation.source.entity.name) || !this.entity(relation.target.entity.name)) {
throw new Error("Relation source or target entity not found");
}
// @todo: potentially add name to relation in order to have multiple
const found = this._relations.find((r) => {
const equalSourceTarget =
r.source.entity.name === relation.source.entity.name &&
r.target.entity.name === relation.target.entity.name;
const equalReferences =
r.source.reference === relation.source.reference &&
r.target.reference === relation.target.reference;
return (
//r.type === relation.type && // ignore type for now
equalSourceTarget && equalReferences
);
});
if (found) {
throw new Error(
`Relation "${relation.type}" between "${relation.source.entity.name}" ` +
`and "${relation.target.entity.name}" already exists`
);
}
this._relations.push(relation);
relation.initialize(this);
}
relationsOf(entity_name: string): EntityRelation[] {
return this.relations.relationsOf(this.entity(entity_name));
}
relationOf(entity_name: string, reference: string): EntityRelation | undefined {
return this.relations.relationOf(this.entity(entity_name), reference);
}
hasRelations(entity_name: string): boolean {
return this.relations.hasRelations(this.entity(entity_name));
}
relatedEntitiesOf(entity_name: string): Entity[] {
return this.relations.relatedEntitiesOf(this.entity(entity_name));
}
relationReferencesOf(entity_name: string): string[] {
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);
}
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<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);
}
addIndex(index: EntityIndex, force = false) {
// check if already exists by name
if (this.indices.find((e) => e.name === index.name)) {
if (force) {
throw new Error(`Index "${index.name}" already exists`);
}
return;
}
this._indices.push(index);
}
getIndicesOf(_entity: Entity | string): EntityIndex[] {
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
return this.indices.filter((index) => index.entity.name === entity.name);
}
schema() {
if (!this._schema) {
this._schema = new SchemaManager(this);
}
return this._schema;
}
// @todo: centralize and add tests
hydrate(entity_name: string, _data: EntityData[]) {
const entity = this.entity(entity_name);
const data: EntityData[] = [];
for (const row of _data) {
for (let [key, value] of Object.entries(row)) {
const field = entity.getField(key);
if (!field || field.isVirtual()) {
// if relation, use related entity to hydrate
const relation = this.relationOf(entity_name, key);
if (relation) {
if (!value) continue;
value = relation.hydrate(key, Array.isArray(value) ? value : [value], this);
row[key] = value;
continue;
} else if (field?.isVirtual()) {
continue;
}
throw new Error(`Field "${key}" not found on entity "${entity.name}"`);
}
try {
if (value === null && field.hasDefault()) {
row[key] = field.getDefault();
}
row[key] = field.transformRetrieve(value as any);
} catch (e: any) {
throw new TransformRetrieveFailedException(
`"${field.type}" field "${key}" on entity "${entity.name}": ${e.message}`
);
}
}
data.push(row);
}
return data;
}
toJSON() {
return {
entities: Object.fromEntries(this.entities.map((e) => [e.name, e.toJSON()])),
relations: Object.fromEntries(this.relations.all.map((r) => [r.getName(), r.toJSON()])),
//relations: this.relations.all.map((r) => r.toJSON()),
indices: Object.fromEntries(this.indices.map((i) => [i.name, i.toJSON()]))
};
}
}

View File

@@ -0,0 +1,270 @@
import type { PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from "..";
import type { Entity, EntityData, EntityManager } from "../entities";
import { InvalidSearchParamsException } from "../errors";
import { MutatorEvents } from "../events";
import { RelationMutator } from "../relations";
import type { RepoQuery } from "../server/data-query-impl";
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 type MutatorResponse<T = EntityData[]> = {
entity: Entity;
sql: string;
parameters: any[];
result: EntityData[];
data: T;
};
export class Mutator<DB> implements EmitsEvents {
em: EntityManager<DB>;
entity: Entity;
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(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
this.em = em;
this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents);
}
private get conn() {
return this.em.connection.kysely;
}
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
const entity = this.entity;
if (!context) {
throw new Error("Context must be provided for validation");
}
const keys = Object.keys(data);
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)) {
//console.log("--- (instructions)", 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}"`);
}
validatedData[key] = await field.transformPersist(data[key], this.em, context);
}
if (Object.keys(validatedData).length === 0) {
throw new Error(`No data left to update "${entity.name}"`);
}
return validatedData;
}
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
const entity = this.entity;
const { sql, parameters } = qb.compile();
//console.log("mutatoar:exec", sql, parameters);
const result = await qb.execute();
const data = this.em.hydrate(entity.name, result) as EntityData[];
return {
entity,
sql,
parameters: [...parameters],
result: result,
data
};
}
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> {
const { data, ...response } = await this.many(qb);
return { ...response, data: data[0]! };
}
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
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`);
}
// @todo: establish the original order from "data"
const validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(data, "create"))
};
await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData }));
// 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`);
}
}
const query = this.conn
.insertInto(entity.name)
.values(validatedData)
.returning(entity.getSelect());
const res = await this.single(query);
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
return res;
}
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update");
}
const validatedData = await this.getValidatedData(data, "update");
await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
);
const query = this.conn
.updateTable(entity.name)
.set(validatedData)
.where(entity.id().name, "=", id)
.returning(entity.getSelect());
const res = await this.single(query);
await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
);
return res;
}
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
const entity = this.entity;
if (!Number.isInteger(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.single(query);
await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);
return res;
}
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 deleteMany(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
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;
}
}

View File

@@ -0,0 +1,6 @@
export * from "./Entity";
export * from "./EntityManager";
export * from "./Mutator";
export * from "./query/Repository";
export * from "./query/WhereBuilder";
export * from "./query/WithBuilder";

View File

@@ -0,0 +1,51 @@
import { ManyToManyRelation, ManyToOneRelation } from "../../relations";
import type { Entity } from "../Entity";
import type { EntityManager } from "../EntityManager";
import type { RepositoryQB } from "./Repository";
export class JoinBuilder {
private static buildClause(
em: EntityManager<any>,
qb: RepositoryQB,
entity: Entity,
withString: string,
) {
const relation = em.relationOf(entity.name, withString);
if (!relation) {
throw new Error(`Relation "${withString}" not found`);
}
return relation.buildJoin(entity, qb, withString);
}
// @todo: returns multiple on manytomany (edit: so?)
static getJoinedEntityNames(em: EntityManager<any>, entity: Entity, joins: string[]): string[] {
return joins.flatMap((join) => {
const relation = em.relationOf(entity.name, join);
if (!relation) {
throw new Error(`Relation "${join}" not found`);
}
const other = relation.other(entity);
if (relation instanceof ManyToOneRelation) {
return [other.entity.name];
} else if (relation instanceof ManyToManyRelation) {
return [other.entity.name, relation.connectionEntity.name];
}
return [];
});
}
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, joins: string[]) {
if (joins.length === 0) return qb;
let newQb = qb;
for (const entry of joins) {
newQb = JoinBuilder.buildClause(em, newQb, entity, entry);
}
return newQb;
}
}

View File

@@ -0,0 +1,407 @@
import type { PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es";
import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents, RepositoryEvents, RepositoryFindManyBefore } from "../../events";
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
import {
type Entity,
type EntityData,
type EntityManager,
WhereBuilder,
WithBuilder
} from "../index";
import { JoinBuilder } from "./JoinBuilder";
export type RepositoryQB = SelectQueryBuilder<any, any, any>;
export type RepositoryRawResponse = {
sql: string;
parameters: any[];
result: EntityData[];
};
export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
entity: Entity;
data: T;
meta: {
total: number;
count: number;
items: number;
time?: number;
query?: {
sql: string;
parameters: readonly any[];
};
};
};
export type RepositoryCountResponse = RepositoryRawResponse & {
count: number;
};
export type RepositoryExistsResponse = RepositoryRawResponse & {
exists: boolean;
};
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents {
em: EntityManager<DB>;
entity: Entity;
static readonly Events = RepositoryEvents;
emgr: EventManager<typeof Repository.Events>;
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
this.em = em;
this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents);
}
private cloneFor(entity: Entity) {
return new Repository(this.em, entity, this.emgr);
}
private get conn() {
return this.em.connection.kysely;
}
private getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
const entity = this.entity;
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
const validated = {
...cloneDeep(defaultQuerySchema),
sort: entity.getDefaultSort(),
select: entity.getSelect()
};
//console.log("validated", validated);
if (!options) return validated;
if (options.sort) {
if (!validated.select.includes(options.sort.by)) {
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
}
if (!["asc", "desc"].includes(options.sort.dir)) {
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
}
validated.sort = options.sort;
}
if (options.select && options.select.length > 0) {
const invalid = options.select.filter((field) => !validated.select.includes(field));
if (invalid.length > 0) {
throw new InvalidSearchParamsException(
`Invalid select field(s): ${invalid.join(", ")}`
);
}
validated.select = options.select;
}
if (options.with && options.with.length > 0) {
for (const entry of options.with) {
const related = this.em.relationOf(entity.name, entry);
if (!related) {
throw new InvalidSearchParamsException(
`WITH: "${entry}" is not a relation of "${entity.name}"`
);
}
validated.with.push(entry);
}
}
if (options.join && options.join.length > 0) {
for (const entry of options.join) {
const related = this.em.relationOf(entity.name, entry);
if (!related) {
throw new InvalidSearchParamsException(
`JOIN: "${entry}" is not a relation of "${entity.name}"`
);
}
validated.join.push(entry);
}
}
if (options.where) {
// @todo: auto-alias base entity when using joins! otherwise "id" is ambiguous
const aliases = [entity.name];
if (validated.join.length > 0) {
aliases.push(...JoinBuilder.getJoinedEntityNames(this.em, entity, validated.join));
}
// @todo: add tests for aliased fields in where
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
if (field.includes(".")) {
const [alias, prop] = field.split(".") as [string, string];
if (!aliases.includes(alias)) {
return true;
}
return !this.em.entity(alias).getField(prop);
}
return typeof entity.getField(field) === "undefined";
});
if (invalid.length > 0) {
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
}
validated.where = options.where;
}
// pass unfiltered
if (options.limit) validated.limit = options.limit;
if (options.offset) validated.offset = options.offset;
return validated;
}
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
/*const { sql, parameters } = qb.compile();
console.log("many", sql, parameters);*/
const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.selectFrom(entity.name).select(selector("total"));
try {
const [_count, _total, result] = await this.em.connection.batchQuery([
countQuery,
totalQuery,
qb
]);
//console.log("result", { _count, _total });
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const data = this.em.hydrate(entity.name, result);
return {
entity,
sql: compiled.sql,
parameters: [...compiled.parameters],
result,
data,
meta: {
total: _total[0]?.total ?? 0,
count: _count[0]?.count ?? 0, // @todo: better graceful method
items: result.length,
time,
query: { sql: compiled.sql, parameters: compiled.parameters }
}
};
} catch (e) {
console.error("many error", e, compiled);
throw e;
}
}
protected async single(
qb: RepositoryQB,
options: RepoQuery
): Promise<RepositoryResponse<EntityData>> {
await this.emgr.emit(
new Repository.Events.RepositoryFindOneBefore({ entity: this.entity, options })
);
const { data, ...response } = await this.performQuery(qb);
await this.emgr.emit(
new Repository.Events.RepositoryFindOneAfter({
entity: this.entity,
options,
data: data[0]!
})
);
return { ...response, data: data[0]! };
}
private buildQuery(
_options?: Partial<RepoQuery>,
exclude_options: (keyof RepoQuery)[] = []
): { qb: RepositoryQB; options: RepoQuery } {
const entity = this.entity;
const options = this.getValidOptions(_options);
const alias = entity.name;
const aliased = (field: string) => `${alias}.${field}`;
let qb = this.conn
.selectFrom(entity.name)
.select(entity.getAliasedSelectFrom(options.select, alias));
//console.log("build query options", options);
if (!exclude_options.includes("with") && options.with) {
qb = WithBuilder.addClause(this.em, qb, entity, options.with);
}
if (!exclude_options.includes("join") && options.join) {
qb = JoinBuilder.addClause(this.em, qb, entity, options.join);
}
// add where if present
if (!exclude_options.includes("where") && options.where) {
qb = WhereBuilder.addClause(qb, options.where);
}
if (!exclude_options.includes("limit")) qb = qb.limit(options.limit);
if (!exclude_options.includes("offset")) qb = qb.offset(options.offset);
// sorting
if (!exclude_options.includes("sort")) {
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
}
return { qb, options };
}
async findId(
id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB]>> {
const { qb, options } = this.buildQuery(
{
..._options,
where: { [this.entity.getPrimaryField().name]: id },
limit: 1
},
["offset", "sort"]
);
return this.single(qb, options) as any;
}
async findOne(
where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,
where,
limit: 1
},
["offset", "sort"]
);
return this.single(qb, options) as any;
}
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", options);
await this.emgr.emit(
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
);
const res = await this.performQuery(qb);
await this.emgr.emit(
new Repository.Events.RepositoryFindManyAfter({
entity: this.entity,
options,
data: res.data
})
);
return res as any;
}
// @todo: add unit tests, specially for many to many
async findManyByReference(
id: PrimaryFieldType,
reference: string,
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>
): Promise<RepositoryResponse<EntityData>> {
const entity = this.entity;
const listable_relations = this.em.relations.listableRelationsOf(entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
if (!relation) {
throw new Error(
`Relation "${reference}" not found or not listable on entity "${entity.name}"`
);
}
const newEntity = relation.other(entity).entity;
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
throw new Error(
`Invalid reference query for "${reference}" on entity "${newEntity.name}"`
);
}
const findManyOptions = {
..._options,
...refQueryOptions,
where: {
...refQueryOptions.where,
..._options?.where
}
};
//console.log("findManyOptions", newEntity.name, findManyOptions);
return this.cloneFor(newEntity).findMany(findManyOptions);
}
async count(where?: RepoQuery["where"]): Promise<RepositoryCountResponse> {
const entity = this.entity;
const options = this.getValidOptions({ where });
const selector = this.conn.fn.count<number>(sql`*`).as("count");
let qb = this.conn.selectFrom(entity.name).select(selector);
// add where if present
if (options.where) {
qb = WhereBuilder.addClause(qb, options.where);
}
const compiled = qb.compile();
const result = await qb.execute();
return {
sql: compiled.sql,
parameters: [...compiled.parameters],
result,
count: result[0]?.count ?? 0
};
}
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
const entity = this.entity;
const options = this.getValidOptions({ where });
const selector = this.conn.fn.count<number>(sql`*`).as("count");
let qb = this.conn.selectFrom(entity.name).select(selector);
// add mandatory where
qb = WhereBuilder.addClause(qb, options.where);
// we only need 1
qb = qb.limit(1);
const compiled = qb.compile();
//console.log("exists query", compiled.sql, compiled.parameters);
const result = await qb.execute();
//console.log("result", result);
return {
sql: compiled.sql,
parameters: [...compiled.parameters],
result,
exists: result[0]!.count > 0
};
}
}

View File

@@ -0,0 +1,132 @@
import {
type BooleanLike,
type FilterQuery,
type Primitive,
type TExpression,
exp,
isBooleanLike,
isPrimitive,
makeValidator
} from "core";
import type {
DeleteQueryBuilder,
ExpressionBuilder,
ExpressionWrapper,
SelectQueryBuilder,
UpdateQueryBuilder
} from "kysely";
import type { RepositoryQB } from "./Repository";
type Builder = ExpressionBuilder<any, any>;
type Wrapper = ExpressionWrapper<any, any, any>;
type WhereQb =
| SelectQueryBuilder<any, any, any>
| UpdateQueryBuilder<any, any, any, any>
| DeleteQueryBuilder<any, any, any>;
function key(e: unknown): string {
if (typeof e !== "string") {
throw new Error(`Invalid key: ${e}`);
}
return e as string;
}
const expressions: TExpression<any, any, any>[] = [
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), "=", v)
),
exp(
"$ne",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), "!=", v)
),
exp(
"$gt",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), ">", v)
),
exp(
"$gte",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), ">=", v)
),
exp(
"$lt",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), "<", v)
),
exp(
"$lte",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), "<=", v)
),
exp(
"$isnull",
(v: BooleanLike) => isBooleanLike(v),
(v, k, eb: Builder) => eb(key(k), v ? "is" : "is not", null)
),
exp(
"$in",
(v: any[]) => Array.isArray(v),
(v, k, eb: Builder) => eb(key(k), "in", v)
),
exp(
"$notin",
(v: any[]) => Array.isArray(v),
(v, k, eb: Builder) => eb(key(k), "not in", v)
),
exp(
"$between",
(v: [number, number]) => Array.isArray(v) && v.length === 2,
(v, k, eb: Builder) => eb.between(key(k), v[0], v[1])
),
exp(
"$like",
(v: Primitive) => isPrimitive(v),
(v, k, eb: Builder) => eb(key(k), "like", String(v).replace(/\*/g, "%"))
)
];
export type WhereQuery = FilterQuery<typeof expressions>;
const validator = makeValidator(expressions);
export class WhereBuilder {
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
if (Object.keys(query).length === 0) {
return qb;
}
// @ts-ignore
return qb.where((eb) => {
const fns = validator.build(query, {
value_is_kv: true,
exp_ctx: eb,
convert: true
});
if (fns.$or.length > 0 && fns.$and.length > 0) {
return eb.and(fns.$and).or(eb.and(fns.$or));
} else if (fns.$or.length > 0) {
return eb.or(fns.$or);
}
return eb.and(fns.$and);
});
}
static convert(query: WhereQuery): WhereQuery {
return validator.convert(query);
}
static getPropertyNames(query: WhereQuery): string[] {
const { keys } = validator.build(query, {
value_is_kv: true,
exp_ctx: () => null,
convert: true
});
return Array.from(keys);
}
}

View File

@@ -0,0 +1,42 @@
import type { Entity, EntityManager, RepositoryQB } from "../../entities";
export class WithBuilder {
private static buildClause(
em: EntityManager<any>,
qb: RepositoryQB,
entity: Entity,
withString: string
) {
const relation = em.relationOf(entity.name, withString);
if (!relation) {
throw new Error(`Relation "${withString}" not found`);
}
const cardinality = relation.ref(withString).cardinality;
//console.log("with--builder", { entity: entity.name, withString, cardinality });
const fns = em.connection.fn;
const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
if (!jsonFrom) {
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
}
try {
return relation.buildWith(entity, qb, jsonFrom, withString);
} catch (e) {
throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`);
}
}
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, withs: string[]) {
if (withs.length === 0) return qb;
let newQb = qb;
for (const entry of withs) {
newQb = WithBuilder.buildClause(em, newQb, entity, entry);
}
return newQb;
}
}

77
app/src/data/errors.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Exception } from "core";
import type { TypeInvalidError } from "core/utils";
import type { Entity } from "./entities";
import type { Field } from "./fields";
export class UnableToConnectException extends Exception {
override name = "UnableToConnectException";
override code = 500;
}
export class InvalidSearchParamsException extends Exception {
override name = "InvalidSearchParamsException";
override code = 422;
}
export class TransformRetrieveFailedException extends Exception {
override name = "TransformRetrieveFailedException";
override code = 422;
}
export class TransformPersistFailedException extends Exception {
override name = "TransformPersistFailedException";
override code = 422;
static invalidType(property: string, expected: string, given: any) {
const givenValue = typeof given === "object" ? JSON.stringify(given) : given;
const message =
`Property "${property}" must be of type "${expected}", ` +
`"${givenValue}" of type "${typeof given}" given.`;
return new TransformPersistFailedException(message);
}
static required(property: string) {
return new TransformPersistFailedException(`Property "${property}" is required`);
}
}
export class InvalidFieldConfigException extends Exception {
override name = "InvalidFieldConfigException";
override code = 400;
constructor(
field: Field<any, any, any>,
public given: any,
error: TypeInvalidError
) {
console.error("InvalidFieldConfigException", {
given,
error: error.firstToString()
});
super(`Invalid Field config given for field "${field.name}": ${error.firstToString()}`);
}
}
export class EntityNotDefinedException extends Exception {
override name = "EntityNotDefinedException";
override code = 400;
constructor(entity?: Entity | string) {
if (!entity) {
super("Cannot find an entity that is undefined");
} else {
super(`Entity "${typeof entity !== "string" ? entity.name : entity}" not defined`);
}
}
}
export class EntityNotFoundException extends Exception {
override name = "EntityNotFoundException";
override code = 404;
constructor(entity: Entity | string, id: any) {
super(
`Entity "${typeof entity !== "string" ? entity.name : entity}" with id "${id}" not found`
);
}
}

View File

@@ -0,0 +1,74 @@
import type { PrimaryFieldType } from "core";
import { Event } from "core/events";
import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "../server/data-query-impl";
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> {
static override slug = "mutator-insert-before";
}
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> {
static override slug = "mutator-insert-after";
}
export class MutatorUpdateBefore extends Event<{
entity: Entity;
entityId: PrimaryFieldType;
data: EntityData;
}> {
static override slug = "mutator-update-before";
}
export class MutatorUpdateAfter extends Event<{
entity: Entity;
entityId: PrimaryFieldType;
data: EntityData;
}> {
static override slug = "mutator-update-after";
}
export class MutatorDeleteBefore extends Event<{ entity: Entity; entityId: PrimaryFieldType }> {
static override slug = "mutator-delete-before";
}
export class MutatorDeleteAfter extends Event<{
entity: Entity;
entityId: PrimaryFieldType;
data: EntityData;
}> {
static override slug = "mutator-delete-after";
}
export const MutatorEvents = {
MutatorInsertBefore,
MutatorInsertAfter,
MutatorUpdateBefore,
MutatorUpdateAfter,
MutatorDeleteBefore,
MutatorDeleteAfter
};
export class RepositoryFindOneBefore extends Event<{ entity: Entity; options: RepoQuery }> {
static override slug = "repository-find-one-before";
}
export class RepositoryFindOneAfter extends Event<{
entity: Entity;
options: RepoQuery;
data: EntityData;
}> {
static override slug = "repository-find-one-after";
}
export class RepositoryFindManyBefore extends Event<{ entity: Entity; options: RepoQuery }> {
static override slug = "repository-find-many-before";
static another = "one";
}
export class RepositoryFindManyAfter extends Event<{
entity: Entity;
options: RepoQuery;
data: EntityData;
}> {
static override slug = "repository-find-many-after";
}
export const RepositoryEvents = {
RepositoryFindOneBefore,
RepositoryFindOneAfter,
RepositoryFindManyBefore,
RepositoryFindManyAfter
};

Some files were not shown because too many files have changed in this diff Show More