mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Release 0.16 (#196)
* initial refactor * fixes * test secrets extraction * updated lock * fix secret schema * updated schemas, fixed tests, skipping flow tests for now * added validator for rjsf, hook form via standard schema * removed @sinclair/typebox * remove unneeded vite dep * fix jsonv literal on Field.tsx * fix schema import path * fix schema modals * fix schema modals * fix json field form, replaced auth form * initial waku * finalize waku example * fix jsonv-ts version * fix schema updates with falsy values * fix media api to respect options' init, improve types * checking media controller test * checking media controller test * checking media controller test * clean up mediacontroller test * added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214) * added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` * fix server test * fix data api (updated jsonv-ts) * enhance cloudflare image optimization plugin with new options and explain endpoint (#215) * feat: add ability to serve static by using dynamic imports (#197) * feat: add ability to serve static by using dynamic imports * serveStaticViaImport: make manifest optional * serveStaticViaImport: add error log * refactor/imports (#217) * refactored core and core/utils imports * refactored core and core/utils imports * refactored media imports * refactored auth imports * refactored data imports * updated package json exports, fixed mm config * fix tests * feat/deno (#219) * update bun version * fix module manager's em reference * add basic deno example * finalize * docs: fumadocs migration (#185) * feat(docs): initialize documentation structure with Fumadocs * feat(docs): remove home route and move /docs route to /route * feat(docs): add redirect to /start page * feat(docs): migrate Getting Started chapters * feat(docs): migrate Usage and Extending chapters * feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive * feat(layout): add Discord and GitHub links to documentation layout * feat(docs): add integration chapters draft * feat(docs): add modules chapters draft * refactor(mdx-components): remove unused Icon import * refactor(StackBlitz): enhance type safety by using unknown instead of any * refactor(layout): update navigation mode to 'top' in layout configuration * feat(docs): add @iconify/react package * docs(mdx-components): add Icon component to MDX components list * feat(docs): update Next.js integration guide * feat(docs): update React Router integration guide * feat(docs): update Astro integration guide * feat(docs): update Vite integration guide * fix(docs): update package manager initialization commands * feat(docs): migrate Modules chapters * chore(docs): update package.json with new devDependencies * feat(docs): migrate Integration Runtimes chapters * feat(docs): update Database usage chapter * feat(docs): restructure documentation paths * chore(docs): clean up unused imports and files in documentation * style(layout): revert navigation mode to previous state * fix(docs): routing for documentation structure * feat(openapi): add API documentation generation from OpenAPI schema * feat(docs): add icons to documentation pages * chore(dependencies): remove unused content-collections packages * fix(types): fix type error for attachFile in source.ts * feat(redirects): update root redirect destination to '/start' * feat(search): add static search functionality * chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions * feat(search): add Powered by Orama link * feat(generate-openapi): add error handling for missing OpenAPI schema * feat(scripts): add OpenAPI generation to build process * feat(config): enable dynamic redirects and rewrites in development mode * feat(layout): add GitHub token support for improved API rate limits * feat(redirects): add 301 redirects for cloudflare pages * feat(docs): add Vercel redirects configuration * feat(config): enable standalone output for development environment * chore(layout): adjust layout settings * refactor(package): clean up ajv dependency versions * feat(docs): add twoslash support * refactor(layout): update DocsLayout import and navigation configuration * chore(layout): clean up layout.tsx by commenting out GithubInfo * fix(Search): add locale to search initialization * chore(package): update fumadocs and orama to latest versions * docs: add menu items descriptions * feat(layout): add GitHub URL to the layout component * feat(docs): add AutoTypeTable component to MDX components * feat(app): implement AutoTypeTable rendering for AppEvents type * docs(layout): switch callouts back to default components * fix(config): use __filename and __dirname for module paths * docs: add note about node.js 22 requirement * feat(styles): add custom color variables for light and dark themes * docs: add S3 setup instructions for media module * docs: fix typos and indentation in media module docs * docs: add local media adapter example for Node.js * docs(media): add S3/R2 URL format examples and fix typo * docs: add cross-links to initial config and seeding sections * indent numbered lists content, clarified media serve locations * fix mediacontroller tests * feat(layout): add AnimatedGridPattern component for dynamic background * style(layout): configure fancy ToC style ('clerk') * fix(AnimatedGridPattern): correct strokeDasharray type * docs: actualize docs * feat: add favicon * style(cloudflare): format code examples * feat(layout): add Github and Discord footer icons * feat(footer): add SVG social media icons for GitHub and Discord * docs: adjusted auto type table, added llm functions * added static deployment to cloudflare workers * docs: change cf redirects to proxy *.mdx instead of redirecting --------- Co-authored-by: dswbx <dennis.senn@gmx.ch> Co-authored-by: cameronapak <cameronandrewpak@gmail.com> * build: improve build script * add missing exports, fix EntityTypescript imports * media: Dropzone: add programmatic upload, additional events, loading state * schema object: disable extended defaults to allow empty config values * Feat/new docs deploy (#224) * test * try fixing pm * try fixing pm * fix docs on imports, export events correctly --------- Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com> Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { SafeUser } from "auth";
|
||||
import type { SafeUser } from "bknd";
|
||||
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
|
||||
import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
||||
import { decode } from "hono/jwt";
|
||||
|
||||
@@ -40,6 +40,9 @@ export class AppConfigUpdatedEvent extends AppEvent<{
|
||||
}> {
|
||||
static override slug = "app-config-updated";
|
||||
}
|
||||
/**
|
||||
* @type {Event<{ app: App }>}
|
||||
*/
|
||||
export class AppBuiltEvent extends AppEvent {
|
||||
static override slug = "app-built";
|
||||
}
|
||||
@@ -71,6 +74,9 @@ export type AppOptions = {
|
||||
};
|
||||
};
|
||||
export type CreateAppConfig = {
|
||||
/**
|
||||
* bla
|
||||
*/
|
||||
connection?: Connection | { url: string };
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
options?: AppOptions;
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import path from "node:path";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from ".";
|
||||
import { config } from "bknd/core";
|
||||
import { config, type App } from "bknd";
|
||||
import type { ServeOptions } from "bun";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import type { App } from "App";
|
||||
|
||||
type BunEnv = Bun.Env;
|
||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||
@@ -21,8 +20,8 @@ export async function createApp<Env = BunEnv>(
|
||||
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
serveStatic: serveStatic({ root }),
|
||||
...config,
|
||||
},
|
||||
args ?? (process.env as Env),
|
||||
opts,
|
||||
@@ -53,6 +52,7 @@ export function serve<Env = BunEnv>(
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
adminOptions,
|
||||
serveStatic,
|
||||
...serveOptions
|
||||
}: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
@@ -70,6 +70,7 @@ export function serve<Env = BunEnv>(
|
||||
buildConfig,
|
||||
adminOptions,
|
||||
distPath,
|
||||
serveStatic,
|
||||
},
|
||||
args,
|
||||
opts,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||
|
||||
export type BunSqliteConnection = GenericSqliteConnection<Database>;
|
||||
export type BunSqliteConnectionConfig = {
|
||||
|
||||
@@ -12,7 +12,10 @@ export function getBindings<T extends GetBindingType>(env: any, type: T): Bindin
|
||||
const bindings: BindingMap<T>[] = [];
|
||||
for (const key in env) {
|
||||
try {
|
||||
if (env[key] && (env[key] as any).constructor.name === type) {
|
||||
if (
|
||||
env[key] &&
|
||||
((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`)
|
||||
) {
|
||||
bindings.push({
|
||||
key,
|
||||
value: env[key] as BindingTypeMap[T],
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { Connection } from "bknd";
|
||||
import { sqlite } from "bknd/adapter/sqlite";
|
||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||
import { getBinding } from "./bindings";
|
||||
import { d1Sqlite } from "./connection/D1Connection";
|
||||
import { Connection } from "bknd/data";
|
||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||
import { App } from "bknd";
|
||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||
import type { Context, ExecutionContext } from "hono";
|
||||
import { $console } from "core/utils";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { sqlite } from "bknd/adapter/sqlite";
|
||||
|
||||
export const constants = {
|
||||
exec_async_event_id: "cf_register_waituntil",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||
import type { QueryResult } from "kysely";
|
||||
|
||||
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||
import type { QueryResult } from "kysely";
|
||||
|
||||
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
|
||||
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
type BindingMap,
|
||||
} from "./bindings";
|
||||
export { constants } from "./config";
|
||||
export { StorageR2Adapter } from "./storage/StorageR2Adapter";
|
||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||
export { registries } from "bknd";
|
||||
|
||||
// for compatibility with old code
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { registries } from "bknd";
|
||||
import { isDebug } from "bknd/core";
|
||||
// @ts-ignore
|
||||
import { StringEnum } from "bknd/utils";
|
||||
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
|
||||
import { registries, isDebug, guessMimeType } from "bknd";
|
||||
import { getBindings } from "../bindings";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
const { Type } = tb;
|
||||
import { s } from "bknd/utils";
|
||||
import { StorageAdapter, type FileBody } from "bknd";
|
||||
|
||||
export function makeSchema(bindings: string[] = []) {
|
||||
return Type.Object(
|
||||
return s.object(
|
||||
{
|
||||
binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String()),
|
||||
binding: bindings.length > 0 ? s.string({ enum: bindings }) : s.string().optional(),
|
||||
},
|
||||
{ title: "R2", description: "Cloudflare R2 storage" },
|
||||
);
|
||||
@@ -93,7 +89,7 @@ export class StorageR2Adapter extends StorageAdapter {
|
||||
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": guess(key),
|
||||
"Content-Type": guessMimeType(key),
|
||||
});
|
||||
|
||||
const range = headers.has("range");
|
||||
@@ -145,7 +141,7 @@ export class StorageR2Adapter extends StorageAdapter {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
// guessing is especially required for dev environment (miniflare)
|
||||
metadata = {
|
||||
contentType: guess(object.key),
|
||||
contentType: guessMimeType(object.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +158,7 @@ export class StorageR2Adapter extends StorageAdapter {
|
||||
}
|
||||
|
||||
return {
|
||||
type: String(head.httpMetadata?.contentType ?? guess(key)),
|
||||
type: String(head.httpMetadata?.contentType ?? guessMimeType(key)),
|
||||
size: head.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { config as $config } from "bknd/core";
|
||||
import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
import { Connection } from "bknd/data";
|
||||
|
||||
export { Connection } from "bknd/data";
|
||||
import type { Manifest } from "vite";
|
||||
|
||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
@@ -72,7 +69,7 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||
const conf = appConfig.connection ?? { url: ":memory:" };
|
||||
connection = sqlite(conf);
|
||||
$console.info(`Using ${connection.name} connection`, conf.url);
|
||||
$console.info(`Using ${connection!.name} connection`, conf.url);
|
||||
}
|
||||
appConfig.connection = connection;
|
||||
}
|
||||
@@ -140,3 +137,54 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a middleware handler to serve static assets via dynamic imports.
|
||||
* This is useful for environments where filesystem access is limited but bundled assets can be imported.
|
||||
*
|
||||
* @param manifest - Vite manifest object containing asset information
|
||||
* @returns Hono middleware handler for serving static assets
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { serveStaticViaImport } from "bknd/adapter";
|
||||
*
|
||||
* serve({
|
||||
* serveStatic: serveStaticViaImport(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
||||
let files: string[] | undefined;
|
||||
|
||||
// @ts-ignore
|
||||
return async (c: Context, next: Next) => {
|
||||
if (!files) {
|
||||
const manifest =
|
||||
opts?.manifest || ((await import("bknd/dist/manifest.json")).default as Manifest);
|
||||
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
|
||||
}
|
||||
|
||||
const path = c.req.path.substring(1);
|
||||
if (files.includes(path)) {
|
||||
try {
|
||||
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
||||
assert: { type: "text" },
|
||||
}).then((m) => m.default);
|
||||
|
||||
if (content) {
|
||||
return c.body(content, {
|
||||
headers: {
|
||||
"Content-Type": guessMimeType(path),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error serving static file:", e);
|
||||
return c.text("File not found", 404);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { genericSqlite } from "bknd/data";
|
||||
import { genericSqlite } from "bknd";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export type NodeSqliteConnectionConfig = {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { registerLocalMediaAdapter } from "adapter/node/storage";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { config as $config } from "bknd/core";
|
||||
import { $console } from "core/utils";
|
||||
import type { App } from "App";
|
||||
import { config as $config, type App } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
type NodeEnv = NodeJS.ProcessEnv;
|
||||
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||
@@ -32,8 +31,8 @@ export async function createApp<Env = NodeEnv>(
|
||||
registerLocalMediaAdapter();
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
serveStatic: serveStatic({ root }),
|
||||
...config,
|
||||
},
|
||||
// @ts-ignore
|
||||
args ?? { env: process.env },
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { type Static, isFile, parse } from "bknd/utils";
|
||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
|
||||
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
const { Type } = tb;
|
||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
|
||||
import { StorageAdapter, guessMimeType } from "bknd";
|
||||
import { parse, s, isFile } from "bknd/utils";
|
||||
|
||||
export const localAdapterConfig = Type.Object(
|
||||
export const localAdapterConfig = s.object(
|
||||
{
|
||||
path: Type.String({ default: "./" }),
|
||||
path: s.string({ default: "./" }),
|
||||
},
|
||||
{ title: "Local", description: "Local file system storage", additionalProperties: false },
|
||||
);
|
||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||
export type LocalAdapterConfig = s.Static<typeof localAdapterConfig>;
|
||||
|
||||
export class StorageLocalAdapter extends StorageAdapter {
|
||||
private config: LocalAdapterConfig;
|
||||
@@ -62,8 +60,7 @@ export class StorageLocalAdapter extends StorageAdapter {
|
||||
}
|
||||
|
||||
const filePath = `${this.config.path}/${key}`;
|
||||
const is_file = isFile(body);
|
||||
await writeFile(filePath, is_file ? body.stream() : body);
|
||||
await writeFile(filePath, isFile(body) ? body.stream() : body);
|
||||
|
||||
return await this.computeEtag(body);
|
||||
}
|
||||
@@ -86,7 +83,7 @@ export class StorageLocalAdapter extends StorageAdapter {
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const content = await readFile(`${this.config.path}/${key}`);
|
||||
const mimeType = guess(key);
|
||||
const mimeType = guessMimeType(key);
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
@@ -108,7 +105,7 @@ export class StorageLocalAdapter extends StorageAdapter {
|
||||
async getObjectMeta(key: string): Promise<FileMeta> {
|
||||
const stats = await stat(`${this.config.path}/${key}`);
|
||||
return {
|
||||
type: guess(key) || "application/octet-stream",
|
||||
type: guessMimeType(key) || "application/octet-stream",
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Connection } from "bknd/data";
|
||||
import type { Connection } from "bknd";
|
||||
import { bunSqlite } from "../bun/connection/BunSqliteConnection";
|
||||
|
||||
export function sqlite(config?: { url: string }): Connection {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Connection, libsql } from "bknd/data";
|
||||
import { type Connection, libsql } from "bknd";
|
||||
|
||||
export function sqlite(config: { url: string }): Connection {
|
||||
return libsql(config);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Connection } from "bknd/data";
|
||||
import type { Connection } from "bknd";
|
||||
import { nodeSqlite } from "../node/connection/NodeSqliteConnection";
|
||||
|
||||
export function sqlite(config?: { url: string }): Connection {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import type { DB } from "core";
|
||||
import type { DB } from "bknd";
|
||||
import * as AuthPermissions from "auth/auth-permissions";
|
||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||
import { $console, secureRandomString, transformObject } from "core/utils";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import type { Entity, EntityManager } from "data/entities";
|
||||
import { em, entity, enumm, type FieldSchema } from "data/prototype";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
@@ -10,9 +11,11 @@ import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"
|
||||
import { AppUserPool } from "auth/AppUserPool";
|
||||
import type { AppEntity } from "core/config";
|
||||
import { usersFields } from "./auth-entities";
|
||||
import { Authenticator } from "./authenticate/Authenticator";
|
||||
import { Role } from "./authorize/Role";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare module "core" {
|
||||
declare module "bknd" {
|
||||
interface Users extends AppEntity, UserFieldSchema {}
|
||||
interface DB {
|
||||
users: Users;
|
||||
@@ -21,7 +24,7 @@ declare module "core" {
|
||||
|
||||
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
export class AppAuth extends Module<AppAuthSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
cache: Record<string, any> = {};
|
||||
_controller!: AuthController;
|
||||
@@ -88,7 +91,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
this.ctx.guard.registerPermissions(AuthPermissions);
|
||||
}
|
||||
|
||||
isStrategyEnabled(strategy: Strategy | string) {
|
||||
isStrategyEnabled(strategy: AuthStrategy | string) {
|
||||
const name = typeof strategy === "string" ? strategy : strategy.getName();
|
||||
// for now, password is always active
|
||||
if (name === "password") return true;
|
||||
@@ -187,6 +190,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
enabled: this.isStrategyEnabled(strategy),
|
||||
...strategy.toJSON(secrets),
|
||||
})),
|
||||
};
|
||||
} as AppAuthSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AuthActionResponse } from "auth/api/AuthController";
|
||||
import type { AppAuthSchema } from "auth/auth-schema";
|
||||
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
|
||||
import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
|
||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||
|
||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||
@@ -39,7 +39,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
async actionSchema(strategy: string, action: string) {
|
||||
return this.get<Strategy>([strategy, "actions", action, "schema.json"]);
|
||||
return this.get<AuthStrategy>([strategy, "actions", action, "schema.json"]);
|
||||
}
|
||||
|
||||
async action(strategy: string, action: string, input: any) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||
import { DataPermissions } from "data";
|
||||
import type { SafeUser } from "bknd";
|
||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||
import type { AppAuth } from "auth/AppAuth";
|
||||
import * as AuthPermissions from "auth/auth-permissions";
|
||||
import * as DataPermissions from "data/permissions";
|
||||
import type { Hono } from "hono";
|
||||
import { Controller, type ServerEnv } from "modules/Controller";
|
||||
import { describeRoute, jsc, s } from "core/object/schema";
|
||||
import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils";
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
@@ -30,7 +32,7 @@ export class AuthController extends Controller {
|
||||
return this.em.repo(entity_name as "users");
|
||||
}
|
||||
|
||||
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
||||
private registerStrategyActions(strategy: AuthStrategy, mainHono: Hono<ServerEnv>) {
|
||||
if (!this.auth.isStrategyEnabled(strategy)) {
|
||||
return;
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export class AuthController extends Controller {
|
||||
try {
|
||||
const body = await this.auth.authenticator.getBody(c);
|
||||
const valid = parse(create.schema, body, {
|
||||
skipMark: true,
|
||||
//skipMark: true,
|
||||
});
|
||||
const processed = (await create.preprocess?.(valid)) ?? valid;
|
||||
|
||||
@@ -78,7 +80,7 @@ export class AuthController extends Controller {
|
||||
data: created as unknown as SafeUser,
|
||||
} as AuthActionResponse);
|
||||
} catch (e) {
|
||||
if (e instanceof TypeInvalidError) {
|
||||
if (e instanceof InvalidSchemaError) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Permission } from "core";
|
||||
import { Permission } from "core/security/Permission";
|
||||
|
||||
export const createUser = new Permission("auth.user.create");
|
||||
//export const updateUser = new Permission("auth.user.update");
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { type Static, StringRecord, objectTransform } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { objectTransform, s } from "bknd/utils";
|
||||
|
||||
export const Strategies = {
|
||||
password: {
|
||||
@@ -21,64 +19,58 @@ export const Strategies = {
|
||||
|
||||
export const STRATEGIES = Strategies;
|
||||
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||
return Type.Object(
|
||||
return s.strictObject(
|
||||
{
|
||||
enabled: Type.Optional(Type.Boolean({ default: true })),
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
enabled: s.boolean({ default: true }).optional(),
|
||||
type: s.literal(name),
|
||||
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>;
|
||||
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||
|
||||
const guardConfigSchema = Type.Object({
|
||||
enabled: Type.Optional(Type.Boolean({ default: false })),
|
||||
const strategiesSchema = s.anyOf(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = s.Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = s.Static<typeof STRATEGIES.oauth.schema>;
|
||||
export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||
|
||||
const guardConfigSchema = s.object({
|
||||
enabled: s.boolean({ default: false }).optional(),
|
||||
});
|
||||
export const guardRoleSchema = s.strictObject({
|
||||
permissions: s.array(s.string()).optional(),
|
||||
is_default: s.boolean().optional(),
|
||||
implicit_allow: s.boolean().optional(),
|
||||
});
|
||||
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(
|
||||
export const authConfigSchema = s.strictObject(
|
||||
{
|
||||
enabled: Type.Boolean({ default: false }),
|
||||
basepath: Type.String({ default: "/api/auth" }),
|
||||
entity_name: Type.String({ default: "users" }),
|
||||
allow_register: Type.Optional(Type.Boolean({ default: true })),
|
||||
enabled: s.boolean({ default: false }),
|
||||
basepath: s.string({ default: "/api/auth" }),
|
||||
entity_name: s.string({ default: "users" }),
|
||||
allow_register: s.boolean({ default: true }).optional(),
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
strategies: Type.Optional(
|
||||
StringRecord(strategiesSchema, {
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
enabled: true,
|
||||
config: {
|
||||
hashing: "sha256",
|
||||
},
|
||||
strategies: s.record(strategiesSchema, {
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
enabled: true,
|
||||
config: {
|
||||
hashing: "sha256",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
guard: Type.Optional(guardConfigSchema),
|
||||
roles: Type.Optional(StringRecord(guardRoleSchema, { default: {} })),
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
guard: guardConfigSchema.optional(),
|
||||
roles: s.record(guardRoleSchema, { default: {} }).optional(),
|
||||
},
|
||||
{ title: "Authentication" },
|
||||
);
|
||||
|
||||
export type AppAuthSchema = Static<typeof authConfigSchema>;
|
||||
export type AppAuthJWTConfig = s.Static<typeof jwtConfig>;
|
||||
|
||||
export type AppAuthSchema = s.Static<typeof authConfigSchema>;
|
||||
|
||||
@@ -1,46 +1,27 @@
|
||||
import { type DB, Exception } from "core";
|
||||
import type { DB } from "bknd";
|
||||
import { Exception } from "core/errors";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import {
|
||||
$console,
|
||||
type Static,
|
||||
StringEnum,
|
||||
type TObject,
|
||||
parse,
|
||||
runtimeSupports,
|
||||
truncate,
|
||||
} from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
import type { CookieOptions } from "hono/utils/cookie";
|
||||
import { type CookieOptions, serializeSigned } from "hono/utils/cookie";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import { pick } from "lodash-es";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { InvalidConditionsException } from "auth/errors";
|
||||
const { Type } = tbbox;
|
||||
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
|
||||
import type { AuthStrategy } from "./strategies/Strategy";
|
||||
|
||||
type Input = any; // workaround
|
||||
export type JWTPayload = Parameters<typeof sign>[0];
|
||||
|
||||
export const strategyActions = ["create", "change"] as const;
|
||||
export type StrategyActionName = (typeof strategyActions)[number];
|
||||
export type StrategyAction<S extends TObject = TObject> = {
|
||||
export type StrategyAction<S extends s.ObjectSchema = s.ObjectSchema> = {
|
||||
schema: S;
|
||||
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||
preprocess: (input: s.Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||
};
|
||||
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
|
||||
|
||||
// @todo: add schema to interface to ensure proper inference
|
||||
// @todo: add tests (e.g. invalid strategy_value)
|
||||
export interface Strategy {
|
||||
getController: (auth: Authenticator) => Hono<any>;
|
||||
getType: () => string;
|
||||
getMode: () => "form" | "external";
|
||||
getName: () => string;
|
||||
toJSON: (secrets?: boolean) => any;
|
||||
getActions?: () => StrategyActions;
|
||||
}
|
||||
|
||||
export type User = DB["users"];
|
||||
|
||||
export type ProfileExchange = {
|
||||
@@ -60,43 +41,45 @@ export interface UserPool {
|
||||
}
|
||||
|
||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||
export const cookieConfig = Type.Partial(
|
||||
Type.Object({
|
||||
path: Type.String({ default: "/" }),
|
||||
sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }),
|
||||
secure: Type.Boolean({ default: true }),
|
||||
httpOnly: Type.Boolean({ default: true }),
|
||||
expires: Type.Number({ default: defaultCookieExpires }), // seconds
|
||||
renew: Type.Boolean({ default: true }),
|
||||
pathSuccess: Type.String({ default: "/" }),
|
||||
pathLoggedOut: Type.String({ default: "/" }),
|
||||
}),
|
||||
{ default: {}, additionalProperties: false },
|
||||
);
|
||||
export const cookieConfig = s
|
||||
.object({
|
||||
path: s.string({ default: "/" }),
|
||||
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
||||
secure: s.boolean({ default: true }),
|
||||
httpOnly: s.boolean({ default: true }),
|
||||
expires: s.number({ default: defaultCookieExpires }), // seconds
|
||||
partitioned: s.boolean({ default: false }),
|
||||
renew: s.boolean({ default: true }),
|
||||
pathSuccess: s.string({ default: "/" }),
|
||||
pathLoggedOut: s.string({ default: "/" }),
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||
// see auth.integration test for further details
|
||||
|
||||
export const jwtConfig = Type.Object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
secret: Type.String({ default: "" }),
|
||||
alg: Type.Optional(StringEnum(["HS256", "HS384", "HS512"], { default: "HS256" })),
|
||||
expires: Type.Optional(Type.Number()), // seconds
|
||||
issuer: Type.Optional(Type.String()),
|
||||
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }),
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const authenticatorConfig = Type.Object({
|
||||
export const jwtConfig = s
|
||||
.object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
secret: secret({ default: "" }),
|
||||
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
|
||||
expires: s.number().optional(), // seconds
|
||||
issuer: s.string().optional(),
|
||||
fields: s.array(s.string(), { default: ["id", "email", "role"] }),
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
},
|
||||
)
|
||||
.strict();
|
||||
export const authenticatorConfig = s.object({
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
});
|
||||
|
||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||
export type AuthAction = "login" | "register";
|
||||
export type AuthResolveOptions = {
|
||||
identifier?: "email" | string;
|
||||
@@ -105,7 +88,7 @@ export type AuthResolveOptions = {
|
||||
};
|
||||
export type AuthUserResolver = (
|
||||
action: AuthAction,
|
||||
strategy: Strategy,
|
||||
strategy: AuthStrategy,
|
||||
profile: ProfileExchange,
|
||||
opts?: AuthResolveOptions,
|
||||
) => Promise<ProfileExchange | undefined>;
|
||||
@@ -115,7 +98,9 @@ type AuthClaims = SafeUser & {
|
||||
exp?: number;
|
||||
};
|
||||
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
export class Authenticator<
|
||||
Strategies extends Record<string, AuthStrategy> = Record<string, AuthStrategy>,
|
||||
> {
|
||||
private readonly config: AuthConfig;
|
||||
|
||||
constructor(
|
||||
@@ -128,7 +113,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
|
||||
async resolveLogin(
|
||||
c: Context,
|
||||
strategy: Strategy,
|
||||
strategy: AuthStrategy,
|
||||
profile: Partial<SafeUser>,
|
||||
verify: (user: User) => Promise<void>,
|
||||
opts?: AuthResolveOptions,
|
||||
@@ -166,7 +151,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
|
||||
async resolveRegister(
|
||||
c: Context,
|
||||
strategy: Strategy,
|
||||
strategy: AuthStrategy,
|
||||
profile: CreateUser,
|
||||
verify: (user: User) => Promise<void>,
|
||||
opts?: AuthResolveOptions,
|
||||
@@ -235,7 +220,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
|
||||
strategy<
|
||||
StrategyName extends keyof Strategies,
|
||||
Strat extends Strategy = Strategies[StrategyName],
|
||||
Strat extends AuthStrategy = Strategies[StrategyName],
|
||||
>(strategy: StrategyName): Strat {
|
||||
try {
|
||||
return this.strategies[strategy] as unknown as Strat;
|
||||
@@ -342,6 +327,11 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||
}
|
||||
|
||||
async unsafeGetAuthCookie(token: string): Promise<string | undefined> {
|
||||
// this works for as long as cookieOptions.prefix is not set
|
||||
return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions);
|
||||
}
|
||||
|
||||
private deleteAuthCookie(c: Context) {
|
||||
$console.debug("deleting auth cookie");
|
||||
deleteCookie(c, "auth", this.cookieOptions);
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
|
||||
import { tbValidator as tb } from "core";
|
||||
import { $console, hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
|
||||
import type { User } from "bknd";
|
||||
import type { Authenticator } from "auth/authenticate/Authenticator";
|
||||
import { InvalidCredentialsException } from "auth/errors";
|
||||
import { hash, $console } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { Strategy } from "./Strategy";
|
||||
import { AuthStrategy } from "./Strategy";
|
||||
import { s, parse, jsc } from "bknd/utils";
|
||||
|
||||
const { Type } = tbbox;
|
||||
const schema = s
|
||||
.object({
|
||||
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
||||
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const schema = StrictObject({
|
||||
hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }),
|
||||
rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
|
||||
});
|
||||
export type PasswordStrategyOptions = s.Static<typeof schema>;
|
||||
|
||||
export type PasswordStrategyOptions = Static<typeof schema>;
|
||||
|
||||
export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
||||
constructor(config: Partial<PasswordStrategyOptions> = {}) {
|
||||
super(config as any, "password", "password", "form");
|
||||
|
||||
@@ -32,11 +33,11 @@ export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
}
|
||||
|
||||
private getPayloadSchema() {
|
||||
return Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
||||
return s.object({
|
||||
email: s.string({
|
||||
format: "email",
|
||||
}),
|
||||
password: Type.String({
|
||||
password: s.string({
|
||||
minLength: 8, // @todo: this should be configurable
|
||||
}),
|
||||
});
|
||||
@@ -79,12 +80,12 @@ export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const redirectQuerySchema = Type.Object({
|
||||
redirect: Type.Optional(Type.String()),
|
||||
const redirectQuerySchema = s.object({
|
||||
redirect: s.string().optional(),
|
||||
});
|
||||
const payloadSchema = this.getPayloadSchema();
|
||||
|
||||
hono.post("/login", tb("query", redirectQuerySchema), async (c) => {
|
||||
hono.post("/login", jsc("query", redirectQuerySchema), async (c) => {
|
||||
try {
|
||||
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||
onError: (errors) => {
|
||||
@@ -102,7 +103,7 @@ export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
}
|
||||
});
|
||||
|
||||
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
|
||||
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
|
||||
try {
|
||||
const { redirect } = c.req.valid("query");
|
||||
const { password, email, ...body } = parse(
|
||||
|
||||
@@ -5,31 +5,31 @@ import type {
|
||||
StrategyActions,
|
||||
} from "../Authenticator";
|
||||
import type { Hono } from "hono";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import { parse, type TObject } from "core/utils";
|
||||
import { type s, parse } from "bknd/utils";
|
||||
|
||||
export type StrategyMode = "form" | "external";
|
||||
|
||||
export abstract class Strategy<Schema extends TSchema = TSchema> {
|
||||
export abstract class AuthStrategy<Schema extends s.Schema = s.Schema> {
|
||||
protected actions: StrategyActions = {};
|
||||
|
||||
constructor(
|
||||
protected config: Static<Schema>,
|
||||
protected config: s.Static<Schema>,
|
||||
public type: string,
|
||||
public name: string,
|
||||
public mode: StrategyMode,
|
||||
) {
|
||||
// don't worry about typing, it'll throw if invalid
|
||||
this.config = parse(this.getSchema(), (config ?? {}) as any) as Static<Schema>;
|
||||
this.config = parse(this.getSchema(), (config ?? {}) as any) as s.Static<Schema>;
|
||||
}
|
||||
|
||||
protected registerAction<S extends TObject = TObject>(
|
||||
protected registerAction<S extends s.ObjectSchema = s.ObjectSchema>(
|
||||
name: StrategyActionName,
|
||||
schema: S,
|
||||
preprocess: StrategyAction<S>["preprocess"],
|
||||
): void {
|
||||
this.actions[name] = {
|
||||
schema,
|
||||
// @ts-expect-error - @todo: fix this
|
||||
preprocess,
|
||||
} as const;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export abstract class Strategy<Schema extends TSchema = TSchema> {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
|
||||
toJSON(secrets?: boolean): { type: string; config: s.Static<Schema> | {} | undefined } {
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: secrets ? this.config : undefined,
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
import { type Static, StrictObject, StringEnum } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type * as oauth from "oauth4webapi";
|
||||
import { OAuthStrategy } from "./OAuthStrategy";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
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 = StrictObject(
|
||||
const UrlString = s.string({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
|
||||
const oauthSchemaCustom = s.strictObject(
|
||||
{
|
||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||
name: Type.String(),
|
||||
client: StrictObject({
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String(),
|
||||
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
|
||||
type: s.string({ enum: ["oidc", "oauth2"] as const, default: "oidc" }),
|
||||
name: s.string(),
|
||||
client: s.object({
|
||||
client_id: s.string(),
|
||||
client_secret: s.string(),
|
||||
token_endpoint_auth_method: s.string({ enum: ["client_secret_basic"] }),
|
||||
}),
|
||||
as: StrictObject({
|
||||
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),
|
||||
as: s.strictObject({
|
||||
issuer: s.string(),
|
||||
code_challenge_methods_supported: s.string({ enum: ["S256"] }).optional(),
|
||||
scopes_supported: s.array(s.string()).optional(),
|
||||
scope_separator: s.string({ default: " " }).optional(),
|
||||
authorization_endpoint: UrlString.optional(),
|
||||
token_endpoint: UrlString.optional(),
|
||||
userinfo_endpoint: UrlString.optional(),
|
||||
}),
|
||||
// @todo: profile mapping
|
||||
},
|
||||
{ title: "Custom OAuth" },
|
||||
);
|
||||
|
||||
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
||||
type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>;
|
||||
|
||||
export type UserProfile = {
|
||||
sub: string;
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
import type { AuthAction, Authenticator } from "auth";
|
||||
import { Exception, isDebug } from "core";
|
||||
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
|
||||
import type { Authenticator, AuthAction } from "auth/authenticate/Authenticator";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import * as oauth from "oauth4webapi";
|
||||
import * as issuers from "./issuers";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { Strategy } from "auth/authenticate/strategies/Strategy";
|
||||
const { Type } = tbbox;
|
||||
import { s, filterKeys } from "bknd/utils";
|
||||
import { Exception } from "core/errors";
|
||||
import { isDebug } from "core/env";
|
||||
import { AuthStrategy } from "../Strategy";
|
||||
|
||||
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(
|
||||
const schemaProvided = s.object(
|
||||
{
|
||||
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
|
||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }),
|
||||
client: StrictObject({
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String(),
|
||||
}),
|
||||
name: s.string({ enum: Object.keys(issuers) as ConfiguredIssuers[] }),
|
||||
type: s.string({ enum: ["oidc", "oauth2"] as const, default: "oauth2" }),
|
||||
client: s
|
||||
.object({
|
||||
client_id: s.string(),
|
||||
client_secret: s.string(),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
{ title: "OAuth" },
|
||||
);
|
||||
type ProvidedOAuthConfig = Static<typeof schemaProvided>;
|
||||
type ProvidedOAuthConfig = s.Static<typeof schemaProvided>;
|
||||
|
||||
export type CustomOAuthConfig = {
|
||||
type: SupportedTypes;
|
||||
@@ -69,7 +70,7 @@ export class OAuthCallbackException extends Exception {
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthStrategy extends Strategy<typeof schemaProvided> {
|
||||
export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
|
||||
constructor(config: ProvidedOAuthConfig) {
|
||||
super(config, "oauth", config.name, "external");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Exception, Permission } from "core";
|
||||
import { Exception } from "core/errors";
|
||||
import { $console, objectTransform } from "core/utils";
|
||||
import { Permission } from "core/security/Permission";
|
||||
import type { Context } from "hono";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import { Role } from "./Role";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Permission } from "core";
|
||||
import { Permission } from "core/security/Permission";
|
||||
|
||||
export class RolePermission {
|
||||
constructor(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Exception, isDebug } from "core";
|
||||
import { HttpStatus } from "core/utils";
|
||||
import { Exception } from "core/errors";
|
||||
import { isDebug } from "core/env";
|
||||
import { HttpStatus } from "bknd/utils";
|
||||
|
||||
export class AuthException extends Exception {
|
||||
getSafeErrorAndCode() {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
|
||||
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";
|
||||
|
||||
export * as AuthPermissions from "./auth-permissions";
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Permission } from "core";
|
||||
import { $console, patternMatch } from "core/utils";
|
||||
import type { Permission } from "core/security/Permission";
|
||||
import { $console, patternMatch } from "bknd/utils";
|
||||
import type { Context } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CliCommand } from "cli/types";
|
||||
import { typewriter, wait } from "cli/utils/cli";
|
||||
import { execAsync, getVersion } from "cli/utils/sys";
|
||||
import { Option } from "commander";
|
||||
import { env } from "core";
|
||||
import { env } from "bknd";
|
||||
import color from "picocolors";
|
||||
import { overridePackageJson, updateBkndPackages } from "./npm";
|
||||
import { type Template, templates, type TemplateSetupCtx } from "./templates";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Config } from "@libsql/client/node";
|
||||
import type { App, CreateAppConfig } from "App";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config } from "core";
|
||||
import { config, type App, type CreateAppConfig } from "bknd";
|
||||
import dotenv from "dotenv";
|
||||
import { registries } from "modules/registries";
|
||||
import c from "picocolors";
|
||||
@@ -16,8 +15,8 @@ import {
|
||||
serveStatic,
|
||||
startServer,
|
||||
} from "./platform";
|
||||
import { createRuntimeApp, makeConfig } from "adapter";
|
||||
import { colorizeConsole, isBun } from "core/utils";
|
||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||
import { colorizeConsole, isBun } from "bknd/utils";
|
||||
|
||||
const env_files = [".env", ".dev.vars"];
|
||||
dotenv.config({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PostHog } from "posthog-js-lite";
|
||||
import { getVersion } from "cli/utils/sys";
|
||||
import { env, isDebug } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { env, isDebug } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
type Properties = { [p: string]: any };
|
||||
|
||||
|
||||
@@ -6,23 +6,3 @@ export interface IEmailDriver<Data = unknown, Options = object> {
|
||||
options?: Options,
|
||||
): Promise<Data>;
|
||||
}
|
||||
|
||||
import type { BkndConfig } from "bknd";
|
||||
import { resendEmail, memoryCache } from "bknd/core";
|
||||
|
||||
export default {
|
||||
onBuilt: async (app) => {
|
||||
app.server.get("/send-email", async (c) => {
|
||||
if (await app.drivers?.email?.send("test@test.com", "Test", "Test")) {
|
||||
return c.text("success");
|
||||
}
|
||||
return c.text("failed");
|
||||
});
|
||||
},
|
||||
options: {
|
||||
drivers: {
|
||||
email: resendEmail({ apiKey: "..." }),
|
||||
cache: memoryCache(),
|
||||
},
|
||||
},
|
||||
} as const satisfies BkndConfig;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Hono, MiddlewareHandler } from "hono";
|
||||
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug, env } from "./env";
|
||||
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
type TemplateObject,
|
||||
type TemplateTypes,
|
||||
type SimpleRendererOptions,
|
||||
} from "./template/SimpleRenderer";
|
||||
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";
|
||||
export { getFlashMessage } from "./server/flash";
|
||||
export {
|
||||
s,
|
||||
parse,
|
||||
jsc,
|
||||
describeRoute,
|
||||
schemaToSpec,
|
||||
openAPISpecs,
|
||||
type ParseOptions,
|
||||
InvalidSchemaError,
|
||||
} from "./object/schema";
|
||||
|
||||
export * from "./drivers";
|
||||
export * from "./events";
|
||||
|
||||
// compatibility
|
||||
export type Middleware = MiddlewareHandler<any, any, any>;
|
||||
export interface ClassController {
|
||||
getController: () => Hono<any, any, any>;
|
||||
getMiddleware?: MiddlewareHandler<any, any, any>;
|
||||
}
|
||||
@@ -1,62 +1,61 @@
|
||||
import { get, has, omit, set } from "lodash-es";
|
||||
import {
|
||||
Default,
|
||||
type Static,
|
||||
type TObject,
|
||||
getFullPathKeys,
|
||||
mergeObjectWith,
|
||||
parse,
|
||||
stripMark,
|
||||
} from "../utils";
|
||||
import { type s, parse, stripMark, getFullPathKeys, mergeObjectWith, deepFreeze } from "bknd/utils";
|
||||
|
||||
export type SchemaObjectOptions<Schema extends TObject> = {
|
||||
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
|
||||
export type SchemaObjectOptions<Schema extends s.Schema> = {
|
||||
onUpdate?: (config: s.Static<Schema>) => void | Promise<void>;
|
||||
onBeforeUpdate?: (
|
||||
from: Static<Schema>,
|
||||
to: Static<Schema>,
|
||||
) => Static<Schema> | Promise<Static<Schema>>;
|
||||
from: s.Static<Schema>,
|
||||
to: s.Static<Schema>,
|
||||
) => s.Static<Schema> | Promise<s.Static<Schema>>;
|
||||
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>;
|
||||
type TSchema = s.ObjectSchema<any>;
|
||||
|
||||
export class SchemaObject<Schema extends TSchema = TSchema> {
|
||||
private readonly _default: Partial<s.Static<Schema>>;
|
||||
private _value: s.Static<Schema>;
|
||||
private _config: s.Static<Schema>;
|
||||
private _restriction_bypass: boolean = false;
|
||||
|
||||
constructor(
|
||||
private _schema: Schema,
|
||||
initial?: Partial<Static<Schema>>,
|
||||
initial?: Partial<s.Static<Schema>>,
|
||||
private options?: SchemaObjectOptions<Schema>,
|
||||
) {
|
||||
this._default = Default(_schema, {} as any) as any;
|
||||
this._value = initial
|
||||
? parse(_schema, structuredClone(initial as any), {
|
||||
forceParse: this.isForceParse(),
|
||||
skipMark: this.isForceParse(),
|
||||
})
|
||||
: this._default;
|
||||
this._config = Object.freeze(this._value);
|
||||
this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any);
|
||||
this._value = deepFreeze(
|
||||
parse(_schema, structuredClone(initial ?? {}), {
|
||||
withDefaults: true,
|
||||
//withExtendedDefaults: true,
|
||||
forceParse: this.isForceParse(),
|
||||
skipMark: this.isForceParse(),
|
||||
}),
|
||||
);
|
||||
this._config = deepFreeze(this._value);
|
||||
}
|
||||
|
||||
protected isForceParse(): boolean {
|
||||
return this.options?.forceParse ?? true;
|
||||
}
|
||||
|
||||
default(): Static<Schema> {
|
||||
default() {
|
||||
return this._default;
|
||||
}
|
||||
|
||||
private async onBeforeUpdate(from: Static<Schema>, to: Static<Schema>): Promise<Static<Schema>> {
|
||||
private async onBeforeUpdate(
|
||||
from: s.Static<Schema>,
|
||||
to: s.Static<Schema>,
|
||||
): Promise<s.Static<Schema>> {
|
||||
if (this.options?.onBeforeUpdate) {
|
||||
return this.options.onBeforeUpdate(from, to);
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
get(options?: { stripMark?: boolean }): Static<Schema> {
|
||||
get(options?: { stripMark?: boolean }): s.Static<Schema> {
|
||||
if (options?.stripMark) {
|
||||
return stripMark(this._config);
|
||||
}
|
||||
@@ -68,8 +67,9 @@ export class SchemaObject<Schema extends TObject> {
|
||||
return structuredClone(this._config);
|
||||
}
|
||||
|
||||
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
|
||||
async set(config: s.Static<Schema>, noEmit?: boolean): Promise<s.Static<Schema>> {
|
||||
const valid = parse(this._schema, structuredClone(config) as any, {
|
||||
coerce: false,
|
||||
forceParse: true,
|
||||
skipMark: this.isForceParse(),
|
||||
});
|
||||
@@ -77,8 +77,8 @@ export class SchemaObject<Schema extends TObject> {
|
||||
// regardless of "noEmit" – this should always be triggered
|
||||
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
|
||||
|
||||
this._value = updatedConfig;
|
||||
this._config = Object.freeze(updatedConfig);
|
||||
this._value = deepFreeze(updatedConfig);
|
||||
this._config = deepFreeze(updatedConfig);
|
||||
|
||||
if (noEmit !== true) {
|
||||
await this.options?.onUpdate?.(this._config);
|
||||
@@ -118,9 +118,9 @@ export class SchemaObject<Schema extends TObject> {
|
||||
return;
|
||||
}
|
||||
|
||||
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
async patch(path: string, value: any): Promise<[Partial<s.Static<Schema>>, s.Static<Schema>]> {
|
||||
const current = this.clone();
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<s.Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
|
||||
@@ -168,9 +168,12 @@ export class SchemaObject<Schema extends TObject> {
|
||||
return [partial, newConfig];
|
||||
}
|
||||
|
||||
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
async overwrite(
|
||||
path: string,
|
||||
value: any,
|
||||
): Promise<[Partial<s.Static<Schema>>, s.Static<Schema>]> {
|
||||
const current = this.clone();
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<s.Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
|
||||
@@ -194,7 +197,7 @@ export class SchemaObject<Schema extends TObject> {
|
||||
return has(this._config, path);
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
async remove(path: string): Promise<[Partial<s.Static<Schema>>, s.Static<Schema>]> {
|
||||
this.throwIfRestricted(path);
|
||||
|
||||
if (!this.has(path)) {
|
||||
@@ -202,9 +205,9 @@ export class SchemaObject<Schema extends TObject> {
|
||||
}
|
||||
|
||||
const current = this.clone();
|
||||
const removed = get(current, path) as Partial<Static<Schema>>;
|
||||
const removed = get(current, path) as Partial<s.Static<Schema>>;
|
||||
const config = omit(current, path);
|
||||
const newConfig = await this.set(config);
|
||||
const newConfig = await this.set(config as any);
|
||||
return [removed, newConfig];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { PrimaryFieldType } from "core/config";
|
||||
|
||||
export type Primitive = PrimaryFieldType | string | number | boolean;
|
||||
export function isPrimitive(value: any): value is Primitive {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { mergeObject } from "core/utils";
|
||||
|
||||
//export { jsc, type Options, type Hook } from "./validator";
|
||||
import * as s from "jsonv-ts";
|
||||
|
||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
|
||||
|
||||
export { s };
|
||||
|
||||
export class InvalidSchemaError extends Error {
|
||||
constructor(
|
||||
public schema: s.TAnySchema,
|
||||
public value: unknown,
|
||||
public errors: s.ErrorDetail[] = [],
|
||||
) {
|
||||
super(
|
||||
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
|
||||
`Error: ${JSON.stringify(errors[0], null, 2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseOptions = {
|
||||
withDefaults?: boolean;
|
||||
coerse?: boolean;
|
||||
clone?: boolean;
|
||||
};
|
||||
|
||||
export const cloneSchema = <S extends s.TSchema>(schema: S): S => {
|
||||
const json = schema.toJSON();
|
||||
return s.fromSchema(json) as S;
|
||||
};
|
||||
|
||||
export function parse<S extends s.TAnySchema>(
|
||||
_schema: S,
|
||||
v: unknown,
|
||||
opts: ParseOptions = {},
|
||||
): s.StaticCoerced<S> {
|
||||
const schema = (opts.clone ? cloneSchema(_schema as any) : _schema) as s.TSchema;
|
||||
const value = opts.coerse !== false ? schema.coerce(v) : v;
|
||||
const result = schema.validate(value, {
|
||||
shortCircuit: true,
|
||||
ignoreUnsupported: true,
|
||||
});
|
||||
if (!result.valid) throw new InvalidSchemaError(schema, v, result.errors);
|
||||
if (opts.withDefaults) {
|
||||
return mergeObject(schema.template({ withOptional: true }), value) as any;
|
||||
}
|
||||
|
||||
return value as any;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Context, Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { validator as honoValidator } from "hono/validator";
|
||||
import type { Static, StaticCoerced, TAnySchema } from "jsonv-ts";
|
||||
|
||||
export type Options = {
|
||||
coerce?: boolean;
|
||||
includeSchema?: boolean;
|
||||
};
|
||||
|
||||
type ValidationResult = {
|
||||
valid: boolean;
|
||||
errors: {
|
||||
keywordLocation: string;
|
||||
instanceLocation: string;
|
||||
error: string;
|
||||
data?: unknown;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Hook<T, E extends Env, P extends string> = (
|
||||
result: { result: ValidationResult; data: T },
|
||||
c: Context<E, P>,
|
||||
) => Response | Promise<Response> | void;
|
||||
|
||||
export const validator = <
|
||||
// @todo: somehow hono prevents the usage of TSchema
|
||||
Schema extends TAnySchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Opts extends Options = Options,
|
||||
Out = Opts extends { coerce: false } ? Static<Schema> : StaticCoerced<Schema>,
|
||||
I extends Input = {
|
||||
in: { [K in Target]: Static<Schema> };
|
||||
out: { [K in Target]: Out };
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: Schema,
|
||||
options?: Opts,
|
||||
hook?: Hook<Out, E, P>,
|
||||
): MiddlewareHandler<E, P, I> => {
|
||||
// @ts-expect-error not typed well
|
||||
return honoValidator(target, async (_value, c) => {
|
||||
const value = options?.coerce !== false ? schema.coerce(_value) : _value;
|
||||
// @ts-ignore
|
||||
const result = schema.validate(value);
|
||||
if (!result.valid) {
|
||||
return c.json({ ...result, schema }, 400);
|
||||
}
|
||||
|
||||
if (hook) {
|
||||
const hookResult = hook({ result, data: value as Out }, c);
|
||||
if (hookResult) {
|
||||
return hookResult;
|
||||
}
|
||||
}
|
||||
|
||||
return value as Out;
|
||||
});
|
||||
};
|
||||
|
||||
export const jsc = validator;
|
||||
@@ -1 +0,0 @@
|
||||
export { tbValidator } from "./tbValidator";
|
||||
@@ -1,37 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SimpleRenderer } from "core";
|
||||
import { SimpleRenderer } from "./SimpleRenderer";
|
||||
|
||||
describe(SimpleRenderer, () => {
|
||||
const renderer = new SimpleRenderer(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { datetimeStringLocal } from "core/utils";
|
||||
import { datetimeStringLocal } from "./dates";
|
||||
import colors from "picocolors";
|
||||
import { env } from "core";
|
||||
import { env } from "core/env";
|
||||
|
||||
function hasColors() {
|
||||
try {
|
||||
|
||||
@@ -6,12 +6,25 @@ export * from "./perf";
|
||||
export * from "./file";
|
||||
export * from "./reqres";
|
||||
export * from "./xml";
|
||||
export type { Prettify, PrettifyRec } from "./types";
|
||||
export * from "./typebox";
|
||||
export type { Prettify, PrettifyRec, RecursivePartial } from "./types";
|
||||
export * from "./dates";
|
||||
export * from "./crypto";
|
||||
export * from "./uuid";
|
||||
export { FromSchema } from "./typebox/from-schema";
|
||||
export * from "./test";
|
||||
export * from "./runtime";
|
||||
export * from "./numbers";
|
||||
export {
|
||||
s,
|
||||
stripMark,
|
||||
mark,
|
||||
stringIdentifier,
|
||||
SecretSchema,
|
||||
secret,
|
||||
parse,
|
||||
jsc,
|
||||
describeRoute,
|
||||
schemaToSpec,
|
||||
openAPISpecs,
|
||||
type ParseOptions,
|
||||
InvalidSchemaError,
|
||||
} from "./schema";
|
||||
|
||||
@@ -94,16 +94,14 @@ 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 },
|
||||
);
|
||||
const result = {} as { [K in keyof T]: U };
|
||||
for (const [key, value] of Object.entries(object) as [keyof T, T[keyof T]][]) {
|
||||
const t = transform(value, key);
|
||||
if (typeof t !== "undefined") {
|
||||
result[key] = t;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export const objectTransform = transformObject;
|
||||
|
||||
@@ -419,3 +417,21 @@ export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pi
|
||||
{} as Pick<T, K>,
|
||||
);
|
||||
}
|
||||
|
||||
export function deepFreeze<T extends object>(object: T): T {
|
||||
if (Object.isFrozen(object)) return object;
|
||||
|
||||
// Retrieve the property names defined on object
|
||||
const propNames = Reflect.ownKeys(object);
|
||||
|
||||
// Freeze properties before freezing self
|
||||
for (const name of propNames) {
|
||||
const value = object[name];
|
||||
|
||||
if ((value && typeof value === "object") || typeof value === "function") {
|
||||
deepFreeze(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(object);
|
||||
}
|
||||
|
||||
87
app/src/core/utils/schema/index.ts
Normal file
87
app/src/core/utils/schema/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as s from "jsonv-ts";
|
||||
|
||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
|
||||
|
||||
export { secret, SecretSchema } from "./secret";
|
||||
|
||||
export { s };
|
||||
|
||||
export const stripMark = <O extends object>(o: O): O => o;
|
||||
export const mark = <O extends object>(o: O): O => o;
|
||||
|
||||
export const stringIdentifier = s.string({
|
||||
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
minLength: 2,
|
||||
maxLength: 150,
|
||||
});
|
||||
|
||||
export class InvalidSchemaError extends Error {
|
||||
constructor(
|
||||
public schema: s.Schema,
|
||||
public value: unknown,
|
||||
public errors: s.ErrorDetail[] = [],
|
||||
) {
|
||||
super(
|
||||
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
|
||||
`Error: ${JSON.stringify(errors[0], null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
first() {
|
||||
return this.errors[0]!;
|
||||
}
|
||||
|
||||
firstToString() {
|
||||
const first = this.first();
|
||||
return `${first.error} at ${first.instanceLocation}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseOptions = {
|
||||
withDefaults?: boolean;
|
||||
withExtendedDefaults?: boolean;
|
||||
coerce?: boolean;
|
||||
coerceDropUnknown?: boolean;
|
||||
clone?: boolean;
|
||||
skipMark?: boolean; // @todo: do something with this
|
||||
forceParse?: boolean; // @todo: do something with this
|
||||
onError?: (errors: s.ErrorDetail[]) => void;
|
||||
};
|
||||
|
||||
export const cloneSchema = <S extends s.Schema>(schema: S): S => {
|
||||
const json = schema.toJSON();
|
||||
return s.fromSchema(json) as S;
|
||||
};
|
||||
|
||||
export function parse<S extends s.Schema, Options extends ParseOptions = ParseOptions>(
|
||||
_schema: S,
|
||||
v: unknown,
|
||||
opts?: Options,
|
||||
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
|
||||
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
|
||||
let value =
|
||||
opts?.coerce !== false
|
||||
? schema.coerce(v, { dropUnknown: opts?.coerceDropUnknown ?? false })
|
||||
: v;
|
||||
if (opts?.withDefaults !== false) {
|
||||
value = schema.template(value, {
|
||||
withOptional: true,
|
||||
withExtendedOptional: opts?.withExtendedDefaults ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
const result = _schema.validate(value, {
|
||||
shortCircuit: true,
|
||||
ignoreUnsupported: true,
|
||||
});
|
||||
if (!result.valid) {
|
||||
if (opts?.onError) {
|
||||
opts.onError(result.errors);
|
||||
} else {
|
||||
throw new InvalidSchemaError(schema, v, result.errors);
|
||||
}
|
||||
}
|
||||
|
||||
return value as any;
|
||||
}
|
||||
6
app/src/core/utils/schema/secret.ts
Normal file
6
app/src/core/utils/schema/secret.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { StringSchema, type IStringOptions } from "jsonv-ts";
|
||||
|
||||
export class SecretSchema<O extends IStringOptions> extends StringSchema<O> {}
|
||||
|
||||
export const secret = <O extends IStringOptions>(o?: O): SecretSchema<O> & O =>
|
||||
new SecretSchema(o) as any;
|
||||
@@ -1,270 +0,0 @@
|
||||
/*--------------------------------------------------------------------------
|
||||
|
||||
@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 =>
|
||||
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||
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);
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
type TFromRest<T extends readonly unknown[], Acc extends Type.TSchema[] = []> = (
|
||||
// biome-ignore lint/complexity/noUselessTypeConstraint: <explanation>
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
type TFromTuple<T extends STuple> = (
|
||||
TFromRest<T['items']> extends infer Elements extends Type.TSchema[]
|
||||
? Type.TTuple<Elements>
|
||||
: Type.TTuple<[]>
|
||||
)
|
||||
// biome-ignore format: keep
|
||||
function FromTuple<T extends STuple>(T: T): TFromTuple<T> {
|
||||
return Type.Tuple(FromRest(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Array
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
type TFromArray<T extends SArray> = (
|
||||
TFromSchema<T['items']> extends infer Items extends Type.TSchema
|
||||
? Type.TArray<Items>
|
||||
: Type.TArray<Type.TUnknown>
|
||||
)
|
||||
// biome-ignore format: keep
|
||||
function FromArray<T extends SArray>(T: T): TFromArray<T> {
|
||||
return Type.Array(FromSchema(T.items), T) as never
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Const
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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;
|
||||
// biome-ignore format: keep
|
||||
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]>
|
||||
}>
|
||||
// biome-ignore format: keep
|
||||
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 {
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
|
||||
...Acc,
|
||||
[K]: 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
|
||||
// ------------------------------------------------------------------
|
||||
// biome-ignore format: keep
|
||||
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> {
|
||||
// biome-ignore format: keep
|
||||
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
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import * as tb from "@sinclair/typebox";
|
||||
import type {
|
||||
TypeRegistry,
|
||||
Static,
|
||||
StaticDecode,
|
||||
TSchema,
|
||||
SchemaOptions,
|
||||
TObject,
|
||||
} from "@sinclair/typebox";
|
||||
import {
|
||||
DefaultErrorFunction,
|
||||
Errors,
|
||||
SetErrorFunction,
|
||||
type ValueErrorIterator,
|
||||
} from "@sinclair/typebox/errors";
|
||||
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
||||
|
||||
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: tb.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<O = any>(obj: O) {
|
||||
const newObj = structuredClone(obj);
|
||||
mark(newObj, false);
|
||||
return newObj as O;
|
||||
}
|
||||
|
||||
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 tb.TSchema = tb.TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<tb.Static<Schema>>,
|
||||
options?: ParseOptions,
|
||||
): tb.Static<Schema> {
|
||||
if (!options?.forceParse && typeof data === "object" && validationSymbol in data) {
|
||||
if (options?.useDefaults === false) {
|
||||
return data as tb.Static<typeof schema>;
|
||||
}
|
||||
|
||||
// this is important as defaults are expected
|
||||
return Default(schema, data as any) as tb.Static<Schema>;
|
||||
}
|
||||
|
||||
const parsed = options?.useDefaults === false ? data : Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
options?.skipMark !== true && mark(parsed, true);
|
||||
return parsed as tb.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 tb.TSchema = tb.TSchema>(
|
||||
schema: Schema,
|
||||
data: RecursivePartial<tb.StaticDecode<Schema>>,
|
||||
): tb.StaticDecode<Schema> {
|
||||
const parsed = Default(schema, data);
|
||||
|
||||
if (Check(schema, parsed)) {
|
||||
return parsed as tb.StaticDecode<typeof schema>;
|
||||
}
|
||||
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
export function strictParse<Schema extends tb.TSchema = tb.TSchema>(
|
||||
schema: Schema,
|
||||
data: tb.Static<Schema>,
|
||||
options?: ParseOptions,
|
||||
): tb.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(tb.TypeRegistry);
|
||||
|
||||
export const StringEnum = <const T extends readonly string[]>(
|
||||
values: T,
|
||||
options?: tb.StringOptions,
|
||||
) =>
|
||||
tb.Type.Unsafe<T[number]>({
|
||||
[tb.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 tb.TSchema>(properties: T, options?: tb.ObjectOptions) =>
|
||||
tb.Type.Object({}, { ...options, additionalProperties: properties }) as unknown as tb.TRecord<
|
||||
tb.TString,
|
||||
typeof properties
|
||||
>;
|
||||
|
||||
// fixed value that only be what is given + prefilled
|
||||
export const Const = <T extends tb.TLiteralValue = tb.TLiteralValue>(
|
||||
value: T,
|
||||
options?: tb.SchemaOptions,
|
||||
) =>
|
||||
tb.Type.Literal(value, {
|
||||
...options,
|
||||
default: value,
|
||||
const: value,
|
||||
readOnly: true,
|
||||
}) as tb.TLiteral<T>;
|
||||
|
||||
export const StringIdentifier = tb.Type.String({
|
||||
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
minLength: 2,
|
||||
maxLength: 150,
|
||||
});
|
||||
|
||||
export const StrictObject = <T extends tb.TProperties>(
|
||||
properties: T,
|
||||
options?: tb.ObjectOptions,
|
||||
): tb.TObject<T> => tb.Type.Object(properties, { ...options, additionalProperties: false });
|
||||
|
||||
SetErrorFunction((error) => {
|
||||
if (error?.schema?.errorMessage) {
|
||||
return error.schema.errorMessage;
|
||||
}
|
||||
|
||||
if (error?.schema?.[tb.Kind] === "StringEnum") {
|
||||
return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`;
|
||||
}
|
||||
|
||||
return DefaultErrorFunction(error);
|
||||
});
|
||||
|
||||
export type { Static, StaticDecode, TSchema, TObject, ValueError, SchemaOptions };
|
||||
|
||||
export { Value, Default, Errors, Check };
|
||||
8
app/src/core/utils/types.d.ts
vendored
8
app/src/core/utils/types.d.ts
vendored
@@ -6,3 +6,11 @@ export type Prettify<T> = {
|
||||
export type PrettifyRec<T> = {
|
||||
[K in keyof T]: T[K] extends object ? Prettify<T[K]> : T[K];
|
||||
} & NonNullable<unknown>;
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import {
|
||||
DataPermissions,
|
||||
type Entity,
|
||||
EntityIndex,
|
||||
type EntityManager,
|
||||
constructEntity,
|
||||
constructRelation,
|
||||
} from "data";
|
||||
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
||||
import { constructEntity, constructRelation } from "./schema/constructor";
|
||||
import type { Entity, EntityManager } from "data/entities";
|
||||
import { EntityIndex } from "data/fields";
|
||||
import * as DataPermissions from "data/permissions";
|
||||
|
||||
export class AppData extends Module<typeof dataConfigSchema> {
|
||||
export class AppData extends Module<AppDataConfig> {
|
||||
override async build() {
|
||||
const {
|
||||
entities: _entities = {},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DB } from "core";
|
||||
import type { EntityData, RepoQueryIn, RepositoryResultJSON } from "data";
|
||||
import type { DB, EntityData, RepoQueryIn } from "bknd";
|
||||
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
|
||||
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
|
||||
|
||||
export type DataApiOptions = BaseModuleApiOptions & {
|
||||
queryLengthLimit: number;
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
type EntityManager,
|
||||
type RepoQuery,
|
||||
repoQuery,
|
||||
} from "data";
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { Controller } from "modules/Controller";
|
||||
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
|
||||
import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { AppDataConfig } from "../data-schema";
|
||||
import { omitKeys } from "core/utils";
|
||||
import type { EntityManager, EntityData } from "data/entities";
|
||||
import * as DataPermissions from "data/permissions";
|
||||
import { repoQuery, type RepoQuery } from "data/server/query";
|
||||
|
||||
export class DataController extends Controller {
|
||||
constructor(
|
||||
@@ -73,10 +68,12 @@ export class DataController extends Controller {
|
||||
}),
|
||||
jsc(
|
||||
"query",
|
||||
s.partialObject({
|
||||
force: s.boolean(),
|
||||
drop: s.boolean(),
|
||||
}),
|
||||
s
|
||||
.object({
|
||||
force: s.boolean(),
|
||||
drop: s.boolean(),
|
||||
})
|
||||
.partial(),
|
||||
),
|
||||
async (c) => {
|
||||
const { force, drop } = c.req.valid("query");
|
||||
@@ -204,7 +201,7 @@ export class DataController extends Controller {
|
||||
|
||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||
// @todo: make dynamic based on entity
|
||||
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any });
|
||||
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string });
|
||||
|
||||
/**
|
||||
* Function endpoints
|
||||
@@ -257,12 +254,14 @@ export class DataController extends Controller {
|
||||
* Read endpoints
|
||||
*/
|
||||
// read many
|
||||
const saveRepoQuery = s.partialObject({
|
||||
...omitKeys(repoQuery.properties, ["with"]),
|
||||
sort: s.string({ default: "id" }),
|
||||
select: s.array(s.string()),
|
||||
join: s.array(s.string()),
|
||||
});
|
||||
const saveRepoQuery = s
|
||||
.object({
|
||||
...omitKeys(repoQuery.properties, ["with"]),
|
||||
sort: s.string({ default: "id" }),
|
||||
select: s.array(s.string()),
|
||||
join: s.array(s.string()),
|
||||
})
|
||||
.partial();
|
||||
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
|
||||
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
|
||||
// @ts-ignore
|
||||
@@ -355,10 +354,12 @@ export class DataController extends Controller {
|
||||
);
|
||||
|
||||
// func query
|
||||
const fnQuery = s.partialObject({
|
||||
...saveRepoQuery.properties,
|
||||
with: s.object({}),
|
||||
});
|
||||
const fnQuery = s
|
||||
.object({
|
||||
...saveRepoQuery.properties,
|
||||
with: s.object({}),
|
||||
})
|
||||
.partial();
|
||||
hono.post(
|
||||
"/:entity/query",
|
||||
describeRoute({
|
||||
@@ -381,7 +382,7 @@ export class DataController extends Controller {
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const options = (await c.req.json()) as RepoQuery;
|
||||
const options = c.req.valid("json") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
|
||||
return c.json(result, { status: result.data ? 200 : 404 });
|
||||
@@ -391,7 +392,7 @@ export class DataController extends Controller {
|
||||
/**
|
||||
* Mutation endpoints
|
||||
*/
|
||||
// insert one
|
||||
// insert one or many
|
||||
hono.post(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
sql,
|
||||
} from "kysely";
|
||||
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
||||
import type { Constructor, DB } from "core";
|
||||
import type { DB } from "bknd";
|
||||
import type { Constructor } from "core/registry/Registry";
|
||||
import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner";
|
||||
import type { Field } from "data/fields/Field";
|
||||
|
||||
@@ -38,7 +39,7 @@ export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O>
|
||||
|
||||
export type SchemaResponse = [string, ColumnDataType, ColumnBuilderCallback] | undefined;
|
||||
|
||||
const FieldSpecTypes = [
|
||||
export const FieldSpecTypes = [
|
||||
"text",
|
||||
"integer",
|
||||
"real",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "../Connection";
|
||||
import type { Constructor } from "core";
|
||||
import type { Constructor } from "core/registry/Registry";
|
||||
import { customIntrospector } from "../Connection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
import type { Field } from "data/fields/Field";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
import { objectTransform } from "core/utils";
|
||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||
import { FieldClassMap } from "data/fields";
|
||||
import { RelationClassMap, RelationFieldClassMap } from "data/relations";
|
||||
import { entityConfigSchema, entityTypes } from "data/entities";
|
||||
import { primaryFieldTypes } from "./fields";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const FIELDS = {
|
||||
...FieldClassMap,
|
||||
@@ -16,69 +16,57 @@ export type FieldType = keyof typeof FIELDS;
|
||||
export const RELATIONS = RelationClassMap;
|
||||
|
||||
export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
|
||||
return tb.Type.Object(
|
||||
return s.strictObject(
|
||||
{
|
||||
type: tb.Type.Const(name, { default: name, readOnly: true }),
|
||||
config: tb.Type.Optional(field.schema),
|
||||
name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI)
|
||||
type: s.literal(name),
|
||||
config: field.schema.optional(),
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
},
|
||||
);
|
||||
});
|
||||
export const fieldsSchema = tb.Type.Union(Object.values(fieldsSchemaObject));
|
||||
export const entityFields = StringRecord(fieldsSchema);
|
||||
export type TAppDataField = Static<typeof fieldsSchema>;
|
||||
export type TAppDataEntityFields = Static<typeof entityFields>;
|
||||
export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
||||
export const entityFields = s.record(fieldsSchema);
|
||||
export type TAppDataField = s.Static<typeof fieldsSchema>;
|
||||
export type TAppDataEntityFields = s.Static<typeof entityFields>;
|
||||
|
||||
export const entitiesSchema = tb.Type.Object({
|
||||
type: tb.Type.Optional(
|
||||
tb.Type.String({ enum: entityTypes, default: "regular", readOnly: true }),
|
||||
),
|
||||
config: tb.Type.Optional(entityConfigSchema),
|
||||
fields: tb.Type.Optional(entityFields),
|
||||
export const entitiesSchema = s.strictObject({
|
||||
name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI)
|
||||
type: s.string({ enum: entityTypes, default: "regular" }),
|
||||
config: entityConfigSchema,
|
||||
fields: entityFields,
|
||||
});
|
||||
export type TAppDataEntity = Static<typeof entitiesSchema>;
|
||||
export type TAppDataEntity = s.Static<typeof entitiesSchema>;
|
||||
|
||||
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
|
||||
return tb.Type.Object(
|
||||
return s.strictObject(
|
||||
{
|
||||
type: tb.Type.Const(name, { default: name, readOnly: true }),
|
||||
source: tb.Type.String(),
|
||||
target: tb.Type.String(),
|
||||
config: tb.Type.Optional(relationClass.schema),
|
||||
type: s.literal(name),
|
||||
source: s.string(),
|
||||
target: s.string(),
|
||||
config: relationClass.schema.optional(),
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
},
|
||||
);
|
||||
});
|
||||
export type TAppDataRelation = Static<(typeof relationsSchema)[number]>;
|
||||
export type TAppDataRelation = s.Static<(typeof relationsSchema)[number]>;
|
||||
|
||||
export const indicesSchema = tb.Type.Object(
|
||||
{
|
||||
entity: tb.Type.String(),
|
||||
fields: tb.Type.Array(tb.Type.String(), { minItems: 1 }),
|
||||
unique: tb.Type.Optional(tb.Type.Boolean({ default: false })),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const indicesSchema = s.strictObject({
|
||||
entity: s.string(),
|
||||
fields: s.array(s.string(), { minItems: 1 }),
|
||||
unique: s.boolean({ default: false }).optional(),
|
||||
});
|
||||
|
||||
export const dataConfigSchema = tb.Type.Object(
|
||||
{
|
||||
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
|
||||
default_primary_format: tb.Type.Optional(
|
||||
StringEnum(primaryFieldTypes, { default: "integer" }),
|
||||
),
|
||||
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
|
||||
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
|
||||
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const dataConfigSchema = s.strictObject({
|
||||
basepath: s.string({ default: "/api/data" }).optional(),
|
||||
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
|
||||
entities: s.record(entitiesSchema, { default: {} }).optional(),
|
||||
relations: s.record(s.anyOf(relationsSchema), { default: {} }).optional(),
|
||||
indices: s.record(indicesSchema, { default: {} }).optional(),
|
||||
});
|
||||
|
||||
export type AppDataConfig = Static<typeof dataConfigSchema>;
|
||||
export type AppDataConfig = s.Static<typeof dataConfigSchema>;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { config } from "core";
|
||||
import {
|
||||
$console,
|
||||
type Static,
|
||||
StringEnum,
|
||||
parse,
|
||||
snakeToPascalWithSpaces,
|
||||
transformObject,
|
||||
} from "core/utils";
|
||||
import { config } from "core/config";
|
||||
import { snakeToPascalWithSpaces, transformObject, $console, s, parse } from "bknd/utils";
|
||||
import {
|
||||
type Field,
|
||||
PrimaryField,
|
||||
@@ -14,25 +7,20 @@ import {
|
||||
type TActionContext,
|
||||
type TRenderContext,
|
||||
} from "../fields";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
// @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" })),
|
||||
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const entityConfigSchema = s
|
||||
.strictObject({
|
||||
name: s.string(),
|
||||
name_singular: s.string(),
|
||||
description: s.string(),
|
||||
sort_field: s.string({ default: config.data.default_primary_field }),
|
||||
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
|
||||
primary_format: s.string({ enum: primaryFieldTypes }),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type EntityConfig = Static<typeof entityConfigSchema>;
|
||||
export type EntityConfig = s.Static<typeof entityConfigSchema>;
|
||||
|
||||
export type EntityData = Record<string, any>;
|
||||
export type EntityJSON = ReturnType<Entity["toJSON"]>;
|
||||
@@ -288,8 +276,10 @@ export class Entity<
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
const schema = Type.Object(
|
||||
transformObject(_fields, (field) => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: transformObject(_fields, (field) => {
|
||||
const fillable = field.isFillable(options?.context);
|
||||
return {
|
||||
title: field.config.label,
|
||||
@@ -299,8 +289,7 @@ export class Entity<
|
||||
...field.toJsonSchema(),
|
||||
};
|
||||
}),
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
};
|
||||
|
||||
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DB as DefaultDB } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import type { DB as DefaultDB } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { EventManager } from "core/events";
|
||||
import { sql } from "kysely";
|
||||
import { Connection } from "../connection/Connection";
|
||||
@@ -67,6 +67,13 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
|
||||
}
|
||||
|
||||
clear(): this {
|
||||
this._entities = [];
|
||||
this._relations = [];
|
||||
this._indices = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
get entities(): Entity[] {
|
||||
return this._entities;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Entity, EntityManager, EntityRelation, TEntityType } from "data";
|
||||
import type { Entity, EntityManager, TEntityType } from "data/entities";
|
||||
import type { EntityRelation } from "data/relations";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { usersFields } from "auth/auth-entities";
|
||||
import { mediaFields } from "media/media-entities";
|
||||
@@ -169,7 +170,7 @@ export class EntityTypescript {
|
||||
const strings: string[] = [];
|
||||
const tables: Record<string, string> = {};
|
||||
const imports: Record<string, string[]> = {
|
||||
"bknd/core": ["DB"],
|
||||
bknd: ["DB"],
|
||||
kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
|
||||
};
|
||||
|
||||
@@ -206,7 +207,7 @@ export class EntityTypescript {
|
||||
strings.push(tables_string);
|
||||
|
||||
// merge
|
||||
let merge = `declare module "bknd/core" {\n`;
|
||||
let merge = `declare module "bknd" {\n`;
|
||||
for (const systemEntity of system_entities) {
|
||||
const system_fields = Object.keys(systemEntities[systemEntity.name]);
|
||||
const additional_fields = systemEntity.fields
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDebug } from "core";
|
||||
import { isDebug } from "core/env";
|
||||
import { pick } from "core/utils";
|
||||
import type { Connection } from "data/connection";
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
|
||||
import type { TActionContext } from "../..";
|
||||
import type { TActionContext } from "data/fields";
|
||||
import { WhereBuilder } from "../query/WhereBuilder";
|
||||
import type { Entity, EntityData, EntityManager } from "../../entities";
|
||||
import { InvalidSearchParamsException } from "../../errors";
|
||||
@@ -9,7 +9,6 @@ import { MutatorEvents } from "../../events";
|
||||
import { RelationMutator } from "../../relations";
|
||||
import type { RepoQuery } from "../../server/query";
|
||||
import { MutatorResult, type MutatorResultOptions } from "./MutatorResult";
|
||||
import { transformObject } from "core/utils";
|
||||
|
||||
type MutatorQB =
|
||||
| InsertQueryBuilder<any, any, any>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { $console } from "core/utils";
|
||||
import type { Entity, EntityData } from "../Entity";
|
||||
import type { EntityManager } from "../EntityManager";
|
||||
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
||||
import { isDebug } from "core";
|
||||
import { isDebug } from "core/env";
|
||||
|
||||
export type MutatorResultOptions = ResultOptions & {
|
||||
silent?: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
|
||||
import { $console } from "core/utils";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../index";
|
||||
import { JoinBuilder } from "./JoinBuilder";
|
||||
import { RepositoryResult, type RepositoryResultOptions } from "./RepositoryResult";
|
||||
import type { ResultOptions } from "../Result";
|
||||
|
||||
export type RepositoryQB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
@@ -78,8 +77,8 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
|
||||
this.checkIndex(entity.name, options.sort.by, "sort");
|
||||
validated.sort = {
|
||||
dir: "asc",
|
||||
...options.sort,
|
||||
dir: options.sort.dir ?? "asc",
|
||||
by: options.sort.by,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
if (options.where) {
|
||||
// @todo: auto-alias base entity when using joins! otherwise "id" is ambiguous
|
||||
const aliases = [entity.name];
|
||||
if (validated.join.length > 0) {
|
||||
if (validated.join?.length > 0) {
|
||||
aliases.push(...JoinBuilder.getJoinedEntityNames(this.em, entity, validated.join));
|
||||
}
|
||||
|
||||
@@ -345,7 +344,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
...refQueryOptions,
|
||||
where: {
|
||||
...refQueryOptions.where,
|
||||
..._options?.where,
|
||||
...(_options?.where ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isBooleanLike,
|
||||
isPrimitive,
|
||||
makeValidator,
|
||||
} from "core";
|
||||
} from "core/object/query/query";
|
||||
import type {
|
||||
DeleteQueryBuilder,
|
||||
ExpressionBuilder,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { isObject } from "core/utils";
|
||||
import type { KyselyJsonFrom, RepoQuery } from "data";
|
||||
|
||||
import type { KyselyJsonFrom } from "data/relations/EntityRelation";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
import { InvalidSearchParamsException } from "data/errors";
|
||||
import type { Entity, EntityManager, RepositoryQB } from "../../entities";
|
||||
import type { Entity, EntityManager, RepositoryQB } from "data/entities";
|
||||
|
||||
export class WithBuilder {
|
||||
static addClause(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Exception } from "core";
|
||||
import { HttpStatus, type TypeInvalidError } from "core/utils";
|
||||
import { Exception } from "core/errors";
|
||||
import { type InvalidSchemaError, HttpStatus } from "bknd/utils";
|
||||
import type { Entity } from "./entities";
|
||||
import type { Field } from "./fields";
|
||||
|
||||
@@ -42,11 +42,11 @@ export class InvalidFieldConfigException extends Exception {
|
||||
constructor(
|
||||
field: Field<any, any, any>,
|
||||
public given: any,
|
||||
error: TypeInvalidError,
|
||||
error: InvalidSchemaError,
|
||||
) {
|
||||
console.error("InvalidFieldConfigException", {
|
||||
given,
|
||||
error: error.firstToString(),
|
||||
error: error.first(),
|
||||
});
|
||||
super(`Invalid Field config given for field "${field.name}": ${error.firstToString()}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import type { PrimaryFieldType } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { Event, InvalidEventReturn } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { Static } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { omitKeys } from "core/utils";
|
||||
import type { EntityManager } from "data/entities";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
const { Type } = tb;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const booleanFieldConfigSchema = Type.Composite([
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.Boolean({ default: false })),
|
||||
}),
|
||||
baseFieldConfigSchema,
|
||||
]);
|
||||
export const booleanFieldConfigSchema = s
|
||||
.strictObject({
|
||||
//default_value: s.boolean({ default: false }),
|
||||
default_value: s.boolean(),
|
||||
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type BooleanFieldConfig = Static<typeof booleanFieldConfigSchema>;
|
||||
export type BooleanFieldConfig = s.Static<typeof booleanFieldConfigSchema>;
|
||||
|
||||
export class BooleanField<Required extends true | false = false> extends Field<
|
||||
BooleanFieldConfig,
|
||||
@@ -86,7 +86,7 @@ export class BooleanField<Required extends true | false = false> extends Field<
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
|
||||
return this.toSchemaWrapIfRequired(s.boolean({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType() {
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { $console, type Static, StringEnum, dayjs } from "core/utils";
|
||||
import { dayjs } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { $console } from "core/utils";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const dateFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
type: StringEnum(["date", "datetime", "week"] as const, { default: "date" }),
|
||||
timezone: Type.Optional(Type.String()),
|
||||
min_date: Type.Optional(Type.String()),
|
||||
max_date: Type.Optional(Type.String()),
|
||||
}),
|
||||
baseFieldConfigSchema,
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const dateFieldConfigSchema = s
|
||||
.strictObject({
|
||||
type: s.string({ enum: ["date", "datetime", "week"], default: "date" }),
|
||||
timezone: s.string(),
|
||||
min_date: s.string(),
|
||||
max_date: s.string(),
|
||||
...baseFieldConfigSchema.properties,
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type DateFieldConfig = Static<typeof dateFieldConfigSchema>;
|
||||
export type DateFieldConfig = s.Static<typeof dateFieldConfigSchema>;
|
||||
|
||||
export class DateField<Required extends true | false = false> extends Field<
|
||||
DateFieldConfig,
|
||||
@@ -142,7 +137,7 @@ export class DateField<Required extends true | false = false> extends Field<
|
||||
|
||||
// @todo: check this
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
|
||||
return this.toSchemaWrapIfRequired(s.string({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
|
||||
@@ -1,50 +1,33 @@
|
||||
import { Const, type Static, StringEnum } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { omitKeys } from "core/utils";
|
||||
import type { EntityManager } from "data/entities";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const enumFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.String()),
|
||||
options: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
type: Const("strings"),
|
||||
values: Type.Array(Type.String()),
|
||||
},
|
||||
{ title: "Strings" },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
type: Const("objects"),
|
||||
values: Type.Array(
|
||||
Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Objects",
|
||||
additionalProperties: false,
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
baseFieldConfigSchema,
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const enumFieldConfigSchema = s
|
||||
.strictObject({
|
||||
default_value: s.string(),
|
||||
options: s.anyOf([
|
||||
s.object({
|
||||
type: s.literal("strings"),
|
||||
values: s.array(s.string()),
|
||||
}),
|
||||
s.object({
|
||||
type: s.literal("objects"),
|
||||
values: s.array(
|
||||
s.object({
|
||||
label: s.string(),
|
||||
value: s.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type EnumFieldConfig = Static<typeof enumFieldConfigSchema>;
|
||||
export type EnumFieldConfig = s.Static<typeof enumFieldConfigSchema>;
|
||||
|
||||
export class EnumField<Required extends true | false = false, TypeOverride = string> extends Field<
|
||||
EnumFieldConfig,
|
||||
@@ -136,7 +119,8 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
|
||||
options.values?.map((option) => (typeof option === "string" ? option : option.value)) ??
|
||||
[];
|
||||
return this.toSchemaWrapIfRequired(
|
||||
StringEnum(values, {
|
||||
s.string({
|
||||
enum: values,
|
||||
default: this.getDefault(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import {
|
||||
parse,
|
||||
snakeToPascalWithSpaces,
|
||||
type Static,
|
||||
StringEnum,
|
||||
type TSchema,
|
||||
TypeInvalidError,
|
||||
} from "core/utils";
|
||||
import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
||||
import type { FieldSpec } from "data/connection/Connection";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s, parse, InvalidSchemaError, snakeToPascalWithSpaces } from "bknd/utils";
|
||||
|
||||
// @todo: contexts need to be reworked
|
||||
// e.g. "table" is irrelevant, because if read is not given, it fails
|
||||
@@ -31,43 +22,26 @@ const DEFAULT_FILLABLE = true;
|
||||
const DEFAULT_HIDDEN = false;
|
||||
|
||||
// @todo: add refine functions (e.g. if required, but not fillable, needs default value)
|
||||
export const baseFieldConfigSchema = Type.Object(
|
||||
{
|
||||
label: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
required: Type.Optional(Type.Boolean({ default: DEFAULT_REQUIRED })),
|
||||
fillable: Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Boolean({ title: "Boolean", default: DEFAULT_FILLABLE }),
|
||||
Type.Array(StringEnum(ActionContext), { title: "Context", uniqueItems: true }),
|
||||
],
|
||||
{
|
||||
default: DEFAULT_FILLABLE,
|
||||
},
|
||||
),
|
||||
),
|
||||
hidden: Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Boolean({ title: "Boolean", default: DEFAULT_HIDDEN }),
|
||||
// @todo: tmp workaround
|
||||
Type.Array(StringEnum(TmpContext), { title: "Context", uniqueItems: true }),
|
||||
],
|
||||
{
|
||||
default: DEFAULT_HIDDEN,
|
||||
},
|
||||
),
|
||||
),
|
||||
export const baseFieldConfigSchema = s
|
||||
.strictObject({
|
||||
label: s.string(),
|
||||
description: s.string(),
|
||||
required: s.boolean({ default: false }),
|
||||
fillable: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
||||
]),
|
||||
hidden: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
// @todo: tmp workaround
|
||||
s.array(s.string({ enum: TmpContext }), { title: "Context", uniqueItems: true }),
|
||||
]),
|
||||
// if field is virtual, it will not call transformPersist & transformRetrieve
|
||||
virtual: Type.Optional(Type.Boolean()),
|
||||
default_value: Type.Optional(Type.Any()),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export type BaseFieldConfig = Static<typeof baseFieldConfigSchema>;
|
||||
virtual: s.boolean(),
|
||||
default_value: s.any(),
|
||||
})
|
||||
.partial();
|
||||
export type BaseFieldConfig = s.Static<typeof baseFieldConfigSchema>;
|
||||
|
||||
export abstract class Field<
|
||||
Config extends BaseFieldConfig = BaseFieldConfig,
|
||||
@@ -92,7 +66,7 @@ export abstract class Field<
|
||||
try {
|
||||
this.config = parse(this.getSchema(), config || {}) as Config;
|
||||
} catch (e) {
|
||||
if (e instanceof TypeInvalidError) {
|
||||
if (e instanceof InvalidSchemaError) {
|
||||
throw new InvalidFieldConfigException(this, config, e);
|
||||
}
|
||||
|
||||
@@ -104,7 +78,7 @@ export abstract class Field<
|
||||
return this.type;
|
||||
}
|
||||
|
||||
protected abstract getSchema(): TSchema;
|
||||
protected abstract getSchema(): s.ObjectSchema;
|
||||
|
||||
/**
|
||||
* Used in SchemaManager.ts
|
||||
@@ -115,7 +89,9 @@ export abstract class Field<
|
||||
name: this.name,
|
||||
type: "text",
|
||||
nullable: true,
|
||||
dflt: this.getDefault(),
|
||||
// see field-test-suite.ts:41
|
||||
dflt: undefined,
|
||||
//dflt: this.getDefault(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,14 +107,14 @@ export abstract class Field<
|
||||
if (Array.isArray(this.config.fillable)) {
|
||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||
}
|
||||
return !!this.config.fillable;
|
||||
return this.config.fillable ?? DEFAULT_FILLABLE;
|
||||
}
|
||||
|
||||
isHidden(context?: TmpActionAndRenderContext): boolean {
|
||||
if (Array.isArray(this.config.hidden)) {
|
||||
return context ? this.config.hidden.includes(context as any) : DEFAULT_HIDDEN;
|
||||
}
|
||||
return this.config.hidden ?? false;
|
||||
return this.config.hidden ?? DEFAULT_HIDDEN;
|
||||
}
|
||||
|
||||
isRequired(): boolean {
|
||||
@@ -224,16 +200,16 @@ export abstract class Field<
|
||||
return value;
|
||||
}
|
||||
|
||||
protected toSchemaWrapIfRequired<Schema extends TSchema>(schema: Schema) {
|
||||
return this.isRequired() ? schema : Type.Optional(schema);
|
||||
protected toSchemaWrapIfRequired<Schema extends s.Schema>(schema: Schema): Schema {
|
||||
return this.isRequired() ? schema : (schema.optional() as any);
|
||||
}
|
||||
|
||||
protected nullish(value: any) {
|
||||
return value === null || value === undefined;
|
||||
}
|
||||
|
||||
toJsonSchema(): TSchema {
|
||||
return this.toSchemaWrapIfRequired(Type.Any());
|
||||
toJsonSchema(): s.Schema {
|
||||
return this.toSchemaWrapIfRequired(s.any());
|
||||
}
|
||||
|
||||
toType(): TFieldTSType {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { Static } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { omitKeys } from "core/utils";
|
||||
import type { EntityManager } from "data/entities";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
||||
export const jsonFieldConfigSchema = s
|
||||
.strictObject({
|
||||
default_value: s.any(),
|
||||
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type JsonFieldConfig = Static<typeof jsonFieldConfigSchema>;
|
||||
export type JsonFieldConfig = s.Static<typeof jsonFieldConfigSchema>;
|
||||
|
||||
export class JsonField<Required extends true | false = false, TypeOverride = object> extends Field<
|
||||
JsonFieldConfig,
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
||||
import { Default, FromSchema, objectToJsLiteral, type Static } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { objectToJsLiteral } from "core/utils";
|
||||
import type { EntityManager } from "data/entities";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const jsonSchemaFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
schema: Type.Object({}, { default: {} }),
|
||||
ui_schema: Type.Optional(Type.Object({})),
|
||||
default_from_schema: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
baseFieldConfigSchema,
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const jsonSchemaFieldConfigSchema = s
|
||||
.strictObject({
|
||||
schema: s.any({ type: "object" }),
|
||||
ui_schema: s.any({ type: "object" }),
|
||||
default_from_schema: s.boolean(),
|
||||
...baseFieldConfigSchema.properties,
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type JsonSchemaFieldConfig = Static<typeof jsonSchemaFieldConfigSchema>;
|
||||
export type JsonSchemaFieldConfig = s.Static<typeof jsonSchemaFieldConfigSchema>;
|
||||
|
||||
export class JsonSchemaField<
|
||||
Required extends true | false = false,
|
||||
@@ -32,7 +26,7 @@ export class JsonSchemaField<
|
||||
|
||||
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
|
||||
super(name, config);
|
||||
this.validator = new Validator(this.getJsonSchema());
|
||||
this.validator = new Validator({ ...this.getJsonSchema() });
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
@@ -84,7 +78,7 @@ export class JsonSchemaField<
|
||||
if (val === null) {
|
||||
if (this.config.default_from_schema) {
|
||||
try {
|
||||
return Default(FromSchema(this.getJsonSchema()), {});
|
||||
return s.fromSchema(this.getJsonSchema()).template();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
@@ -116,7 +110,7 @@ export class JsonSchemaField<
|
||||
override toJsonSchema() {
|
||||
const schema = this.getJsonSchema() ?? { type: "object" };
|
||||
return this.toSchemaWrapIfRequired(
|
||||
FromSchema({
|
||||
s.fromSchema({
|
||||
default: this.getDefault(),
|
||||
...schema,
|
||||
}),
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
import type { Static } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import type { EntityManager } from "data/entities";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
import { omitKeys } from "core/utils";
|
||||
|
||||
export const numberFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.Number()),
|
||||
minimum: Type.Optional(Type.Number()),
|
||||
maximum: Type.Optional(Type.Number()),
|
||||
exclusiveMinimum: Type.Optional(Type.Number()),
|
||||
exclusiveMaximum: Type.Optional(Type.Number()),
|
||||
multipleOf: Type.Optional(Type.Number()),
|
||||
}),
|
||||
baseFieldConfigSchema,
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const numberFieldConfigSchema = s
|
||||
.strictObject({
|
||||
default_value: s.number(),
|
||||
minimum: s.number(),
|
||||
maximum: s.number(),
|
||||
exclusiveMinimum: s.number(),
|
||||
exclusiveMaximum: s.number(),
|
||||
multipleOf: s.number(),
|
||||
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type NumberFieldConfig = Static<typeof numberFieldConfigSchema>;
|
||||
export type NumberFieldConfig = s.Static<typeof numberFieldConfigSchema>;
|
||||
|
||||
export class NumberField<Required extends true | false = false> extends Field<
|
||||
NumberFieldConfig,
|
||||
@@ -93,7 +87,7 @@ export class NumberField<Required extends true | false = false> extends Field<
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Number({
|
||||
s.number({
|
||||
default: this.getDefault(),
|
||||
minimum: this.config?.minimum,
|
||||
maximum: this.config?.maximum,
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { config } from "core";
|
||||
import { StringEnum, uuidv7, type Static } from "core/utils";
|
||||
import { config } from "core/config";
|
||||
import { omitKeys, uuidv7, s } from "bknd/utils";
|
||||
import { Field, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const primaryFieldTypes = ["integer", "uuid"] as const;
|
||||
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
|
||||
|
||||
export const primaryFieldConfigSchema = Type.Composite([
|
||||
Type.Omit(baseFieldConfigSchema, ["required"]),
|
||||
Type.Object({
|
||||
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
|
||||
required: Type.Optional(Type.Literal(false)),
|
||||
}),
|
||||
]);
|
||||
export const primaryFieldConfigSchema = s
|
||||
.strictObject({
|
||||
format: s.string({ enum: primaryFieldTypes, default: "integer" }),
|
||||
required: s.boolean({ default: false }),
|
||||
...omitKeys(baseFieldConfigSchema.properties, ["required"]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type PrimaryFieldConfig = Static<typeof primaryFieldConfigSchema>;
|
||||
export type PrimaryFieldConfig = s.Static<typeof primaryFieldConfigSchema>;
|
||||
|
||||
export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
PrimaryFieldConfig,
|
||||
@@ -26,7 +24,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
override readonly type = "primary";
|
||||
|
||||
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
|
||||
super(name, { fillable: false, required: false, ...cfg });
|
||||
super(name, { ...cfg, fillable: false, required: false });
|
||||
}
|
||||
|
||||
override isRequired(): boolean {
|
||||
@@ -41,7 +39,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
return this.config.format ?? "integer";
|
||||
}
|
||||
|
||||
get fieldType() {
|
||||
get fieldType(): "integer" | "text" {
|
||||
return this.format === "integer" ? "integer" : "text";
|
||||
}
|
||||
|
||||
@@ -67,11 +65,11 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
if (this.format === "uuid") {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ writeOnly: undefined }));
|
||||
}
|
||||
|
||||
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
|
||||
return this.toSchemaWrapIfRequired(
|
||||
this.format === "integer"
|
||||
? s.number({ writeOnly: undefined })
|
||||
: s.string({ writeOnly: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import type { EntityManager } from "data";
|
||||
import type { Static } from "core/utils";
|
||||
import type { EntityManager } from "data/entities";
|
||||
import { omitKeys } from "core/utils";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
const { Type } = tb;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const textFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.String()),
|
||||
minLength: Type.Optional(Type.Number()),
|
||||
maxLength: Type.Optional(Type.Number()),
|
||||
pattern: Type.Optional(Type.String()),
|
||||
html_config: Type.Optional(
|
||||
Type.Object({
|
||||
element: Type.Optional(Type.String({ default: "input" })),
|
||||
props: Type.Optional(
|
||||
Type.Object(
|
||||
{},
|
||||
{
|
||||
additionalProperties: Type.Union([
|
||||
Type.String({ title: "String" }),
|
||||
Type.Number({ title: "Number" }),
|
||||
]),
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
export const textFieldConfigSchema = s
|
||||
.strictObject({
|
||||
default_value: s.string(),
|
||||
minLength: s.number(),
|
||||
maxLength: s.number(),
|
||||
pattern: s.string(),
|
||||
html_config: s.partialObject({
|
||||
element: s.string(),
|
||||
props: s.record(s.anyOf([s.string({ title: "String" }), s.number({ title: "Number" })])),
|
||||
}),
|
||||
baseFieldConfigSchema,
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type TextFieldConfig = Static<typeof textFieldConfigSchema>;
|
||||
export type TextFieldConfig = s.Static<typeof textFieldConfigSchema>;
|
||||
|
||||
export class TextField<Required extends true | false = false> extends Field<
|
||||
TextFieldConfig,
|
||||
@@ -113,7 +95,7 @@ export class TextField<Required extends true | false = false> extends Field<
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.String({
|
||||
s.string({
|
||||
default: this.getDefault(),
|
||||
minLength: this.config?.minLength,
|
||||
maxLength: this.config?.maxLength,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Static } from "core/utils";
|
||||
import { Field, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const virtualFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
||||
export const virtualFieldConfigSchema = s
|
||||
.strictObject({
|
||||
...baseFieldConfigSchema.properties,
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type VirtualFieldConfig = Static<typeof virtualFieldConfigSchema>;
|
||||
export type VirtualFieldConfig = s.Static<typeof virtualFieldConfigSchema>;
|
||||
|
||||
export class VirtualField extends Field<VirtualFieldConfig> {
|
||||
override readonly type = "virtual";
|
||||
@@ -25,7 +27,7 @@ export class VirtualField extends Field<VirtualFieldConfig> {
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Any({
|
||||
s.any({
|
||||
default: this.getDefault(),
|
||||
readOnly: true,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseFieldConfig, Field, TActionContext } from "data";
|
||||
import type { BaseFieldConfig, Field, TActionContext } from "data/fields";
|
||||
import type { ColumnDataType } from "kysely";
|
||||
import { omit } from "lodash-es";
|
||||
import type { TestRunner } from "core/test";
|
||||
@@ -50,7 +50,7 @@ export function fieldTestSuite(
|
||||
expect(noConfigField.hasDefault()).toBe(false);
|
||||
expect(noConfigField.getDefault()).toBeUndefined();
|
||||
expect(dflt.hasDefault()).toBe(true);
|
||||
expect(dflt.getDefault()).toBe(config.defaultValue);
|
||||
expect(dflt.getDefault()).toEqual(config.defaultValue);
|
||||
});
|
||||
|
||||
test("isFillable", async () => {
|
||||
@@ -98,9 +98,7 @@ export function fieldTestSuite(
|
||||
test("toJSON", async () => {
|
||||
const _config = {
|
||||
..._requiredConfig,
|
||||
fillable: true,
|
||||
required: false,
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
function fieldJson(field: Field) {
|
||||
@@ -118,7 +116,10 @@ export function fieldTestSuite(
|
||||
|
||||
expect(fieldJson(fillable)).toEqual({
|
||||
type: noConfigField.type,
|
||||
config: _config,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fieldJson(required)).toEqual({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EntityData, EntityManager, Field } from "data";
|
||||
import type { EntityData, EntityManager } from "data/entities";
|
||||
import type { Field } from "data/fields";
|
||||
import { transform } from "lodash-es";
|
||||
|
||||
export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MutatorEvents, RepositoryEvents } from "./events";
|
||||
|
||||
export * from "./fields";
|
||||
export * from "./entities";
|
||||
export * from "./relations";
|
||||
export * from "./schema/SchemaManager";
|
||||
export * from "./prototype";
|
||||
export * from "./connection";
|
||||
|
||||
export {
|
||||
type RepoQuery,
|
||||
type RepoQueryIn,
|
||||
getRepoQueryTemplate,
|
||||
repoQuery,
|
||||
} from "./server/query";
|
||||
|
||||
export type { WhereQuery } from "./entities/query/WhereBuilder";
|
||||
|
||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||
|
||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||
|
||||
export const DatabaseEvents = {
|
||||
...MutatorEvents,
|
||||
...RepositoryEvents,
|
||||
};
|
||||
export { MutatorEvents, RepositoryEvents };
|
||||
|
||||
export * as DataPermissions from "./permissions";
|
||||
|
||||
export { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
|
||||
export { libsql } from "./connection/sqlite/libsql/LibsqlConnection";
|
||||
export {
|
||||
genericSqlite,
|
||||
genericSqliteUtils,
|
||||
type GenericSqliteConnection,
|
||||
} from "./connection/sqlite/GenericSqliteConnection";
|
||||
|
||||
export {
|
||||
EntityTypescript,
|
||||
type EntityTypescriptOptions,
|
||||
type TEntityTSType,
|
||||
type TFieldTSType,
|
||||
} from "./entities/EntityTypescript";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Permission } from "core";
|
||||
import { Permission } from "core/security/Permission";
|
||||
|
||||
export const entityRead = new Permission("data.entity.read");
|
||||
export const entityCreate = new Permission("data.entity.create");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Static, parse } from "core/utils";
|
||||
import type { PrimaryFieldType } from "bknd";
|
||||
import { s, parse } from "bknd/utils";
|
||||
import type { ExpressionBuilder, SelectQueryBuilder } from "kysely";
|
||||
import type { Entity, EntityData, EntityManager } from "../entities";
|
||||
import {
|
||||
@@ -8,9 +9,6 @@ import {
|
||||
} from "../relations";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import type { RelationType } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const directions = ["source", "target"] as const;
|
||||
export type TDirection = (typeof directions)[number];
|
||||
@@ -18,13 +16,13 @@ export type TDirection = (typeof directions)[number];
|
||||
export type KyselyJsonFrom = any;
|
||||
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
export type BaseRelationConfig = Static<typeof EntityRelation.schema>;
|
||||
export type BaseRelationConfig = s.Static<typeof EntityRelation.schema>;
|
||||
|
||||
// @todo: add generic type for relation config
|
||||
export abstract class EntityRelation<
|
||||
Schema extends typeof EntityRelation.schema = typeof EntityRelation.schema,
|
||||
> {
|
||||
config: Static<Schema>;
|
||||
config: s.Static<Schema>;
|
||||
|
||||
source: EntityRelationAnchor;
|
||||
target: EntityRelationAnchor;
|
||||
@@ -33,17 +31,17 @@ export abstract class EntityRelation<
|
||||
// allowed directions, used in RelationAccessor for visibility
|
||||
directions: TDirection[] = ["source", "target"];
|
||||
|
||||
static schema = Type.Object({
|
||||
mappedBy: Type.Optional(Type.String()),
|
||||
inversedBy: Type.Optional(Type.String()),
|
||||
required: Type.Optional(Type.Boolean()),
|
||||
static schema = s.strictObject({
|
||||
mappedBy: s.string().optional(),
|
||||
inversedBy: s.string().optional(),
|
||||
required: s.boolean().optional(),
|
||||
});
|
||||
|
||||
// don't make protected, App requires it to instantiatable
|
||||
constructor(
|
||||
source: EntityRelationAnchor,
|
||||
target: EntityRelationAnchor,
|
||||
config: Partial<Static<Schema>> = {},
|
||||
config: Partial<s.Static<Schema>> = {},
|
||||
) {
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import { Entity, type EntityManager } from "../entities";
|
||||
import { type Field, PrimaryField } from "../fields";
|
||||
@@ -7,10 +6,9 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField } from "./RelationField";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export type ManyToManyRelationConfig = Static<typeof ManyToManyRelation.schema>;
|
||||
export type ManyToManyRelationConfig = s.Static<typeof ManyToManyRelation.schema>;
|
||||
|
||||
export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation.schema> {
|
||||
connectionEntity: Entity;
|
||||
@@ -18,18 +16,11 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
connectionTableMappedName: string;
|
||||
private em?: EntityManager<any>;
|
||||
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
connectionTable: Type.Optional(Type.String()),
|
||||
connectionTableMappedName: Type.Optional(Type.String()),
|
||||
}),
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
static override schema = s.strictObject({
|
||||
connectionTable: s.string().optional(),
|
||||
connectionTableMappedName: s.string().optional(),
|
||||
...EntityRelation.schema.properties,
|
||||
});
|
||||
|
||||
constructor(
|
||||
source: Entity,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import type { Static } from "core/utils";
|
||||
import type { PrimaryFieldType } from "bknd";
|
||||
import { snakeToPascalWithSpaces, s } from "bknd/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
@@ -9,8 +8,6 @@ import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
/**
|
||||
* Source entity receives the mapping field
|
||||
@@ -20,7 +17,7 @@ const { Type } = tbbox;
|
||||
* posts gets a users_id field
|
||||
*/
|
||||
|
||||
export type ManyToOneRelationConfig = Static<typeof ManyToOneRelation.schema>;
|
||||
export type ManyToOneRelationConfig = s.Static<typeof ManyToOneRelation.schema>;
|
||||
|
||||
export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.schema> {
|
||||
private fieldConfig?: RelationFieldBaseConfig;
|
||||
@@ -28,30 +25,21 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
|
||||
with_limit: 5,
|
||||
};
|
||||
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
sourceCardinality: Type.Optional(Type.Number()),
|
||||
with_limit: Type.Optional(
|
||||
Type.Number({ default: ManyToOneRelation.DEFAULTS.with_limit }),
|
||||
),
|
||||
fieldConfig: Type.Optional(
|
||||
Type.Object({
|
||||
label: Type.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
static override schema = s.strictObject({
|
||||
sourceCardinality: s.number().optional(),
|
||||
with_limit: s.number({ default: ManyToOneRelation.DEFAULTS.with_limit }).optional(),
|
||||
fieldConfig: s
|
||||
.object({
|
||||
label: s.string(),
|
||||
})
|
||||
.optional(),
|
||||
...EntityRelation.schema.properties,
|
||||
});
|
||||
|
||||
constructor(
|
||||
source: Entity,
|
||||
target: Entity,
|
||||
config: Partial<Static<typeof ManyToOneRelation.schema>> = {},
|
||||
config: Partial<s.Static<typeof ManyToOneRelation.schema>> = {},
|
||||
) {
|
||||
const mappedBy = config.mappedBy || target.name;
|
||||
const inversedBy = config.inversedBy || source.name;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
@@ -6,24 +5,16 @@ import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export type PolymorphicRelationConfig = Static<typeof PolymorphicRelation.schema>;
|
||||
export type PolymorphicRelationConfig = s.Static<typeof PolymorphicRelation.schema>;
|
||||
|
||||
// @todo: what about cascades?
|
||||
export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelation.schema> {
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
targetCardinality: Type.Optional(Type.Number()),
|
||||
}),
|
||||
],
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
static override schema = s.strictObject({
|
||||
targetCardinality: s.number().optional(),
|
||||
...EntityRelation.schema.properties,
|
||||
});
|
||||
|
||||
constructor(source: Entity, target: Entity, config: Partial<PolymorphicRelationConfig> = {}) {
|
||||
const mappedBy = config.mappedBy || target.name;
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { type Static, StringEnum } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
|
||||
import { Field, baseFieldConfigSchema } from "../fields";
|
||||
import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||
|
||||
export const relationFieldConfigSchema = Type.Composite([
|
||||
baseFieldConfigSchema,
|
||||
Type.Object({
|
||||
reference: Type.String(),
|
||||
target: Type.String(), // @todo: potentially has to be an instance!
|
||||
target_field: Type.Optional(Type.String({ default: "id" })),
|
||||
target_field_type: Type.Optional(StringEnum(["integer", "text"], { default: "integer" })),
|
||||
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
|
||||
}),
|
||||
]);
|
||||
export const relationFieldConfigSchema = s.strictObject({
|
||||
reference: s.string(),
|
||||
target: s.string(), // @todo: potentially has to be an instance!
|
||||
target_field: s.string({ default: "id" }).optional(),
|
||||
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
|
||||
on_delete: s.string({ enum: CASCADES, default: "set null" }).optional(),
|
||||
...baseFieldConfigSchema.properties,
|
||||
});
|
||||
|
||||
export type RelationFieldConfig = Static<typeof relationFieldConfigSchema>;
|
||||
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
|
||||
export type RelationFieldBaseConfig = { label?: string };
|
||||
|
||||
export class RelationField extends Field<RelationFieldConfig> {
|
||||
@@ -81,7 +77,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Number({
|
||||
s.number({
|
||||
$ref: `${this.config?.target}#/properties/${this.config?.target_field}`,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { PrimaryFieldType } from "bknd";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import {
|
||||
type EntityRelation,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { CompiledQuery, TableMetadata } from "kysely";
|
||||
import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { PrimaryField } from "../fields";
|
||||
import { $console } from "core/utils";
|
||||
import type { IndexMetadata, SchemaResponse } from "data/connection/Connection";
|
||||
import type { Entity, EntityManager } from "data/entities";
|
||||
import { PrimaryField } from "data/fields";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
type IntrospectedTable = TableMetadata & {
|
||||
indices: IndexMetadata[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { Entity, type Field } from "data";
|
||||
import { Entity } from "data/entities";
|
||||
import type { Field } from "data/fields";
|
||||
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
|
||||
|
||||
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import * as q from "./query";
|
||||
import { s as schema, parse as $parse, type ParseOptions } from "core/object/schema";
|
||||
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
||||
|
||||
const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, o);
|
||||
const parse = (v: unknown, o: ParseOptions = {}) =>
|
||||
$parse(q.repoQuery, v, {
|
||||
...o,
|
||||
withDefaults: false,
|
||||
});
|
||||
|
||||
// compatibility
|
||||
const decode = (input: any, output: any) => {
|
||||
@@ -11,7 +15,7 @@ const decode = (input: any, output: any) => {
|
||||
|
||||
describe("server/query", () => {
|
||||
test("limit & offset", () => {
|
||||
expect(() => parse({ limit: false })).toThrow();
|
||||
//expect(() => parse({ limit: false })).toThrow();
|
||||
expect(parse({ limit: "11" })).toEqual({ limit: 11 });
|
||||
expect(parse({ limit: 20 })).toEqual({ limit: 20 });
|
||||
expect(parse({ offset: "1" })).toEqual({ offset: 1 });
|
||||
@@ -44,6 +48,7 @@ describe("server/query", () => {
|
||||
});
|
||||
expect(parse({ sort: { by: "title" } }).sort).toEqual({
|
||||
by: "title",
|
||||
dir: "asc",
|
||||
});
|
||||
expect(
|
||||
parse(
|
||||
@@ -102,9 +107,12 @@ describe("server/query", () => {
|
||||
|
||||
test("template", () => {
|
||||
expect(
|
||||
q.repoQuery.template({
|
||||
withOptional: true,
|
||||
}),
|
||||
q.repoQuery.template(
|
||||
{},
|
||||
{
|
||||
withOptional: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { s } from "core/object/schema";
|
||||
import { s } from "bknd/utils";
|
||||
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
|
||||
import { isObject, $console } from "core/utils";
|
||||
import type { CoercionOptions, TAnyOf } from "jsonv-ts";
|
||||
import type { anyOf, CoercionOptions, Schema } from "jsonv-ts";
|
||||
|
||||
// -------
|
||||
// helpers
|
||||
@@ -35,10 +35,12 @@ const stringArray = s.anyOf(
|
||||
// -------
|
||||
// sorting
|
||||
const sortDefault = { by: "id", dir: "asc" };
|
||||
const sortSchema = s.object({
|
||||
by: s.string(),
|
||||
dir: s.string({ enum: ["asc", "desc"] }).optional(),
|
||||
});
|
||||
const sortSchema = s
|
||||
.object({
|
||||
by: s.string(),
|
||||
dir: s.string({ enum: ["asc", "desc"] }).optional(),
|
||||
})
|
||||
.strict();
|
||||
type SortSchema = s.Static<typeof sortSchema>;
|
||||
const sort = s.anyOf([s.string(), sortSchema], {
|
||||
default: sortDefault,
|
||||
@@ -48,11 +50,19 @@ const sort = s.anyOf([s.string(), sortSchema], {
|
||||
const dir = v[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? v.slice(1) : v, dir } as any;
|
||||
} else if (/^{.*}$/.test(v)) {
|
||||
return JSON.parse(v) as any;
|
||||
return {
|
||||
...sortDefault,
|
||||
...JSON.parse(v),
|
||||
} as any;
|
||||
}
|
||||
|
||||
$console.warn(`Invalid sort given: '${JSON.stringify(v)}'`);
|
||||
return sortDefault as any;
|
||||
} else if (isObject(v)) {
|
||||
return {
|
||||
...sortDefault,
|
||||
...v,
|
||||
} as any;
|
||||
}
|
||||
return v as any;
|
||||
},
|
||||
@@ -87,9 +97,9 @@ export type RepoWithSchema = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
|
||||
const withSchema = <Type = unknown>(self: Schema): Schema<{}, Type, Type> =>
|
||||
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
|
||||
coerce: function (this: TAnyOf<any>, _value: unknown, opts: CoercionOptions = {}) {
|
||||
coerce: function (this: typeof anyOf, _value: unknown, opts: CoercionOptions = {}) {
|
||||
let value: any = _value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
@@ -125,20 +135,25 @@ const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
|
||||
// ==========
|
||||
// REPO QUERY
|
||||
export const repoQuery = s.recursive((self) =>
|
||||
s.partialObject({
|
||||
limit: s.number({ default: 10 }),
|
||||
offset: s.number({ default: 0 }),
|
||||
sort,
|
||||
where,
|
||||
select: stringArray,
|
||||
join: stringArray,
|
||||
with: withSchema<RepoWithSchema>(self),
|
||||
}),
|
||||
s
|
||||
.object({
|
||||
limit: s.number({ default: 10 }),
|
||||
offset: s.number({ default: 0 }),
|
||||
sort,
|
||||
where,
|
||||
select: stringArray,
|
||||
join: stringArray,
|
||||
with: withSchema<RepoWithSchema>(self),
|
||||
})
|
||||
.partial(),
|
||||
);
|
||||
export const getRepoQueryTemplate = () =>
|
||||
repoQuery.template({
|
||||
withOptional: true,
|
||||
}) as Required<RepoQuery>;
|
||||
repoQuery.template(
|
||||
{},
|
||||
{
|
||||
withOptional: true,
|
||||
},
|
||||
) as Required<RepoQuery>;
|
||||
|
||||
export type RepoQueryIn = {
|
||||
limit?: number;
|
||||
@@ -152,3 +167,15 @@ export type RepoQueryIn = {
|
||||
export type RepoQuery = s.StaticCoerced<typeof repoQuery> & {
|
||||
sort: SortSchema;
|
||||
};
|
||||
|
||||
//export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
|
||||
// @todo: CURRENT WORKAROUND
|
||||
/* export type RepoQuery = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: Record<string, RepoQuery>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
}; */
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type Static, transformObject } from "core/utils";
|
||||
import { Flow, HttpTrigger } from "flows";
|
||||
import { Hono } from "hono";
|
||||
import { Module } from "modules/Module";
|
||||
import { TASKS, flowsConfigSchema } from "./flows-schema";
|
||||
import { type s, transformObject } from "bknd/utils";
|
||||
|
||||
export type AppFlowsSchema = Static<typeof flowsConfigSchema>;
|
||||
export type AppFlowsSchema = s.Static<typeof flowsConfigSchema>;
|
||||
export type TAppFlowSchema = AppFlowsSchema["flows"][number];
|
||||
export type TAppFlowTriggerSchema = TAppFlowSchema["trigger"];
|
||||
export type { TAppFlowTaskSchema } from "./flows-schema";
|
||||
|
||||
export class AppFlows extends Module<typeof flowsConfigSchema> {
|
||||
export class AppFlows extends Module<AppFlowsSchema> {
|
||||
private flows: Record<string, Flow> = {};
|
||||
|
||||
getSchema() {
|
||||
@@ -80,6 +80,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
// @todo: fix this
|
||||
// @ts-expect-error
|
||||
override toJSON() {
|
||||
return {
|
||||
...this.config,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Const, type Static, StringRecord, transformObject } from "core/utils";
|
||||
import { transformObject } from "core/utils";
|
||||
import { TaskMap, TriggerMap } from "flows";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export const TASKS = {
|
||||
...TaskMap,
|
||||
@@ -10,77 +9,59 @@ export const TASKS = {
|
||||
export const TRIGGERS = TriggerMap;
|
||||
|
||||
const taskSchemaObject = transformObject(TASKS, (task, name) => {
|
||||
return Type.Object(
|
||||
return s.strictObject(
|
||||
{
|
||||
type: Const(name),
|
||||
type: s.literal(name),
|
||||
params: task.cls.schema,
|
||||
},
|
||||
{ title: String(name), additionalProperties: false },
|
||||
{ title: String(name) },
|
||||
);
|
||||
});
|
||||
const taskSchema = Type.Union(Object.values(taskSchemaObject));
|
||||
export type TAppFlowTaskSchema = Static<typeof taskSchema>;
|
||||
const taskSchema = s.anyOf(Object.values(taskSchemaObject));
|
||||
export type TAppFlowTaskSchema = s.Static<typeof taskSchema>;
|
||||
|
||||
const triggerSchemaObject = transformObject(TRIGGERS, (trigger, name) => {
|
||||
return Type.Object(
|
||||
return s.strictObject(
|
||||
{
|
||||
type: Const(name),
|
||||
config: trigger.cls.schema,
|
||||
type: s.literal(name),
|
||||
config: trigger.cls.schema.optional(),
|
||||
},
|
||||
{ title: String(name), additionalProperties: false },
|
||||
{ title: String(name) },
|
||||
);
|
||||
});
|
||||
const triggerSchema = s.anyOf(Object.values(triggerSchemaObject));
|
||||
export type TAppFlowTriggerSchema = s.Static<typeof triggerSchema>;
|
||||
|
||||
const connectionSchema = Type.Object({
|
||||
source: Type.String(),
|
||||
target: Type.String(),
|
||||
config: Type.Object(
|
||||
{
|
||||
condition: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Object(
|
||||
{ type: Const("success") },
|
||||
{ additionalProperties: false, title: "success" },
|
||||
),
|
||||
Type.Object(
|
||||
{ type: Const("error") },
|
||||
{ additionalProperties: false, title: "error" },
|
||||
),
|
||||
Type.Object(
|
||||
{ type: Const("matches"), path: Type.String(), value: Type.String() },
|
||||
{ additionalProperties: false, title: "matches" },
|
||||
),
|
||||
]),
|
||||
),
|
||||
max_retries: Type.Optional(Type.Number()),
|
||||
},
|
||||
{ default: {}, additionalProperties: false },
|
||||
),
|
||||
const connectionSchema = s.strictObject({
|
||||
source: s.string(),
|
||||
target: s.string(),
|
||||
config: s
|
||||
.strictObject({
|
||||
condition: s.anyOf([
|
||||
s.strictObject({ type: s.literal("success") }, { title: "success" }),
|
||||
s.strictObject({ type: s.literal("error") }, { title: "error" }),
|
||||
s.strictObject(
|
||||
{ type: s.literal("matches"), path: s.string(), value: s.string() },
|
||||
{ title: "matches" },
|
||||
),
|
||||
]),
|
||||
max_retries: s.number(),
|
||||
})
|
||||
.partial(),
|
||||
});
|
||||
|
||||
// @todo: rework to have fixed ids per task and connections (and preferrably arrays)
|
||||
// causes issues with canvas
|
||||
export const flowSchema = Type.Object(
|
||||
{
|
||||
trigger: Type.Union(Object.values(triggerSchemaObject)),
|
||||
tasks: Type.Optional(StringRecord(Type.Union(Object.values(taskSchemaObject)))),
|
||||
connections: Type.Optional(StringRecord(connectionSchema)),
|
||||
start_task: Type.Optional(Type.String()),
|
||||
responding_task: Type.Optional(Type.String()),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export type TAppFlowSchema = Static<typeof flowSchema>;
|
||||
export const flowSchema = s.strictObject({
|
||||
trigger: s.anyOf(Object.values(triggerSchemaObject)),
|
||||
tasks: s.record(s.anyOf(Object.values(taskSchemaObject))).optional(),
|
||||
connections: s.record(connectionSchema).optional(),
|
||||
start_task: s.string().optional(),
|
||||
responding_task: s.string().optional(),
|
||||
});
|
||||
export type TAppFlowSchema = s.Static<typeof flowSchema>;
|
||||
|
||||
export const flowsConfigSchema = Type.Object(
|
||||
{
|
||||
basepath: Type.String({ default: "/api/flows" }),
|
||||
flows: StringRecord(flowSchema, { default: {} }),
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const flowsConfigSchema = s.strictObject({
|
||||
basepath: s.string({ default: "/api/flows" }),
|
||||
flows: s.record(flowSchema, { default: {} }),
|
||||
});
|
||||
|
||||
@@ -2,19 +2,15 @@ import type { EventManager } from "core/events";
|
||||
import type { Flow } from "../Flow";
|
||||
import { Trigger } from "./Trigger";
|
||||
import { $console } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
export class EventTrigger extends Trigger<typeof EventTrigger.schema> {
|
||||
override type = "event";
|
||||
|
||||
static override schema = Type.Composite([
|
||||
Trigger.schema,
|
||||
Type.Object({
|
||||
event: Type.String(),
|
||||
// add match
|
||||
}),
|
||||
]);
|
||||
static override schema = s.strictObject({
|
||||
event: s.string(),
|
||||
...Trigger.schema.properties,
|
||||
});
|
||||
|
||||
override async register(flow: Flow, emgr: EventManager<any>) {
|
||||
if (!emgr.eventExists(this.config.event)) {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { StringEnum } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import type { Flow } from "../Flow";
|
||||
import { Trigger } from "./Trigger";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
const httpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
|
||||
export class HttpTrigger extends Trigger<typeof HttpTrigger.schema> {
|
||||
override type = "http";
|
||||
|
||||
static override schema = Type.Composite([
|
||||
Trigger.schema,
|
||||
Type.Object({
|
||||
path: Type.String({ pattern: "^/.*$" }),
|
||||
method: StringEnum(httpMethods, { default: "GET" }),
|
||||
response_type: StringEnum(["json", "text", "html"], { default: "json" }),
|
||||
}),
|
||||
]);
|
||||
static override schema = s.strictObject({
|
||||
path: s.string({ pattern: "^/.*$" }),
|
||||
method: s.string({ enum: httpMethods, default: "GET" }),
|
||||
response_type: s.string({ enum: ["json", "text", "html"], default: "json" }),
|
||||
...Trigger.schema.properties,
|
||||
});
|
||||
|
||||
override async register(flow: Flow, hono: Hono<any>) {
|
||||
const method = this.config.method.toLowerCase() as any;
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { type Static, StringEnum, parse } from "core/utils";
|
||||
import type { Execution } from "../Execution";
|
||||
import type { Flow } from "../Flow";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s, parse } from "bknd/utils";
|
||||
|
||||
export class Trigger<Schema extends typeof Trigger.schema = typeof Trigger.schema> {
|
||||
// @todo: remove this
|
||||
executions: Execution[] = [];
|
||||
type = "manual";
|
||||
config: Static<Schema>;
|
||||
config: s.Static<Schema>;
|
||||
|
||||
static schema = Type.Object({
|
||||
mode: StringEnum(["sync", "async"], { default: "async" }),
|
||||
static schema = s.strictObject({
|
||||
mode: s.string({ enum: ["sync", "async"], default: "async" }),
|
||||
});
|
||||
|
||||
constructor(config?: Partial<Static<Schema>>) {
|
||||
constructor(config?: Partial<s.Static<Schema>>) {
|
||||
const schema = (this.constructor as typeof Trigger).schema;
|
||||
// @ts-ignore for now
|
||||
this.config = parse(schema, config ?? {});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { StaticDecode, TSchema } from "@sinclair/typebox";
|
||||
import { BkndError, SimpleRenderer } from "core";
|
||||
import { type Static, type TObject, Value, parse, ucFirst } from "core/utils";
|
||||
//import { BkndError, SimpleRenderer } from "core";
|
||||
import { BkndError } from "core/errors";
|
||||
|
||||
import { s, parse } from "bknd/utils";
|
||||
import type { InputsMap } from "../flows/Execution";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { SimpleRenderer } from "core/template/SimpleRenderer";
|
||||
|
||||
//type InstanceOf<T> = T extends new (...args: any) => infer R ? R : never;
|
||||
|
||||
export type TaskResult<Output = any> = {
|
||||
@@ -16,7 +17,9 @@ export type TaskResult<Output = any> = {
|
||||
|
||||
export type TaskRenderProps<T extends Task = Task> = any;
|
||||
|
||||
export function dynamic<Type extends TSchema>(
|
||||
export const dynamic = <S extends s.Schema>(a: S, b?: any) => a;
|
||||
|
||||
/* export function dynamic<Type extends TSchema>(
|
||||
type: Type,
|
||||
parse?: (val: any | string) => Static<Type>,
|
||||
) {
|
||||
@@ -51,23 +54,23 @@ export function dynamic<Type extends TSchema>(
|
||||
// @ts-ignore
|
||||
.Encode((val) => val)
|
||||
);
|
||||
}
|
||||
} */
|
||||
|
||||
export abstract class Task<Params extends TObject = TObject, Output = unknown> {
|
||||
export abstract class Task<Params extends s.Schema = s.Schema, Output = unknown> {
|
||||
abstract type: string;
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The schema of the task's parameters.
|
||||
*/
|
||||
static schema = Type.Object({});
|
||||
static schema = s.any();
|
||||
|
||||
/**
|
||||
* The task's parameters.
|
||||
*/
|
||||
_params: Static<Params>;
|
||||
_params: s.Static<Params>;
|
||||
|
||||
constructor(name: string, params?: Static<Params>) {
|
||||
constructor(name: string, params?: s.Static<Params>) {
|
||||
if (typeof name !== "string") {
|
||||
throw new Error(`Task name must be a string, got ${typeof name}`);
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export abstract class Task<Params extends TObject = TObject, Output = unknown> {
|
||||
if (
|
||||
schema === Task.schema &&
|
||||
typeof params !== "undefined" &&
|
||||
Object.keys(params).length > 0
|
||||
Object.keys(params || {}).length > 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Task "${name}" has no schema defined but params passed: ${JSON.stringify(params)}`,
|
||||
@@ -93,18 +96,18 @@ export abstract class Task<Params extends TObject = TObject, Output = unknown> {
|
||||
}
|
||||
|
||||
get params() {
|
||||
return this._params as StaticDecode<Params>;
|
||||
return this._params as s.StaticCoerced<Params>;
|
||||
}
|
||||
|
||||
protected clone(name: string, params: Static<Params>): Task {
|
||||
protected clone(name: string, params: s.Static<Params>): Task {
|
||||
return new (this.constructor as any)(name, params);
|
||||
}
|
||||
|
||||
static async resolveParams<S extends TSchema>(
|
||||
static async resolveParams<S extends s.Schema>(
|
||||
schema: S,
|
||||
params: any,
|
||||
inputs: object = {},
|
||||
): Promise<StaticDecode<S>> {
|
||||
): Promise<s.StaticCoerced<S>> {
|
||||
const newParams: any = {};
|
||||
const renderer = new SimpleRenderer(inputs, { renderKeys: true });
|
||||
|
||||
@@ -134,7 +137,8 @@ export abstract class Task<Params extends TObject = TObject, Output = unknown> {
|
||||
newParams[key] = value;
|
||||
}
|
||||
|
||||
return Value.Decode(schema, newParams);
|
||||
return schema.coerce(newParams);
|
||||
//return Value.Decode(schema, newParams);
|
||||
}
|
||||
|
||||
private async cloneWithResolvedParams(_inputs: Map<string, any>) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { StringEnum } from "core/utils";
|
||||
import { Task, dynamic } from "../Task";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
const FetchMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
@@ -11,24 +9,22 @@ export class FetchTask<Output extends Record<string, any>> extends Task<
|
||||
> {
|
||||
type = "fetch";
|
||||
|
||||
static override schema = Type.Object({
|
||||
url: Type.String({
|
||||
static override schema = s.strictObject({
|
||||
url: s.string({
|
||||
pattern: "^(http|https)://",
|
||||
}),
|
||||
method: Type.Optional(dynamic(StringEnum(FetchMethods, { default: "GET" }))),
|
||||
headers: Type.Optional(
|
||||
dynamic(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
key: Type.String(),
|
||||
value: Type.String(),
|
||||
}),
|
||||
),
|
||||
JSON.parse,
|
||||
method: dynamic(s.string({ enum: FetchMethods, default: "GET" })).optional(),
|
||||
headers: dynamic(
|
||||
s.array(
|
||||
s.strictObject({
|
||||
key: s.string(),
|
||||
value: s.string(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
body: Type.Optional(dynamic(Type.String())),
|
||||
normal: Type.Optional(dynamic(Type.Number(), Number.parseInt)),
|
||||
JSON.parse,
|
||||
).optional(),
|
||||
body: dynamic(s.string()).optional(),
|
||||
normal: dynamic(s.number(), Number.parseInt).optional(),
|
||||
});
|
||||
|
||||
protected getBody(): string | undefined {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user