From d6dcfe3acc61ee368b78326e4e6bd6f92befaf9f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 1 Oct 2025 09:46:16 +0200 Subject: [PATCH 01/10] feat: implement file acceptance validation in utils and integrate with Dropzone component --- app/__test__/core/utils.spec.ts | 29 +++++++++++++++++ app/src/core/utils/file.ts | 43 ++++++++++++++++++++++++++ app/src/ui/elements/media/Dropzone.tsx | 10 +++--- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index 36b4969..66d289e 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -264,6 +264,35 @@ describe("Core Utils", async () => { height: 512, }); }); + + test("isFileAccepted", () => { + const file = new File([""], "file.txt", { + type: "text/plain", + }); + expect(utils.isFileAccepted(file, "text/plain")).toBe(true); + expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true); + expect(utils.isFileAccepted(file, "text/html")).toBe(false); + + { + const file = new File([""], "file.jpg", { + type: "image/jpeg", + }); + expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true); + expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true); + expect(utils.isFileAccepted(file, "image/png")).toBe(false); + expect(utils.isFileAccepted(file, "image/*")).toBe(true); + expect(utils.isFileAccepted(file, ".jpg")).toBe(true); + expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true); + expect(utils.isFileAccepted(file, ".png")).toBe(false); + } + + { + const file = new File([""], "file.png"); + expect(utils.isFileAccepted(file, undefined as any)).toBe(true); + } + + expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow(); + }); }); describe("dates", () => { diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index 8e812cf..a2093c0 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -240,3 +240,46 @@ export async function blobToFile( lastModified: Date.now(), }); } + +export function isFileAccepted(file: File | unknown, _accept: string | string[]): boolean { + const accept = Array.isArray(_accept) ? _accept.join(",") : _accept; + if (!accept || !accept.trim()) return true; // no restrictions + if (!isFile(file)) { + throw new Error("Given file is not a File instance"); + } + + const name = file.name.toLowerCase(); + const type = (file.type || "").trim().toLowerCase(); + + // split on commas, trim whitespace + const tokens = accept + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + + // try each token until one matches + return tokens.some((token) => { + if (token.startsWith(".")) { + // extension match, e.g. ".png" or ".tar.gz" + return name.endsWith(token); + } + + const slashIdx = token.indexOf("/"); + if (slashIdx !== -1) { + const [major, minor] = token.split("/"); + if (minor === "*") { + // wildcard like "image/*" + if (!type) return false; + const [fMajor] = type.split("/"); + return fMajor === major; + } else { + // exact MIME like "image/svg+xml" or "application/pdf" + // because of "text/plain;charset=utf-8" + return type.startsWith(token); + } + } + + // unknown token shape, ignore + return false; + }); +} diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index ffaa5df..b7cb384 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -9,8 +9,8 @@ import { useEffect, useMemo, useRef, - useState, } from "react"; +import { isFileAccepted } from "bknd/utils"; import { type FileWithPath, useDropzone } from "./use-dropzone"; import { checkMaxReached } from "./helper"; import { DropzoneInner } from "./DropzoneInner"; @@ -173,12 +173,14 @@ export function Dropzone({ return specs.every((spec) => { if (spec.kind !== "file") { - console.log("not a file", spec.kind); + console.warn("file not accepted: not a file", spec.kind); return false; } if (allowedMimeTypes && allowedMimeTypes.length > 0) { - console.log("not allowed mimetype", spec.type); - return allowedMimeTypes.includes(spec.type); + if (!isFileAccepted(i, allowedMimeTypes)) { + console.warn("file not accepted: not allowed mimetype", spec.type); + return false; + } } return true; }); From 0e870cda81856776110241dc724d6e06ced3302c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Oct 2025 22:09:03 +0200 Subject: [PATCH 02/10] Bump version --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c946b6a..5159e77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app # define bknd version to be used as: # `docker build --build-arg VERSION= -t bknd .` -ARG VERSION=0.17.1 +ARG VERSION=0.18.0 # Install & copy required cli RUN npm install --omit=dev bknd@${VERSION} From 5377ac1a4181699bbdf6a6e2d80fa70ebe80b90c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Oct 2025 22:41:43 +0200 Subject: [PATCH 03/10] Update docker builder --- docker/Dockerfile | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5159e77..71c2716 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,5 @@ # Stage 1: Build stage FROM node:24 as builder - WORKDIR /app # define bknd version to be used as: @@ -9,7 +8,6 @@ ARG VERSION=0.18.0 # Install & copy required cli RUN npm install --omit=dev bknd@${VERSION} -RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist # Stage 2: Final minimal image FROM node:24-alpine @@ -19,14 +17,14 @@ WORKDIR /app # Install required dependencies RUN npm install -g pm2 RUN echo '{"type":"module"}' > package.json -RUN npm install jsonv-ts @libsql/client + +# Copy dist and node_modules from builder +COPY --from=builder /app/node_modules/bknd/dist ./dist +COPY --from=builder /app/node_modules ./node_modules # Create volume and init args VOLUME /data ENV DEFAULT_ARGS="--db-url file:/data/data.db" -# Copy output from builder -COPY --from=builder /output/dist ./dist - EXPOSE 1337 CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"] From 9070f965710356b37d23fefdcd801c6c73c65c8b Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 15 Oct 2025 18:41:04 +0200 Subject: [PATCH 04/10] feat: enhance API and AuthApi with credentials support and async storage handling - Added `credentials` option to `ApiOptions` and `BaseModuleApiOptions` for better request handling. - Updated `AuthApi` to pass `verified` status during token updates. - Refactored storage handling in `Api` to support async operations using a Proxy. - Improved `Authenticator` to handle cookie domain configuration and JSON request detection. - Adjusted `useAuth` to ensure logout and verify methods return promises for better async handling. - Fixed navigation URL construction in `useNavigate` and updated context menu actions in `_data.root.tsx`. --- app/src/Api.ts | 78 +++++++++++++--------- app/src/auth/api/AuthApi.ts | 21 +++--- app/src/auth/authenticate/Authenticator.ts | 7 +- app/src/modules/ModuleApi.ts | 2 + app/src/modules/server/AppServer.ts | 9 ++- app/src/ui/client/ClientProvider.tsx | 9 ++- app/src/ui/client/schema/auth/use-auth.ts | 9 +-- app/src/ui/lib/routes.ts | 2 +- app/src/ui/routes/data/_data.root.tsx | 4 +- 9 files changed, 85 insertions(+), 56 deletions(-) diff --git a/app/src/Api.ts b/app/src/Api.ts index 28d2ef0..fd4394a 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -40,6 +40,7 @@ export type ApiOptions = { data?: SubApiOptions; auth?: SubApiOptions; media?: SubApiOptions; + credentials?: RequestCredentials; } & ( | { token?: string; @@ -67,7 +68,7 @@ export class Api { public auth!: AuthApi; public media!: MediaApi; - constructor(private options: ApiOptions = {}) { + constructor(public options: ApiOptions = {}) { // only mark verified if forced this.verified = options.verified === true; @@ -129,29 +130,45 @@ export class Api { } else if (this.storage) { this.storage.getItem(this.tokenKey).then((token) => { this.token_transport = "header"; - this.updateToken(token ? String(token) : undefined); + this.updateToken(token ? String(token) : undefined, { + verified: true, + trigger: false, + }); }); } } + /** + * Make storage async to allow async storages even if sync given + * @private + */ private get storage() { - if (!this.options.storage) return null; - return { - getItem: async (key: string) => { - return await this.options.storage!.getItem(key); + const storage = this.options.storage; + return new Proxy( + {}, + { + get(_, prop) { + return (...args: any[]) => { + const response = storage ? storage[prop](...args) : undefined; + if (response instanceof Promise) { + return response; + } + return { + // biome-ignore lint/suspicious/noThenProperty: it's a promise :) + then: (fn) => fn(response), + }; + }; + }, }, - setItem: async (key: string, value: string) => { - return await this.options.storage!.setItem(key, value); - }, - removeItem: async (key: string) => { - return await this.options.storage!.removeItem(key); - }, - }; + ) as any; } - updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { + updateToken( + token?: string, + opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean }, + ) { this.token = token; - this.verified = false; + this.verified = opts?.verified === true; if (token) { this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any; @@ -159,21 +176,22 @@ export class Api { this.user = undefined; } + const emit = () => { + if (opts?.trigger !== false) { + this.options.onAuthStateChange?.(this.getAuthState()); + } + }; if (this.storage) { const key = this.tokenKey; if (token) { - this.storage.setItem(key, token).then(() => { - this.options.onAuthStateChange?.(this.getAuthState()); - }); + this.storage.setItem(key, token).then(emit); } else { - this.storage.removeItem(key).then(() => { - this.options.onAuthStateChange?.(this.getAuthState()); - }); + this.storage.removeItem(key).then(emit); } } else { if (opts?.trigger !== false) { - this.options.onAuthStateChange?.(this.getAuthState()); + emit(); } } @@ -182,6 +200,7 @@ export class Api { private markAuthVerified(verfied: boolean) { this.verified = verfied; + this.options.onAuthStateChange?.(this.getAuthState()); return this; } @@ -208,11 +227,6 @@ export class Api { } async verifyAuth() { - if (!this.token) { - this.markAuthVerified(false); - return; - } - try { const { ok, data } = await this.auth.me(); const user = data?.user; @@ -221,10 +235,10 @@ export class Api { } this.user = user; - this.markAuthVerified(true); } catch (e) { - this.markAuthVerified(false); this.updateToken(undefined); + } finally { + this.markAuthVerified(true); } } @@ -239,6 +253,7 @@ export class Api { headers: this.options.headers, token_transport: this.token_transport, verbose: this.options.verbose, + credentials: this.options.credentials, }); } @@ -257,10 +272,9 @@ export class Api { this.auth = new AuthApi( { ...baseParams, - credentials: this.options.storage ? "omit" : "include", ...this.options.auth, - onTokenUpdate: (token) => { - this.updateToken(token, { rebuild: true }); + onTokenUpdate: (token, verified) => { + this.updateToken(token, { rebuild: true, verified, trigger: true }); this.options.auth?.onTokenUpdate?.(token); }, }, diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index cd22ada..e3c0843 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; export type AuthApiOptions = BaseModuleApiOptions & { - onTokenUpdate?: (token?: string) => void | Promise; + onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise; credentials?: "include" | "same-origin" | "omit"; }; @@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi { } async login(strategy: string, input: any) { - const res = await this.post([strategy, "login"], input, { - credentials: this.options.credentials, - }); + const res = await this.post([strategy, "login"], input); if (res.ok && res.body.token) { - await this.options.onTokenUpdate?.(res.body.token); + await this.options.onTokenUpdate?.(res.body.token, true); } return res; } async register(strategy: string, input: any) { - const res = await this.post([strategy, "register"], input, { - credentials: this.options.credentials, - }); + const res = await this.post([strategy, "register"], input); if (res.ok && res.body.token) { - await this.options.onTokenUpdate?.(res.body.token); + await this.options.onTokenUpdate?.(res.body.token, true); } return res; } @@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi { } async logout() { - await this.options.onTokenUpdate?.(undefined); + return this.get(["logout"], undefined, { + headers: { + // this way bknd detects a json request and doesn't redirect back + Accept: "application/json", + }, + }).then(() => this.options.onTokenUpdate?.(undefined, true)); } } diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 52a2e42..465cbd1 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -44,6 +44,7 @@ export interface UserPool { const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds export const cookieConfig = s .strictObject({ + domain: s.string().optional(), path: s.string({ default: "/" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), secure: s.boolean({ default: true }), @@ -290,6 +291,7 @@ export class Authenticator< return { ...cookieConfig, + domain: cookieConfig.domain ?? undefined, expires: new Date(Date.now() + expires * 1000), }; } @@ -354,7 +356,10 @@ export class Authenticator< // @todo: move this to a server helper isJsonRequest(c: Context): boolean { - return c.req.header("Content-Type") === "application/json"; + return ( + c.req.header("Content-Type") === "application/json" || + c.req.header("Accept") === "application/json" + ); } async getBody(c: Context) { diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index f89fb99..9b9ebb7 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -8,6 +8,7 @@ export type BaseModuleApiOptions = { host: string; basepath?: string; token?: string; + credentials?: RequestCredentials; headers?: Headers; token_transport?: "header" | "cookie" | "none"; verbose?: boolean; @@ -106,6 +107,7 @@ export abstract class ModuleApi { } override async build() { - const origin = this.config.cors.origin ?? ""; + const origin = this.config.cors.origin ?? "*"; + const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin]; + const all_origins = origins.includes("*"); this.client.use( "*", cors({ - origin: origin.includes(",") ? origin.split(",").map((o) => o.trim()) : origin, + origin: (origin: string) => { + if (all_origins) return origin; + return origins.includes(origin) ? origin : undefined; + }, allowMethods: this.config.cors.allow_methods, allowHeaders: this.config.cors.allow_headers, credentials: this.config.cors.allow_credentials, diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 13352d1..31dbae6 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -44,18 +44,17 @@ export const ClientProvider = ({ ...apiProps, verbose: isDebug(), onAuthStateChange: (state) => { + const { token, ...rest } = state; props.onAuthStateChange?.(state); - if (!authState?.token || state.token !== authState?.token) { - setAuthState(state); + if (!authState?.token || token !== authState?.token) { + setAuthState(rest); } }, }), [JSON.stringify(apiProps)], ); - const [authState, setAuthState] = useState | undefined>( - apiProps.user ? api.getAuthState() : undefined, - ); + const [authState, setAuthState] = useState | undefined>(api.getAuthState()); return ( diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index e3fb4a6..291c963 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -16,8 +16,8 @@ type UseAuth = { verified: boolean; login: (data: LoginData) => Promise; register: (data: LoginData) => Promise; - logout: () => void; - verify: () => void; + logout: () => Promise; + verify: () => Promise; setToken: (token: string) => void; }; @@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => { } async function logout() { - api.updateToken(undefined); - invalidate(); + await api.auth.logout(); + await invalidate(); } async function verify() { await api.verifyAuth(); + await invalidate(); } return { diff --git a/app/src/ui/lib/routes.ts b/app/src/ui/lib/routes.ts index 7243099..46ed4fb 100644 --- a/app/src/ui/lib/routes.ts +++ b/app/src/ui/lib/routes.ts @@ -95,7 +95,7 @@ export function useNavigate() { window.location.href = url; return; } else if ("target" in options) { - const _url = window.location.origin + basepath + router.base + url; + const _url = window.location.origin + router.base + url; window.open(_url, options.target); return; } diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index fb4bc2f..ba27bf9 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -215,7 +215,9 @@ const EntityContextMenu = ({ href && { icon: IconExternalLink, label: "Open in tab", - onClick: () => navigate(href, { target: "_blank" }), + onClick: () => { + navigate(href, { target: "_blank", absolute: true }); + }, }, separator, !$data.system(entity.name).any && { From 511c6539fb85635771948f244e4baae2abbe2857 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 15 Oct 2025 18:46:21 +0200 Subject: [PATCH 05/10] fix: update authentication verification logic in Api tests - Adjusted test cases in Api.spec.ts to reflect the correct authentication verification state. - Updated expectations to ensure that the `isAuthVerified` method returns true when no claims are provided, aligning with the intended behavior of the API. --- app/__test__/api/Api.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/__test__/api/Api.spec.ts b/app/__test__/api/Api.spec.ts index c1041d9..4384928 100644 --- a/app/__test__/api/Api.spec.ts +++ b/app/__test__/api/Api.spec.ts @@ -6,13 +6,16 @@ describe("Api", async () => { it("should construct without options", () => { const api = new Api(); expect(api.baseUrl).toBe("http://localhost"); - expect(api.isAuthVerified()).toBe(false); + + // verified is true, because no token, user, headers or request given + // therefore nothing to check, auth state is verified + expect(api.isAuthVerified()).toBe(true); }); it("should ignore force verify if no claims given", () => { const api = new Api({ verified: true }); expect(api.baseUrl).toBe("http://localhost"); - expect(api.isAuthVerified()).toBe(false); + expect(api.isAuthVerified()).toBe(true); }); it("should construct from request (token)", async () => { From e68e5792be22ec8ea38c254a06202d608b52fa37 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 17 Oct 2025 08:47:00 +0200 Subject: [PATCH 06/10] set raw state to ClientProviders auth state --- app/src/ui/client/ClientProvider.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 31dbae6..88a54c1 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -44,10 +44,9 @@ export const ClientProvider = ({ ...apiProps, verbose: isDebug(), onAuthStateChange: (state) => { - const { token, ...rest } = state; props.onAuthStateChange?.(state); - if (!authState?.token || token !== authState?.token) { - setAuthState(rest); + if (!authState?.token || state.token !== authState?.token) { + setAuthState(state); } }, }), From 22e43c2523142baa778708c837fda2fb9cfcb4bb Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 18 Oct 2025 16:58:54 +0200 Subject: [PATCH 07/10] feat: introduce new modes helpers --- app/src/App.ts | 1 + app/src/adapter/astro/astro.adapter.ts | 9 +- app/src/adapter/bun/bun.adapter.ts | 12 +- app/src/adapter/bun/index.ts | 8 + app/src/adapter/index.ts | 39 ++-- app/src/adapter/nextjs/nextjs.adapter.ts | 6 +- app/src/adapter/node/index.ts | 10 + app/src/adapter/node/node.adapter.ts | 11 +- .../react-router/react-router.adapter.ts | 6 +- app/src/core/types.ts | 4 + app/src/index.ts | 2 +- app/src/modes/code.ts | 49 +++++ app/src/modes/hybrid.ts | 88 +++++++++ app/src/modes/index.ts | 3 + app/src/modes/shared.ts | 183 ++++++++++++++++++ app/src/modules/db/DbModuleManager.ts | 7 +- app/tsconfig.json | 4 +- 17 files changed, 402 insertions(+), 40 deletions(-) create mode 100644 app/src/modes/code.ts create mode 100644 app/src/modes/hybrid.ts create mode 100644 app/src/modes/index.ts create mode 100644 app/src/modes/shared.ts diff --git a/app/src/App.ts b/app/src/App.ts index 0f535f8..b8cbde7 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -385,6 +385,7 @@ export class App< } } } + await this.options?.manager?.onModulesBuilt?.(ctx); } } diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 7f24923..92a8604 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -8,12 +8,15 @@ export type AstroBkndConfig = FrameworkBkndConfig; export async function getApp( config: AstroBkndConfig = {}, - args: Env = {} as Env, + args: Env = import.meta.env as Env, ) { - return await createFrameworkApp(config, args ?? import.meta.env); + return await createFrameworkApp(config, args); } -export function serve(config: AstroBkndConfig = {}, args: Env = {} as Env) { +export function serve( + config: AstroBkndConfig = {}, + args: Env = import.meta.env as Env, +) { return async (fnArgs: TAstro) => { return (await getApp(config, args)).fetch(fnArgs.request); }; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 00b61b5..44e7ccf 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -12,7 +12,7 @@ export type BunBkndConfig = RuntimeBkndConfig & Omit( { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); registerLocalMediaAdapter(); @@ -26,18 +26,18 @@ export async function createApp( }), ...config, }, - args ?? (process.env as Env), + args, ); } export function createHandler( config: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { let app: App | undefined; return async (req: Request) => { if (!app) { - app = await createApp(config, args ?? (process.env as Env)); + app = await createApp(config, args); } return app.fetch(req); }; @@ -54,9 +54,10 @@ export function serve( buildConfig, adminOptions, serveStatic, + beforeBuild, ...serveOptions }: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { Bun.serve({ ...serveOptions, @@ -71,6 +72,7 @@ export function serve( adminOptions, distPath, serveStatic, + beforeBuild, }, args, ), diff --git a/app/src/adapter/bun/index.ts b/app/src/adapter/bun/index.ts index 5f85135..a0ca1ed 100644 --- a/app/src/adapter/bun/index.ts +++ b/app/src/adapter/bun/index.ts @@ -1,3 +1,11 @@ export * from "./bun.adapter"; export * from "../node/storage"; export * from "./connection/BunSqliteConnection"; + +export async function writer(path: string, content: string) { + await Bun.write(path, content); +} + +export async function reader(path: string) { + return await Bun.file(path).text(); +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 2548efa..949aaba 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -6,18 +6,23 @@ import { guessMimeType, type MaybePromise, registries as $registries, + type Merge, } from "bknd"; import { $console } from "bknd/utils"; import type { Context, MiddlewareHandler, Next } from "hono"; import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; -export type BkndConfig = CreateAppConfig & { - app?: Omit | ((args: Args) => MaybePromise, "app">>); - onBuilt?: (app: App) => MaybePromise; - beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; - buildConfig?: Parameters[0]; -}; +export type BkndConfig = Merge< + CreateAppConfig & { + app?: + | Merge & Additional> + | ((args: Args) => MaybePromise, "app"> & Additional>>); + onBuilt?: (app: App) => MaybePromise; + beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; + buildConfig?: Parameters[0]; + } & Additional +>; export type FrameworkBkndConfig = BkndConfig; @@ -51,11 +56,10 @@ export async function makeConfig( return { ...rest, ...additionalConfig }; } -// a map that contains all apps by id export async function createAdapterApp( config: Config = {} as Config, args?: Args, -): Promise { +): Promise<{ app: App; config: BkndConfig }> { await config.beforeBuild?.(undefined, $registries); const appConfig = await makeConfig(config, args); @@ -65,34 +69,37 @@ export async function createAdapterApp( config: FrameworkBkndConfig = {}, args?: Args, ): Promise { - const app = await createAdapterApp(config, args); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { if (config.onBuilt) { app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); }, "sync", ); } - await config.beforeBuild?.(app, $registries); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -103,7 +110,7 @@ export async function createRuntimeApp( { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, args?: Args, ): Promise { - const app = await createAdapterApp(config, args); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { app.emgr.onEvent( @@ -116,7 +123,7 @@ export async function createRuntimeApp( app.modules.server.get(path, handler); } - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); if (adminOptions !== false) { app.registerAdminController(adminOptions); } @@ -124,7 +131,7 @@ export async function createRuntimeApp( "sync", ); - await config.beforeBuild?.(app, $registries); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index ba0953b..eed1c35 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -9,9 +9,9 @@ export type NextjsBkndConfig = FrameworkBkndConfig & { export async function getApp( config: NextjsBkndConfig, - args: Env = {} as Env, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? (process.env as Env)); + return await createFrameworkApp(config, args); } function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { @@ -39,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ export function serve( { cleanRequest, ...config }: NextjsBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { return async (req: Request) => { const app = await getApp(config, args); diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index b430450..befd771 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,3 +1,13 @@ +import { readFile, writeFile } from "node:fs/promises"; + export * from "./node.adapter"; export * from "./storage"; export * from "./connection/NodeSqliteConnection"; + +export async function writer(path: string, content: string) { + await writeFile(path, content); +} + +export async function reader(path: string) { + return await readFile(path, "utf-8"); +} diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index fd96086..83feba8 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -17,7 +17,7 @@ export type NodeBkndConfig = RuntimeBkndConfig & { export async function createApp( { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { const root = path.relative( process.cwd(), @@ -33,19 +33,18 @@ export async function createApp( serveStatic: serveStatic({ root }), ...config, }, - // @ts-ignore - args ?? { env: process.env }, + args, ); } export function createHandler( config: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { let app: App | undefined; return async (req: Request) => { if (!app) { - app = await createApp(config, args ?? (process.env as Env)); + app = await createApp(config, args); } return app.fetch(req); }; @@ -53,7 +52,7 @@ export function createHandler( export function serve( { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { honoServe( { diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index f37260d..f624bde 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -8,14 +8,14 @@ export type ReactRouterBkndConfig = FrameworkBkndConfig( config: ReactRouterBkndConfig, - args: Env = {} as Env, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? process.env); + return await createFrameworkApp(config, args); } export function serve( config: ReactRouterBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { return async (fnArgs: ReactRouterFunctionArgs) => { return (await getApp(config, args)).fetch(fnArgs.request); diff --git a/app/src/core/types.ts b/app/src/core/types.ts index 03beae5..c0550db 100644 --- a/app/src/core/types.ts +++ b/app/src/core/types.ts @@ -6,3 +6,7 @@ export interface Serializable { export type MaybePromise = T | Promise; export type PartialRec = { [P in keyof T]?: PartialRec }; + +export type Merge = { + [K in keyof T]: T[K]; +}; diff --git a/app/src/index.ts b/app/src/index.ts index ae01151..4f48439 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -41,7 +41,7 @@ export { getSystemMcp } from "modules/mcp/system-mcp"; /** * Core */ -export type { MaybePromise } from "core/types"; +export type { MaybePromise, Merge } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts new file mode 100644 index 0000000..30e4dc3 --- /dev/null +++ b/app/src/modes/code.ts @@ -0,0 +1,49 @@ +import type { BkndConfig } from "bknd/adapter"; +import { makeModeConfig, type BkndModeConfig } from "./shared"; +import { $console } from "bknd/utils"; + +export type BkndCodeModeConfig = BkndModeConfig; + +export type CodeMode = AdapterConfig extends BkndConfig< + infer Args +> + ? BkndModeConfig + : never; + +export function code(config: BkndCodeModeConfig): BkndConfig { + return { + ...config, + app: async (args) => { + const { + config: appConfig, + plugins, + isProd, + syncSchemaOptions, + } = await makeModeConfig(config, args); + + if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { + $console.warn("You should not set a different mode than `db` when using code mode"); + } + + return { + ...appConfig, + options: { + ...appConfig?.options, + mode: "code", + plugins, + manager: { + // skip validation in prod for a speed boost + skipValidation: isProd, + onModulesBuilt: async (ctx) => { + if (!isProd && syncSchemaOptions.force) { + $console.log("[code] syncing schema"); + await ctx.em.schema().sync(syncSchemaOptions); + } + }, + ...appConfig?.options?.manager, + }, + }, + }; + }, + }; +} diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts new file mode 100644 index 0000000..b545270 --- /dev/null +++ b/app/src/modes/hybrid.ts @@ -0,0 +1,88 @@ +import type { BkndConfig } from "bknd/adapter"; +import { makeModeConfig, type BkndModeConfig } from "./shared"; +import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import type { DbModuleManager } from "modules/db/DbModuleManager"; +import { invariant, $console } from "bknd/utils"; + +export type BkndHybridModeOptions = { + /** + * Reader function to read the configuration from the file system. + * This is required for hybrid mode to work. + */ + reader?: (path: string) => MaybePromise; + /** + * Provided secrets to be merged into the configuration + */ + secrets?: Record; +}; + +export type HybridBkndConfig = BkndModeConfig; +export type HybridMode = AdapterConfig extends BkndConfig< + infer Args +> + ? BkndModeConfig> + : never; + +export function hybrid({ + configFilePath = "bknd-config.json", + ...rest +}: HybridBkndConfig): BkndConfig { + return { + ...rest, + config: undefined, + app: async (args) => { + const { + config: appConfig, + isProd, + plugins, + syncSchemaOptions, + } = await makeModeConfig( + { + ...rest, + configFilePath, + }, + args, + ); + + if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") { + $console.warn("You should not set a different mode than `db` when using hybrid mode"); + } + invariant( + typeof appConfig.reader === "function", + "You must set the `reader` option when using hybrid mode", + ); + + let fileConfig: ModuleConfigs; + try { + fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs; + } catch (e) { + const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs; + await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2)); + fileConfig = defaultConfig; + } + + return { + ...(appConfig as any), + beforeBuild: async (app) => { + if (app && !isProd) { + const mm = app.modules as DbModuleManager; + mm.buildSyncConfig = syncSchemaOptions; + } + }, + config: fileConfig, + options: { + ...appConfig?.options, + mode: isProd ? "code" : "db", + plugins, + manager: { + // skip validation in prod for a speed boost + skipValidation: isProd, + // secrets are required for hybrid mode + secrets: appConfig.secrets, + ...appConfig?.options?.manager, + }, + }, + }; + }, + }; +} diff --git a/app/src/modes/index.ts b/app/src/modes/index.ts new file mode 100644 index 0000000..b053671 --- /dev/null +++ b/app/src/modes/index.ts @@ -0,0 +1,3 @@ +export * from "./code"; +export * from "./hybrid"; +export * from "./shared"; diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts new file mode 100644 index 0000000..afc2c6a --- /dev/null +++ b/app/src/modes/shared.ts @@ -0,0 +1,183 @@ +import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd"; +import { syncTypes, syncConfig } from "bknd/plugins"; +import { syncSecrets } from "plugins/dev/sync-secrets.plugin"; +import { invariant, $console } from "bknd/utils"; + +export type BkndModeOptions = { + /** + * Whether the application is running in production. + */ + isProduction?: boolean; + /** + * Writer function to write the configuration to the file system + */ + writer?: (path: string, content: string) => MaybePromise; + /** + * Configuration file path + */ + configFilePath?: string; + /** + * Types file path + * @default "bknd-types.d.ts" + */ + typesFilePath?: string; + /** + * Syncing secrets options + */ + syncSecrets?: { + /** + * Whether to enable syncing secrets + */ + enabled?: boolean; + /** + * Output file path + */ + outFile?: string; + /** + * Format of the output file + * @default "env" + */ + format?: "json" | "env"; + /** + * Whether to include secrets in the output file + * @default false + */ + includeSecrets?: boolean; + }; + /** + * Determines whether to automatically sync the schema if not in production. + * @default true + */ + syncSchema?: boolean | { force?: boolean; drop?: boolean }; +}; + +export type BkndModeConfig = BkndConfig< + Args, + Merge +>; + +export async function makeModeConfig< + Args = any, + Config extends BkndModeConfig = BkndModeConfig, +>(_config: Config, args: Args) { + const appConfig = typeof _config.app === "function" ? await _config.app(args) : _config.app; + + const config = { + ..._config, + ...appConfig, + } as Omit; + + if (typeof config.isProduction !== "boolean") { + $console.warn( + "You should set `isProduction` option when using managed modes to prevent accidental issues", + ); + } + + invariant( + typeof config.writer === "function", + "You must set the `writer` option when using managed modes", + ); + + const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; + + const isProd = config.isProduction; + const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const syncSchemaOptions = + typeof config.syncSchema === "object" + ? config.syncSchema + : { + force: config.syncSchema !== false, + drop: true, + }; + + if (!isProd) { + if (typesFilePath) { + if (plugins.some((p) => p.name === "bknd-sync-types")) { + throw new Error("You have to unregister the `syncTypes` plugin"); + } + plugins.push( + syncTypes({ + enabled: true, + includeFirstBoot: true, + write: async (et) => { + try { + await config.writer?.(typesFilePath, et.toString()); + } catch (e) { + console.error(`Error writing types to"${typesFilePath}"`, e); + } + }, + }) as any, + ); + } + + if (configFilePath) { + if (plugins.some((p) => p.name === "bknd-sync-config")) { + throw new Error("You have to unregister the `syncConfig` plugin"); + } + plugins.push( + syncConfig({ + enabled: true, + includeFirstBoot: true, + write: async (config) => { + try { + await writer?.(configFilePath, JSON.stringify(config, null, 2)); + } catch (e) { + console.error(`Error writing config to "${configFilePath}"`, e); + } + }, + }) as any, + ); + } + + if (syncSecretsOptions?.enabled) { + if (plugins.some((p) => p.name === "bknd-sync-secrets")) { + throw new Error("You have to unregister the `syncSecrets` plugin"); + } + + let outFile = syncSecretsOptions.outFile; + const format = syncSecretsOptions.format ?? "env"; + if (!outFile) { + outFile = ["env", !syncSecretsOptions.includeSecrets && "example", format] + .filter(Boolean) + .join("."); + } + + plugins.push( + syncSecrets({ + enabled: true, + includeFirstBoot: true, + write: async (secrets) => { + const values = Object.fromEntries( + Object.entries(secrets).map(([key, value]) => [ + key, + syncSecretsOptions.includeSecrets ? value : "", + ]), + ); + + try { + if (format === "env") { + await writer?.( + outFile, + Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ); + } else { + await writer?.(outFile, JSON.stringify(values, null, 2)); + } + } catch (e) { + console.error(`Error writing secrets to "${outFile}"`, e); + } + }, + }) as any, + ); + } + } + + return { + config, + isProd, + plugins, + syncSchemaOptions, + }; +} diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts index a7bc903..8af95e8 100644 --- a/app/src/modules/db/DbModuleManager.ts +++ b/app/src/modules/db/DbModuleManager.ts @@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager { private readonly _booted_with?: "provided" | "partial"; private _stable_configs: ModuleConfigs | undefined; + // config used when syncing database + public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true }; + constructor(connection: Connection, options?: Partial) { let initial = {} as InitialModuleConfigs; let booted_with = "partial" as any; @@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager { const version_before = this.version(); const [_version, _configs] = await migrate(version_before, result.configs.json, { - db: this.db + db: this.db, }); this._version = _version; @@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager { this.logger.log("db sync requested"); // sync db - await ctx.em.schema().sync({ force: true }); + await ctx.em.schema().sync(this.buildSyncConfig); state.synced = true; // save diff --git a/app/tsconfig.json b/app/tsconfig.json index 55264d4..10260b4 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -33,7 +33,9 @@ "bknd": ["./src/index.ts"], "bknd/utils": ["./src/core/utils/index.ts"], "bknd/adapter": ["./src/adapter/index.ts"], - "bknd/client": ["./src/ui/client/index.ts"] + "bknd/adapter/*": ["./src/adapter/*/index.ts"], + "bknd/client": ["./src/ui/client/index.ts"], + "bknd/modes": ["./src/modes/index.ts"] } }, "include": [ From 292e4595ea1243a67f77c4d283d0aac44ffe1c92 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 12:49:39 +0200 Subject: [PATCH 08/10] feat: add endpoint/tool to retrieve TypeScript definitions for data entities Implemented a new endpoint at "/types" in the DataController to return TypeScript definitions for data entities, enhancing type safety and developer experience. --- app/src/data/api/DataController.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 163f0af..e2608d2 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -15,6 +15,7 @@ import type { AppDataConfig } from "../data-schema"; import type { EntityManager, EntityData } from "data/entities"; import * as DataPermissions from "data/permissions"; import { repoQuery, type RepoQuery } from "data/server/query"; +import { EntityTypescript } from "data/entities/EntityTypescript"; export class DataController extends Controller { constructor( @@ -153,6 +154,20 @@ export class DataController extends Controller { }, ); + hono.get( + "/types", + permission(DataPermissions.entityRead), + describeRoute({ + summary: "Retrieve data typescript definitions", + tags: ["data"], + }), + mcpTool("data_types"), + async (c) => { + const et = new EntityTypescript(this.em); + return c.text(et.toString()); + }, + ); + // entity endpoints hono.route("/entity", this.getEntityRoutes()); From f2aad9caacac14c28d3d099859fb25275fabc1dd Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 24 Oct 2025 14:07:37 +0200 Subject: [PATCH 09/10] make non-fillable fields visible but disabled in UI --- .../modules/migrations/migrations.spec.ts | 16 +- .../modules/migrations/samples/v10.json | 270 ++++++++++++++++++ app/src/data/entities/Entity.ts | 22 +- app/src/data/entities/mutation/Mutator.ts | 6 +- app/src/data/fields/Field.ts | 22 +- app/src/data/fields/field-test-suite.ts | 7 +- app/src/data/schema/SchemaManager.ts | 2 +- app/src/modules/db/migrations.ts | 24 ++ .../ui/components/form/Formy/components.tsx | 11 +- .../ui/modules/data/components/EntityForm.tsx | 8 +- .../ui/modules/data/hooks/useEntityForm.tsx | 2 +- 11 files changed, 353 insertions(+), 37 deletions(-) create mode 100644 app/__test__/modules/migrations/samples/v10.json diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index 1266746..f68b462 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -7,7 +7,9 @@ import v7 from "./samples/v7.json"; import v8 from "./samples/v8.json"; import v8_2 from "./samples/v8-2.json"; import v9 from "./samples/v9.json"; +import v10 from "./samples/v10.json"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { CURRENT_VERSION } from "modules/db/migrations"; beforeAll(() => disableConsoleLog()); afterAll(enableConsoleLog); @@ -61,7 +63,7 @@ async function getRawConfig( return await db .selectFrom("__bknd") .selectAll() - .$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version)) + .where("version", "=", opts?.version ?? CURRENT_VERSION) .$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types)) .execute(); } @@ -115,7 +117,6 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); const [config, secrets] = (await getRawConfig(app, { - version: 10, types: ["config", "secrets"], })) as any; @@ -129,4 +130,15 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); }); + + test("migration from 10 to 11", async () => { + expect(v10.version).toBe(10); + expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]); + + const app = await createVersionedApp(v10); + + expect(app.version()).toBeGreaterThan(10); + const [config] = (await getRawConfig(app, { types: ["config"] })) as any; + expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true); + }); }); diff --git a/app/__test__/modules/migrations/samples/v10.json b/app/__test__/modules/migrations/samples/v10.json new file mode 100644 index 0000000..022ef2f --- /dev/null +++ b/app/__test__/modules/migrations/samples/v10.json @@ -0,0 +1,270 @@ +{ + "version": 10, + "server": { + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "allow_credentials": true + }, + "mcp": { + "enabled": true, + "path": "/api/system/mcp", + "logLevel": "warning" + } + }, + "data": { + "basepath": "/api/data", + "default_primary_format": "integer", + "entities": { + "test": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": ["read", "update"] + } + }, + "status": { + "type": "enum", + "config": { + "default_value": "INACTIVE", + "options": { + "type": "strings", + "values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"] + }, + "required": true, + "fillable": true + } + }, + "created_at": { + "type": "date", + "config": { + "type": "datetime", + "required": true, + "fillable": true + } + }, + "schema": { + "type": "jsonschema", + "config": { + "default_from_schema": true, + "schema": { + "type": "object", + "properties": { + "one": { + "type": "number", + "default": 1 + } + } + }, + "required": true, + "fillable": true + } + }, + "text": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "items": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "media": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "path": { + "type": "text", + "config": { + "required": true, + "fillable": true + } + }, + "folder": { + "type": "boolean", + "config": { + "default_value": false, + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "mime_type": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "size": { + "type": "number", + "config": { + "required": false, + "fillable": true + } + }, + "scope": { + "type": "text", + "config": { + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "etag": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "modified_at": { + "type": "date", + "config": { + "type": "datetime", + "required": false, + "fillable": true + } + }, + "reference": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "entity_id": { + "type": "text", + "config": { + "required": false, + "fillable": true + } + }, + "metadata": { + "type": "json", + "config": { + "required": false, + "fillable": true + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": {}, + "indices": { + "idx_unique_media_path": { + "entity": "media", + "fields": ["path"], + "unique": true + }, + "idx_media_reference": { + "entity": "media", + "fields": ["reference"], + "unique": false + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + } + } + }, + "auth": { + "enabled": false, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "", + "alg": "HS256", + "fields": ["id", "email", "role"] + }, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "partitioned": false, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "type": "password", + "enabled": true, + "config": { + "hashing": "sha256" + } + } + }, + "guard": { + "enabled": false + }, + "roles": {} + }, + "media": { + "enabled": false + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index fcbe092..db7e6f4 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -136,8 +136,10 @@ export class Entity< .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name)); } - getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] { - return this.getFields(include_virtual).filter((field) => field.isFillable(context)); + getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] { + return this.getFields({ virtual: include_virtual }).filter((field) => + field.isFillable(context), + ); } getRequiredFields(): Field[] { @@ -189,9 +191,15 @@ export class Entity< return this.fields.findIndex((field) => field.name === name) !== -1; } - getFields(include_virtual: boolean = false): Field[] { - if (include_virtual) return this.fields; - return this.fields.filter((f) => !f.isVirtual()); + getFields({ + virtual = false, + primary = true, + }: { virtual?: boolean; primary?: boolean } = {}): Field[] { + return this.fields.filter((f) => { + if (!virtual && f.isVirtual()) return false; + if (!primary && f instanceof PrimaryField) return false; + return true; + }); } addField(field: Field) { @@ -231,7 +239,7 @@ export class Entity< } } - const fields = this.getFillableFields(context, false); + const fields = this.getFillableFields(context as any, false); if (options?.ignoreUnknown !== true) { const field_names = fields.map((f) => f.name); @@ -275,7 +283,7 @@ export class Entity< fields = this.getFillableFields(options.context); break; default: - fields = this.getFields(true); + fields = this.getFields({ virtual: true }); } const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); diff --git a/app/src/data/entities/mutation/Mutator.ts b/app/src/data/entities/mutation/Mutator.ts index 84389c3..e4bb6f0 100644 --- a/app/src/data/entities/mutation/Mutator.ts +++ b/app/src/data/entities/mutation/Mutator.ts @@ -83,8 +83,10 @@ export class Mutator< } // we should never get here, but just to be sure (why?) - if (!field.isFillable(context)) { - throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`); + if (!field.isFillable(context as any)) { + throw new Error( + `Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`, + ); } // transform from field diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index 98a1f45..8451d1d 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s .strictObject({ label: s.string(), description: s.string(), - required: s.boolean({ default: false }), - fillable: s.anyOf([ - s.boolean({ title: "Boolean" }), - s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }), - ]), + required: s.boolean({ default: DEFAULT_REQUIRED }), + fillable: s.anyOf( + [ + s.boolean({ title: "Boolean" }), + s.array(s.string({ enum: ["create", "update"] }), { + title: "Context", + uniqueItems: true, + }), + ], + { + default: DEFAULT_FILLABLE, + }, + ), hidden: s.anyOf([ s.boolean({ title: "Boolean" }), // @todo: tmp workaround @@ -103,7 +111,7 @@ export abstract class Field< return this.config?.default_value; } - isFillable(context?: TActionContext): boolean { + isFillable(context?: "create" | "update"): boolean { if (Array.isArray(this.config.fillable)) { return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE; } @@ -165,7 +173,7 @@ export abstract class Field< // @todo: add field level validation isValid(value: any, context: TActionContext): boolean { if (typeof value !== "undefined") { - return this.isFillable(context); + return this.isFillable(context as any); } else if (context === "create") { return !this.isRequired(); } diff --git a/app/src/data/fields/field-test-suite.ts b/app/src/data/fields/field-test-suite.ts index 369d41b..bf81fbd 100644 --- a/app/src/data/fields/field-test-suite.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -99,6 +99,7 @@ export function fieldTestSuite( const _config = { ..._requiredConfig, required: false, + fillable: true, }; function fieldJson(field: Field) { @@ -116,10 +117,7 @@ export function fieldTestSuite( expect(fieldJson(fillable)).toEqual({ type: noConfigField.type, - config: { - ..._config, - fillable: true, - }, + config: _config, }); expect(fieldJson(required)).toEqual({ @@ -150,7 +148,6 @@ export function fieldTestSuite( type: requiredAndDefault.type, config: { ..._config, - fillable: true, required: true, default_value: config.defaultValue, }, diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index 78708d6..8a06957 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -77,7 +77,7 @@ export class SchemaManager { } getIntrospectionFromEntity(entity: Entity): IntrospectedTable { - const fields = entity.getFields(false); + const fields = entity.getFields({ virtual: false }); const indices = this.em.getIndicesOf(entity); // this is intentionally setting values to defaults, like "nullable" and "default" diff --git a/app/src/modules/db/migrations.ts b/app/src/modules/db/migrations.ts index 13f39ee..e97071b 100644 --- a/app/src/modules/db/migrations.ts +++ b/app/src/modules/db/migrations.ts @@ -1,6 +1,7 @@ import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; +import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -107,6 +108,29 @@ export const migrations: Migration[] = [ return config; }, }, + { + // change field.config.fillable to only "create" and "update" + version: 11, + up: async (config: InitialModuleConfigs) => { + const { data, ...rest } = config; + return { + ...rest, + data: { + ...data, + entities: transformObject(data?.entities ?? {}, (entity) => { + return { + ...entity, + fields: transformObject(entity?.fields ?? {}, (field) => { + const fillable = field!.config?.fillable; + if (!fillable || typeof fillable === "boolean") return field; + return { ...field, config: { ...field!.config, fillable: true } }; + }), + }; + }), + }, + }; + }, + }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index 502a844..cd85aa4 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -29,7 +29,7 @@ export const Group = ({ > ref={ref} className={twMerge( "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed", - disabledOrReadonly && "bg-muted/50 text-primary/50", + disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed", !disabledOrReadonly && "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", props.className, @@ -153,7 +153,7 @@ export const Textarea = forwardRef @@ -213,7 +213,7 @@ export const BooleanInput = forwardRef { - console.log("setting", bool); props.onChange?.({ target: { value: bool } }); }} {...(props as any)} @@ -293,7 +292,7 @@ export const Select = forwardRef< {...props} ref={ref} className={twMerge( - "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", + "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed", "appearance-none h-11 w-full", !props.multiple && "border-r-8 border-r-transparent", props.className, diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index ff778a1..9943aba 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -44,7 +44,7 @@ export function EntityForm({ className, action, }: EntityFormProps) { - const fields = entity.getFillableFields(action, true); + const fields = entity.getFields({ virtual: true, primary: false }); const options = useEntityAdminOptions(entity, action); return ( @@ -92,10 +92,6 @@ export function EntityForm({ ); } - if (!field.isFillable(action)) { - return; - } - const _key = `${entity.name}-${field.name}-${key}`; return ( @@ -127,7 +123,7 @@ export function EntityForm({ Date: Fri, 24 Oct 2025 14:08:32 +0200 Subject: [PATCH 10/10] Revert "make non-fillable fields visible but disabled in UI" This reverts commit f2aad9caacac14c28d3d099859fb25275fabc1dd. --- .../modules/migrations/migrations.spec.ts | 16 +- .../modules/migrations/samples/v10.json | 270 ------------------ app/src/data/entities/Entity.ts | 22 +- app/src/data/entities/mutation/Mutator.ts | 6 +- app/src/data/fields/Field.ts | 22 +- app/src/data/fields/field-test-suite.ts | 7 +- app/src/data/schema/SchemaManager.ts | 2 +- app/src/modules/db/migrations.ts | 24 -- .../ui/components/form/Formy/components.tsx | 11 +- .../ui/modules/data/components/EntityForm.tsx | 8 +- .../ui/modules/data/hooks/useEntityForm.tsx | 2 +- 11 files changed, 37 insertions(+), 353 deletions(-) delete mode 100644 app/__test__/modules/migrations/samples/v10.json diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index f68b462..1266746 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -7,9 +7,7 @@ import v7 from "./samples/v7.json"; import v8 from "./samples/v8.json"; import v8_2 from "./samples/v8-2.json"; import v9 from "./samples/v9.json"; -import v10 from "./samples/v10.json"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; -import { CURRENT_VERSION } from "modules/db/migrations"; beforeAll(() => disableConsoleLog()); afterAll(enableConsoleLog); @@ -63,7 +61,7 @@ async function getRawConfig( return await db .selectFrom("__bknd") .selectAll() - .where("version", "=", opts?.version ?? CURRENT_VERSION) + .$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version)) .$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types)) .execute(); } @@ -117,6 +115,7 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); const [config, secrets] = (await getRawConfig(app, { + version: 10, types: ["config", "secrets"], })) as any; @@ -130,15 +129,4 @@ describe("Migrations", () => { "^^s3.secret_access_key^^", ); }); - - test("migration from 10 to 11", async () => { - expect(v10.version).toBe(10); - expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]); - - const app = await createVersionedApp(v10); - - expect(app.version()).toBeGreaterThan(10); - const [config] = (await getRawConfig(app, { types: ["config"] })) as any; - expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true); - }); }); diff --git a/app/__test__/modules/migrations/samples/v10.json b/app/__test__/modules/migrations/samples/v10.json deleted file mode 100644 index 022ef2f..0000000 --- a/app/__test__/modules/migrations/samples/v10.json +++ /dev/null @@ -1,270 +0,0 @@ -{ - "version": 10, - "server": { - "cors": { - "origin": "*", - "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], - "allow_headers": [ - "Content-Type", - "Content-Length", - "Authorization", - "Accept" - ], - "allow_credentials": true - }, - "mcp": { - "enabled": true, - "path": "/api/system/mcp", - "logLevel": "warning" - } - }, - "data": { - "basepath": "/api/data", - "default_primary_format": "integer", - "entities": { - "test": { - "type": "regular", - "fields": { - "id": { - "type": "primary", - "config": { - "format": "integer", - "fillable": false, - "required": false - } - }, - "title": { - "type": "text", - "config": { - "required": false, - "fillable": ["read", "update"] - } - }, - "status": { - "type": "enum", - "config": { - "default_value": "INACTIVE", - "options": { - "type": "strings", - "values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"] - }, - "required": true, - "fillable": true - } - }, - "created_at": { - "type": "date", - "config": { - "type": "datetime", - "required": true, - "fillable": true - } - }, - "schema": { - "type": "jsonschema", - "config": { - "default_from_schema": true, - "schema": { - "type": "object", - "properties": { - "one": { - "type": "number", - "default": 1 - } - } - }, - "required": true, - "fillable": true - } - }, - "text": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - } - }, - "config": { - "sort_field": "id", - "sort_dir": "asc" - } - }, - "items": { - "type": "regular", - "fields": { - "id": { - "type": "primary", - "config": { - "format": "integer", - "fillable": false, - "required": false - } - }, - "title": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - } - }, - "config": { - "sort_field": "id", - "sort_dir": "asc" - } - }, - "media": { - "type": "system", - "fields": { - "id": { - "type": "primary", - "config": { - "format": "integer", - "fillable": false, - "required": false - } - }, - "path": { - "type": "text", - "config": { - "required": true, - "fillable": true - } - }, - "folder": { - "type": "boolean", - "config": { - "default_value": false, - "hidden": true, - "fillable": ["create"], - "required": false - } - }, - "mime_type": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "size": { - "type": "number", - "config": { - "required": false, - "fillable": true - } - }, - "scope": { - "type": "text", - "config": { - "hidden": true, - "fillable": ["create"], - "required": false - } - }, - "etag": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "modified_at": { - "type": "date", - "config": { - "type": "datetime", - "required": false, - "fillable": true - } - }, - "reference": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "entity_id": { - "type": "text", - "config": { - "required": false, - "fillable": true - } - }, - "metadata": { - "type": "json", - "config": { - "required": false, - "fillable": true - } - } - }, - "config": { - "sort_field": "id", - "sort_dir": "asc" - } - } - }, - "relations": {}, - "indices": { - "idx_unique_media_path": { - "entity": "media", - "fields": ["path"], - "unique": true - }, - "idx_media_reference": { - "entity": "media", - "fields": ["reference"], - "unique": false - }, - "idx_media_entity_id": { - "entity": "media", - "fields": ["entity_id"], - "unique": false - } - } - }, - "auth": { - "enabled": false, - "basepath": "/api/auth", - "entity_name": "users", - "allow_register": true, - "jwt": { - "secret": "", - "alg": "HS256", - "fields": ["id", "email", "role"] - }, - "cookie": { - "path": "/", - "sameSite": "lax", - "secure": true, - "httpOnly": true, - "expires": 604800, - "partitioned": false, - "renew": true, - "pathSuccess": "/", - "pathLoggedOut": "/" - }, - "strategies": { - "password": { - "type": "password", - "enabled": true, - "config": { - "hashing": "sha256" - } - } - }, - "guard": { - "enabled": false - }, - "roles": {} - }, - "media": { - "enabled": false - }, - "flows": { - "basepath": "/api/flows", - "flows": {} - } -} diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index db7e6f4..fcbe092 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -136,10 +136,8 @@ export class Entity< .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name)); } - getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] { - return this.getFields({ virtual: include_virtual }).filter((field) => - field.isFillable(context), - ); + getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] { + return this.getFields(include_virtual).filter((field) => field.isFillable(context)); } getRequiredFields(): Field[] { @@ -191,15 +189,9 @@ export class Entity< return this.fields.findIndex((field) => field.name === name) !== -1; } - getFields({ - virtual = false, - primary = true, - }: { virtual?: boolean; primary?: boolean } = {}): Field[] { - return this.fields.filter((f) => { - if (!virtual && f.isVirtual()) return false; - if (!primary && f instanceof PrimaryField) return false; - return true; - }); + getFields(include_virtual: boolean = false): Field[] { + if (include_virtual) return this.fields; + return this.fields.filter((f) => !f.isVirtual()); } addField(field: Field) { @@ -239,7 +231,7 @@ export class Entity< } } - const fields = this.getFillableFields(context as any, false); + const fields = this.getFillableFields(context, false); if (options?.ignoreUnknown !== true) { const field_names = fields.map((f) => f.name); @@ -283,7 +275,7 @@ export class Entity< fields = this.getFillableFields(options.context); break; default: - fields = this.getFields({ virtual: true }); + fields = this.getFields(true); } const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); diff --git a/app/src/data/entities/mutation/Mutator.ts b/app/src/data/entities/mutation/Mutator.ts index e4bb6f0..84389c3 100644 --- a/app/src/data/entities/mutation/Mutator.ts +++ b/app/src/data/entities/mutation/Mutator.ts @@ -83,10 +83,8 @@ export class Mutator< } // we should never get here, but just to be sure (why?) - if (!field.isFillable(context as any)) { - throw new Error( - `Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`, - ); + if (!field.isFillable(context)) { + throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`); } // transform from field diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index 8451d1d..98a1f45 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -26,19 +26,11 @@ export const baseFieldConfigSchema = s .strictObject({ label: s.string(), description: s.string(), - required: s.boolean({ default: DEFAULT_REQUIRED }), - fillable: s.anyOf( - [ - s.boolean({ title: "Boolean" }), - s.array(s.string({ enum: ["create", "update"] }), { - title: "Context", - uniqueItems: true, - }), - ], - { - default: DEFAULT_FILLABLE, - }, - ), + required: s.boolean({ default: false }), + fillable: s.anyOf([ + s.boolean({ title: "Boolean" }), + s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }), + ]), hidden: s.anyOf([ s.boolean({ title: "Boolean" }), // @todo: tmp workaround @@ -111,7 +103,7 @@ export abstract class Field< return this.config?.default_value; } - isFillable(context?: "create" | "update"): boolean { + isFillable(context?: TActionContext): boolean { if (Array.isArray(this.config.fillable)) { return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE; } @@ -173,7 +165,7 @@ export abstract class Field< // @todo: add field level validation isValid(value: any, context: TActionContext): boolean { if (typeof value !== "undefined") { - return this.isFillable(context as any); + return this.isFillable(context); } else if (context === "create") { return !this.isRequired(); } diff --git a/app/src/data/fields/field-test-suite.ts b/app/src/data/fields/field-test-suite.ts index bf81fbd..369d41b 100644 --- a/app/src/data/fields/field-test-suite.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -99,7 +99,6 @@ export function fieldTestSuite( const _config = { ..._requiredConfig, required: false, - fillable: true, }; function fieldJson(field: Field) { @@ -117,7 +116,10 @@ export function fieldTestSuite( expect(fieldJson(fillable)).toEqual({ type: noConfigField.type, - config: _config, + config: { + ..._config, + fillable: true, + }, }); expect(fieldJson(required)).toEqual({ @@ -148,6 +150,7 @@ export function fieldTestSuite( type: requiredAndDefault.type, config: { ..._config, + fillable: true, required: true, default_value: config.defaultValue, }, diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index 8a06957..78708d6 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -77,7 +77,7 @@ export class SchemaManager { } getIntrospectionFromEntity(entity: Entity): IntrospectedTable { - const fields = entity.getFields({ virtual: false }); + const fields = entity.getFields(false); const indices = this.em.getIndicesOf(entity); // this is intentionally setting values to defaults, like "nullable" and "default" diff --git a/app/src/modules/db/migrations.ts b/app/src/modules/db/migrations.ts index e97071b..13f39ee 100644 --- a/app/src/modules/db/migrations.ts +++ b/app/src/modules/db/migrations.ts @@ -1,7 +1,6 @@ import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; -import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -108,29 +107,6 @@ export const migrations: Migration[] = [ return config; }, }, - { - // change field.config.fillable to only "create" and "update" - version: 11, - up: async (config: InitialModuleConfigs) => { - const { data, ...rest } = config; - return { - ...rest, - data: { - ...data, - entities: transformObject(data?.entities ?? {}, (entity) => { - return { - ...entity, - fields: transformObject(entity?.fields ?? {}, (field) => { - const fillable = field!.config?.fillable; - if (!fillable || typeof fillable === "boolean") return field; - return { ...field, config: { ...field!.config, fillable: true } }; - }), - }; - }), - }, - }; - }, - }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index cd85aa4..502a844 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -29,7 +29,7 @@ export const Group = ({ > ref={ref} className={twMerge( "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed", - disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed", + disabledOrReadonly && "bg-muted/50 text-primary/50", !disabledOrReadonly && "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", props.className, @@ -153,7 +153,7 @@ export const Textarea = forwardRef @@ -213,7 +213,7 @@ export const BooleanInput = forwardRef { + console.log("setting", bool); props.onChange?.({ target: { value: bool } }); }} {...(props as any)} @@ -292,7 +293,7 @@ export const Select = forwardRef< {...props} ref={ref} className={twMerge( - "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed", + "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", "appearance-none h-11 w-full", !props.multiple && "border-r-8 border-r-transparent", props.className, diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 9943aba..ff778a1 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -44,7 +44,7 @@ export function EntityForm({ className, action, }: EntityFormProps) { - const fields = entity.getFields({ virtual: true, primary: false }); + const fields = entity.getFillableFields(action, true); const options = useEntityAdminOptions(entity, action); return ( @@ -92,6 +92,10 @@ export function EntityForm({ ); } + if (!field.isFillable(action)) { + return; + } + const _key = `${entity.name}-${field.name}-${key}`; return ( @@ -123,7 +127,7 @@ export function EntityForm({