Merge pull request #363 from bknd-io/release/0.21

Release 0.21
This commit is contained in:
dswbx
2026-03-14 13:34:23 +01:00
committed by GitHub
11 changed files with 434 additions and 68 deletions

1
app/.gitignore vendored
View File

@@ -2,3 +2,4 @@ playwright-report
test-results test-results
bknd.config.* bknd.config.*
__test__/helper.d.ts __test__/helper.d.ts
.env.local

View File

@@ -110,10 +110,11 @@ describe("MediaApi", () => {
const api = new MediaApi({}, mockedBackend.request); const api = new MediaApi({}, mockedBackend.request);
const name = "image.png"; const name = "image.png";
const res = await api.getFileStream(name); const res = await api.getFile(name);
expect(isReadableStream(res)).toBe(true); const stream = await api.getFileStream(name);
expect(isReadableStream(stream)).toBe(true);
const blob = (await new Response(res).blob()) as File; const blob = (await res.res.blob()) as File;
expect(isFile(blob)).toBe(true); expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0); expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png"); expect(blob.type).toBe("image/png");

View File

@@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach } from "vitest";
import { AppReduced, type AppType } from "ui/client/utils/AppReduced";
import type { BkndAdminProps } from "ui/Admin";
// Import the normalizeAdminPath function for testing
// Note: This assumes the function is exported or we need to test it indirectly through public methods
describe("AppReduced", () => {
let mockAppJson: AppType;
let appReduced: AppReduced;
beforeEach(() => {
mockAppJson = {
data: {
entities: {},
relations: {},
},
flows: {
flows: {},
},
auth: {},
} as AppType;
});
describe("getSettingsPath", () => {
it("should return settings path with basepath", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
expect(result).toBe("~/admin/settings");
});
it("should return settings path with empty basepath", () => {
const options: BkndAdminProps["config"] = {
basepath: "",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
expect(result).toBe("~/settings");
});
it("should append additional path segments", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["user", "profile"]);
expect(result).toBe("~/admin/settings/user/profile");
});
it("should normalize multiple slashes", () => {
const options: BkndAdminProps["config"] = {
basepath: "//admin//",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["//user//"]);
expect(result).toBe("~/admin/settings/user");
});
it("should handle basepath without leading slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
expect(result).toBe("~/admin/settings");
});
});
describe("getAbsolutePath", () => {
it("should return absolute path with basepath", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("dashboard");
expect(result).toBe("~/admin/dashboard");
});
it("should return base path when no path provided", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath();
expect(result).toBe("~/admin");
});
it("should normalize paths correctly", () => {
const options: BkndAdminProps["config"] = {
basepath: "//admin//",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("//dashboard//");
expect(result).toBe("~/admin/dashboard");
});
});
describe("options getter", () => {
it("should return merged options with defaults", () => {
const customOptions: BkndAdminProps["config"] = {
basepath: "/custom-admin",
logo_return_path: "/custom-home",
};
appReduced = new AppReduced(mockAppJson, customOptions);
const options = appReduced.options;
expect(options).toEqual({
logo_return_path: "/custom-home",
basepath: "/custom-admin",
});
});
it("should use default logo_return_path when not provided", () => {
const customOptions: BkndAdminProps["config"] = {
basepath: "/admin",
};
appReduced = new AppReduced(mockAppJson, customOptions);
const options = appReduced.options;
expect(options.logo_return_path).toBe("/");
expect(options.basepath).toBe("/admin");
});
});
describe("path normalization behavior", () => {
it("should normalize duplicate slashes in settings path", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["//nested//path//"]);
expect(result).toBe("~/admin/settings/nested/path");
});
it("should handle root path normalization", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath();
// The normalizeAdminPath function removes trailing slashes except for root "/"
// When basepath is "/", the result is "~/" which becomes "~" after normalization
expect(result).toBe("~");
});
it("should preserve entity paths ending with slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("entity/");
expect(result).toBe("~/admin/entity");
});
it("should remove trailing slashes from non-entity paths", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("dashboard/");
expect(result).toBe("~/admin/dashboard");
});
});
describe("withBasePath - double slash fix (admin_basepath with trailing slash)", () => {
it("should not produce double slashes when admin_basepath has trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.withBasePath("/data");
expect(result).toBe("/admin/data");
expect(result).not.toContain("//");
});
it("should work correctly when admin_basepath has no trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.withBasePath("/data");
expect(result).toBe("/admin/data");
});
it("should handle absolute paths with admin_basepath trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getAbsolutePath("data");
expect(result).toBe("~/admin/data");
expect(result).not.toContain("//");
});
it("should handle settings path with admin_basepath trailing slash", () => {
const options: BkndAdminProps["config"] = {
basepath: "/",
admin_basepath: "/admin/",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["general"]);
expect(result).toBe("~/admin/settings/general");
expect(result).not.toContain("//");
});
});
describe("edge cases", () => {
it("should handle undefined basepath", () => {
const options: BkndAdminProps["config"] = {
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath();
// When basepath is undefined, it defaults to empty string
expect(result).toBe("~/settings");
});
it("should handle null path segments", () => {
const options: BkndAdminProps["config"] = {
basepath: "/admin",
logo_return_path: "/",
};
appReduced = new AppReduced(mockAppJson, options);
const result = appReduced.getSettingsPath(["", "valid", ""]);
expect(result).toBe("~/admin/settings/valid");
});
});
});

View File

@@ -21,46 +21,47 @@ export const sync: CliCommand = (program) => {
console.info(""); console.info("");
if (stmts.length === 0) { if (stmts.length === 0) {
console.info(c.yellow("No changes to sync")); console.info(c.yellow("No changes to sync"));
process.exit(0); } else {
} // @todo: currently assuming parameters aren't used
// @todo: currently assuming parameters aren't used const sql = stmts.map((d) => d.sql).join(";\n") + ";";
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.force) { if (options.force) {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql)); console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop }); await schema.sync({ force: true, drop: options.drop });
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`); console.info(`${c.green("Database synced")}`);
} else {
if (options.seed) { if (options.out) {
console.info(c.dim("\nExecuting seed...")); const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
const seed = app.options?.seed; await writeFile(options.out, output);
if (seed) { console.info(`SQL written to ${c.cyan(options.out)}`);
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else { } else {
console.info(c.yellow("No seed function provided")); console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
} }
} }
} else { }
if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} else {
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info( if (options.seed) {
c.yellow( console.info(c.dim("\nExecuting seed..."));
"\nNo statements have been executed. Use --force to perform database syncing operations", const seed = app.options?.seed;
), if (seed) {
); await seed({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
} }
} }
process.exit(0); process.exit(0);
}); });
}; };

View File

@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { TApiUser } from "Api"; import type { TApiUser } from "Api";
import type { AppTheme } from "ui/client/use-theme";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -20,7 +21,7 @@ export type AdminBkndWindowContext = {
logout_route: string; logout_route: string;
admin_basepath: string; admin_basepath: string;
logo_return_path?: string; logo_return_path?: string;
theme?: "dark" | "light" | "system"; theme?: AppTheme;
}; };
// @todo: add migration to remove admin path from config // @todo: add migration to remove admin path from config
@@ -31,7 +32,7 @@ export type AdminControllerOptions = {
html?: string; html?: string;
forceDev?: boolean | { mainPath: string }; forceDev?: boolean | { mainPath: string };
debugRerenders?: boolean; debugRerenders?: boolean;
theme?: "dark" | "light" | "system"; theme?: AppTheme;
logoReturnPath?: string; logoReturnPath?: string;
manifest?: Manifest; manifest?: Manifest;
}; };
@@ -122,7 +123,7 @@ export class AdminController extends Controller {
const obj: AdminBkndWindowContext = { const obj: AdminBkndWindowContext = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: authRoutes.logout, logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath, admin_basepath: this.options.adminBasepath.replace(/\/+$/, ""),
theme: this.options.theme, theme: this.options.theme,
logo_return_path: this.options.logoReturnPath, logo_return_path: this.options.logoReturnPath,
}; };

View File

@@ -71,4 +71,54 @@ describe("timestamps plugin", () => {
expect(updatedData.updated_at).toBeDefined(); expect(updatedData.updated_at).toBeDefined();
expect(updatedData.updated_at).toBeInstanceOf(Date); expect(updatedData.updated_at).toBeInstanceOf(Date);
}); });
test("index strategy", async () => {
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"] })],
},
});
await app.build();
expect(app.em.indices.length).toBe(0);
{
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"], indexStrategy: "composite" })],
},
});
await app.build();
expect(app.em.indices.length).toBe(1);
}
{
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"], indexStrategy: "individual" })],
},
});
await app.build();
expect(app.em.indices.length).toBe(2);
}
});
}); });

View File

@@ -4,6 +4,7 @@ import { $console } from "bknd/utils";
export type TimestampsPluginOptions = { export type TimestampsPluginOptions = {
entities: string[]; entities: string[];
setUpdatedOnCreate?: boolean; setUpdatedOnCreate?: boolean;
indexStrategy?: "composite" | "individual";
}; };
/** /**
@@ -19,6 +20,7 @@ export type TimestampsPluginOptions = {
export function timestamps({ export function timestamps({
entities = [], entities = [],
setUpdatedOnCreate = true, setUpdatedOnCreate = true,
indexStrategy,
}: TimestampsPluginOptions): AppPlugin { }: TimestampsPluginOptions): AppPlugin {
return (app: App) => ({ return (app: App) => ({
name: "timestamps", name: "timestamps",
@@ -29,19 +31,35 @@ export function timestamps({
} }
const appEntities = app.em.entities.map((e) => e.name); const appEntities = app.em.entities.map((e) => e.name);
const actualEntities = entities.filter((e) => appEntities.includes(e));
return em( return em(
Object.fromEntries( Object.fromEntries(
entities actualEntities.map((e) => [
.filter((e) => appEntities.includes(e)) e,
.map((e) => [ entity(e, {
e, created_at: datetime(),
entity(e, { updated_at: datetime(),
created_at: datetime(), }),
updated_at: datetime(), ]),
}),
]),
), ),
(fns, schema) => {
if (indexStrategy) {
for (const entity of actualEntities) {
if (entity in schema) {
switch (indexStrategy) {
case "composite":
fns.index(schema[entity]!).on(["created_at", "updated_at"]);
break;
case "individual":
fns.index(schema[entity]!).on(["created_at"]);
fns.index(schema[entity]!).on(["updated_at"]);
break;
}
}
}
}
},
); );
}, },
onBuilt: async () => { onBuilt: async () => {

View File

@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType, protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {}, protected _options: BkndAdminProps["config"] = {},
) { ) {
//console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity); return constructEntity(name, entity);
@@ -70,20 +69,27 @@ export class AppReduced {
get options() { get options() {
return { return {
basepath: "", basepath: "/",
logo_return_path: "/", logo_return_path: "/",
...this._options, ...this._options,
}; };
} }
withBasePath(path: string | string[], absolute = false): string {
const paths = Array.isArray(path) ? path : [path];
return [absolute ? "~" : null, this.options.basepath, this.options.admin_basepath, ...paths]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/")
.replace(/\/$/, "");
}
getSettingsPath(path: string[] = []): string { getSettingsPath(path: string[] = []): string {
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/"); return this.withBasePath(["settings", ...path], true);
return [base, ...path].join("/");
} }
getAbsolutePath(path?: string): string { getAbsolutePath(path?: string): string {
const { basepath } = this.options; return this.withBasePath(path ?? [], true);
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
} }
getAuthConfig() { getAuthConfig() {

View File

@@ -33,7 +33,7 @@ export function Routes({
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
const ctx = useBkndWindowContext(); const ctx = useBkndWindowContext();
const actualBasePath = basePath || ctx.admin_basepath; const actualBasePath = (basePath || ctx.admin_basepath).replace(/\/+$/, "");
return ( return (
<div id="bknd-admin" className={theme + " antialiased"}> <div id="bknd-admin" className={theme + " antialiased"}>

View File

@@ -1,11 +1,11 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/types", "outDir": "./dist/types",
"rootDir": "./src", "rootDir": "./src",
"baseUrl": ".", "baseUrl": ".",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx"], "include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./node_modules", "./__test__", "./e2e"] "exclude": ["./node_modules", "./__test__", "./e2e"]
} }

View File

@@ -1,6 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0, "configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "bknd", "name": "bknd",
@@ -3846,7 +3846,7 @@
"@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"@bknd/plasmic/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@bknd/plasmic/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
@@ -4750,7 +4750,7 @@
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="],
"@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],