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:
dswbx
2025-08-01 15:55:59 +02:00
committed by GitHub
parent daaaae82b6
commit a298b65abf
430 changed files with 15041 additions and 12375 deletions

View File

@@ -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";

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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],

View File

@@ -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",

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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();
};
}

View File

@@ -1,4 +1,4 @@
import { genericSqlite } from "bknd/data";
import { genericSqlite } from "bknd";
import { DatabaseSync } from "node:sqlite";
export type NodeSqliteConnectionConfig = {

View File

@@ -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 },

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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");

View File

@@ -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>;

View File

@@ -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);

View File

@@ -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(

View File

@@ -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,

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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";

View File

@@ -1,4 +1,4 @@
import { Permission } from "core";
import { Permission } from "core/security/Permission";
export class RolePermission {
constructor(

View File

@@ -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() {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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({

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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>;
}

View File

@@ -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];
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -1 +0,0 @@
export { tbValidator } from "./tbValidator";

View File

@@ -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);
});
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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);
}

View 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;
}

View 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;

View File

@@ -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
}

View File

@@ -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 };

View File

@@ -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];
};

View File

@@ -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 = {},

View File

@@ -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;

View File

@@ -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({

View File

@@ -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",

View File

@@ -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";

View File

@@ -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>;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 ?? {}),
},
};

View File

@@ -6,7 +6,7 @@ import {
isBooleanLike,
isPrimitive,
makeValidator,
} from "core";
} from "core/object/query/query";
import type {
DeleteQueryBuilder,
ExpressionBuilder,

View File

@@ -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(

View File

@@ -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()}`);
}

View File

@@ -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";

View File

@@ -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() {

View File

@@ -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 {

View File

@@ -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(),
}),
);

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
}),

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
}),

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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");

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}`,
}),
);

View File

@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core";
import type { PrimaryFieldType } from "bknd";
import type { Entity, EntityManager } from "../entities";
import {
type EntityRelation,

View File

@@ -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[];

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}; */

View File

@@ -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,

View File

@@ -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: {} }),
});

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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 ?? {});

View File

@@ -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>) {

View File

@@ -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