fix: on delete
Run Tests / test (pull_request) Successful in 7m24s

This commit is contained in:
2026-09-27 17:57:45 +05:30
parent b294ee68ac
commit 282c9f6054
38 changed files with 742 additions and 655 deletions
+8 -5
View File
@@ -1,19 +1,22 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [
"js/ts.preferences.importModuleSpecifier": "non-relative",
"js/ts.preferences.autoImportFileExcludePatterns": [
"**/dist/**",
"**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
],
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": {
"js/ts.preferences.includePackageJsonAutoImports": "on",
"js/ts.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+53 -54
View File
@@ -4,73 +4,72 @@ import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
omitKeys(cfg, ["connection"]);
const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
describe("makeConfig", () => {
it("returns empty config for empty inputs", async () => {
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
[{}],
[{}, { env: { TEST: "test" } }],
];
describe("makeConfig", () => {
it("returns empty config for empty inputs", async () => {
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
[{}],
[{}, { env: { TEST: "test" } }],
];
for (const args of cases) {
const cfg = await adapter.makeConfig(...(args as any));
expect(stripConnection(cfg)).toEqual({});
}
});
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
);
expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
for (const args of cases) {
const cfg = await adapter.makeConfig(...(args as any));
expect(stripConnection(cfg)).toEqual({});
}
});
});
it("allows all properties in app() result", async () => {
const called = mock(() => null);
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
);
const cfg = await adapter.makeConfig(
{
app: (env) => ({
connection: { url: "test" },
expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const },
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
});
});
it("allows all properties in app() result", async () => {
const called = mock(() => null);
const cfg = await adapter.makeConfig(
{
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const },
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
}),
},
{ foo: "bar" },
);
{ foo: "bar" },
);
expect(cfg.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
expect(cfg.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
});
+9 -3
View File
@@ -73,7 +73,13 @@ describe("Api", async () => {
it("should extract tokens case insensitive", async () => {
const token = await sign({ sub: "test" }, "1234");
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
})
expect(
new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState()
.token,
).toBe(token);
expect(
new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState()
.token,
).toBe(token);
});
});
+13 -9
View File
@@ -51,14 +51,18 @@ describe("Authenticator", async () => {
});
const token = await auth.jwt({ sub: "test" });
const res = await auth.resolveAuthFromRequest(new Headers({
Authorization: `Bearer ${token}`,
}));
expect((res as any).sub).toBe("test")
const res = await auth.resolveAuthFromRequest(
new Headers({
Authorization: `Bearer ${token}`,
}),
);
expect((res as any).sub).toBe("test");
const res2 = await auth.resolveAuthFromRequest(new Headers({
Authorization: `bearer ${token}`,
}));
expect((res2 as any).sub).toBe("test")
})
const res2 = await auth.resolveAuthFromRequest(
new Headers({
Authorization: `bearer ${token}`,
}),
);
expect((res2 as any).sub).toBe("test");
});
});
+1
View File
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
target: "media",
config: {
mappedBy: "image",
on_delete: "set null",
},
});
// media should not see categories
+297 -317
View File
@@ -14,15 +14,15 @@ const clean = args.includes("--clean");
// silence tsup
const oldConsole = {
log: console.log,
warn: console.warn,
log: console.log,
warn: console.warn,
};
console.log = () => {};
console.warn = () => {};
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) {
@@ -43,149 +43,146 @@ if (clean) {
let types_running = false;
function buildTypes() {
if (types_running || !types) return;
types_running = true;
if (types_running || !types) return;
types_running = true;
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false;
},
});
},
});
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false;
},
});
},
});
}
if (types && !watch) {
buildTypes();
buildTypes();
}
let watcher_timeout: any;
function delayTypes() {
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
}
const dependencies = Object.keys(pkg.dependencies);
// collection of always-external packages
const external = [
...dependencies,
"bun:test",
"node:test",
"node:assert/strict",
"@libsql/client",
"bknd",
/^bknd\/.*/,
"jsonv-ts",
/^jsonv-ts\/.*/,
...dependencies,
"bun:test",
"node:test",
"node:assert/strict",
"@libsql/client",
"bknd",
/^bknd\/.*/,
"jsonv-ts",
/^jsonv-ts\/.*/,
] as const;
/**
* Building backend and general API
*/
async function buildApi() {
await tsup.build({
minify,
sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves
watch: false,
define,
entry: [
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
removeNodeProtocol: false,
format: ["esm"],
splitting: false,
loader: {
".svg": "dataurl",
},
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built"));
},
});
await tsup.build({
minify,
sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves
watch: false,
define,
entry: [
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
removeNodeProtocol: false,
format: ["esm"],
splitting: false,
loader: {
".svg": "dataurl",
},
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built"));
},
});
}
async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text();
await Bun.write(
path,
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
);
const bundle = await Bun.file(path).text();
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
}
/**
* Building UI for direct imports
*/
async function buildUi() {
const base = {
minify,
sourcemap,
watch: false,
define,
external: [
...external,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
const base = {
minify,
sourcemap,
watch: false,
define,
external: [
...external,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
});
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
});
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
});
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
});
}
/**
@@ -194,195 +191,186 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client"
*/
async function buildUiElements() {
await tsup.build({
minify,
sourcemap,
watch: false,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.alias = {
// not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
};
},
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
},
});
await tsup.build({
minify,
sourcemap,
watch: false,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.alias = {
// not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
};
},
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
},
});
}
/**
* Building adapters
*/
function baseConfig(
adapter: string,
overrides: Partial<tsup.Options> = {},
): tsup.Options {
return {
minify,
sourcemap,
watch: false,
entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
removeNodeProtocol: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
},
...overrides,
define: {
...define,
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
return {
minify,
sourcemap,
watch: false,
entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
removeNodeProtocol: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
},
...overrides,
define: {
...define,
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
}
async function buildAdapters() {
await Promise.all([
// base adapter handles
tsup.build({
...baseConfig(""),
target: "esnext",
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}),
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
await Promise.all([
// base adapter handles
tsup.build({
...baseConfig(""),
target: "esnext",
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}),
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
tsup.build({
...baseConfig("nextjs"),
platform: "node",
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
tsup.build({
...baseConfig("tanstack-start"),
platform: "node",
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
}),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
tsup.build({
...baseConfig("nextjs"),
platform: "node",
}),
tsup.build({
...baseConfig("nuxt"),
platform: "node",
}),
tsup.build({
...baseConfig("tanstack-start"),
platform: "node",
}),
tsup.build({
...baseConfig("node"),
platform: "node",
}),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
tsup.build({
...baseConfig("solid-start"),
platform: "node",
}),
tsup.build({
...baseConfig("nuxt"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("node"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("solid-start"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
}
async function buildAll() {
await Promise.all([
buildApi(),
buildUi(),
buildUiElements(),
buildAdapters(),
]);
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
}
// initial build
@@ -390,47 +378,39 @@ await buildAll();
// custom watcher since tsup's watch is broken in 8.3.5+
if (watch) {
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false;
const rebuild = async () => {
if (isBuilding) return;
isBuilding = true;
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
try {
await buildAll();
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
} catch (e) {
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
}
isBuilding = false;
};
const rebuild = async () => {
if (isBuilding) return;
isBuilding = true;
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
try {
await buildAll();
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
} catch (e) {
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
}
isBuilding = false;
};
const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100);
};
const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100);
};
// watch src directory recursively
fsWatch(
join(import.meta.dir, "src"),
{ recursive: true },
(event, filename) => {
// watch src directory recursively
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
if (!filename) return;
// ignore non-source files
if (
!filename.endsWith(".ts") &&
!filename.endsWith(".tsx") &&
!filename.endsWith(".css")
)
return;
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
return;
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
debouncedRebuild();
},
);
});
// keep process alive
await new Promise(() => {});
// keep process alive
await new Promise(() => {});
}
+4 -1
View File
@@ -3,7 +3,10 @@ import path from "node:path";
import c from "picocolors";
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
type RunOptions = Omit<
Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">,
"stdout" | "stderr"
>;
async function run(
cmd: string[] | string,
+39 -39
View File
@@ -1,53 +1,53 @@
const adapter = process.env.TEST_ADAPTER;
const default_config = {
media_adapter: "local",
base_path: "",
media_adapter: "local",
base_path: "",
} as const;
const configs = {
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
nuxt: {
base_path: "/admin",
},
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
nuxt: {
base_path: "/admin",
},
"solid-start": {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
"tanstack-start": {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
"tanstack-start": {
base_path: "/admin",
},
};
export function getAdapterConfig(): typeof default_config {
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
return default_config;
return default_config;
}
+1 -4
View File
@@ -20,10 +20,7 @@ export async function getApp<Env>(
* @param config - bknd configuration
* @param args - environment variables
*/
export function serve<Env>(
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
args: Env,
) {
export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
return async (request: Request) => {
return (await getApp(config, args)).fetch(request);
};
@@ -2,8 +2,7 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
export type TanstackStartEnv = NodeJS.ProcessEnv;
export type TanstackStartConfig<Env = TanstackStartEnv> =
FrameworkBkndConfig<Env>;
export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
/**
* Get bknd app instance
@@ -11,10 +10,10 @@ export type TanstackStartConfig<Env = TanstackStartEnv> =
* @param args - environment variables
*/
export async function getApp<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args);
return await createFrameworkApp(config, args);
}
/**
@@ -23,11 +22,11 @@ export async function getApp<Env = TanstackStartEnv>(
* @param args - environment variables
*/
export function serve<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return async (request: Request) => {
const app = await getApp(config, args);
return app.fetch(request);
};
return async (request: Request) => {
const app = await getApp(config, args);
return app.fetch(request);
};
}
+1 -3
View File
@@ -26,9 +26,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
return result;
}
export const auth = (options?: {
skip?: (string | RegExp)[];
}) =>
export const auth = (options?: { skip?: (string | RegExp)[] }) =>
createMiddleware<ServerEnv>(async (c, next) => {
if (!c.get("auth")) {
c.set("auth", {
+41 -45
View File
@@ -4,52 +4,48 @@ import { plunkEmail } from "./plunk";
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("plunk", () => {
it("should throw on failed", async () => {
const driver = plunkEmail({ apiKey: "invalid" });
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
});
it("should throw on failed", async () => {
const driver = plunkEmail({ apiKey: "invalid" });
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
});
it("should send an email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined, // Default to what Plunk sets
});
const response = await driver.send(
"help@bknd.io",
"Test Email from Plunk",
"This is a test email",
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
expect(response.emails).toBeDefined();
expect(response.timestamp).toBeDefined();
});
it("should send an email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined, // Default to what Plunk sets
});
const response = await driver.send(
"help@bknd.io",
"Test Email from Plunk",
"This is a test email",
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
expect(response.emails).toBeDefined();
expect(response.timestamp).toBeDefined();
});
it("should send HTML email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send(
"help@bknd.io",
"HTML Test",
htmlBody,
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send HTML email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send with text and html", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const response = await driver.send("test@example.com", "Test Email", {
text: "help@bknd.io",
html: "<p>This is HTML</p>",
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send with text and html", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const response = await driver.send("test@example.com", "Test Email", {
text: "help@bknd.io",
html: "<p>This is HTML</p>",
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
});
+51 -51
View File
@@ -1,70 +1,70 @@
import type { IEmailDriver } from "./index";
export type PlunkEmailOptions = {
apiKey: string;
host?: string;
from?: string;
apiKey: string;
host?: string;
from?: string;
};
export type PlunkEmailSendOptions = {
subscribed?: boolean;
name?: string;
from?: string;
reply?: string;
headers?: Record<string, string>;
subscribed?: boolean;
name?: string;
from?: string;
reply?: string;
headers?: Record<string, string>;
};
export type PlunkEmailResponse = {
success: boolean;
emails: Array<{
contact: {
id: string;
email: string;
};
email: string;
}>;
timestamp: string;
success: boolean;
emails: Array<{
contact: {
id: string;
email: string;
};
email: string;
}>;
timestamp: string;
};
export const plunkEmail = (
config: PlunkEmailOptions,
config: PlunkEmailOptions,
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
const host = config.host ?? "https://api.useplunk.com/v1/send";
const from = config.from;
const host = config.host ?? "https://api.useplunk.com/v1/send";
const from = config.from;
return {
send: async (
to: string,
subject: string,
body: string | { text: string; html: string },
options?: PlunkEmailSendOptions,
) => {
const payload: any = {
from,
to,
subject,
};
return {
send: async (
to: string,
subject: string,
body: string | { text: string; html: string },
options?: PlunkEmailSendOptions,
) => {
const payload: any = {
from,
to,
subject,
};
if (typeof body === "string") {
payload.body = body;
} else {
payload.body = body.html;
}
if (typeof body === "string") {
payload.body = body;
} else {
payload.body = body.html;
}
const res = await fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ ...payload, ...options }),
});
const res = await fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ ...payload, ...options }),
});
if (!res.ok) {
throw new Error(`Plunk API error: ${await res.text()}`);
}
if (!res.ok) {
throw new Error(`Plunk API error: ${await res.text()}`);
}
return (await res.json()) as PlunkEmailResponse;
},
};
return (await res.json()) as PlunkEmailResponse;
},
};
};
+3 -3
View File
@@ -2,15 +2,15 @@ import { v4, v7, validate, version as uuidVersion } from "uuid";
// generates v4
export function uuid(): string {
return v4();
return v4();
}
// generates v7
export function uuidv7(): string {
return v7();
return v7();
}
// validate uuid
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
return validate(uuid) && uuidVersion(uuid) === version;
return validate(uuid) && uuidVersion(uuid) === version;
}
@@ -1,4 +1,8 @@
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
import {
Kysely,
PostgresDialect,
type PostgresDialectConfig as KyselyPostgresDialectConfig,
} from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
@@ -24,8 +24,6 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
}
}
export function postgresJs(
config: PostgresJSDialectConfig,
): PostgresJsConnection {
export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
return new PostgresJsConnection(config);
}
+4 -1
View File
@@ -194,7 +194,10 @@ export class Entity<
getFields({
virtual = false,
primary = true,
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
}: {
virtual?: boolean;
primary?: boolean;
} = {}): Field[] {
return this.fields.filter((f) => {
if (!virtual && f.isVirtual()) return false;
if (!primary && f instanceof PrimaryField) return false;
+3 -3
View File
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
emgr?: EventManager<any>,
) {
// add entities & relations
entities.forEach((entity) => this.addEntity(entity));
relations.forEach((relation) => this.addRelation(relation));
indices.forEach((index) => this.addIndex(index));
entities.forEach((entity) => void this.addEntity(entity));
relations.forEach((relation) => void this.addRelation(relation));
indices.forEach((index) => void this.addIndex(index));
if (!Connection.isConnection(connection)) {
throw new UnableToConnectException("");
+2 -1
View File
@@ -8,7 +8,7 @@ import {
RelationHelper,
} from "../relations";
import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationType } from "./relation-types";
const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number];
@@ -35,6 +35,7 @@ export abstract class EntityRelation<
mappedBy: s.string().optional(),
inversedBy: s.string().optional(),
required: s.boolean().optional(),
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
});
// don't make protected, App requires it to instantiatable
+2 -1
View File
@@ -7,7 +7,7 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
import type { MutationInstructionResponse } from "./RelationMutator";
import { type RelationType, RelationTypes } from "./relation-types";
import { type RelationType, RelationCascades, RelationTypes } from "./relation-types";
/**
* Source entity receives the mapping field
@@ -31,6 +31,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
fieldConfig: s
.object({
label: s.string(),
on_delete: s.string({ enum: RelationCascades }).optional(),
})
.optional(),
...EntityRelation.schema.properties,
+13 -5
View File
@@ -4,20 +4,22 @@ import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils";
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
export const relationFieldConfigSchema = s.strictObject({
reference: s.string(),
target: s.string(), // @todo: potentially has to be an instance!
target_field: s.string({ default: "id" }).optional(),
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
on_delete: s.string({ enum: CASCADES, default: "set null" }).optional(),
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
...baseFieldConfigSchema.properties,
});
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
export type RelationFieldBaseConfig = { label?: string };
export type RelationFieldBaseConfig = {
label?: string;
on_delete?: RelationCascade;
};
export class RelationField extends Field<RelationFieldConfig> {
override readonly type = "relation";
@@ -39,6 +41,8 @@ export class RelationField extends Field<RelationFieldConfig> {
return new RelationField(name, {
...config,
required: relation.required,
// the relation owns the referential action, the field only materializes it
on_delete: config?.on_delete ?? relation.config.on_delete,
reference: target.reference,
target: target.entity.name,
target_field: target.entity.getPrimaryField().name,
@@ -50,6 +54,10 @@ export class RelationField extends Field<RelationFieldConfig> {
return this.config.reference;
}
onDelete(): RelationCascade | undefined {
return this.config.on_delete;
}
target() {
return this.config.target;
}
@@ -63,7 +71,7 @@ export class RelationField extends Field<RelationFieldConfig> {
...super.schema()!,
type: this.config.target_field_type ?? "integer",
references: `${this.config.target}.${this.config.target_field}`,
onDelete: this.config.on_delete ?? "set null",
onDelete: this.config.on_delete ?? DEFAULT_RELATION_CASCADE,
});
}
+4
View File
@@ -3,6 +3,7 @@ import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRela
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
import { type RelationType, RelationTypes } from "./relation-types";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
export * from "./EntityRelation";
export * from "./EntityRelationAnchor";
@@ -28,6 +29,9 @@ export {
type PolymorphicRelationConfig,
RelationTypes,
type RelationType,
RelationCascades,
type RelationCascade,
DEFAULT_RELATION_CASCADE,
// field
RelationField,
relationFieldConfigSchema,
+10
View File
@@ -5,3 +5,13 @@ export const RelationTypes = {
Polymorphic: "poly",
} as const;
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
export const RelationCascades = [
"cascade",
"set null",
"set default",
"restrict",
"no action",
] as const;
export type RelationCascade = (typeof RelationCascades)[number];
export const DEFAULT_RELATION_CASCADE: RelationCascade = "set null";
+3 -3
View File
@@ -17,9 +17,9 @@ export type BkndAdminConfig = {
*/
basepath?: string;
/**
* Sub-path for the Admin UI within the base path
* @default ``
*/
* Sub-path for the Admin UI within the base path
* @default ``
*/
admin_basepath?: string;
/**
* Path to return to when clicking the logo
+1 -1
View File
@@ -4,7 +4,7 @@ export {
type ClientProviderProps,
useApi,
useBaseUrl,
useClientContext
useClientContext,
} from "./ClientProvider";
export * from "./api/use-api";
-1
View File
@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {},
) {
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity);
});
+5 -1
View File
@@ -4,7 +4,11 @@ export function Logo({
scale = 0.2,
fill,
...props
}: { scale?: number; fill?: string; theme?: string }) {
}: {
scale?: number;
fill?: string;
theme?: string;
}) {
const t = useTheme();
const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
@@ -117,7 +117,11 @@ const ArrayAdd = ({
schema,
path: _path,
label = "Add",
}: { schema: JsonSchema; path: string; label?: string }) => {
}: {
schema: JsonSchema;
path: string;
label?: string;
}) => {
const {
setValue,
value: { currentIndex },
+6 -1
View File
@@ -64,6 +64,11 @@ export function Step({
path = [],
id,
...rest
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
}: {
children: React.ReactNode;
disabled?: boolean;
id: string;
path?: string[];
}) {
return <div {...rest}>{children}</div>;
}
+6 -1
View File
@@ -35,7 +35,12 @@ export function checkMaxReached({
current = 0,
overwrite,
added,
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) {
}: {
maxItems?: number;
current?: number;
overwrite?: boolean;
added: number;
}) {
if (!maxItems) {
return {
reject: false,
+6 -1
View File
@@ -101,7 +101,12 @@ export function useNavigate() {
}
}
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url;
const _url = options?.absolute
? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(
/\/+/g,
"/",
)
: url;
const state = {
...options?.state,
referrer: location,
@@ -283,7 +283,10 @@ function EntityJsonFormField({
fieldApi,
field,
...props
}: { fieldApi: TFieldApi; field: JsonField }) {
}: {
fieldApi: TFieldApi;
field: JsonField;
}) {
const [error, setError] = useState<any>(null);
const handleUpdate = useEvent((value: any) => {
setError(null);
@@ -312,7 +315,10 @@ function EntityEnumFormField({
fieldApi,
field,
...props
}: { fieldApi: TFieldApi; field: EnumField }) {
}: {
fieldApi: TFieldApi;
field: EnumField;
}) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value);
});
@@ -1,6 +1,12 @@
import { Switch, TextInput } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react";
import { ManyToOneRelation, type RelationType, RelationTypes } from "data/relations";
import {
DEFAULT_RELATION_CASCADE,
ManyToOneRelation,
type RelationType,
RelationCascades,
RelationTypes,
} from "data/relations";
import type { ReactNode } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { TbRefresh } from "react-icons/tb";
@@ -46,7 +52,9 @@ const schema = s.strictObject({
type: s.string({ enum: Relations.map((r) => r.type) }),
source: stringIdentifier,
target: stringIdentifier,
config: s.object({}),
config: s.object({
on_delete: s.string({ enum: RelationCascades }).optional(),
}),
});
type ComponentCtx<T extends FieldValues = FieldValues> = {
@@ -186,6 +194,25 @@ const Callout = ({ children }: { children: ReactNode }) => (
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
);
function OnDelete({
control,
label = "On target delete",
}: {
control: Control<any>;
label?: string;
}) {
return (
<MantineSelect
control={control}
name="config.on_delete"
label={label}
allowDeselect={false}
defaultValue={DEFAULT_RELATION_CASCADE}
data={RelationCascades.map((cascade) => ({ value: cascade, label: cascade }))}
/>
);
}
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
return (
<>
@@ -209,6 +236,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
control={control}
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
/>
<OnDelete control={control} />
</div>
<div />
<div className="flex flex-col gap-4">
@@ -222,30 +250,32 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
</div>
{source && target && config && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{config.mappedBy || target}_id {"→"} {target}
</pre>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{config.mappedBy || target}_id {"→"} {target}
</pre>
<p>
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
</p>
<p>
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{config.required ? "required" : "optional"}</i>.
</p>
<p>
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
{config.sourceCardinality ? (
<p>
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
<Pre>{target}</Pre> should not have more than{" "}
<Pre>{config.sourceCardinality}</Pre> referencing entr
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
</p>
<p>
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{config.required ? "required" : "optional"}</i>.
</p>
{config.sourceCardinality ? (
<p>
<Pre>{target}</Pre> should not have more than{" "}
<Pre>{config.sourceCardinality}</Pre> referencing entr
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
</p>
) : null}
</>
) : null}
</Callout>
)}
</>
@@ -258,7 +288,7 @@ function OneToOne({
data: {
source,
target,
config: { mappedBy, required },
config: { mappedBy, required, on_delete },
},
}: ComponentCtx) {
return (
@@ -271,6 +301,7 @@ function OneToOne({
placeholder={target}
/>
<Switch label="Required" {...register("config.required")} />
<OnDelete control={control} />
</div>
<div />
<div className="flex flex-col gap-4">
@@ -283,24 +314,26 @@ function OneToOne({
</div>
{source && target && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{mappedBy || target}_id {"↔"} {target}
</pre>
<p>
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
<Pre>{target}</Pre>.
</p>
<p>
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{required ? "required" : "optional"}</i>.
</p>
</>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{mappedBy || target}_id {"↔"} {target}
</pre>
<p>
A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
.
</p>
<p>
A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
(which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{required ? "required" : "optional"}</i>.
</p>
<p>
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout>
)}
</>
@@ -334,6 +367,7 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
{...register("config.connectionTableMappedName")}
placeholder={table}
/>
<OnDelete control={control} label="On delete (both references)" />
</div>
<div className="flex flex-col gap-4">
{/*<TextInput
@@ -345,18 +379,20 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
</div>
{source && target && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"→"} {table} {"←"} {target}
</pre>
<p>
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
A connection table <Pre>{table}</Pre> will be created to store the relations.
</p>
</>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"→"} {table} {"←"} {target}
</pre>
<p>
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
A connection table <Pre>{table}</Pre> will be created to store the relations.
</p>
<p>
If an entry of <Pre>{source}</Pre> or <Pre>{target}</Pre> is deleted, the
connection is <i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout>
)}
</>
@@ -391,26 +427,24 @@ function Polymorphic({ register, control, data: { type, source, target, config }
</div>
{source && target && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"←"} {target}
</pre>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"←"} {target}
</pre>
<p>
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
<Pre>entity_id</Pre> to make the (polymorphic) reference.
</p>
{config.targetCardinality ? (
<p>
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
<Pre>{source}</Pre> should not have more than{" "}
<Pre>{config.targetCardinality}</Pre> reference
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
</p>
<p>
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
<Pre>entity_id</Pre> to make the (polymorphic) reference.
</p>
{config.targetCardinality ? (
<p>
<Pre>{source}</Pre> should not have more than{" "}
<Pre>{config.targetCardinality}</Pre> reference
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
</p>
) : null}
</>
) : null}
</Callout>
)}
</>
+11 -2
View File
@@ -110,7 +110,12 @@ const EntityLinkList = ({
title,
context,
suggestCreate = false,
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
}: {
entities: Entity[];
title?: string;
context: "data" | "schema";
suggestCreate?: boolean;
}) => {
const { $data } = useBkndData();
const { readonly } = useBknd();
const navigate = useRouteNavigate();
@@ -195,7 +200,11 @@ const EntityContextMenu = ({
entity,
children,
enabled = true,
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
}: {
entity: Entity;
children: DropdownClickableChild;
enabled?: boolean;
}) => {
if (!enabled) return children;
const [navigate] = useNavigate();
const { $data } = useBkndData();
@@ -66,7 +66,10 @@ const relationAnyOfValues = {
export const DataSettings = ({
schema,
config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
}: {
schema: ModuleSchemas["data"];
config: ModuleConfigs["data"];
}) => {
const { app, readonly } = useBknd();
const prefix = app.getAbsolutePath("settings");
const entities = Object.keys(config.entities ?? {});
@@ -69,11 +69,7 @@ function CustomUserAvatarDropzone() {
showPlaceholder,
actions: { openFileInput },
} = Media.useDropzone();
const {
isOver,
isOverAccepted,
files: [file] = [],
} = Media.useDropzoneState();
const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
return (
<div
+3 -1
View File
@@ -98,7 +98,9 @@ input[disabled]::placeholder {
}
.flex-animate {
transition: flex-grow 0.2s ease, background-color 0.2s ease;
transition:
flex-grow 0.2s ease,
background-color 0.2s ease;
}
.flex-initial {
flex: 0 1 auto;
+1 -1
View File
@@ -1 +1 @@
declare module '*.css';
declare module "*.css";