initial refactor

This commit is contained in:
dswbx
2025-06-21 13:35:58 +02:00
parent b2086c4da7
commit 42edce904f
93 changed files with 1021 additions and 1216 deletions
+35
View File
@@ -0,0 +1,35 @@
import { AppServer, serverConfigSchema } from "modules/server/AppServer";
import { describe, test, expect } from "bun:test";
describe("AppServer", () => {
test("config", () => {
{
const server = new AppServer();
expect(server).toBeDefined();
expect(server.config).toEqual({
cors: {
origin: "*",
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
});
}
{
const server = new AppServer({
cors: {
origin: "https",
allow_methods: ["GET", "POST"],
},
});
expect(server).toBeDefined();
expect(server.config).toEqual({
cors: {
origin: "https",
allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
});
}
});
});
+2 -2
View File
@@ -4,7 +4,6 @@ import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { DebugLogger } from "../../src/core";
import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
import { getDummyConnection } from "../helper";
@@ -45,7 +44,8 @@ export function moduleTestSuite(module: { new (): Module }) {
it("uses the default config", async () => {
const m = new module();
await m.setContext(ctx).build();
expect(stripMark(m.toJSON())).toEqual(Default(m.getSchema(), {}));
expect(m.toJSON()).toEqual(m.getSchema().template());
//expect(stripMark(m.toJSON())).toEqual(Default(m.getSchema(), {}));
});
});
}
+1 -1
View File
@@ -226,7 +226,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
},
external: [
/^cloudflare*/,
/^@?(hono|libsql).*?/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
+1 -1
View File
@@ -102,7 +102,7 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.1.0",
"jsonv-ts": "link:jsonv-ts",
"kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"libsql-stateless-easy": "^1.8.0",
@@ -1,15 +1,13 @@
import { registries } from "bknd";
import { isDebug } from "bknd/core";
import { StringEnum } from "bknd/utils";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
import { s } from "core/object/schema";
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" },
);
@@ -1,17 +1,16 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, isFile, parse } from "bknd/utils";
import { isFile } 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 { parse, s } from "core/object/schema";
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 +61,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);
}
+2 -2
View File
@@ -20,7 +20,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;
@@ -197,6 +197,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
enabled: this.isStrategyEnabled(strategy),
...strategy.toJSON(secrets),
})),
};
} as AppAuthSchema;
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { transformObject } from "core/utils";
import { DataPermissions } from "data";
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 } from "core/object/schema";
export type AuthActionResponse = {
success: boolean;
@@ -58,7 +58,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 +78,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,
+54 -45
View File
@@ -1,8 +1,7 @@
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 } from "core/utils";
import { s } from "core/object/schema";
export const Strategies = {
password: {
@@ -21,64 +20,74 @@ 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(
const a = s.record(strategiesSchema, {
// ^?
title: "Strategies",
default: {
password: {
type: "password",
enabled: true,
config: {
hashing: "sha256",
},
},
},
});
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" },
);
const b = authConfigSchema.properties.basepath;
// ^?
const c = authConfigSchema.properties.strategies;
// ^?
export type AppAuthSchema = Static<typeof authConfigSchema>;
export type AppAuthSchema = s.Static<typeof authConfigSchema>;
+34 -41
View File
@@ -1,31 +1,23 @@
import { $console, type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import {
type Static,
StringEnum,
type TObject,
parse,
runtimeSupports,
truncate,
} from "core/utils";
import { runtimeSupports, truncate } from "core/utils";
import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import type { CookieOptions } 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 } from "core/object/schema";
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>>;
@@ -59,43 +51,44 @@ 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
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: s.string({ 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;
@@ -1,19 +1,19 @@
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
import { $console, tbValidator as tb } from "core";
import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
import { $console } from "core";
import { hash } 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 { s, parse, jsc } from "core/object/schema";
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 = Static<typeof schema>;
export type PasswordStrategyOptions = s.Static<typeof schema>;
export class PasswordStrategy extends Strategy<typeof schema> {
constructor(config: Partial<PasswordStrategyOptions> = {}) {
@@ -32,11 +32,11 @@ export class PasswordStrategy extends Strategy<typeof schema> {
}
private getPayloadSchema() {
return Type.Object({
email: Type.String({
return s.object({
email: s.string({
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
password: s.string({
minLength: 8, // @todo: this should be configurable
}),
});
@@ -79,12 +79,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 +102,7 @@ export class PasswordStrategy extends Strategy<typeof schema> {
}
});
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
@@ -5,31 +5,31 @@ import type {
StrategyActions,
} from "../Authenticator";
import type { Hono } from "hono";
import type { Static, TSchema } from "@sinclair/typebox";
import { parse, type TObject } from "core/utils";
import { type s, parse } from "core/object/schema";
export type StrategyMode = "form" | "external";
export abstract class Strategy<Schema extends TSchema = TSchema> {
export abstract class Strategy<Schema extends s.Schema = s.Schema> {
protected actions: StrategyActions = {};
constructor(
protected config: Static<Schema>,
protected config: s.Static<Schema>,
public type: string,
public name: string,
public mode: StrategyMode,
) {
// don't worry about typing, it'll throw if invalid
this.config = parse(this.getSchema(), (config ?? {}) as any) as Static<Schema>;
this.config = parse(this.getSchema(), (config ?? {}) as any) as s.Static<Schema>;
}
protected registerAction<S extends TObject = TObject>(
protected registerAction<S extends s.ObjectSchema = s.ObjectSchema>(
name: StrategyActionName,
schema: S,
preprocess: StrategyAction<S>["preprocess"],
): void {
this.actions[name] = {
schema,
// @ts-expect-error - @todo: fix this
preprocess,
} as const;
}
@@ -50,7 +50,7 @@ export abstract class Strategy<Schema extends TSchema = TSchema> {
return this.name;
}
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
toJSON(secrets?: boolean): { type: string; config: s.Static<Schema> | {} | undefined } {
return {
type: this.getType(),
config: secrets ? this.config : undefined,
@@ -1,38 +1,36 @@
import { type Static, StrictObject, StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import type * as oauth from "oauth4webapi";
import { OAuthStrategy } from "./OAuthStrategy";
const { Type } = tbbox;
import { s } from "core/object/schema";
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = StrictObject(
const UrlString = s.string({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = s.strictObject(
{
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: Type.String(),
client: StrictObject({
client_id: Type.String(),
client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
type: s.string({ enum: ["oidc", "oauth2"] as const, default: "oidc" }),
name: s.string(),
client: s.object({
client_id: s.string(),
client_secret: s.string(),
token_endpoint_auth_method: s.string({ enum: ["client_secret_basic"] }),
}),
as: StrictObject({
issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())),
scope_separator: Type.Optional(Type.String({ default: " " })),
authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString),
as: s.strictObject({
issuer: s.string(),
code_challenge_methods_supported: s.string({ enum: ["S256"] }).optional(),
scopes_supported: s.array(s.string()).optional(),
scope_separator: s.string({ default: " " }).optional(),
authorization_endpoint: UrlString.optional(),
token_endpoint: UrlString.optional(),
userinfo_endpoint: UrlString.optional(),
}),
// @todo: profile mapping
},
{ title: "Custom OAuth" },
);
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>;
export type UserProfile = {
sub: string;
@@ -1,31 +1,32 @@
import type { AuthAction, Authenticator } from "auth";
import { Exception, isDebug } from "core";
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
import { filterKeys } from "core/utils";
import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi";
import * as issuers from "./issuers";
import * as tbbox from "@sinclair/typebox";
import { Strategy } from "auth/authenticate/strategies/Strategy";
const { Type } = tbbox;
import { s } from "core/object/schema";
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;
+2 -2
View File
@@ -26,7 +26,7 @@ export {
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash";
export {
/* export {
s,
parse,
jsc,
@@ -35,7 +35,7 @@ export {
openAPISpecs,
type ParseOptions,
InvalidSchemaError,
} from "./object/schema";
} from "./object/schema"; */
export * from "./drivers";
export * from "./console";
+34 -32
View File
@@ -1,43 +1,39 @@
import { get, has, omit, set } from "lodash-es";
import {
Default,
type Static,
type TObject,
getFullPathKeys,
mergeObjectWith,
parse,
stripMark,
} from "../utils";
import { getFullPathKeys, mergeObjectWith } from "../utils";
import { type s, parse, stripMark } from "core/object/schema";
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._default = _schema.template() as any;
this._value = initial
? parse(_schema, structuredClone(initial as any), {
withDefaults: true,
forceParse: this.isForceParse(),
skipMark: this.isForceParse(),
})
: this._default;
: (this._default as any);
this._config = Object.freeze(this._value);
}
@@ -45,18 +41,21 @@ export class SchemaObject<Schema extends TObject> {
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,7 +67,7 @@ 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, {
forceParse: true,
skipMark: this.isForceParse(),
@@ -118,9 +117,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 +167,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 +196,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 +204,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];
}
}
+39 -14
View File
@@ -1,6 +1,3 @@
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";
@@ -8,9 +5,18 @@ export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
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.TAnySchema,
public schema: s.Schema,
public value: unknown,
public errors: s.ErrorDetail[] = [],
) {
@@ -19,33 +25,52 @@ export class InvalidSchemaError extends Error {
`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;
coerse?: boolean;
coerce?: 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.TSchema>(schema: S): S => {
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.TAnySchema>(
export function parse<S extends s.Schema, Options extends ParseOptions = ParseOptions>(
_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;
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) : v;
if (opts?.withDefaults) {
value = schema.template(value, { withOptional: true });
}
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;
if (!result.valid) {
if (opts?.onError) {
opts.onError(result.errors);
} else {
throw new InvalidSchemaError(schema, v, result.errors);
}
}
return value as any;
-63
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;
+1 -1
View File
@@ -5,7 +5,7 @@ export * from "./perf";
export * from "./file";
export * from "./reqres";
export * from "./xml";
export type { Prettify, PrettifyRec } from "./types";
export type { Prettify, PrettifyRec, RecursivePartial } from "./types";
export * from "./typebox";
export * from "./dates";
export * from "./crypto";
+3 -10
View File
@@ -14,16 +14,9 @@ import {
type ValueErrorIterator,
} from "@sinclair/typebox/errors";
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
import type { RecursivePartial } from "../types";
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 = {
/* type ParseOptions = {
useDefaults?: boolean;
decode?: boolean;
onError?: (errors: ValueErrorIterator) => void;
@@ -198,4 +191,4 @@ SetErrorFunction((error) => {
export type { Static, StaticDecode, TSchema, TObject, ValueError, SchemaOptions };
export { Value, Default, Errors, Check };
export { Value, Default, Errors, Check }; */
+8
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];
};
+1 -1
View File
@@ -11,7 +11,7 @@ import { Module } from "modules/Module";
import { DataController } from "./api/DataController";
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
export class AppData extends Module<typeof dataConfigSchema> {
export class AppData extends Module<AppDataConfig> {
override async build() {
const {
entities: _entities = {},
+20 -14
View File
@@ -74,10 +74,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");
@@ -258,12 +260,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
@@ -356,10 +360,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({
+1 -1
View File
@@ -38,7 +38,7 @@ export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O>
export type SchemaResponse = [string, ColumnDataType, ColumnBuilderCallback] | undefined;
const FieldSpecTypes = [
export const FieldSpecTypes = [
"text",
"integer",
"real",
+33 -47
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 "core/object/schema";
export const FIELDS = {
...FieldClassMap,
@@ -16,69 +16,55 @@ export type FieldType = keyof typeof FIELDS;
export const RELATIONS = RelationClassMap;
export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
return tb.Type.Object(
return s.object(
{
type: tb.Type.Const(name, { default: name, readOnly: true }),
config: tb.Type.Optional(field.schema),
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.object({
type: s.string({ enum: entityTypes, default: "regular", readOnly: true }),
config: entityConfigSchema.optional(),
fields: entityFields.optional(),
});
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.object(
{
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>;
+18 -27
View File
@@ -1,11 +1,5 @@
import { $console, config } from "core";
import {
type Static,
StringEnum,
parse,
snakeToPascalWithSpaces,
transformObject,
} from "core/utils";
import { snakeToPascalWithSpaces, transformObject } from "core/utils";
import {
type Field,
PrimaryField,
@@ -13,25 +7,21 @@ import {
type TActionContext,
type TRenderContext,
} from "../fields";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s, parse } from "core/object/schema";
// @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"]>;
@@ -287,8 +277,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,
@@ -298,8 +290,7 @@ export class Entity<
...field.toJsonSchema(),
};
}),
{ additionalProperties: false },
);
};
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
}
+3 -3
View File
@@ -78,8 +78,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,
};
}
@@ -345,7 +345,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
...refQueryOptions,
where: {
...refQueryOptions.where,
..._options?.where,
...(_options?.where ?? {}),
},
};
+3 -2
View File
@@ -1,5 +1,6 @@
import { Exception } from "core";
import { HttpStatus, type TypeInvalidError } from "core/utils";
import { HttpStatus } from "core/utils";
import type { InvalidSchemaError } from "core/object/schema";
import type { Entity } from "./entities";
import type { Field } from "./fields";
@@ -42,7 +43,7 @@ export class InvalidFieldConfigException extends Exception {
constructor(
field: Field<any, any, any>,
public given: any,
error: TypeInvalidError,
error: InvalidSchemaError,
) {
console.error("InvalidFieldConfigException", {
given,
+10 -11
View File
@@ -1,18 +1,17 @@
import type { Static } from "core/utils";
import { omitKeys } from "core/utils";
import type { EntityManager } from "data";
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 "core/object/schema";
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 }),
...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 +85,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() {
+13 -19
View File
@@ -1,27 +1,21 @@
import { type Static, StringEnum, dayjs } from "core/utils";
import { dayjs, omitKeys } from "core/utils";
import type { EntityManager } from "../entities";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import { $console } from "core";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
import { s } from "core/object/schema";
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(),
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
})
.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,
@@ -143,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 {
+26 -42
View File
@@ -1,50 +1,33 @@
import { Const, type Static, StringEnum } from "core/utils";
import { omitKeys } from "core/utils";
import type { EntityManager } from "data";
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 "core/object/schema";
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(),
}),
);
+35 -50
View File
@@ -1,18 +1,10 @@
import {
parse,
snakeToPascalWithSpaces,
type Static,
StringEnum,
type TSchema,
TypeInvalidError,
} from "core/utils";
import { snakeToPascalWithSpaces } 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 } from "core/object/schema";
// @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails
@@ -31,43 +23,36 @@ 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,
},
),
export const baseFieldConfigSchema = s
.strictObject({
label: s.string(),
description: s.string(),
required: s.boolean({ default: DEFAULT_REQUIRED }),
fillable: s.anyOf(
[
s.boolean({ title: "Boolean", default: DEFAULT_FILLABLE }),
s.array(s.string({ enum: 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,
},
),
hidden: s.anyOf(
[
s.boolean({ title: "Boolean", default: DEFAULT_HIDDEN }),
// @todo: tmp workaround
s.array(s.string({ enum: TmpContext }), { title: "Context", uniqueItems: true }),
],
{
default: DEFAULT_HIDDEN,
},
),
// 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 +77,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 +89,7 @@ export abstract class Field<
return this.type;
}
protected abstract getSchema(): TSchema;
protected abstract getSchema(): s.ObjectSchema;
/**
* Used in SchemaManager.ts
@@ -224,16 +209,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 {
+9 -5
View File
@@ -1,14 +1,18 @@
import type { Static } from "core/utils";
import { omitKeys } from "core/utils";
import type { EntityManager } from "data";
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 "core/object/schema";
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,
+13 -19
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 { FromSchema, objectToJsLiteral, omitKeys } from "core/utils";
import type { EntityManager } from "data";
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 "core/object/schema";
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", default: {} }),
ui_schema: s.any({ type: "object", default: {} }),
default_from_schema: s.boolean(),
...omitKeys(baseFieldConfigSchema.properties, ["default_value"]),
})
.partial();
export type JsonSchemaFieldConfig = Static<typeof jsonSchemaFieldConfigSchema>;
export type JsonSchemaFieldConfig = s.Static<typeof jsonSchemaFieldConfigSchema>;
export class JsonSchemaField<
Required extends true | false = false,
@@ -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,
}),
+15 -21
View File
@@ -1,29 +1,23 @@
import type { Static } from "core/utils";
import type { EntityManager } from "data";
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 "core/object/schema";
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,
+17 -17
View File
@@ -1,22 +1,22 @@
import { config } from "core";
import { StringEnum, uuidv7, type Static } from "core/utils";
import { omitKeys, uuidv7 } from "core/utils";
import { Field, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
import { s } from "core/object/schema";
import type { FieldSpec } from "data/connection/Connection";
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,
@@ -41,7 +41,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 +67,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 {
+16 -34
View File
@@ -1,42 +1,24 @@
import type { EntityManager } from "data";
import type { Static } from "core/utils";
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 "core/object/schema";
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.object({
element: s.string({ default: "input" }),
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,
+8 -6
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 "core/object/schema";
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,
}),
+9 -10
View File
@@ -1,4 +1,4 @@
import { type Static, parse } from "core/utils";
import { parse } from "core/object/schema";
import type { ExpressionBuilder, SelectQueryBuilder } from "kysely";
import type { Entity, EntityData, EntityManager } from "../entities";
import {
@@ -8,9 +8,8 @@ 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;
import { s } from "core/object/schema";
const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number];
@@ -18,13 +17,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 +32,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;
+7 -16
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 "core/object/schema";
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,
+13 -24
View File
@@ -1,6 +1,5 @@
import type { PrimaryFieldType } from "core";
import { snakeToPascalWithSpaces } from "core/utils";
import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import type { RepoQuery } from "../server/query";
@@ -9,8 +8,7 @@ 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;
import { s } from "core/object/schema";
/**
* Source entity receives the mapping field
@@ -20,7 +18,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 +26,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;
+6 -15
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 "core/object/schema";
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;
+12 -16
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 "core/object/schema";
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}`,
}),
);
+32 -16
View File
@@ -2,7 +2,7 @@ import { s } from "core/object/schema";
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
import { $console } from "core";
import { isObject } from "core/utils";
import type { CoercionOptions, TAnyOf } from "jsonv-ts";
import type { anyOf, CoercionOptions, Schema } from "jsonv-ts";
// -------
// helpers
@@ -36,10 +36,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,
@@ -88,9 +90,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") {
@@ -126,15 +128,17 @@ 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({
@@ -151,3 +155,15 @@ export type RepoQueryIn = {
where?: WhereQuery;
};
export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
//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;
}; */
+6 -3
View File
@@ -1,15 +1,16 @@
import { type Static, transformObject } from "core/utils";
import { 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 } from "core/object/schema";
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 +81,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
this.setBuilt();
}
// @todo: fix this
// @ts-expect-error
override toJSON() {
return {
...this.config,
+41 -60
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 "core/object/schema";
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: {} }),
});
+5 -9
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";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
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)) {
+7 -11
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 "core/object/schema";
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;
+5 -7
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 "core/object/schema";
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 ?? {});
+22 -17
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 "core/object/schema";
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,10 @@ export type TaskResult<Output = any> = {
export type TaskRenderProps<T extends Task = Task> = any;
export function dynamic<Type extends TSchema>(
// @todo: CURRENT WORKAROUND
export const dynamic = <S extends s.Schema>(a: S, b?: any) => null as unknown as S;
/* export function dynamic<Type extends TSchema>(
type: Type,
parse?: (val: any | string) => Static<Type>,
) {
@@ -51,23 +55,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 +85,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 +97,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 +138,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>) {
+14 -18
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 "core/object/schema";
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 {
+3 -4
View File
@@ -1,13 +1,12 @@
import { Task } from "../Task";
import { $console } from "core";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export class LogTask extends Task<typeof LogTask.schema> {
type = "log";
static override schema = Type.Object({
delay: Type.Number({ default: 10 }),
static override schema = s.strictObject({
delay: s.number({ default: 10 }),
});
async execute() {
+3 -4
View File
@@ -1,6 +1,5 @@
import { Task } from "../Task";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export class RenderTask<Output extends Record<string, any>> extends Task<
typeof RenderTask.schema,
@@ -8,8 +7,8 @@ export class RenderTask<Output extends Record<string, any>> extends Task<
> {
type = "render";
static override schema = Type.Object({
render: Type.String(),
static override schema = s.strictObject({
render: s.string(),
});
async execute() {
+5 -6
View File
@@ -1,7 +1,6 @@
import { Flow } from "../../flows/Flow";
import { Task, dynamic } from "../Task";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export class SubFlowTask<Output extends Record<string, any>> extends Task<
typeof SubFlowTask.schema,
@@ -9,10 +8,10 @@ export class SubFlowTask<Output extends Record<string, any>> extends Task<
> {
type = "subflow";
static override schema = Type.Object({
flow: Type.Any(),
input: Type.Optional(dynamic(Type.Any(), JSON.parse)),
loop: Type.Optional(Type.Boolean()),
static override schema = s.strictObject({
flow: s.any(),
input: dynamic(s.any(), JSON.parse).optional(),
loop: s.boolean().optional(),
});
async execute() {
+2 -2
View File
@@ -13,7 +13,7 @@ import {
text,
} from "../data/prototype";
import { MediaController } from "./api/MediaController";
import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
declare module "core" {
@@ -23,7 +23,7 @@ declare module "core" {
}
}
export class AppMedia extends Module<typeof mediaConfigSchema> {
export class AppMedia extends Module<TAppMediaConfig> {
private _storage?: Storage;
override async build() {
+11 -13
View File
@@ -1,19 +1,17 @@
import type { Static } from "core/utils";
import { Field, baseFieldConfigSchema } from "data/fields";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export const mediaFieldConfigSchema = Type.Composite([
Type.Object({
entity: Type.String(), // @todo: is this really required?
min_items: Type.Optional(Type.Number()),
max_items: Type.Optional(Type.Number()),
mime_types: Type.Optional(Type.Array(Type.String())),
}),
baseFieldConfigSchema,
]);
export const mediaFieldConfigSchema = s
.strictObject({
entity: s.string(), // @todo: is this really required?
min_items: s.number(),
max_items: s.number(),
mime_types: s.array(s.string()),
...baseFieldConfigSchema.properties,
})
.partial();
export type MediaFieldConfig = Static<typeof mediaFieldConfigSchema>;
export type MediaFieldConfig = s.Static<typeof mediaFieldConfigSchema>;
export type MediaItem = {
id: number;
+3 -2
View File
@@ -16,6 +16,7 @@ import {
StorageCloudinaryAdapter,
} from "./storage/adapters/cloudinary/StorageCloudinaryAdapter";
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/s3/StorageS3Adapter";
import type { s } from "core/object/schema";
export { StorageAdapter };
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
@@ -29,10 +30,10 @@ type ClassThatImplements<T> = Constructor<T> & { prototype: T };
export const MediaAdapterRegistry = new Registry<{
cls: ClassThatImplements<StorageAdapter>;
schema: TObject;
schema: s.Schema;
}>((cls: ClassThatImplements<StorageAdapter>) => ({
cls,
schema: cls.prototype.getSchema() as TObject,
schema: cls.prototype.getSchema() as s.Schema,
}))
.register("s3", StorageS3Adapter)
.register("cloudinary", StorageCloudinaryAdapter);
+19 -29
View File
@@ -1,8 +1,7 @@
import { Const, type Static, objectTransform } from "core/utils";
import { objectTransform } from "core/utils";
import { Adapters } from "media";
import { registries } from "modules/registries";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export const ADAPTERS = {
...Adapters,
@@ -12,42 +11,33 @@ export const registry = registries.media;
export function buildMediaSchema() {
const adapterSchemaObject = objectTransform(registry.all(), (adapter, name) => {
return Type.Object(
return s.strictObject(
{
type: Const(name),
type: s.literal(name),
config: adapter.schema,
},
{
title: adapter.schema?.title ?? name,
description: adapter.schema?.description,
additionalProperties: false,
},
);
});
const adapterSchema = Type.Union(Object.values(adapterSchemaObject));
return Type.Object(
{
enabled: Type.Boolean({ default: false }),
basepath: Type.String({ default: "/api/media" }),
entity_name: Type.String({ default: "media" }),
storage: Type.Object(
{
body_max_size: Type.Optional(
Type.Number({
description: "Max size of the body in bytes. Leave blank for unlimited.",
}),
),
},
{ default: {} },
),
adapter: Type.Optional(adapterSchema),
},
{
additionalProperties: false,
},
);
return s.strictObject({
enabled: s.boolean({ default: false }),
basepath: s.string({ default: "/api/media" }),
entity_name: s.string({ default: "media" }),
storage: s.strictObject(
{
body_max_size: s.number({
description: "Max size of the body in bytes. Leave blank for unlimited.",
}),
},
{ default: {} },
),
adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(),
});
}
export const mediaConfigSchema = buildMediaSchema();
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;
export type TAppMediaConfig = s.Static<typeof mediaConfigSchema>;
+2 -2
View File
@@ -1,6 +1,6 @@
import type { FileListObject, FileMeta } from "media";
import type { FileBody, FileUploadPayload } from "media/storage/Storage";
import type { TSchema } from "@sinclair/typebox";
import type { s } from "core/object/schema";
const SYMBOL = Symbol.for("bknd:storage");
@@ -32,6 +32,6 @@ export abstract class StorageAdapter {
abstract getObject(key: string, headers: Headers): Promise<Response>;
abstract getObjectUrl(key: string): string;
abstract getObjectMeta(key: string): Promise<FileMeta>;
abstract getSchema(): TSchema | undefined;
abstract getSchema(): s.Schema | undefined;
abstract toJSON(secrets?: boolean): any;
}
@@ -1,21 +1,19 @@
import { hash, pickHeaders } from "core/utils";
import { type Static, parse } from "core/utils";
import type { FileBody, FileListObject, FileMeta } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s, parse } from "core/object/schema";
export const cloudinaryAdapterConfig = Type.Object(
export const cloudinaryAdapterConfig = s.object(
{
cloud_name: Type.String(),
api_key: Type.String(),
api_secret: Type.String(),
upload_preset: Type.Optional(Type.String()),
cloud_name: s.string(),
api_key: s.string(),
api_secret: s.string(),
upload_preset: s.string().optional(),
},
{ title: "Cloudinary", description: "Cloudinary media storage" },
);
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
export type CloudinaryConfig = s.Static<typeof cloudinaryAdapterConfig>;
type CloudinaryObject = {
asset_id: string;
@@ -7,18 +7,17 @@ import type {
PutObjectRequest,
} from "@aws-sdk/client-s3";
import { AwsClient, isDebug } from "core";
import { type Static, isFile, parse, pickHeaders2 } from "core/utils";
import { isFile, pickHeaders2 } from "core/utils";
import { transform } from "lodash-es";
import type { FileBody, FileListObject } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { parse, s } from "core/object/schema";
export const s3AdapterConfig = Type.Object(
export const s3AdapterConfig = s.object(
{
access_key: Type.String(),
secret_access_key: Type.String(),
url: Type.String({
access_key: s.string(),
secret_access_key: s.string(),
url: s.string({
pattern: "^https?://(?:.*)?[^/.]+$",
description: "URL to S3 compatible endpoint without trailing slash",
examples: [
@@ -33,7 +32,7 @@ export const s3AdapterConfig = Type.Object(
},
);
export type S3AdapterConfig = Static<typeof s3AdapterConfig>;
export type S3AdapterConfig = s.Static<typeof s3AdapterConfig>;
export class StorageS3Adapter extends StorageAdapter {
readonly #config: S3AdapterConfig;
+17 -2
View File
@@ -5,7 +5,7 @@ import type { SafeUser } from "auth";
import type { EntityManager } from "data";
import { s } from "core/object/schema";
export type ServerEnv = Env & {
export interface ServerEnv extends Env {
Variables: {
app: App;
// to prevent resolving auth multiple times
@@ -17,7 +17,22 @@ export type ServerEnv = Env & {
};
html?: string;
};
};
[key: string]: any;
}
/* export type ServerEnv = Env & {
Variables: {
app: App;
// to prevent resolving auth multiple times
auth?: {
resolved: boolean;
registered: boolean;
skip: boolean;
user?: SafeUser;
};
html?: string;
};
}; */
export class Controller {
protected middlewares = middlewares;
+13 -8
View File
@@ -1,11 +1,13 @@
import type { Guard } from "auth";
import { type DebugLogger, SchemaObject } from "core";
import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils";
import type { Connection, EntityManager } from "data";
import type { Hono } from "hono";
import type { ServerEnv } from "modules/Controller";
import type { ModuleHelper } from "./ModuleHelper";
import type { s } from "core/object/schema";
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type ModuleBuildContext = {
connection: Connection;
@@ -18,13 +20,13 @@ export type ModuleBuildContext = {
helper: ModuleHelper;
};
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
export abstract class Module<Schema extends object = object> {
private _built = false;
private _schema: SchemaObject<ReturnType<(typeof this)["getSchema"]>>;
private _listener: any = () => null;
constructor(
initial?: Partial<Static<Schema>>,
initial?: PartialRec<Schema>,
protected _ctx?: ModuleBuildContext,
) {
this._schema = new SchemaObject(this.getSchema(), initial, {
@@ -47,7 +49,7 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
ctx_reload_required: boolean;
};
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
onBeforeUpdate(from: Schema, to: Schema): Schema | Promise<Schema> {
return to;
}
@@ -75,11 +77,13 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
return undefined;
}
get configDefault(): Static<ReturnType<(typeof this)["getSchema"]>> {
return this._schema.default();
//get configDefault(): s.Static<ReturnType<(typeof this)["getSchema"]>> {
get configDefault(): Schema {
return this._schema.default() as any;
}
get config(): Static<ReturnType<(typeof this)["getSchema"]>> {
//get config(): s.Static<ReturnType<(typeof this)["getSchema"]>> {
get config(): Schema {
return this._schema.get();
}
@@ -130,7 +134,8 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
}
}
toJSON(secrets?: boolean): Static<ReturnType<(typeof this)["getSchema"]>> {
//toJSON(secrets?: boolean): s.Static<ReturnType<(typeof this)["getSchema"]>> {
toJSON(secrets?: boolean): Schema {
return this.config;
}
}
+14 -22
View File
@@ -2,15 +2,7 @@ import { Guard } from "auth";
import { $console, BkndError, DebugLogger, env } from "core";
import { EventManager } from "core/events";
import * as $diff from "core/object/diff";
import {
Default,
type Static,
StringEnum,
mark,
objectEach,
stripMark,
transformObject,
} from "core/utils";
import { objectEach, transformObject } from "core/utils";
import type { Connection, Schema } from "data";
import { EntityManager } from "data/entities/EntityManager";
import * as proto from "data/prototype";
@@ -26,18 +18,17 @@ import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia";
import type { ServerEnv } from "./Controller";
import { Module, type ModuleBuildContext } from "./Module";
import * as tbbox from "@sinclair/typebox";
import { ModuleHelper } from "./ModuleHelper";
const { Type } = tbbox;
import { s, mark, stripMark } from "core/object/schema";
export type { ModuleBuildContext };
export const MODULES = {
server: AppServer,
data: AppData,
data: AppData, // @todo:
auth: AppAuth,
media: AppMedia,
flows: AppFlows,
flows: AppFlows, // @todo:
} as const;
// get names of MODULES as an array
@@ -53,7 +44,7 @@ export type ModuleSchemas = {
};
export type ModuleConfigs = {
[K in keyof ModuleSchemas]: Static<ModuleSchemas[K]>;
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
};
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
@@ -101,14 +92,14 @@ export type ConfigTable<Json = ModuleConfigs> = {
updated_at?: Date;
};
const configJsonSchema = Type.Union([
const configJsonSchema = s.anyOf([
getDefaultSchema(),
Type.Array(
Type.Object({
t: StringEnum(["a", "r", "e"]),
p: Type.Array(Type.Union([Type.String(), Type.Number()])),
o: Type.Optional(Type.Any()),
n: Type.Optional(Type.Any()),
s.array(
s.strictObject({
t: s.string({ enum: ["a", "r", "e"] }),
p: s.array(s.anyOf([s.string(), s.number()])),
o: s.any().optional(),
n: s.any().optional(),
}),
),
]);
@@ -717,7 +708,8 @@ export function getDefaultSchema() {
export function getDefaultConfig(): ModuleConfigs {
const config = transformObject(MODULES, (module) => {
return Default(module.prototype.getSchema(), {});
return module.prototype.getSchema().template();
//return Default(module.prototype.getSchema(), {});
});
return config as any;
+16 -26
View File
@@ -1,37 +1,27 @@
import { Exception, isDebug, $console } from "core";
import { type Static, StringEnum } from "core/utils";
import { cors } from "hono/cors";
import { Module } from "modules/Module";
import * as tbbox from "@sinclair/typebox";
import { AuthException } from "auth/errors";
const { Type } = tbbox;
import { s } from "core/object/schema";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
export const serverConfigSchema = Type.Object(
{
cors: Type.Object(
{
origin: Type.String({ default: "*" }),
allow_methods: Type.Array(StringEnum(serverMethods), {
default: serverMethods,
uniqueItems: true,
}),
allow_headers: Type.Array(Type.String(), {
default: ["Content-Type", "Content-Length", "Authorization", "Accept"],
}),
},
{ default: {}, additionalProperties: false },
),
},
{
additionalProperties: false,
},
);
export const serverConfigSchema = s.strictObject({
cors: s.strictObject({
origin: s.string({ default: "*" }),
allow_methods: s.array(s.string({ enum: serverMethods }), {
default: serverMethods,
uniqueItems: true,
}),
allow_headers: s.array(s.string(), {
default: ["Content-Type", "Content-Length", "Authorization", "Accept"],
}),
}),
});
export type AppServerConfig = Static<typeof serverConfigSchema>;
export type AppServerConfig = s.Static<typeof serverConfigSchema>;
export class AppServer extends Module<typeof serverConfigSchema> {
export class AppServer extends Module<AppServerConfig> {
override getRestrictedPaths() {
return [];
}
+12 -17
View File
@@ -1,15 +1,8 @@
/// <reference types="@cloudflare/workers-types" />
import type { App } from "App";
import { $console, tbValidator as tb } from "core";
import {
StringEnum,
TypeInvalidError,
datetimeStringLocal,
datetimeStringUTC,
getTimezone,
getTimezoneOffset,
} from "core/utils";
import { $console } from "core";
import { datetimeStringLocal, datetimeStringUTC, getTimezone, getTimezoneOffset } from "core/utils";
import { getRuntimeKey } from "core/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
@@ -20,11 +13,11 @@ import {
type ModuleConfigs,
type ModuleSchemas,
type ModuleKey,
getDefaultConfig,
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { jsc, s, describeRoute } from "core/object/schema";
import { jsc, s, describeRoute, InvalidSchemaError } from "core/object/schema";
import { getVersion } from "core/env";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
module: Key;
@@ -104,7 +97,7 @@ export class SystemController extends Controller {
} catch (e) {
$console.error("config update error", e);
if (e instanceof TypeInvalidError) {
if (e instanceof InvalidSchemaError) {
return c.json(
{ success: false, type: "type-invalid", errors: e.errors },
{ status: 400 },
@@ -234,11 +227,13 @@ export class SystemController extends Controller {
permission(SystemPermissions.schemaRead),
jsc(
"query",
s.partialObject({
config: s.boolean(),
secrets: s.boolean(),
fresh: s.boolean(),
}),
s
.object({
config: s.boolean(),
secrets: s.boolean(),
fresh: s.boolean(),
})
.partial(),
),
async (c) => {
const module = c.req.param("module") as ModuleKey | undefined;
+4 -15
View File
@@ -1,4 +1,4 @@
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { transformObject } from "core/utils";
import { constructEntity } from "data";
import {
type TAppDataEntity,
@@ -13,8 +13,7 @@ import {
import { useBknd } from "ui/client/bknd";
import type { TSchemaActions } from "ui/client/schema/actions";
import { bkndModals } from "ui/modals";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
import { s, parse, InvalidSchemaError } from "core/object/schema";
export function useBkndData() {
const { config, app, schema, actions: bkndActions } = useBknd();
@@ -27,12 +26,10 @@ export function useBkndData() {
const actions = {
entity: {
add: async (name: string, data: TAppDataEntity) => {
console.log("create entity", { data });
const validated = parse(entitiesSchema, data, {
skipMark: true,
forceParse: true,
});
console.log("validated", validated);
// @todo: check for existing?
return await bkndActions.add("data", `entities.${name}`, validated);
},
@@ -44,7 +41,6 @@ export function useBkndData() {
return {
config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => {
console.log("patch config", entityName, partial);
return await bkndActions.overwrite(
"data",
`entities.${entityName}.config`,
@@ -57,13 +53,11 @@ export function useBkndData() {
},
relations: {
add: async (relation: TAppDataRelation) => {
console.log("create relation", { relation });
const name = crypto.randomUUID();
const validated = parse(Type.Union(relationsSchema), relation, {
const validated = parse(s.anyOf(relationsSchema), relation, {
skipMark: true,
forceParse: true,
});
console.log("validated", validated);
return await bkndActions.add("data", `relations.${name}`, validated);
},
},
@@ -120,17 +114,14 @@ const modals = {
function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
return {
add: async (name: string, field: TAppDataField) => {
console.log("create field", { name, field });
const validated = parse(fieldsSchema, field, {
skipMark: true,
forceParse: true,
});
console.log("validated", validated);
return await bkndActions.add("data", `entities.${entityName}.fields.${name}`, validated);
},
patch: () => null,
set: async (fields: TAppDataEntityFields) => {
console.log("set fields", entityName, fields);
try {
const validated = parse(entityFields, fields, {
skipMark: true,
@@ -141,11 +132,9 @@ function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
`entities.${entityName}.fields`,
validated,
);
console.log("res", res);
//bkndActions.set("data", "entities", fields);
} catch (e) {
console.error("error", e);
if (e instanceof TypeInvalidError) {
if (e instanceof InvalidSchemaError) {
alert("Error updating fields: " + e.firstToString());
} else {
alert("An error occured, check console. There will be nice error handling soon.");
+1 -4
View File
@@ -1,4 +1,4 @@
import { type Static, parse } from "core/utils";
import { parse } from "core/object/schema";
import { type TAppFlowSchema, flowSchema } from "flows/flows-schema";
import { useBknd } from "../../BkndProvider";
@@ -8,11 +8,8 @@ export function useFlows() {
const actions = {
flow: {
create: async (name: string, data: TAppFlowSchema) => {
console.log("would create", name, data);
const parsed = parse(flowSchema, data, { skipMark: true, forceParse: true });
console.log("parsed", parsed);
const res = await bkndActions.add("flows", `flows.${name}`, parsed);
console.log("res", res);
},
},
};
@@ -1,4 +1,5 @@
import { Check, Errors } from "core/utils";
import { Check } from "@sinclair/typebox/value";
import { Errors } from "@sinclair/typebox/errors";
import { FromSchema } from "./from-schema";
import type {
+9 -11
View File
@@ -6,15 +6,13 @@ import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink";
import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils";
import type { Validator } from "json-schema-form-react";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
import type { ErrorDetail } from "jsonv-ts";
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
class JsonvTsValidator implements Validator<ErrorDetail> {
async validate(schema: s.Schema, data: any) {
return schema.validate(data).errors;
}
}
@@ -27,12 +25,12 @@ export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" |
buttonLabel?: string;
};
const validator = new TypeboxValidator();
const schema = Type.Object({
email: Type.String({
const validator = new JsonvTsValidator();
const schema = s.strictObject({
email: s.string({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
password: s.string({
minLength: 8, // @todo: this should be configurable
}),
});
+2 -2
View File
@@ -4,12 +4,12 @@ import { useLocation, useSearch as useWouterSearch } from "wouter";
import { type s, parse } from "core/object/schema";
import { useEffect, useMemo, useState } from "react";
export type UseSearchOptions<Schema extends s.TAnySchema = s.TAnySchema> = {
export type UseSearchOptions<Schema extends s.Schema = s.Schema> = {
defaultValue?: Partial<s.StaticCoerced<Schema>>;
beforeEncode?: (search: Partial<s.StaticCoerced<Schema>>) => object;
};
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
export function useSearch<Schema extends s.Schema = s.Schema>(
schema: Schema,
options?: UseSearchOptions<Schema>,
) {
@@ -1,6 +1,5 @@
import type { ModalProps } from "@mantine/core";
import type { ContextModalProps } from "@mantine/modals";
import { type Static, StringEnum, StringIdentifier } from "core/utils";
import { entitiesSchema, fieldsSchema, relationsSchema } from "data/data-schema";
import { useState } from "react";
import { type Modal2Ref, ModalBody, ModalFooter, ModalTitle } from "ui/components/modal/Modal2";
@@ -11,58 +10,51 @@ import { StepEntityFields } from "./step.entity.fields";
import { StepRelation } from "./step.relation";
import { StepSelect } from "./step.select";
import Templates from "./templates/register";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export type CreateModalRef = Modal2Ref;
export const ModalActions = ["entity", "relation", "media"] as const;
export const entitySchema = Type.Composite([
Type.Object({
name: StringIdentifier,
}),
entitiesSchema,
]);
const schemaAction = Type.Union([
StringEnum(["entity", "relation", "media"]),
Type.String({ pattern: "^template-" }),
]);
export type TSchemaAction = Static<typeof schemaAction>;
const createFieldSchema = Type.Object({
entity: StringIdentifier,
name: StringIdentifier,
field: Type.Array(fieldsSchema),
export const entitySchema = s.object({
name: s.string(),
...entitiesSchema.properties,
});
export type TFieldCreate = Static<typeof createFieldSchema>;
const createModalSchema = Type.Object(
{
action: schemaAction,
initial: Type.Optional(Type.Any()),
entities: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(entitySchema)),
}),
),
relations: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(Type.Union(relationsSchema))),
}),
),
fields: Type.Optional(
Type.Object({
create: Type.Optional(Type.Array(createFieldSchema)),
}),
),
},
{
additionalProperties: false,
},
);
export type TCreateModalSchema = Static<typeof createModalSchema>;
// @todo: this union is not fully working, just "string"
const schemaAction = s.anyOf([
s.string({ enum: ["entity", "relation", "media"] }),
s.string({ pattern: "^template-" }),
]);
export type TSchemaAction = s.Static<typeof schemaAction>;
const createFieldSchema = s.object({
entity: s.string(),
name: s.string(),
field: s.array(fieldsSchema),
});
export type TFieldCreate = s.Static<typeof createFieldSchema>;
const createModalSchema = s.strictObject({
action: schemaAction,
initial: s.any().optional(),
entities: s
.object({
create: s.array(entitySchema).optional(),
})
.optional(),
relations: s
.object({
create: s.array(s.anyOf(relationsSchema)).optional(),
})
.optional(),
fields: s
.object({
create: s.array(createFieldSchema).optional(),
})
.optional(),
});
export type TCreateModalSchema = s.Static<typeof createModalSchema>;
export function CreateModal({
context,
@@ -70,7 +62,6 @@ export function CreateModal({
innerProps: { initialPath = [], initialState },
}: ContextModalProps<{ initialPath?: string[]; initialState?: TCreateModalSchema }>) {
const [path, setPath] = useState<string[]>(initialPath);
console.log("...", initialPath, initialState);
function close() {
context.closeModal(id);
@@ -1,5 +1,5 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { type Static, objectCleanEmpty } from "core/utils";
//import { typeboxResolver } from "@hookform/resolvers/typebox";
import { objectCleanEmpty } from "core/utils";
import { type TAppDataEntityFields, entitiesSchema } from "data/data-schema";
import { mergeWith } from "lodash-es";
import { useRef } from "react";
@@ -12,9 +12,10 @@ import {
} from "ui/routes/data/forms/entity.fields.form";
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import type { s } from "core/object/schema";
const schema = entitiesSchema;
type Schema = Static<typeof schema>;
type Schema = s.Static<typeof schema>;
export function StepEntityFields() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
@@ -40,7 +41,8 @@ export function StepEntityFields() {
setValue,
} = useForm({
mode: "onTouched",
resolver: typeboxResolver(schema),
// @todo: add resolver
//resolver: typeboxResolver(schema),
defaultValues: initial as NonNullable<Schema>,
});
@@ -1,4 +1,4 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
//import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput, Textarea } from "@mantine/core";
import { useFocusTrap } from "@mantine/hooks";
@@ -10,7 +10,6 @@ import {
entitySchema,
useStepContext,
} from "./CreateModal";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
export function StepEntity() {
const focusTrapRef = useFocusTrap();
@@ -18,7 +17,8 @@ export function StepEntity() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { register, handleSubmit, formState, watch, control } = useForm({
mode: "onTouched",
resolver: typeboxResolver(entitySchema),
// @todo: add resolver
//resolver: typeboxResolver(entitySchema),
defaultValues: state.entities?.create?.[0] ?? {},
});
/*const data = watch();
@@ -1,8 +1,5 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Switch, TextInput } from "@mantine/core";
import { TypeRegistry } from "@sinclair/typebox";
import { IconDatabase } from "@tabler/icons-react";
import { type Static, StringEnum, StringIdentifier, registerCustomTypeboxKinds } from "core/utils";
import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
import type { ReactNode } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
@@ -14,11 +11,7 @@ import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelec
import { useStepContext } from "ui/components/steps/Steps";
import { useEvent } from "ui/hooks/use-event";
import { ModalBody, ModalFooter, type TCreateModalSchema } from "./CreateModal";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
// @todo: check if this could become an issue
registerCustomTypeboxKinds(TypeRegistry);
import { s, stringIdentifier } from "core/object/schema";
const Relations: {
type: RelationType;
@@ -47,11 +40,11 @@ const Relations: {
},
];
const schema = Type.Object({
type: StringEnum(Relations.map((r) => r.type)),
source: StringIdentifier,
target: StringIdentifier,
config: Type.Object({}),
const schema = s.strictObject({
type: s.string({ enum: Relations.map((r) => r.type) }),
source: stringIdentifier,
target: stringIdentifier,
config: s.object({}),
});
type ComponentCtx<T extends FieldValues = FieldValues> = {
@@ -73,8 +66,9 @@ export function StepRelation() {
watch,
control,
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: (state.relations?.create?.[0] ?? {}) as Static<typeof schema>,
// @todo: implement resolver
//resolver: typeboxResolver(schema),
defaultValues: (state.relations?.create?.[0] ?? {}) as s.Static<typeof schema>,
});
const data = watch();
@@ -1,6 +1,5 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Radio, TextInput } from "@mantine/core";
import { Default, type Static, StringEnum, StringIdentifier, transformObject } from "core/utils";
import { transformObject } from "core/utils";
import type { MediaFieldConfig } from "media/MediaField";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -15,16 +14,15 @@ import {
type TFieldCreate,
useStepContext,
} from "../../CreateModal";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s, stringIdentifier } from "core/object/schema";
const schema = Type.Object({
entity: StringIdentifier,
cardinality_type: StringEnum(["single", "multiple"], { default: "multiple" }),
cardinality: Type.Optional(Type.Number({ minimum: 1 })),
name: StringIdentifier,
const schema = s.object({
entity: stringIdentifier,
cardinality_type: s.string({ enum: ["single", "multiple"], default: "multiple" }),
cardinality: s.number({ minimum: 1 }).optional(),
name: stringIdentifier,
});
type TCreateModalMediaSchema = Static<typeof schema>;
type TCreateModalMediaSchema = s.Static<typeof schema>;
export function TemplateMediaComponent() {
const { stepBack, setState, state, path, nextStep } = useStepContext<TCreateModalSchema>();
@@ -36,8 +34,10 @@ export function TemplateMediaComponent() {
control,
} = useForm({
mode: "onChange",
resolver: typeboxResolver(schema),
defaultValues: Default(schema, state.initial ?? {}) as TCreateModalMediaSchema,
// @todo: add resolver
//resolver: typeboxResolver(schema),
defaultValues: schema.template(state.initial ?? {}) as TCreateModalMediaSchema,
//defaultValues: Default(schema, state.initial ?? {}) as TCreateModalMediaSchema,
});
const [forbidden, setForbidden] = useState<boolean>(false);
@@ -1,11 +1,10 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { Const, transformObject } from "core/utils";
import { transformObject } from "core/utils";
import { type Trigger, TriggerMap } from "flows";
import type { IconType } from "react-icons";
import { TbCircleLetterT } from "react-icons/tb";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
Icon?: IconType;
@@ -14,9 +13,9 @@ export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
const triggerSchemas = Object.values(
transformObject(TriggerMap, (trigger, name) =>
Type.Object(
s.object(
{
type: Const(name),
type: s.literal(name),
config: trigger.cls.schema,
},
{ title: String(name), additionalProperties: false },
@@ -47,7 +46,7 @@ export function TriggerComponent({
<div className="flex flex-col gap-2 px-3 py-2">
<JsonSchemaForm
className="legacy"
schema={Type.Union(triggerSchemas)}
schema={s.anyOf(triggerSchemas)}
onChange={console.log}
formData={trigger}
{...props}
@@ -1,30 +1,25 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, NativeSelect, Select, TextInput } from "@mantine/core";
import { Input, TextInput } from "@mantine/core";
import { useToggle } from "@mantine/hooks";
import { IconMinus, IconPlus, IconWorld } from "@tabler/icons-react";
import type { Node, NodeProps } from "@xyflow/react";
import type { Static } from "core/utils";
import { s } from "core/object/schema";
import { FetchTask } from "flows";
import { useRef, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "ui/components/buttons/Button";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { SegmentedControl } from "ui/components/form/SegmentedControl";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { type TFlowNodeData, useFlowSelector } from "../../../hooks/use-flow";
import type { TFlowNodeData } from "../../../hooks/use-flow";
import { KeyValueInput } from "../../form/KeyValueInput";
import { BaseNode } from "../BaseNode";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const schema = Type.Composite([
FetchTask.schema,
Type.Object({
query: Type.Optional(Type.Record(Type.String(), Type.String())),
}),
]);
const schema = s.object({
query: s.record(s.string()).optional(),
...FetchTask.schema.properties,
});
type TFetchTaskSchema = Static<typeof FetchTask.schema>;
type TFetchTaskSchema = s.Static<typeof FetchTask.schema>;
type FetchTaskFormProps = NodeProps<Node<TFlowNodeData>> & {
params: TFetchTaskSchema;
onChange: (params: any) => void;
@@ -42,8 +37,9 @@ export function FetchTaskForm({ onChange, params, ...props }: FetchTaskFormProps
watch,
control,
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: params as Static<typeof schema>,
// @todo: add resolver
//resolver: typeboxResolver(schema),
defaultValues: params as s.Static<typeof schema>,
mode: "onChange",
//defaultValues: (state.relations?.create?.[0] ?? {}) as Static<typeof schema>
});
@@ -1,14 +1,10 @@
import { TypeRegistry } from "@sinclair/typebox";
import { type Node, type NodeProps, Position } from "@xyflow/react";
import { registerCustomTypeboxKinds } from "core/utils";
import type { TAppFlowTaskSchema } from "flows/AppFlows";
import { useFlowCanvas, useFlowSelector } from "../../../hooks/use-flow";
import { Handle } from "../Handle";
import { FetchTaskForm } from "./FetchTaskNode";
import { RenderNode } from "./RenderNode";
registerCustomTypeboxKinds(TypeRegistry);
const TaskComponents = {
fetch: FetchTaskForm,
render: RenderNode,
@@ -1,7 +1,6 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput } from "@mantine/core";
import type { Node, NodeProps } from "@xyflow/react";
import { Const, type Static, registerCustomTypeboxKinds, transformObject } from "core/utils";
import { transformObject } from "core/utils";
import { TriggerMap } from "flows";
import type { TAppFlowTriggerSchema } from "flows/AppFlows";
import { useForm } from "react-hook-form";
@@ -11,22 +10,18 @@ import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelec
import { useFlowCanvas, useFlowSelector } from "../../../hooks/use-flow";
import { BaseNode } from "../BaseNode";
import { Handle } from "../Handle";
import * as tb from "@sinclair/typebox";
const { Type, TypeRegistry } = tb;
import { s } from "core/object/schema";
// @todo: check if this could become an issue
registerCustomTypeboxKinds(TypeRegistry);
const schema = Type.Object({
trigger: Type.Union(
const schema = s.object({
trigger: s.anyOf(
Object.values(
transformObject(TriggerMap, (trigger, name) =>
Type.Object(
s.strictObject(
{
type: Const(name),
type: s.literal(name),
config: trigger.cls.schema,
},
{ title: String(name), additionalProperties: false },
{ title: String(name) },
),
),
),
@@ -50,13 +45,14 @@ export const TriggerNode = (props: NodeProps<Node<TAppFlowTriggerSchema & { labe
watch,
control,
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: { trigger: state } as Static<typeof schema>,
// @todo: add resolver
//resolver: typeboxResolver(schema),
defaultValues: { trigger: state } as s.Static<typeof schema>,
mode: "onChange",
});
const data = watch("trigger");
async function onSubmit(data: Static<typeof schema>) {
async function onSubmit(data: s.Static<typeof schema>) {
console.log("submit", data.trigger);
// @ts-ignore
await actions.trigger.update(data.trigger);
@@ -46,7 +46,7 @@ export const flowStateAtom = atom<TFlowState>({
const FlowCanvasContext = createContext<FlowContextType>(undefined!);
const DEFAULT_FLOW = { trigger: {}, tasks: {}, connections: {} };
const DEFAULT_FLOW: TAppFlowSchema = { trigger: { type: "manual" }, tasks: {}, connections: {} };
export function FlowCanvasProvider({ children, name }: { children: any; name?: string }) {
//const [dirty, setDirty] = useState(false);
const setFlowState = useSetAtom(flowStateAtom);
@@ -71,7 +71,7 @@ export function FlowCanvasProvider({ children, name }: { children: any; name?: s
update: async (trigger: TAppFlowTriggerSchema | any) => {
console.log("update trigger", trigger);
setFlowState((state) => {
const flow = state.flow || DEFAULT_FLOW;
const flow = state.flow || (DEFAULT_FLOW as any);
return { ...state, dirty: true, flow: { ...flow, trigger } };
});
//return s.actions.patch("flows", `flows.flows.${name}`, { trigger });
+3 -2
View File
@@ -1,4 +1,4 @@
import { StringIdentifier, transformObject, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { transformObject, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Alert } from "ui/components/display/Alert";
import { bkndModals } from "ui/modals";
@@ -6,6 +6,7 @@ import { Button } from "../../components/buttons/Button";
import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
import { stringIdentifier } from "core/object/schema";
export function AuthRolesList() {
const [navigate] = useNavigate();
@@ -31,7 +32,7 @@ export function AuthRolesList() {
schema: {
type: "object",
properties: {
name: StringIdentifier,
name: stringIdentifier,
},
required: ["name"],
},
+7 -3
View File
@@ -64,8 +64,7 @@ function AuthStrategiesListInternal() {
const config = $auth.config.strategies;
const schema = $auth.schema.properties.strategies;
const schemas = Object.fromEntries(
// @ts-ignore
$auth.schema.properties.strategies.additionalProperties.anyOf.map((s) => [
$auth.schema.properties.strategies?.additionalProperties?.anyOf.map((s) => [
s.properties.type.const,
s,
]),
@@ -76,7 +75,12 @@ function AuthStrategiesListInternal() {
}
return (
<Form schema={schema} initialValues={config} onSubmit={handleSubmit} options={formOptions}>
<Form
schema={schema.toJSON()}
initialValues={config}
onSubmit={handleSubmit}
options={formOptions}
>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
+7 -5
View File
@@ -1,15 +1,16 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
//import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, Switch, Tooltip } from "@mantine/core";
import { guardRoleSchema } from "auth/auth-schema";
import { type Static, ucFirst } from "core/utils";
import { ucFirst } from "core/utils";
import { forwardRef, useImperativeHandle } from "react";
import { type UseControllerProps, useController, useForm } from "react-hook-form";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
import type { s } from "core/object/schema";
const schema = guardRoleSchema;
type Role = Static<typeof guardRoleSchema>;
type Role = s.Static<typeof guardRoleSchema>;
export type AuthRoleFormRef = {
getData: () => Role;
@@ -33,7 +34,8 @@ export const AuthRoleForm = forwardRef<
reset,
getValues,
} = useForm({
resolver: typeboxResolver(schema),
// @todo: add resolver
//resolver: typeboxResolver(schema),
defaultValues: role,
});
@@ -87,7 +89,7 @@ const Permissions = ({
const {
field: { value, onChange: fieldOnChange, ...field },
fieldState,
} = useController<Static<typeof schema>, "permissions">({
} = useController<s.Static<typeof schema>, "permissions">({
name: "permissions",
control,
});
@@ -1,13 +1,5 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Tabs, TextInput, Textarea, Tooltip, Switch } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
Default,
type Static,
StringIdentifier,
objectCleanEmpty,
ucFirstAllSnakeToPascalWithSpaces,
} from "core/utils";
import { objectCleanEmpty, omitKeys, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import {
type TAppDataEntityFields,
fieldsSchemaObject as originalFieldsSchemaObject,
@@ -26,31 +18,25 @@ import { type SortableItemProps, SortableList } from "ui/components/list/Sortabl
import { Popover } from "ui/components/overlay/Popover";
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
import * as tbbox from "@sinclair/typebox";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
const { Type } = tbbox;
import { s, stringIdentifier } from "core/object/schema";
const fieldsSchemaObject = originalFieldsSchemaObject;
const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject));
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
const fieldSchema = Type.Object(
{
name: StringIdentifier,
new: Type.Optional(Type.Boolean({ const: true })),
field: fieldsSchema,
},
{
additionalProperties: false,
},
);
type TFieldSchema = Static<typeof fieldSchema>;
const schema = Type.Object({
fields: Type.Array(fieldSchema),
const fieldSchema = s.strictObject({
name: stringIdentifier,
new: s.boolean({ const: true }).optional(),
field: fieldsSchema,
});
type TFieldsFormSchema = Static<typeof schema>;
type TFieldSchema = s.Static<typeof fieldSchema>;
const schema = s.strictObject({
fields: s.array(fieldSchema),
});
type TFieldsFormSchema = s.Static<typeof schema>;
const fieldTypes = Object.keys(fieldsSchemaObject);
const defaultType = fieldTypes[0];
@@ -58,7 +44,9 @@ const commonProps = ["label", "description", "required", "fillable", "hidden", "
function specificFieldSchema(type: keyof typeof fieldsSchemaObject) {
//console.log("specificFieldSchema", type);
return Type.Omit(fieldsSchemaObject[type]?.properties.config, commonProps);
return s.object(
omitKeys(fieldsSchemaObject[type]?.properties.config.properties, commonProps as any),
);
}
export type EntityFieldsFormProps = {
@@ -100,7 +88,8 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
reset,
} = useForm({
mode: "all",
resolver: typeboxResolver(schema),
// @todo: add resolver
//resolver: typeboxResolver(schema),
defaultValues: {
fields: entityFields,
} as TFieldsFormSchema,
@@ -135,15 +124,14 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
}));
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
const newField = {
append({
name: "",
new: true,
field: {
type: _type,
config: Default(fieldsSchemaObject[_type]?.properties.config, {}) as any,
config: fieldsSchemaObject[_type]?.properties.config.template() as any,
},
};
append(newField);
});
}
const formProps = {
@@ -1,8 +1,5 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { TextInput } from "@mantine/core";
import { useFocusTrap } from "@mantine/hooks";
import { TypeRegistry } from "@sinclair/typebox";
import { type Static, StringEnum, StringIdentifier, registerCustomTypeboxKinds } from "core/utils";
import { TRIGGERS } from "flows/flows-schema";
import { forwardRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -16,18 +13,15 @@ import {
ModalTitle,
} from "../../../components/modal/Modal2";
import { Step, Steps, useStepContext } from "../../../components/steps/Steps";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
registerCustomTypeboxKinds(TypeRegistry);
import { s, stringIdentifier } from "core/object/schema";
export type TCreateFlowModalSchema = any;
const triggerNames = Object.keys(TRIGGERS) as unknown as (keyof typeof TRIGGERS)[];
const schema = Type.Object({
name: StringIdentifier,
trigger: StringEnum(triggerNames),
mode: StringEnum(["async", "sync"]),
const schema = s.strictObject({
name: stringIdentifier,
trigger: s.string({ enum: triggerNames }),
mode: s.string({ enum: ["async", "sync"] }),
});
export const FlowCreateModal = forwardRef<Modal2Ref>(function FlowCreateModal(props, ref) {
@@ -61,16 +55,17 @@ export function StepCreate() {
register,
formState: { isValid, errors },
} = useForm({
resolver: typeboxResolver(schema),
// @todo: implement resolver
//resolver: typeboxResolver(schema),
defaultValues: {
name: "",
trigger: "manual",
mode: "async",
} as Static<typeof schema>,
} as s.Static<typeof schema>,
mode: "onSubmit",
});
async function onSubmit(data: Static<typeof schema>) {
async function onSubmit(data: s.Static<typeof schema>) {
console.log(data, isValid);
actions.flow.create(data.name, {
trigger: {
@@ -1,5 +1,5 @@
import { useHotkeys } from "@mantine/hooks";
import { type TObject, ucFirst } from "core/utils";
import { ucFirst } from "core/utils";
import { omit } from "lodash-es";
import { type ReactNode, useMemo, useRef, useState } from "react";
import { TbSettings } from "react-icons/tb";
@@ -18,10 +18,11 @@ import { Link, Route, useLocation } from "wouter";
import { extractSchema } from "../utils/schema";
import { SettingNewModal, type SettingsNewModalProps } from "./SettingNewModal";
import { SettingSchemaModal, type SettingsSchemaModalRef } from "./SettingSchemaModal";
import type { s } from "core/object/schema";
export type SettingProps<
Schema extends TObject = TObject,
Props = Schema extends TObject<infer TProperties> ? TProperties : any,
Schema extends s.ObjectSchema = s.ObjectSchema,
Props = Schema extends s.ObjectSchema<infer TProperties> ? TProperties : any,
> = {
schema: Schema;
config: any;
@@ -44,7 +45,7 @@ export type SettingProps<
};
};
export function Setting<Schema extends TObject = any>({
export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
schema,
uiSchema,
config,
@@ -1,8 +1,6 @@
import { useDisclosure, useFocusTrap } from "@mantine/hooks";
import type { TObject } from "core/utils";
import { omit } from "lodash-es";
import { useRef, useState } from "react";
import { TbCirclePlus, TbVariable } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
@@ -10,9 +8,10 @@ import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-
import { Dropdown } from "ui/components/overlay/Dropdown";
import { Modal } from "ui/components/overlay/Modal";
import { useLocation } from "wouter";
import type { s } from "core/object/schema";
export type SettingsNewModalProps = {
schema: TObject;
schema: s.ObjectSchema;
uiSchema?: object;
anyOfValues?: Record<string, { label: string; icon?: any }>;
path: string[];
+5 -5
View File
@@ -1,11 +1,11 @@
import type { Static, TObject } from "core/utils";
import type { JSONSchema7 } from "json-schema";
import { cloneDeep, omit, pick } from "lodash-es";
import type { s } from "core/object/schema";
export function extractSchema<
Schema extends TObject,
Schema extends s.ObjectSchema,
Keys extends keyof Schema["properties"],
Config extends Static<Schema>,
Config extends s.Static<Schema>,
>(
schema: Schema,
config: Config,
@@ -22,12 +22,12 @@ export function extractSchema<
},
] {
if (!schema.properties) {
return [{ ...schema }, config, {} as any];
return [{ ...schema.toJSON() }, config, {} as any];
}
const newSchema = cloneDeep(schema);
const updated = {
...newSchema,
...newSchema.toJSON(),
properties: omit(newSchema.properties, keys),
};
if (updated.required) {
@@ -1,9 +1,9 @@
import { parse } from "core/utils";
import { AppFlows } from "flows/AppFlows";
import { useState } from "react";
import { JsonViewer } from "../../../components/code/JsonViewer";
import { JsonSchemaForm } from "../../../components/form/json-schema";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
import { parse } from "core/object/schema";
export default function FlowCreateSchemaTest() {
//const schema = flowsConfigSchema;
@@ -73,7 +73,7 @@ export default function JsonSchemaForm3() {
return (
<Scrollable>
<div className="flex flex-col p-3">
<Form schema={_schema.auth} options={formOptions} />
<Form schema={_schema.auth.toJSON()} options={formOptions} />
{/*<Form
onChange={(data) => console.log("change", data)}
+1 -1
View File
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"types": ["bun-types"],
"composite": false,
"incremental": true,
"module": "ESNext",
@@ -32,6 +31,7 @@
"paths": {
"*": ["./src/*"],
"bknd": ["./src/index.ts"],
"bknd/utils": ["./src/core/utils/index.ts"],
"bknd/core": ["./src/core/index.ts"],
"bknd/adapter": ["./src/adapter/index.ts"],
"bknd/client": ["./src/ui/client/index.ts"],
+6 -6
View File
@@ -15,7 +15,7 @@
},
"app": {
"name": "bknd",
"version": "0.15.0-rc.2",
"version": "0.15.0-rc.3",
"bin": "./dist/cli/index.js",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
@@ -43,7 +43,6 @@
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"uuid": "^11.1.0",
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
@@ -75,7 +74,7 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.1.0",
"jsonv-ts": "link:jsonv-ts",
"kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"libsql-stateless-easy": "^1.8.0",
@@ -98,6 +97,7 @@
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"uuid": "^11.1.0",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
@@ -1222,7 +1222,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="],
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -2504,7 +2504,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.1.0", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-wJ+79o49MNie2Xk9w1hPN8ozjqemVWXOfWUTdioLui/SeGDC7C+QKXTDxsmUaIay86lorkjb3CCGo6JDKbyTZQ=="],
"jsonv-ts": ["jsonv-ts@link:jsonv-ts", {}],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
@@ -4020,7 +4020,7 @@
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@types/bun/bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
"@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
"@types/pg/pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="],