Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e0086fcd | ||
|
|
282c9f6054 | ||
|
|
b294ee68ac | ||
|
|
e8a256af2a | ||
|
|
8c596781ff | ||
|
|
43cbdb3010 | ||
|
|
b3c9f258de |
@@ -16,8 +16,8 @@ jobs:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: bknd
|
||||
ports:
|
||||
- 5430:5432
|
||||
# ports:
|
||||
# - 5430:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
|
||||
Vendored
+8
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
|
||||
target: "media",
|
||||
config: {
|
||||
mappedBy: "image",
|
||||
on_delete: "set null",
|
||||
},
|
||||
});
|
||||
// media should not see categories
|
||||
|
||||
@@ -8,9 +8,11 @@ import { $ } from "bun";
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
|
||||
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
|
||||
|
||||
const credentials = {
|
||||
host: "localhost",
|
||||
port: 5430,
|
||||
host: isCI ? "postgres" : "localhost",
|
||||
port: 5432,
|
||||
user: "postgres",
|
||||
password: "postgres",
|
||||
database: "bknd",
|
||||
@@ -39,6 +41,11 @@ async function isPostgresRunning() {
|
||||
|
||||
describe("postgres", () => {
|
||||
beforeAll(async () => {
|
||||
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
|
||||
console.log("Running in CI: Skipping manual docker run");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isPostgresRunning())) {
|
||||
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
||||
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
||||
|
||||
+297
-317
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
type ClientProviderProps,
|
||||
useApi,
|
||||
useBaseUrl,
|
||||
useClientContext
|
||||
useClientContext,
|
||||
} from "./ClientProvider";
|
||||
|
||||
export * from "./api/use-api";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
declare module '*.css';
|
||||
declare module "*.css";
|
||||
|
||||
@@ -71,7 +71,7 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
|
||||
## Serve the API
|
||||
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
|
||||
|
||||
```ts title="src/bknd.ts"
|
||||
import {
|
||||
@@ -127,31 +127,80 @@ export const DELETE = handler;
|
||||
```
|
||||
|
||||
## Enabling the Admin UI
|
||||
Create a file at `admin/[[...admin]]/page.client.tsx`:
|
||||
|
||||
``` tsx title="admin/[[...admin]]/page.client.tsx"
|
||||
"use client";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const Admin = dynamic(async () => (await import("bknd/ui")).Admin, {
|
||||
ssr: false,
|
||||
});
|
||||
```
|
||||
<Callout type="info">
|
||||
We are using [Colocation](https://nextjs.org/docs/app/getting-started/project-structure#colocation) to prevent server-side hydration error, and the dynamic function to load Admin component only on client side.
|
||||
</Callout>
|
||||
|
||||
|
||||
Create a page at `admin/[[...admin]]/page.tsx`:
|
||||
|
||||
```tsx title="admin/[[...admin]]/page.tsx"
|
||||
import { Admin } from "bknd/ui";
|
||||
import { getApi } from "@/bknd";
|
||||
import "bknd/dist/styles.css";
|
||||
import { Admin } from "./page.client";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminPage() {
|
||||
// make sure to verify auth using headers
|
||||
const api = await getApi({ verify: true });
|
||||
|
||||
// early return if not logged in
|
||||
if (!api.getUser()) {
|
||||
return redirect("/unauthorized");
|
||||
}
|
||||
|
||||
return (
|
||||
<Admin
|
||||
withProvider={{ user: api.getUser() }}
|
||||
config={{
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/../",
|
||||
theme: "system",
|
||||
}}
|
||||
/>
|
||||
<Suspense>
|
||||
<Admin
|
||||
withProvider={{ user: api.getUser() }}
|
||||
config={{
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/../",
|
||||
theme: "system",
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## React SDK configuration
|
||||
|
||||
To use queries and mutation on client side, bknd provides first class support for it with it's [React SDK](/usage/react)
|
||||
to set it up with Next.js add the `ClientProvider` to your root layout
|
||||
|
||||
|
||||
```tsx title="app/layout.tsx"
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ClientProvider>
|
||||
{children}
|
||||
</ClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
## Example usage of the API
|
||||
|
||||
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
|
||||
@@ -172,3 +221,26 @@ export default async function Home() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Note for Production
|
||||
If your admin component is not rendering and has a error like `Identifier 'b' has already been declared`, you will need to add these lines to your `next.config.(mjs|ts)` file:
|
||||
|
||||
```ts
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// ...
|
||||
// for turbopack
|
||||
experimental: {
|
||||
turbopackMinify: false,
|
||||
},
|
||||
|
||||
// for webpack
|
||||
// webpack: (config) => {
|
||||
// config.optimization.minimize = false;
|
||||
// return config;
|
||||
// },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user