mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e77982996 | |||
| 7b128c9701 | |||
| 061181d59d | |||
| af6cb0c8f0 | |||
| 5a693c0370 | |||
| 262588decc | |||
| 17ab35e245 | |||
| db795ec050 | |||
| 773df544dd | |||
| 0ac7d1fd6e |
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "1.2.5"
|
||||
bun-version: "1.2.14"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./app
|
||||
|
||||
+3
-1
@@ -29,4 +29,6 @@ packages/media/.env
|
||||
.idea
|
||||
.vscode
|
||||
.git_old
|
||||
docker/tmp
|
||||
docker/tmp
|
||||
.debug
|
||||
.history
|
||||
@@ -0,0 +1,42 @@
|
||||
# ===== DB Settings =====
|
||||
VITE_DB_URL=:memory:
|
||||
# you can set a location for a database here, it'll overwrite the previous setting
|
||||
# ideally use the ".db" folder (create it first), it's git ignored
|
||||
VITE_DB_URL=file:.db/dev.db
|
||||
|
||||
# alternatively, you can use url/token combination
|
||||
#VITE_DB_URL=
|
||||
#VITE_DB_TOKEN=
|
||||
|
||||
|
||||
# ===== DEV Server =====
|
||||
# restart the dev server on every change (enable with "1")
|
||||
VITE_APP_FRESH=
|
||||
# displays react-scan widget (enable with "1")
|
||||
VITE_DEBUG_RERENDERS=
|
||||
# console logs registered routes on start (enable with "1")
|
||||
VITE_SHOW_ROUTES=
|
||||
|
||||
|
||||
# ===== Test Credentials =====
|
||||
RESEND_API_KEY=
|
||||
R2_TOKEN=
|
||||
|
||||
R2_ACCESS_KEY=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_URL=
|
||||
|
||||
AWS_ACCESS_KEY=
|
||||
AWS_SECRET_KEY=
|
||||
AWS_S3_URL=
|
||||
|
||||
OAUTH_CLIENT_ID=
|
||||
OAUTH_CLIENT_SECRET=
|
||||
|
||||
PUBLIC_POSTHOG_KEY=
|
||||
PUBLIC_POSTHOG_HOST=
|
||||
|
||||
# ===== Internals =====
|
||||
BKND_CLI_CREATE_REF=main
|
||||
BKND_CLI_LOG_LEVEL=debug
|
||||
BKND_MODULES_DEBUG=1
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
|
||||
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
|
||||
|
||||
describe("object-query", () => {
|
||||
const q: ObjectQuery = { name: "Michael" };
|
||||
|
||||
+13
-80
@@ -1,25 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Value, _jsonp } from "../../src/core/utils";
|
||||
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
|
||||
import { getDummyConnection } from "./helper";
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { type WhereQuery, WhereBuilder } from "data";
|
||||
|
||||
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
||||
const result = Value.Decode(querySchema, input);
|
||||
expect(result).toEqual(expected);
|
||||
};
|
||||
|
||||
describe("data-query-impl", () => {
|
||||
function qb() {
|
||||
const c = getDummyConnection();
|
||||
const kysely = c.dummyConnection.kysely;
|
||||
return kysely.selectFrom("t").selectAll();
|
||||
}
|
||||
function compile(q: WhereQuery) {
|
||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||
return { sql, parameters };
|
||||
}
|
||||
function qb() {
|
||||
const c = getDummyConnection();
|
||||
const kysely = c.dummyConnection.kysely;
|
||||
return kysely.selectFrom("t").selectAll();
|
||||
}
|
||||
function compile(q: WhereQuery) {
|
||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||
return { sql, parameters };
|
||||
}
|
||||
|
||||
describe("WhereBuilder", () => {
|
||||
test("single validation", () => {
|
||||
const tests: [WhereQuery, string, any[]][] = [
|
||||
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
||||
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
|
||||
expect(keys).toEqual(expectedKeys);
|
||||
}
|
||||
});
|
||||
|
||||
test("with", () => {
|
||||
decode({ with: ["posts"] }, { with: { posts: {} } });
|
||||
decode({ with: { posts: {} } }, { with: { posts: {} } });
|
||||
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
|
||||
decode(
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
select: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
select: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// over http
|
||||
{
|
||||
const output = { with: { images: {} } };
|
||||
decode({ with: "images" }, output);
|
||||
decode({ with: '["images"]' }, output);
|
||||
decode({ with: ["images"] }, output);
|
||||
decode({ with: { images: {} } }, output);
|
||||
}
|
||||
|
||||
{
|
||||
const output = { with: { images: {}, comments: {} } };
|
||||
decode({ with: "images,comments" }, output);
|
||||
decode({ with: ["images", "comments"] }, output);
|
||||
decode({ with: '["images", "comments"]' }, output);
|
||||
decode({ with: { images: {}, comments: {} } }, output);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("data-query-impl: Typebox", () => {
|
||||
test("sort", async () => {
|
||||
const _dflt = { sort: { by: "id", dir: "asc" } };
|
||||
|
||||
decode({ sort: "" }, _dflt);
|
||||
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
|
||||
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
|
||||
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
|
||||
decode({ sort: "-1name" }, _dflt);
|
||||
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
|
||||
});
|
||||
});
|
||||
@@ -43,8 +43,9 @@ beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("MediaController", () => {
|
||||
test.only("accepts direct", async () => {
|
||||
test("accepts direct", async () => {
|
||||
const app = await makeApp();
|
||||
console.log("app", app);
|
||||
|
||||
const file = Bun.file(path);
|
||||
const name = makeName("png");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
import c from "picocolors";
|
||||
import { formatNumber } from "core/utils";
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/cli/index.ts"],
|
||||
target: "node",
|
||||
outdir: "./dist/cli",
|
||||
env: "PUBLIC_*",
|
||||
minify: true,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
|
||||
for (const output of result.outputs) {
|
||||
const size_ = await output.text();
|
||||
console.info(
|
||||
c.cyan(formatNumber.fileSize(size_.length)),
|
||||
c.dim(output.path.replace(import.meta.dir + "/", "")),
|
||||
);
|
||||
}
|
||||
+17
-8
@@ -1,5 +1,6 @@
|
||||
import { $ } from "bun";
|
||||
import * as tsup from "tsup";
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const watch = args.includes("--watch");
|
||||
@@ -8,8 +9,13 @@ const types = args.includes("--types");
|
||||
const sourcemap = args.includes("--sourcemap");
|
||||
const clean = args.includes("--clean");
|
||||
|
||||
const define = {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
};
|
||||
|
||||
if (clean) {
|
||||
console.log("Cleaning dist (w/o static)");
|
||||
console.info("Cleaning dist (w/o static)");
|
||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||
}
|
||||
|
||||
@@ -21,11 +27,11 @@ function buildTypes() {
|
||||
Bun.spawn(["bun", "build:types"], {
|
||||
stdout: "inherit",
|
||||
onExit: () => {
|
||||
console.log("Types built");
|
||||
console.info("Types built");
|
||||
Bun.spawn(["bun", "tsc-alias"], {
|
||||
stdout: "inherit",
|
||||
onExit: () => {
|
||||
console.log("Types aliased");
|
||||
console.info("Types aliased");
|
||||
types_running = false;
|
||||
},
|
||||
});
|
||||
@@ -47,10 +53,10 @@ if (types && !watch) {
|
||||
}
|
||||
|
||||
function banner(title: string) {
|
||||
console.log("");
|
||||
console.log("=".repeat(40));
|
||||
console.log(title.toUpperCase());
|
||||
console.log("-".repeat(40));
|
||||
console.info("");
|
||||
console.info("=".repeat(40));
|
||||
console.info(title.toUpperCase());
|
||||
console.info("-".repeat(40));
|
||||
}
|
||||
|
||||
// collection of always-external packages
|
||||
@@ -65,6 +71,7 @@ async function buildApi() {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
"src/core/index.ts",
|
||||
@@ -101,6 +108,7 @@ async function buildUi() {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
external: [
|
||||
...external,
|
||||
"react",
|
||||
@@ -160,6 +168,7 @@ async function buildUiElements() {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
entry: ["src/ui/elements/index.ts"],
|
||||
outDir: "dist/ui/elements",
|
||||
external: [
|
||||
@@ -211,7 +220,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
||||
},
|
||||
...overrides,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
...define,
|
||||
...overrides.define,
|
||||
},
|
||||
external: [
|
||||
|
||||
+8
-6
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -18,7 +18,7 @@
|
||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
||||
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
||||
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
|
||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
|
||||
"build:cli": "bun run build.cli.ts",
|
||||
"build:static": "vite build",
|
||||
"watch": "bun run build.ts --types --watch",
|
||||
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
||||
@@ -49,9 +49,11 @@
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hono/swagger-ui": "^0.5.1",
|
||||
"@libsql/client": "^0.15.2",
|
||||
"@mantine/core": "^7.17.1",
|
||||
"@mantine/hooks": "^7.17.1",
|
||||
"@sinclair/typebox": "0.34.30",
|
||||
"@tanstack/react-form": "^1.0.5",
|
||||
"@uiw/react-codemirror": "^4.23.10",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
@@ -64,12 +66,11 @@
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"kysely": "^0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"radix-ui": "^1.1.3",
|
||||
"swr": "^2.3.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"@sinclair/typebox": "0.34.30"
|
||||
"swr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
@@ -98,14 +99,15 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"jotai": "^2.12.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsonv-ts": "^0.0.14-alpha.6",
|
||||
"kysely-d1": "^0.3.0",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"posthog-js-lite": "^3.4.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
|
||||
+72
-22
@@ -1,10 +1,11 @@
|
||||
import type { SafeUser } from "auth";
|
||||
import { AuthApi } from "auth/api/AuthApi";
|
||||
import { DataApi } from "data/api/DataApi";
|
||||
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
|
||||
import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
||||
import { decode } from "hono/jwt";
|
||||
import { MediaApi } from "media/api/MediaApi";
|
||||
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
|
||||
import { SystemApi } from "modules/SystemApi";
|
||||
import { omitKeys } from "core/utils";
|
||||
import type { BaseModuleApiOptions } from "modules";
|
||||
|
||||
export type TApiUser = SafeUser;
|
||||
|
||||
@@ -21,14 +22,24 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
|
||||
|
||||
export type ApiOptions = {
|
||||
host?: string;
|
||||
headers?: Headers;
|
||||
key?: string;
|
||||
localStorage?: boolean;
|
||||
storage?: {
|
||||
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
|
||||
setItem: (key: string, value: string) => void | Promise<void>;
|
||||
removeItem: (key: string) => void | Promise<void>;
|
||||
};
|
||||
onAuthStateChange?: (state: AuthState) => void;
|
||||
fetcher?: ApiFetcher;
|
||||
verbose?: boolean;
|
||||
verified?: boolean;
|
||||
data?: SubApiOptions<DataApiOptions>;
|
||||
auth?: SubApiOptions<AuthApiOptions>;
|
||||
media?: SubApiOptions<MediaApiOptions>;
|
||||
} & (
|
||||
| {
|
||||
token?: string;
|
||||
@@ -61,18 +72,18 @@ export class Api {
|
||||
this.verified = options.verified === true;
|
||||
|
||||
// prefer request if given
|
||||
if ("request" in options) {
|
||||
if ("request" in options && options.request) {
|
||||
this.options.host = options.host ?? new URL(options.request.url).origin;
|
||||
this.options.headers = options.headers ?? options.request.headers;
|
||||
this.extractToken();
|
||||
|
||||
// then check for a token
|
||||
} else if ("token" in options) {
|
||||
} else if ("token" in options && options.token) {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(options.token);
|
||||
this.updateToken(options.token, { trigger: false });
|
||||
|
||||
// then check for an user object
|
||||
} else if ("user" in options) {
|
||||
} else if ("user" in options && options.user) {
|
||||
this.token_transport = "none";
|
||||
this.user = options.user;
|
||||
this.verified = options.verified !== false;
|
||||
@@ -115,16 +126,30 @@ export class Api {
|
||||
this.updateToken(headerToken);
|
||||
return;
|
||||
}
|
||||
} else if (this.options.localStorage) {
|
||||
const token = localStorage.getItem(this.tokenKey);
|
||||
if (token) {
|
||||
} else if (this.storage) {
|
||||
this.storage.getItem(this.tokenKey).then((token) => {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(token);
|
||||
}
|
||||
this.updateToken(token ? String(token) : undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateToken(token?: string, rebuild?: boolean) {
|
||||
private get storage() {
|
||||
if (!this.options.storage) return null;
|
||||
return {
|
||||
getItem: async (key: string) => {
|
||||
return await this.options.storage!.getItem(key);
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
return await this.options.storage!.setItem(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
return await this.options.storage!.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
||||
this.token = token;
|
||||
this.verified = false;
|
||||
|
||||
@@ -134,17 +159,25 @@ export class Api {
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
if (this.options.localStorage) {
|
||||
if (this.storage) {
|
||||
const key = this.tokenKey;
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(key, token);
|
||||
this.storage.setItem(key, token).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
this.storage.removeItem(key).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (opts?.trigger !== false) {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
}
|
||||
}
|
||||
|
||||
if (rebuild) this.buildApis();
|
||||
if (opts?.rebuild) this.buildApis();
|
||||
}
|
||||
|
||||
private markAuthVerified(verfied: boolean) {
|
||||
@@ -214,15 +247,32 @@ export class Api {
|
||||
const fetcher = this.options.fetcher;
|
||||
|
||||
this.system = new SystemApi(baseParams, fetcher);
|
||||
this.data = new DataApi(baseParams, fetcher);
|
||||
this.auth = new AuthApi(
|
||||
this.data = new DataApi(
|
||||
{
|
||||
...baseParams,
|
||||
onTokenUpdate: (token) => this.updateToken(token, true),
|
||||
...this.options.data,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
this.auth = new AuthApi(
|
||||
{
|
||||
...baseParams,
|
||||
credentials: this.options.storage ? "omit" : "include",
|
||||
...this.options.auth,
|
||||
onTokenUpdate: (token) => {
|
||||
this.updateToken(token, { rebuild: true });
|
||||
this.options.auth?.onTokenUpdate?.(token);
|
||||
},
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
this.media = new MediaApi(
|
||||
{
|
||||
...baseParams,
|
||||
...this.options.media,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
this.media = new MediaApi(baseParams, fetcher);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ export class App {
|
||||
}
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
return this.server.fetch;
|
||||
return this.server.fetch as any;
|
||||
}
|
||||
|
||||
get module() {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isNode } from "bknd/utils";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
type NextjsEnv = NextApiRequest["env"];
|
||||
|
||||
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
cleanRequest?: { searchParams?: string[] };
|
||||
};
|
||||
|
||||
@@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
|
||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||
|
||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||
onTokenUpdate?: (token: string) => void | Promise<void>;
|
||||
onTokenUpdate?: (token?: string) => void | Promise<void>;
|
||||
credentials?: "include" | "same-origin" | "omit";
|
||||
};
|
||||
|
||||
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
||||
return {
|
||||
basepath: "/api/auth",
|
||||
credentials: "include",
|
||||
};
|
||||
}
|
||||
|
||||
async login(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
||||
credentials: "include",
|
||||
credentials: this.options.credentials,
|
||||
});
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
@@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
|
||||
async register(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||
credentials: "include",
|
||||
credentials: this.options.credentials,
|
||||
});
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||
}
|
||||
|
||||
async logout() {}
|
||||
async logout() {
|
||||
await this.options.onTokenUpdate?.(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||
import { tbValidator as tb } from "core";
|
||||
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||
import { DataPermissions } from "data";
|
||||
import type { Hono } from "hono";
|
||||
import { Controller, type ServerEnv } from "modules/Controller";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { describeRoute, jsc, s } from "core/object/schema";
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
@@ -14,10 +12,6 @@ export type AuthActionResponse = {
|
||||
errors?: any;
|
||||
};
|
||||
|
||||
const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
|
||||
export class AuthController extends Controller {
|
||||
constructor(private auth: AppAuth) {
|
||||
super();
|
||||
@@ -56,6 +50,10 @@ export class AuthController extends Controller {
|
||||
hono.post(
|
||||
"/create",
|
||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||
describeRoute({
|
||||
summary: "Create a new user",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
async (c) => {
|
||||
try {
|
||||
const body = await this.auth.authenticator.getBody(c);
|
||||
@@ -93,9 +91,16 @@ export class AuthController extends Controller {
|
||||
}
|
||||
},
|
||||
);
|
||||
hono.get("create/schema.json", async (c) => {
|
||||
return c.json(create.schema);
|
||||
});
|
||||
hono.get(
|
||||
"create/schema.json",
|
||||
describeRoute({
|
||||
summary: "Get the schema for creating a user",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(create.schema);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
mainHono.route(`/${name}/actions`, hono);
|
||||
@@ -104,42 +109,54 @@ export class AuthController extends Controller {
|
||||
override getController() {
|
||||
const { auth } = this.middlewares;
|
||||
const hono = this.create();
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||
hono.get(
|
||||
"/me",
|
||||
describeRoute({
|
||||
summary: "Get the current user",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
auth(),
|
||||
async (c) => {
|
||||
const claims = c.get("auth")?.user;
|
||||
if (claims) {
|
||||
const { data: user } = await this.userRepo.findId(claims.id);
|
||||
return c.json({ user });
|
||||
}
|
||||
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
this.registerStrategyActions(strategy, hono);
|
||||
}
|
||||
return c.json({ user: null }, 403);
|
||||
},
|
||||
);
|
||||
|
||||
hono.get("/me", auth(), async (c) => {
|
||||
const claims = c.get("auth")?.user;
|
||||
if (claims) {
|
||||
const { data: user } = await this.userRepo.findId(claims.id);
|
||||
return c.json({ user });
|
||||
}
|
||||
hono.get(
|
||||
"/logout",
|
||||
describeRoute({
|
||||
summary: "Logout the current user",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
auth(),
|
||||
async (c) => {
|
||||
await this.auth.authenticator.logout(c);
|
||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
return c.json({ user: null }, 403);
|
||||
});
|
||||
const referer = c.req.header("referer");
|
||||
if (referer) {
|
||||
return c.redirect(referer);
|
||||
}
|
||||
|
||||
hono.get("/logout", auth(), async (c) => {
|
||||
await this.auth.authenticator.logout(c);
|
||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
const referer = c.req.header("referer");
|
||||
if (referer) {
|
||||
return c.redirect(referer);
|
||||
}
|
||||
|
||||
return c.redirect("/");
|
||||
});
|
||||
return c.redirect("/");
|
||||
},
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/strategies",
|
||||
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
|
||||
describeRoute({
|
||||
summary: "Get the available authentication strategies",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const { include_disabled } = c.req.valid("query");
|
||||
const { strategies, basepath } = this.auth.toJSON(false);
|
||||
@@ -157,6 +174,15 @@ export class AuthController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
this.registerStrategyActions(strategy, hono);
|
||||
}
|
||||
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ export function isDebug(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function getVersion(): string {
|
||||
try {
|
||||
// @ts-expect-error - this is a global variable in dev
|
||||
return __version;
|
||||
} catch (e) {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
const envs = {
|
||||
// used in $console to determine the log level
|
||||
cli_log_level: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
} from "./object/query/query";
|
||||
export { Registry, type Constructor } from "./registry/Registry";
|
||||
export { getFlashMessage } from "./server/flash";
|
||||
export { s, jsc, describeRoute } from "./object/schema";
|
||||
|
||||
export * from "./console";
|
||||
export * from "./events";
|
||||
|
||||
@@ -34,6 +34,8 @@ type ExpressionMap<Exps extends Expressions> = {
|
||||
? E
|
||||
: never;
|
||||
};
|
||||
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
|
||||
|
||||
type ExpressionCondition<Exps extends Expressions> = {
|
||||
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
|
||||
}[keyof ExpressionMap<Exps>];
|
||||
@@ -195,5 +197,7 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
||||
const fns = _build(query, expressions, options);
|
||||
return _validate(fns);
|
||||
},
|
||||
expressions,
|
||||
expressionKeys: expressions.map((e) => e.key) as ExpressionKeys<Exps>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeObject } from "core/utils";
|
||||
|
||||
//export { jsc, type Options, type Hook } from "./validator";
|
||||
import * as s from "jsonv-ts";
|
||||
|
||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
|
||||
|
||||
export { s };
|
||||
|
||||
export class InvalidSchemaError extends Error {
|
||||
constructor(
|
||||
public schema: s.TAnySchema,
|
||||
public value: unknown,
|
||||
public errors: s.ErrorDetail[] = [],
|
||||
) {
|
||||
super(
|
||||
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
|
||||
`Error: ${JSON.stringify(errors[0], null, 2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseOptions = {
|
||||
withDefaults?: boolean;
|
||||
coerse?: boolean;
|
||||
clone?: boolean;
|
||||
};
|
||||
|
||||
const cloneSchema = <S extends s.TSchema>(schema: S): S => {
|
||||
const json = schema.toJSON();
|
||||
return s.fromSchema(json) as S;
|
||||
};
|
||||
|
||||
export function parse<S extends s.TAnySchema>(
|
||||
_schema: S,
|
||||
v: unknown,
|
||||
opts: ParseOptions = {},
|
||||
): s.StaticCoerced<S> {
|
||||
const schema = (opts.clone ? cloneSchema(_schema as any) : _schema) as s.TSchema;
|
||||
const value = opts.coerse !== false ? schema.coerce(v) : v;
|
||||
const result = schema.validate(value, {
|
||||
shortCircuit: true,
|
||||
ignoreUnsupported: true,
|
||||
});
|
||||
if (!result.valid) throw new InvalidSchemaError(schema, v, result.errors);
|
||||
if (opts.withDefaults) {
|
||||
return mergeObject(schema.template({ withOptional: true }), value) as any;
|
||||
}
|
||||
|
||||
return value as any;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Context, Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { validator as honoValidator } from "hono/validator";
|
||||
import type { Static, StaticCoerced, TAnySchema } from "jsonv-ts";
|
||||
|
||||
export type Options = {
|
||||
coerce?: boolean;
|
||||
includeSchema?: boolean;
|
||||
};
|
||||
|
||||
type ValidationResult = {
|
||||
valid: boolean;
|
||||
errors: {
|
||||
keywordLocation: string;
|
||||
instanceLocation: string;
|
||||
error: string;
|
||||
data?: unknown;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Hook<T, E extends Env, P extends string> = (
|
||||
result: { result: ValidationResult; data: T },
|
||||
c: Context<E, P>,
|
||||
) => Response | Promise<Response> | void;
|
||||
|
||||
export const validator = <
|
||||
// @todo: somehow hono prevents the usage of TSchema
|
||||
Schema extends TAnySchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Opts extends Options = Options,
|
||||
Out = Opts extends { coerce: false } ? Static<Schema> : StaticCoerced<Schema>,
|
||||
I extends Input = {
|
||||
in: { [K in Target]: Static<Schema> };
|
||||
out: { [K in Target]: Out };
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: Schema,
|
||||
options?: Opts,
|
||||
hook?: Hook<Out, E, P>,
|
||||
): MiddlewareHandler<E, P, I> => {
|
||||
// @ts-expect-error not typed well
|
||||
return honoValidator(target, async (_value, c) => {
|
||||
const value = options?.coerce !== false ? schema.coerce(_value) : _value;
|
||||
// @ts-ignore
|
||||
const result = schema.validate(value);
|
||||
if (!result.valid) {
|
||||
return c.json({ ...result, schema }, 400);
|
||||
}
|
||||
|
||||
if (hook) {
|
||||
const hookResult = hook({ result, data: value as Out }, c);
|
||||
if (hookResult) {
|
||||
return hookResult;
|
||||
}
|
||||
}
|
||||
|
||||
return value as Out;
|
||||
});
|
||||
};
|
||||
|
||||
export const jsc = validator;
|
||||
@@ -0,0 +1 @@
|
||||
export { tbValidator } from "./tbValidator";
|
||||
@@ -218,7 +218,7 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
|
||||
* @param object
|
||||
* @param sources
|
||||
*/
|
||||
export function mergeObject<R = unknown>(object, ...sources): R {
|
||||
export function mergeObject(object, ...sources) {
|
||||
for (const source of sources) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) {
|
||||
@@ -406,3 +406,16 @@ export function objectToJsLiteral(value: object, indent: number = 0, _level: num
|
||||
|
||||
throw new TypeError(`Unsupported data type: ${t}`);
|
||||
}
|
||||
|
||||
// lodash-es compatible `pick` with perfect type inference
|
||||
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return keys.reduce(
|
||||
(acc, key) => {
|
||||
if (key in obj) {
|
||||
acc[key] = obj[key];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Pick<T, K>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type EntityManager,
|
||||
constructEntity,
|
||||
constructRelation,
|
||||
constructIndex,
|
||||
} from "data";
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
@@ -36,7 +35,9 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
);
|
||||
|
||||
const indices = transformObject(_indices, (index, name) => {
|
||||
return constructIndex(index, _entity, name);
|
||||
const entity = _entity(index.entity)!;
|
||||
const fields = index.fields.map((f) => entity.field(f)!);
|
||||
return new EntityIndex(entity, fields, index.unique, name);
|
||||
});
|
||||
|
||||
for (const entity of Object.values(entities)) {
|
||||
|
||||
+215
-104
@@ -1,6 +1,4 @@
|
||||
import { $console, isDebug, tbValidator as tb } from "core";
|
||||
import { StringEnum } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { $console, isDebug } from "core";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
@@ -8,14 +6,15 @@ import {
|
||||
type MutatorResponse,
|
||||
type RepoQuery,
|
||||
type RepositoryResponse,
|
||||
querySchema,
|
||||
repoQuery,
|
||||
} from "data";
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { Controller } from "modules/Controller";
|
||||
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { AppDataConfig } from "../data-schema";
|
||||
const { Type } = tbbox;
|
||||
import { omitKeys } from "core/utils";
|
||||
|
||||
export class DataController extends Controller {
|
||||
constructor(
|
||||
@@ -71,6 +70,7 @@ export class DataController extends Controller {
|
||||
override getController() {
|
||||
const { permission, auth } = this.middlewares;
|
||||
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||
|
||||
// @todo: sample implementation how to augment handler with additional info
|
||||
function handler<HH extends Handler>(name: string, h: HH): any {
|
||||
@@ -83,6 +83,10 @@ export class DataController extends Controller {
|
||||
// info
|
||||
hono.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Retrieve data configuration",
|
||||
tags: ["data"],
|
||||
}),
|
||||
handler("data info", (c) => {
|
||||
// sample implementation
|
||||
return c.json(this.em.toJSON());
|
||||
@@ -90,49 +94,75 @@ export class DataController extends Controller {
|
||||
);
|
||||
|
||||
// sync endpoint
|
||||
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
|
||||
const force = c.req.query("force") === "1";
|
||||
const drop = c.req.query("drop") === "1";
|
||||
//console.log("force", force);
|
||||
const tables = await this.em.schema().introspect();
|
||||
//console.log("tables", tables);
|
||||
const changes = await this.em.schema().sync({
|
||||
force,
|
||||
drop,
|
||||
});
|
||||
return c.json({ tables: tables.map((t) => t.name), changes });
|
||||
});
|
||||
hono.get(
|
||||
"/sync",
|
||||
permission(DataPermissions.databaseSync),
|
||||
describeRoute({
|
||||
summary: "Sync database schema",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc(
|
||||
"query",
|
||||
s.partialObject({
|
||||
force: s.boolean(),
|
||||
drop: s.boolean(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { force, drop } = c.req.valid("query");
|
||||
//console.log("force", force);
|
||||
const tables = await this.em.schema().introspect();
|
||||
//console.log("tables", tables);
|
||||
const changes = await this.em.schema().sync({
|
||||
force,
|
||||
drop,
|
||||
});
|
||||
return c.json({ tables: tables.map((t) => t.name), changes });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Schema endpoints
|
||||
*/
|
||||
// read entity schema
|
||||
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
||||
const $id = `${this.config.basepath}/schema.json`;
|
||||
const schemas = Object.fromEntries(
|
||||
this.em.entities.map((e) => [
|
||||
e.name,
|
||||
{
|
||||
$ref: `${this.config.basepath}/schemas/${e.name}`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return c.json({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id,
|
||||
properties: schemas,
|
||||
});
|
||||
});
|
||||
hono.get(
|
||||
"/schema.json",
|
||||
permission(DataPermissions.entityRead),
|
||||
describeRoute({
|
||||
summary: "Retrieve data schema",
|
||||
tags: ["data"],
|
||||
}),
|
||||
async (c) => {
|
||||
const $id = `${this.config.basepath}/schema.json`;
|
||||
const schemas = Object.fromEntries(
|
||||
this.em.entities.map((e) => [
|
||||
e.name,
|
||||
{
|
||||
$ref: `${this.config.basepath}/schemas/${e.name}`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return c.json({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id,
|
||||
properties: schemas,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// read schema
|
||||
hono.get(
|
||||
"/schemas/:entity/:context?",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
describeRoute({
|
||||
summary: "Retrieve entity schema",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
context: Type.Optional(StringEnum(["create", "update"])),
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -161,30 +191,39 @@ export class DataController extends Controller {
|
||||
/**
|
||||
* Info endpoints
|
||||
*/
|
||||
hono.get("/info/:entity", async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
const fields = _entity.fields.map((f) => f.name);
|
||||
const $rels = (r: any) =>
|
||||
r.map((r: any) => ({
|
||||
entity: r.other(_entity).entity.name,
|
||||
ref: r.other(_entity).reference,
|
||||
}));
|
||||
hono.get(
|
||||
"/info/:entity",
|
||||
permission(DataPermissions.entityRead),
|
||||
describeRoute({
|
||||
summary: "Retrieve entity info",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
const fields = _entity.fields.map((f) => f.name);
|
||||
const $rels = (r: any) =>
|
||||
r.map((r: any) => ({
|
||||
entity: r.other(_entity).entity.name,
|
||||
ref: r.other(_entity).reference,
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
name: _entity.name,
|
||||
fields,
|
||||
relations: {
|
||||
all: $rels(this.em.relations.relationsOf(_entity)),
|
||||
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
|
||||
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
|
||||
target: $rels(this.em.relations.targetRelationsOf(_entity)),
|
||||
},
|
||||
});
|
||||
});
|
||||
return c.json({
|
||||
name: _entity.name,
|
||||
fields,
|
||||
relations: {
|
||||
all: $rels(this.em.relations.relationsOf(_entity)),
|
||||
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
|
||||
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
|
||||
target: $rels(this.em.relations.targetRelationsOf(_entity)),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
}
|
||||
@@ -193,10 +232,7 @@ export class DataController extends Controller {
|
||||
const { permission } = this.middlewares;
|
||||
const hono = this.create();
|
||||
|
||||
const definedEntities = this.em.entities.map((e) => e.name);
|
||||
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
||||
.Decode(Number.parseInt)
|
||||
.Encode(String);
|
||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||
|
||||
/**
|
||||
* Function endpoints
|
||||
@@ -205,14 +241,19 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/fn/count",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
describeRoute({
|
||||
summary: "Count entities",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
const where = (await c.req.json()) as any;
|
||||
const where = c.req.valid("json") as any;
|
||||
const result = await this.em.repository(entity).count(where);
|
||||
return c.json({ entity, count: result.count });
|
||||
},
|
||||
@@ -222,14 +263,19 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/fn/exists",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
describeRoute({
|
||||
summary: "Check if entity exists",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
const where = c.req.json() as any;
|
||||
const where = c.req.valid("json") as any;
|
||||
const result = await this.em.repository(entity).exists(where);
|
||||
return c.json({ entity, exists: result.exists });
|
||||
},
|
||||
@@ -239,13 +285,31 @@ export class DataController extends Controller {
|
||||
* Read endpoints
|
||||
*/
|
||||
// read many
|
||||
const saveRepoQuery = s.partialObject({
|
||||
...omitKeys(repoQuery.properties, ["with"]),
|
||||
sort: s.string({ default: "id" }),
|
||||
select: s.array(s.string()),
|
||||
join: s.array(s.string()),
|
||||
});
|
||||
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
|
||||
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
|
||||
// @ts-ignore
|
||||
(p) => pick.includes(p.name),
|
||||
) as any),
|
||||
];
|
||||
|
||||
hono.get(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Read many",
|
||||
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("query", querySchema),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -259,17 +323,22 @@ export class DataController extends Controller {
|
||||
// read one
|
||||
hono.get(
|
||||
"/:entity/:id",
|
||||
describeRoute({
|
||||
summary: "Read one",
|
||||
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
jsc(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: s.string(),
|
||||
}),
|
||||
),
|
||||
tb("query", querySchema),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -283,18 +352,23 @@ export class DataController extends Controller {
|
||||
// read many by reference
|
||||
hono.get(
|
||||
"/:entity/:id/:reference",
|
||||
describeRoute({
|
||||
summary: "Read many by reference",
|
||||
parameters: saveRepoQueryParams(),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
jsc(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
reference: Type.String(),
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: s.string(),
|
||||
reference: s.string(),
|
||||
}),
|
||||
),
|
||||
tb("query", querySchema),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const { entity, id, reference } = c.req.param();
|
||||
const { entity, id, reference } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -309,17 +383,33 @@ export class DataController extends Controller {
|
||||
);
|
||||
|
||||
// func query
|
||||
const fnQuery = s.partialObject({
|
||||
...saveRepoQuery.properties,
|
||||
with: s.object({}),
|
||||
});
|
||||
hono.post(
|
||||
"/:entity/query",
|
||||
describeRoute({
|
||||
summary: "Query entities",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: fnQuery.toJSON(),
|
||||
example: fnQuery.template({ withOptional: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery, { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const options = (await c.req.valid("json")) as RepoQuery;
|
||||
const options = (await c.req.json()) as RepoQuery;
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
@@ -332,11 +422,15 @@ export class DataController extends Controller {
|
||||
// insert one
|
||||
hono.post(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Insert one or many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityCreate),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -355,13 +449,17 @@ export class DataController extends Controller {
|
||||
// update many
|
||||
hono.patch(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Update many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb(
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc(
|
||||
"json",
|
||||
Type.Object({
|
||||
update: Type.Object({}),
|
||||
where: querySchema.properties.where,
|
||||
s.object({
|
||||
update: s.object({}),
|
||||
where: repoQuery.properties.where,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -382,10 +480,15 @@ export class DataController extends Controller {
|
||||
// update one
|
||||
hono.patch(
|
||||
"/:entity/:id",
|
||||
describeRoute({
|
||||
summary: "Update one",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
|
||||
jsc("json", s.object({})),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -399,10 +502,14 @@ export class DataController extends Controller {
|
||||
// delete one
|
||||
hono.delete(
|
||||
"/:entity/:id",
|
||||
describeRoute({
|
||||
summary: "Delete one",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -415,15 +522,19 @@ export class DataController extends Controller {
|
||||
// delete many
|
||||
hono.delete(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Delete many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const where = c.req.valid("json") as RepoQuery["where"];
|
||||
const where = (await c.req.json()) as RepoQuery["where"];
|
||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
|
||||
@@ -44,13 +44,6 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
}
|
||||
|
||||
client = createClient({ url, authToken });
|
||||
|
||||
// currently there is an issue in limbo implementation
|
||||
// that prevents batching from working correctly
|
||||
if (/\.aws.*turso\.io$/.test(url)) {
|
||||
$console.warn("Using an Turso AWS endpoint currently disables batching support");
|
||||
batching_enabled = false;
|
||||
}
|
||||
} else {
|
||||
client = clientOrCredentials;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ export const indicesSchema = tb.Type.Object(
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export type TAppDataIndex = Static<(typeof indicesSchema)[number]>;
|
||||
|
||||
export const dataConfigSchema = tb.Type.Object(
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Entity, EntityData, EntityManager } from "../entities";
|
||||
import { InvalidSearchParamsException } from "../errors";
|
||||
import { MutatorEvents } from "../events";
|
||||
import { RelationMutator } from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
|
||||
type MutatorQB =
|
||||
| InsertQueryBuilder<any, any, any>
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { $console } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { InvalidSearchParamsException } from "../../errors";
|
||||
import { MutatorEvents, RepositoryEvents } from "../../events";
|
||||
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
|
||||
import { type RepoQuery, getRepoQueryTemplate } from "data/server/query";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
@@ -84,14 +83,14 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
}
|
||||
}
|
||||
|
||||
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
getValidOptions(options?: RepoQuery): RepoQuery {
|
||||
const entity = this.entity;
|
||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||
const validated = {
|
||||
...cloneDeep(defaultQuerySchema),
|
||||
...structuredClone(getRepoQueryTemplate()),
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect(),
|
||||
};
|
||||
} satisfies Required<RepoQuery>;
|
||||
|
||||
if (!options) return validated;
|
||||
|
||||
@@ -99,12 +98,15 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
if (!validated.select.includes(options.sort.by)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
|
||||
}
|
||||
if (!["asc", "desc"].includes(options.sort.dir)) {
|
||||
if (!["asc", "desc"].includes(options.sort.dir!)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
|
||||
}
|
||||
|
||||
this.checkIndex(entity.name, options.sort.by, "sort");
|
||||
validated.sort = options.sort;
|
||||
validated.sort = {
|
||||
dir: "asc",
|
||||
...options.sort,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.select && options.select.length > 0) {
|
||||
@@ -505,7 +507,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
};
|
||||
}
|
||||
|
||||
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
|
||||
async exists(where: Required<RepoQuery>["where"]): Promise<RepositoryExistsResponse> {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions({ where });
|
||||
|
||||
@@ -513,7 +515,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||
|
||||
// add mandatory where
|
||||
qb = WhereBuilder.addClause(qb, options.where).limit(1);
|
||||
qb = WhereBuilder.addClause(qb, options.where!).limit(1);
|
||||
|
||||
const { result, ...compiled } = await this.executeQb(qb);
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ const expressions = [
|
||||
export type WhereQuery = FilterQuery<typeof expressions>;
|
||||
|
||||
const validator = makeValidator(expressions);
|
||||
export const expressionKeys = validator.expressionKeys;
|
||||
|
||||
export class WhereBuilder {
|
||||
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $console, type PrimaryFieldType } from "core";
|
||||
import { Event, InvalidEventReturn } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
|
||||
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
|
||||
static override slug = "mutator-insert-before";
|
||||
|
||||
@@ -10,14 +10,15 @@ export * from "./connection";
|
||||
export {
|
||||
type RepoQuery,
|
||||
type RepoQueryIn,
|
||||
defaultQuerySchema,
|
||||
querySchema,
|
||||
whereSchema,
|
||||
} from "./server/data-query-impl";
|
||||
getRepoQueryTemplate,
|
||||
repoQuery,
|
||||
} from "./server/query";
|
||||
|
||||
export type { WhereQuery } from "./entities/query/WhereBuilder";
|
||||
|
||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||
|
||||
export { constructEntity, constructRelation, constructIndex } from "./schema/constructor";
|
||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||
|
||||
export const DatabaseEvents = {
|
||||
...MutatorEvents,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type MutationInstructionResponse,
|
||||
RelationHelper,
|
||||
} from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import type { RelationType } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import { Entity, type EntityManager } from "../entities";
|
||||
import { type Field, PrimaryField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField } from "./RelationField";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { Entity, EntityIndex, type Field } from "data";
|
||||
import {
|
||||
FIELDS,
|
||||
RELATIONS,
|
||||
type TAppDataEntity,
|
||||
type TAppDataField,
|
||||
type TAppDataIndex,
|
||||
type TAppDataRelation,
|
||||
} from "data/data-schema";
|
||||
import { Entity, type Field } from "data";
|
||||
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
|
||||
|
||||
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
@@ -39,17 +32,3 @@ export function constructRelation(
|
||||
relationConfig.config,
|
||||
);
|
||||
}
|
||||
|
||||
export function constructIndex(
|
||||
indexConfig: TAppDataIndex,
|
||||
resolver: (name: Entity | string) => Entity,
|
||||
name: string,
|
||||
) {
|
||||
const entity = resolver(indexConfig.entity);
|
||||
return new EntityIndex(
|
||||
entity,
|
||||
entity.fields.filter((f) => indexConfig.fields.includes(f.name)),
|
||||
indexConfig.unique,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { TThis } from "@sinclair/typebox";
|
||||
import { type SchemaOptions, type StaticDecode, StringEnum, Value, isObject } from "core/utils";
|
||||
import { WhereBuilder, type WhereQuery } from "../entities";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const NumberOrString = (options: SchemaOptions = {}) =>
|
||||
Type.Transform(Type.Union([Type.Number(), Type.String()], options))
|
||||
.Decode((value) => Number.parseInt(String(value)))
|
||||
.Encode(String);
|
||||
|
||||
const limit = NumberOrString({ default: 10 });
|
||||
const offset = NumberOrString({ default: 0 });
|
||||
|
||||
const sort_default = { by: "id", dir: "asc" };
|
||||
const sort = Type.Transform(
|
||||
Type.Union(
|
||||
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
|
||||
{
|
||||
default: sort_default,
|
||||
},
|
||||
),
|
||||
)
|
||||
.Decode((value): { by: string; dir: "asc" | "desc" } => {
|
||||
if (typeof value === "string") {
|
||||
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
|
||||
const dir = value[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? value.slice(1) : value, dir } as any;
|
||||
} else if (/^{.*}$/.test(value)) {
|
||||
return JSON.parse(value) as any;
|
||||
}
|
||||
|
||||
return sort_default as any;
|
||||
}
|
||||
return value as any;
|
||||
})
|
||||
.Encode((value) => value);
|
||||
|
||||
const stringArray = Type.Transform(
|
||||
Type.Union([Type.String(), Type.Array(Type.String())], { default: [] }),
|
||||
)
|
||||
.Decode((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
} else if (value.includes(",")) {
|
||||
return value.split(",");
|
||||
}
|
||||
return [value];
|
||||
})
|
||||
.Encode((value) => (Array.isArray(value) ? value : [value]));
|
||||
|
||||
export const whereSchema = Type.Transform(
|
||||
Type.Union([Type.String(), Type.Object({})], { default: {} }),
|
||||
)
|
||||
.Decode((value) => {
|
||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return WhereBuilder.convert(q);
|
||||
})
|
||||
.Encode(JSON.stringify);
|
||||
|
||||
export type RepoWithSchema = Record<
|
||||
string,
|
||||
Omit<RepoQueryIn, "with"> & {
|
||||
with?: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
|
||||
Type.Transform(
|
||||
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)]),
|
||||
)
|
||||
.Decode((value) => {
|
||||
// images
|
||||
// images,comments
|
||||
// ["images","comments"]
|
||||
// { "images": {} }
|
||||
|
||||
if (!Array.isArray(value) && isObject(value)) {
|
||||
return value as RepoWithSchema;
|
||||
}
|
||||
|
||||
let _value: any = null;
|
||||
if (typeof value === "string") {
|
||||
// if stringified object
|
||||
if (value.match(/^\{/)) {
|
||||
return JSON.parse(value) as RepoWithSchema;
|
||||
}
|
||||
|
||||
// if stringified array
|
||||
if (value.match(/^\[/)) {
|
||||
_value = JSON.parse(value) as string[];
|
||||
|
||||
// if comma-separated string
|
||||
} else if (value.includes(",")) {
|
||||
_value = value.split(",");
|
||||
|
||||
// if single string
|
||||
} else {
|
||||
_value = [value];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
_value = value;
|
||||
}
|
||||
|
||||
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
|
||||
throw new Error("Invalid 'with' schema");
|
||||
}
|
||||
|
||||
return _value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as RepoWithSchema);
|
||||
})
|
||||
.Encode((value) => value);
|
||||
|
||||
export const querySchema = Type.Recursive(
|
||||
(Self) =>
|
||||
Type.Partial(
|
||||
Type.Object(
|
||||
{
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
sort: sort,
|
||||
select: stringArray,
|
||||
with: withSchema(Self),
|
||||
join: stringArray,
|
||||
where: whereSchema,
|
||||
},
|
||||
{
|
||||
// @todo: determine if unknown is allowed, it's ignore anyway
|
||||
additionalProperties: false,
|
||||
},
|
||||
),
|
||||
),
|
||||
{ $id: "query-schema" },
|
||||
);
|
||||
|
||||
export type RepoQueryIn = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
|
||||
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import * as q from "./query";
|
||||
import { s as schema, parse as $parse, type ParseOptions } from "core/object/schema";
|
||||
|
||||
const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, o);
|
||||
|
||||
// compatibility
|
||||
const decode = (input: any, output: any) => {
|
||||
expect(parse(input)).toEqual(output);
|
||||
};
|
||||
|
||||
describe("server/query", () => {
|
||||
test("limit & offset", () => {
|
||||
expect(() => parse({ limit: false })).toThrow();
|
||||
expect(parse({ limit: "11" })).toEqual({ limit: 11 });
|
||||
expect(parse({ limit: 20 })).toEqual({ limit: 20 });
|
||||
expect(parse({ offset: "1" })).toEqual({ offset: 1 });
|
||||
});
|
||||
|
||||
test("select", () => {
|
||||
expect(parse({ select: "id" })).toEqual({ select: ["id"] });
|
||||
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
|
||||
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
|
||||
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
|
||||
|
||||
expect(() => parse({ select: "not allowed" })).toThrow();
|
||||
expect(() => parse({ select: "id," })).toThrow();
|
||||
});
|
||||
|
||||
test("join", () => {
|
||||
expect(parse({ join: "id" })).toEqual({ join: ["id"] });
|
||||
expect(parse({ join: "id,title" })).toEqual({ join: ["id", "title"] });
|
||||
expect(parse({ join: ["id", "title"] })).toEqual({ join: ["id", "title"] });
|
||||
});
|
||||
|
||||
test("sort", () => {
|
||||
expect(parse({ sort: "id" }).sort).toEqual({
|
||||
by: "id",
|
||||
dir: "asc",
|
||||
});
|
||||
expect(parse({ sort: "-id" }).sort).toEqual({
|
||||
by: "id",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(parse({ sort: { by: "title" } }).sort).toEqual({
|
||||
by: "title",
|
||||
});
|
||||
expect(
|
||||
parse(
|
||||
{ sort: { by: "id" } },
|
||||
{
|
||||
withDefaults: true,
|
||||
},
|
||||
).sort,
|
||||
).toEqual({
|
||||
by: "id",
|
||||
dir: "asc",
|
||||
});
|
||||
expect(parse({ sort: { by: "count", dir: "desc" } }).sort).toEqual({
|
||||
by: "count",
|
||||
dir: "desc",
|
||||
});
|
||||
// invalid gives default
|
||||
expect(parse({ sort: "not allowed" }).sort).toEqual({
|
||||
by: "id",
|
||||
dir: "asc",
|
||||
});
|
||||
|
||||
// json
|
||||
expect(parse({ sort: JSON.stringify({ by: "count", dir: "desc" }) }).sort).toEqual({
|
||||
by: "count",
|
||||
dir: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
test("sort2", () => {
|
||||
const _dflt = { sort: { by: "id", dir: "asc" } } as const;
|
||||
|
||||
decode({ sort: "" }, _dflt);
|
||||
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
|
||||
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
|
||||
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
|
||||
decode({ sort: "-1name" }, _dflt);
|
||||
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
|
||||
});
|
||||
|
||||
test("where", () => {
|
||||
expect(parse({ where: { id: 1 } }).where).toEqual({
|
||||
id: { $eq: 1 },
|
||||
});
|
||||
expect(parse({ where: JSON.stringify({ id: 1 }) }).where).toEqual({
|
||||
id: { $eq: 1 },
|
||||
});
|
||||
|
||||
expect(parse({ where: { count: { $gt: 1 } } }).where).toEqual({
|
||||
count: { $gt: 1 },
|
||||
});
|
||||
expect(parse({ where: JSON.stringify({ count: { $gt: 1 } }) }).where).toEqual({
|
||||
count: { $gt: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("template", () => {
|
||||
expect(
|
||||
q.repoQuery.template({
|
||||
withOptional: true,
|
||||
}),
|
||||
).toEqual({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort: { by: "id", dir: "asc" },
|
||||
where: {},
|
||||
select: [],
|
||||
join: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("with", () => {
|
||||
let example = {
|
||||
limit: 10,
|
||||
with: {
|
||||
posts: { limit: "10", with: ["comments"] },
|
||||
},
|
||||
};
|
||||
expect(parse(example)).toEqual({
|
||||
limit: 10,
|
||||
with: {
|
||||
posts: {
|
||||
limit: 10,
|
||||
with: {
|
||||
comments: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
decode({ with: ["posts"] }, { with: { posts: {} } });
|
||||
decode({ with: { posts: {} } }, { with: { posts: {} } });
|
||||
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
|
||||
decode(
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
limit: "10",
|
||||
select: "id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
limit: 10,
|
||||
select: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// over http
|
||||
{
|
||||
const output = { with: { images: {} } };
|
||||
decode({ with: "images" }, output);
|
||||
decode({ with: '["images"]' }, output);
|
||||
decode({ with: ["images"] }, output);
|
||||
decode({ with: { images: {} } }, output);
|
||||
}
|
||||
|
||||
{
|
||||
const output = { with: { images: {}, comments: {} } };
|
||||
decode({ with: "images,comments" }, output);
|
||||
decode({ with: ["images", "comments"] }, output);
|
||||
decode({ with: '["images", "comments"]' }, output);
|
||||
decode({ with: { images: {}, comments: {} } }, output);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { s } from "core/object/schema";
|
||||
import { WhereBuilder, type WhereQuery } from "data";
|
||||
import { $console } from "core";
|
||||
import { isObject } from "core/utils";
|
||||
import type { CoercionOptions, TAnyOf } from "jsonv-ts";
|
||||
|
||||
// -------
|
||||
// helpers
|
||||
const stringIdentifier = s.string({
|
||||
// allow "id", "id,title" – but not "id," or "not allowed"
|
||||
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
||||
});
|
||||
const stringArray = s.anyOf(
|
||||
[
|
||||
stringIdentifier,
|
||||
s.array(stringIdentifier, {
|
||||
uniqueItems: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
default: [],
|
||||
coerce: (v): string[] => {
|
||||
if (Array.isArray(v)) {
|
||||
return v;
|
||||
} else if (typeof v === "string") {
|
||||
if (v.includes(",")) {
|
||||
return v.split(",");
|
||||
}
|
||||
return [v];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// -------
|
||||
// sorting
|
||||
const sortDefault = { by: "id", dir: "asc" };
|
||||
const sortSchema = s.object({
|
||||
by: s.string(),
|
||||
dir: s.string({ enum: ["asc", "desc"] }).optional(),
|
||||
});
|
||||
type SortSchema = s.Static<typeof sortSchema>;
|
||||
const sort = s.anyOf([s.string(), sortSchema], {
|
||||
default: sortDefault,
|
||||
coerce: (v): SortSchema => {
|
||||
if (typeof v === "string") {
|
||||
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(v)) {
|
||||
const dir = v[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? v.slice(1) : v, dir } as any;
|
||||
} else if (/^{.*}$/.test(v)) {
|
||||
return JSON.parse(v) as any;
|
||||
}
|
||||
|
||||
$console.warn(`Invalid sort given: '${JSON.stringify(v)}'`);
|
||||
return sortDefault as any;
|
||||
}
|
||||
return v as any;
|
||||
},
|
||||
});
|
||||
|
||||
// ------
|
||||
// filter
|
||||
const where = s.anyOf([s.string(), s.object({})], {
|
||||
default: {},
|
||||
examples: [
|
||||
{
|
||||
attribute: {
|
||||
$eq: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
coerce: (value: unknown) => {
|
||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return WhereBuilder.convert(q);
|
||||
},
|
||||
});
|
||||
//type WhereSchemaIn = s.Static<typeof where>;
|
||||
//type WhereSchema = s.StaticCoerced<typeof where>;
|
||||
|
||||
// ------
|
||||
// with
|
||||
// @todo: waiting for recursion support
|
||||
export type RepoWithSchema = Record<
|
||||
string,
|
||||
Omit<RepoQueryIn, "with"> & {
|
||||
with?: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
|
||||
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
|
||||
coerce: function (this: TAnyOf<any>, _value: unknown, opts: CoercionOptions = {}) {
|
||||
let value: any = _value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
// if stringified object
|
||||
if (value.match(/^\{/) || value.match(/^\[/)) {
|
||||
value = JSON.parse(value);
|
||||
} else if (value.includes(",")) {
|
||||
value = value.split(",");
|
||||
} else {
|
||||
value = [value];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert arrays to objects
|
||||
if (Array.isArray(value)) {
|
||||
value = value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
// Handle object case
|
||||
if (isObject(value)) {
|
||||
for (const k in value) {
|
||||
value[k] = self.coerce(value[k], opts);
|
||||
}
|
||||
}
|
||||
|
||||
return value as unknown as any;
|
||||
},
|
||||
}) as any;
|
||||
|
||||
// ==========
|
||||
// REPO QUERY
|
||||
export const repoQuery = s.recursive((self) =>
|
||||
s.partialObject({
|
||||
limit: s.number({ default: 10 }),
|
||||
offset: s.number({ default: 0 }),
|
||||
sort,
|
||||
where,
|
||||
select: stringArray,
|
||||
join: stringArray,
|
||||
with: withSchema<RepoWithSchema>(self),
|
||||
}),
|
||||
);
|
||||
export const getRepoQueryTemplate = () =>
|
||||
repoQuery.template({
|
||||
withOptional: true,
|
||||
}) as Required<RepoQuery>;
|
||||
|
||||
export type RepoQueryIn = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
|
||||
@@ -6,12 +6,7 @@ import { DataPermissions } from "data";
|
||||
import { Controller } from "modules/Controller";
|
||||
import type { AppMedia } from "../AppMedia";
|
||||
import { MediaField } from "../MediaField";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
import { jsc, s, describeRoute } from "core/object/schema";
|
||||
|
||||
export class MediaController extends Controller {
|
||||
constructor(private readonly media: AppMedia) {
|
||||
@@ -31,90 +26,165 @@ export class MediaController extends Controller {
|
||||
// @todo: implement range requests
|
||||
const { auth, permission } = this.middlewares;
|
||||
const hono = this.create().use(auth());
|
||||
const entitiesEnum = this.getEntitiesEnum(this.media.em);
|
||||
|
||||
// get files list (temporary)
|
||||
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
|
||||
const files = await this.getStorageAdapter().listObjects();
|
||||
return c.json(files);
|
||||
});
|
||||
hono.get(
|
||||
"/files",
|
||||
describeRoute({
|
||||
summary: "Get the list of files",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.listFiles),
|
||||
async (c) => {
|
||||
const files = await this.getStorageAdapter().listObjects();
|
||||
return c.json(files);
|
||||
},
|
||||
);
|
||||
|
||||
// get file by name
|
||||
// @todo: implement more aggressive cache? (configurable)
|
||||
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
throw new Error("No file name provided");
|
||||
}
|
||||
hono.get(
|
||||
"/file/:filename",
|
||||
describeRoute({
|
||||
summary: "Get a file by name",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.readFile),
|
||||
async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
throw new Error("No file name provided");
|
||||
}
|
||||
|
||||
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
|
||||
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
||||
await this.getStorage().emgr.emit(
|
||||
new StorageEvents.FileAccessEvent({ name: filename }),
|
||||
);
|
||||
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
});
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// delete a file by name
|
||||
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
throw new Error("No file name provided");
|
||||
}
|
||||
await this.getStorage().deleteFile(filename);
|
||||
hono.delete(
|
||||
"/file/:filename",
|
||||
describeRoute({
|
||||
summary: "Delete a file by name",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.deleteFile),
|
||||
async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
throw new Error("No file name provided");
|
||||
}
|
||||
await this.getStorage().deleteFile(filename);
|
||||
|
||||
return c.json({ message: "File deleted" });
|
||||
});
|
||||
return c.json({ message: "File deleted" });
|
||||
},
|
||||
);
|
||||
|
||||
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
if (isDebug()) {
|
||||
hono.post("/inspect", async (c) => {
|
||||
const file = await getFileFromContext(c);
|
||||
return c.json({
|
||||
type: file?.type,
|
||||
name: file?.name,
|
||||
size: file?.size,
|
||||
});
|
||||
});
|
||||
hono.post(
|
||||
"/inspect",
|
||||
describeRoute({
|
||||
summary: "Inspect a file",
|
||||
tags: ["media"],
|
||||
}),
|
||||
async (c) => {
|
||||
const file = await getFileFromContext(c);
|
||||
return c.json({
|
||||
type: file?.type,
|
||||
name: file?.name,
|
||||
size: file?.size,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
},
|
||||
required: ["file"],
|
||||
},
|
||||
},
|
||||
"application/octet-stream": {
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
// upload file
|
||||
// @todo: add required type for "upload endpoints"
|
||||
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => {
|
||||
const reqname = c.req.param("filename");
|
||||
hono.post(
|
||||
"/upload/:filename?",
|
||||
describeRoute({
|
||||
summary: "Upload a file",
|
||||
tags: ["media"],
|
||||
requestBody,
|
||||
}),
|
||||
jsc("param", s.object({ filename: s.string().optional() })),
|
||||
permission(MediaPermissions.uploadFile),
|
||||
async (c) => {
|
||||
const reqname = c.req.param("filename");
|
||||
|
||||
const body = await getFileFromContext(c);
|
||||
if (!body) {
|
||||
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (body.size > maxSize) {
|
||||
return c.json(
|
||||
{ error: `Max size (${maxSize} bytes) exceeded` },
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
const body = await getFileFromContext(c);
|
||||
if (!body) {
|
||||
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (body.size > maxSize) {
|
||||
return c.json(
|
||||
{ error: `Max size (${maxSize} bytes) exceeded` },
|
||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
);
|
||||
}
|
||||
|
||||
const filename = reqname ?? getRandomizedFilename(body as File);
|
||||
const res = await this.getStorage().uploadFile(body, filename);
|
||||
const filename = reqname ?? getRandomizedFilename(body as File);
|
||||
const res = await this.getStorage().uploadFile(body, filename);
|
||||
|
||||
return c.json(res, HttpStatus.CREATED);
|
||||
});
|
||||
return c.json(res, HttpStatus.CREATED);
|
||||
},
|
||||
);
|
||||
|
||||
// add upload file to entity
|
||||
// @todo: add required type for "upload endpoints"
|
||||
hono.post(
|
||||
"/entity/:entity/:id/:field",
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
overwrite: Type.Optional(booleanLike),
|
||||
describeRoute({
|
||||
summary: "Add a file to an entity",
|
||||
tags: ["media"],
|
||||
requestBody,
|
||||
}),
|
||||
jsc(
|
||||
"param",
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: s.number(),
|
||||
field: s.string(),
|
||||
}),
|
||||
),
|
||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||
async (c) => {
|
||||
const entity_name = c.req.param("entity");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { App } from "App";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { type Context, type Env, Hono } from "hono";
|
||||
import * as middlewares from "modules/middlewares";
|
||||
import type { SafeUser } from "auth";
|
||||
import type { EntityManager } from "data";
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
export type ServerEnv = {
|
||||
export type ServerEnv = Env & {
|
||||
Variables: {
|
||||
app: App;
|
||||
// to prevent resolving auth multiple times
|
||||
@@ -46,4 +48,9 @@ export class Controller {
|
||||
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
protected getEntitiesEnum(em: EntityManager<any>) {
|
||||
const entities = em.entities.map((e) => e.name);
|
||||
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class AdminController extends Controller {
|
||||
hono.use("*", async (c, next) => {
|
||||
const obj = {
|
||||
user: c.get("auth")?.user,
|
||||
logout_route: this.withAdminBasePath(authRoutes.logout),
|
||||
logout_route: authRoutes.logout,
|
||||
admin_basepath: this.options.adminBasepath,
|
||||
};
|
||||
const html = await this.getHtml(obj);
|
||||
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
import { getRuntimeKey } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
import { openAPISpecs } from "jsonv-ts/hono";
|
||||
import { swaggerUI } from "@hono/swagger-ui";
|
||||
import {
|
||||
MODULE_NAMES,
|
||||
type ModuleConfigs,
|
||||
@@ -24,12 +23,8 @@ import {
|
||||
getDefaultConfig,
|
||||
} from "modules/ModuleManager";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { generateOpenAPI } from "modules/server/openapi";
|
||||
|
||||
const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
|
||||
import { jsc, s, describeRoute } from "core/object/schema";
|
||||
import { getVersion } from "core/env";
|
||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||
success: true;
|
||||
module: Key;
|
||||
@@ -61,20 +56,27 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.use(permission(SystemPermissions.configRead));
|
||||
|
||||
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
|
||||
// @ts-expect-error "fetch" is private
|
||||
return c.json(await this.app.modules.fetch());
|
||||
});
|
||||
hono.get(
|
||||
"/raw",
|
||||
describeRoute({
|
||||
summary: "Get the raw config",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission([SystemPermissions.configReadSecrets]),
|
||||
async (c) => {
|
||||
// @ts-expect-error "fetch" is private
|
||||
return c.json(await this.app.modules.fetch());
|
||||
},
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/:module?",
|
||||
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
secrets: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
summary: "Get the config for a module",
|
||||
tags: ["system"],
|
||||
}),
|
||||
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
|
||||
jsc("query", s.object({ secrets: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
// @todo: allow secrets if authenticated user is admin
|
||||
const { secrets } = c.req.valid("query");
|
||||
@@ -119,12 +121,7 @@ export class SystemController extends Controller {
|
||||
hono.post(
|
||||
"/set/:module",
|
||||
permission(SystemPermissions.configWrite),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
force: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as any;
|
||||
const { force } = c.req.valid("query");
|
||||
@@ -230,13 +227,17 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/schema/:module?",
|
||||
describeRoute({
|
||||
summary: "Get the schema for a module",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead),
|
||||
tb(
|
||||
jsc(
|
||||
"query",
|
||||
Type.Object({
|
||||
config: Type.Optional(booleanLike),
|
||||
secrets: Type.Optional(booleanLike),
|
||||
fresh: Type.Optional(booleanLike),
|
||||
s.partialObject({
|
||||
config: s.boolean(),
|
||||
secrets: s.boolean(),
|
||||
fresh: s.boolean(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -274,13 +275,11 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.post(
|
||||
"/build",
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
sync: Type.Optional(booleanLike),
|
||||
fetch: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
describeRoute({
|
||||
summary: "Build the app",
|
||||
tags: ["system"],
|
||||
}),
|
||||
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const options = c.req.valid("query") as Record<string, boolean>;
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||
@@ -293,25 +292,44 @@ export class SystemController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
hono.get("/ping", (c) => c.json({ pong: true }));
|
||||
hono.get(
|
||||
"/ping",
|
||||
describeRoute({
|
||||
summary: "Ping the server",
|
||||
tags: ["system"],
|
||||
}),
|
||||
(c) => c.json({ pong: true }),
|
||||
);
|
||||
|
||||
hono.get("/info", (c) =>
|
||||
c.json({
|
||||
version: c.get("app")?.version(),
|
||||
runtime: getRuntimeKey(),
|
||||
timezone: {
|
||||
name: getTimezone(),
|
||||
offset: getTimezoneOffset(),
|
||||
local: datetimeStringLocal(),
|
||||
utc: datetimeStringUTC(),
|
||||
hono.get(
|
||||
"/info",
|
||||
describeRoute({
|
||||
summary: "Get the server info",
|
||||
tags: ["system"],
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
version: c.get("app")?.version(),
|
||||
runtime: getRuntimeKey(),
|
||||
timezone: {
|
||||
name: getTimezone(),
|
||||
offset: getTimezoneOffset(),
|
||||
local: datetimeStringLocal(),
|
||||
utc: datetimeStringUTC(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/openapi.json",
|
||||
openAPISpecs(this.ctx.server, {
|
||||
info: {
|
||||
title: "bknd API",
|
||||
version: getVersion(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
hono.get("/openapi.json", async (c) => {
|
||||
const config = getDefaultConfig();
|
||||
return c.json(generateOpenAPI(config));
|
||||
});
|
||||
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
}
|
||||
|
||||
@@ -1,52 +1,64 @@
|
||||
import { Api, type ApiOptions, type TApiUser } from "Api";
|
||||
import { Api, type ApiOptions, type AuthState } from "Api";
|
||||
import { isDebug } from "core";
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
||||
import type { AdminBkndWindowContext } from "modules/server/AdminController";
|
||||
|
||||
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
|
||||
baseUrl: undefined,
|
||||
} as any);
|
||||
export type BkndClientContext = {
|
||||
baseUrl: string;
|
||||
api: Api;
|
||||
authState?: Partial<AuthState>;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<BkndClientContext>(undefined!);
|
||||
|
||||
export type ClientProviderProps = {
|
||||
children?: ReactNode;
|
||||
} & (
|
||||
| { baseUrl?: string; user?: TApiUser | null | undefined }
|
||||
| {
|
||||
api: Api;
|
||||
}
|
||||
);
|
||||
baseUrl?: string;
|
||||
} & ApiOptions;
|
||||
|
||||
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
|
||||
let api: Api;
|
||||
export const ClientProvider = ({
|
||||
children,
|
||||
host,
|
||||
baseUrl: _baseUrl = host,
|
||||
...props
|
||||
}: ClientProviderProps) => {
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx = useClientContext();
|
||||
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
|
||||
let user: any = undefined;
|
||||
|
||||
if (props && "api" in props) {
|
||||
api = props.api;
|
||||
} else {
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
const { baseUrl, user } = props;
|
||||
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||
|
||||
try {
|
||||
if (!baseUrl) {
|
||||
if (_ctx_baseUrl) {
|
||||
actualBaseUrl = _ctx_baseUrl;
|
||||
console.warn("wrapped many times, take from context", actualBaseUrl);
|
||||
} else if (typeof window !== "undefined") {
|
||||
actualBaseUrl = window.location.origin;
|
||||
//console.log("setting from window", actualBaseUrl);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error in ClientProvider", e);
|
||||
}
|
||||
|
||||
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
|
||||
if (winCtx) {
|
||||
user = winCtx.user;
|
||||
}
|
||||
|
||||
if (!actualBaseUrl) {
|
||||
try {
|
||||
actualBaseUrl = window.location.origin;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const apiProps = { user, ...props, host: actualBaseUrl };
|
||||
const api = useMemo(
|
||||
() =>
|
||||
new Api({
|
||||
...apiProps,
|
||||
verbose: isDebug(),
|
||||
onAuthStateChange: (state) => {
|
||||
props.onAuthStateChange?.(state);
|
||||
if (!authState?.token || state.token !== authState?.token) {
|
||||
setAuthState(state);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[JSON.stringify(apiProps)],
|
||||
);
|
||||
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
|
||||
apiProps.user ? api.getAuthState() : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
);
|
||||
@@ -61,12 +73,16 @@ export const useApi = (host?: ApiOptions["host"]): Api => {
|
||||
return context.api;
|
||||
};
|
||||
|
||||
export const useClientContext = () => {
|
||||
return useContext(ClientContext);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use useApi().baseUrl instead
|
||||
*/
|
||||
export const useBaseUrl = () => {
|
||||
const context = useContext(ClientContext);
|
||||
return context.baseUrl;
|
||||
const context = useClientContext();
|
||||
return context?.baseUrl;
|
||||
};
|
||||
|
||||
export function useBkndWindowContext(): AdminBkndWindowContext {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthState } from "Api";
|
||||
import type { AuthResponse } from "auth";
|
||||
import { useState } from "react";
|
||||
import { useApi, useInvalidate } from "ui/client";
|
||||
import { useClientContext } from "ui/client/ClientProvider";
|
||||
|
||||
type LoginData = {
|
||||
email: string;
|
||||
@@ -10,7 +10,7 @@ type LoginData = {
|
||||
};
|
||||
|
||||
type UseAuth = {
|
||||
data: AuthState | undefined;
|
||||
data: Partial<AuthState> | undefined;
|
||||
user: AuthState["user"] | undefined;
|
||||
token: AuthState["token"] | undefined;
|
||||
verified: boolean;
|
||||
@@ -24,46 +24,36 @@ type UseAuth = {
|
||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
const api = useApi(options?.baseUrl);
|
||||
const invalidate = useInvalidate();
|
||||
const authState = api.getAuthState();
|
||||
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
||||
const { authState } = useClientContext();
|
||||
const verified = authState?.verified ?? false;
|
||||
|
||||
function updateAuthState() {
|
||||
setAuthData(api.getAuthState());
|
||||
}
|
||||
|
||||
async function login(input: LoginData) {
|
||||
const res = await api.auth.loginWithPassword(input);
|
||||
updateAuthState();
|
||||
const res = await api.auth.login("password", input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function register(input: LoginData) {
|
||||
const res = await api.auth.registerWithPassword(input);
|
||||
updateAuthState();
|
||||
const res = await api.auth.register("password", input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
api.updateToken(token);
|
||||
updateAuthState();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.updateToken(undefined);
|
||||
setAuthData(undefined);
|
||||
api.updateToken(undefined);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await api.verifyAuth();
|
||||
updateAuthState();
|
||||
}
|
||||
|
||||
return {
|
||||
data: authData,
|
||||
user: authData?.user,
|
||||
token: authData?.token,
|
||||
data: authState,
|
||||
user: authState?.user,
|
||||
token: authState?.token,
|
||||
verified,
|
||||
login,
|
||||
register,
|
||||
|
||||
@@ -70,7 +70,6 @@ export function useBkndData() {
|
||||
};
|
||||
const $data = {
|
||||
entity: (name: string) => entities[name],
|
||||
indicesOf: (name: string) => app.indices.filter((i) => i.entity.name === name),
|
||||
modals,
|
||||
system: (name: string) => ({
|
||||
any: entities[name]?.type === "system",
|
||||
@@ -83,7 +82,6 @@ export function useBkndData() {
|
||||
$data,
|
||||
entities,
|
||||
relations: app.relations,
|
||||
indices: app.indices,
|
||||
config: config.data,
|
||||
schema: schema.data,
|
||||
actions,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { App } from "App";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityIndex,
|
||||
type EntityRelation,
|
||||
constructEntity,
|
||||
constructRelation,
|
||||
constructIndex,
|
||||
} from "data";
|
||||
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
|
||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||
import { Flow, TaskMap } from "flows";
|
||||
import type { BkndAdminOptions } from "ui/client/BkndProvider";
|
||||
@@ -21,7 +14,6 @@ export class AppReduced {
|
||||
// @todo: change to record
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _flows: Flow[] = [];
|
||||
|
||||
constructor(
|
||||
@@ -38,10 +30,6 @@ export class AppReduced {
|
||||
return constructRelation(relation, this.entity.bind(this));
|
||||
});
|
||||
|
||||
this._indices = Object.entries(this.appJson.data.indices ?? {}).map(([name, index]) => {
|
||||
return constructIndex(index, this.entity.bind(this), name);
|
||||
});
|
||||
|
||||
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
|
||||
// @ts-ignore
|
||||
// @todo: fix constructing flow
|
||||
@@ -70,10 +58,6 @@ export class AppReduced {
|
||||
return new RelationAccessor(this._relations);
|
||||
}
|
||||
|
||||
get indices(): EntityIndex[] {
|
||||
return this._indices;
|
||||
}
|
||||
|
||||
get flows(): Flow[] {
|
||||
return this._flows;
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ import {
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
|
||||
export type CanvasProps = Omit<ReactFlowProps, "onNodesChange" | "onEdgesChange"> & {
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
externalProvider?: boolean;
|
||||
backgroundStyle?: "lines" | "dots";
|
||||
minimap?: boolean | MiniMapProps;
|
||||
children?: Element | ReactNode;
|
||||
onDropNewNode?: (base: any) => any;
|
||||
onDropNewEdge?: (base: any) => any;
|
||||
onNodesChange?: (changes: any) => void;
|
||||
onEdgesChange?: (changes: any) => void;
|
||||
};
|
||||
|
||||
export function Canvas({
|
||||
@@ -35,8 +33,8 @@ export function Canvas({
|
||||
onDropNewEdge,
|
||||
...props
|
||||
}: CanvasProps) {
|
||||
const [nodes, setNodes, _onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, _onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const { theme } = useTheme();
|
||||
|
||||
@@ -44,22 +42,6 @@ export function Canvas({
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPointerPressed, setIsPointerPressed] = useState(false);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes) => {
|
||||
_onNodesChange(changes);
|
||||
props.onNodesChange?.(changes);
|
||||
},
|
||||
[_onNodesChange, props.onNodesChange],
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
_onEdgesChange(changes);
|
||||
props.onEdgesChange?.(changes);
|
||||
},
|
||||
[_onEdgesChange, props.onEdgesChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey) {
|
||||
setIsCommandPressed(true);
|
||||
@@ -191,6 +173,8 @@ export function Canvas({
|
||||
snapToGrid
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodesConnectable={false}
|
||||
/*panOnDrag={isSpacePressed}*/
|
||||
panOnDrag={true}
|
||||
@@ -199,8 +183,6 @@ export function Canvas({
|
||||
zoomOnDoubleClick={false}
|
||||
selectionOnDrag={!isSpacePressed}
|
||||
{...props}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
>
|
||||
{backgroundStyle === "lines" && (
|
||||
<Background
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DefaultNode({ selected, children, className, ...props }: TDefaul
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative w-80 shadow-lg rounded-lg bg-background",
|
||||
selected && "ring-4 ring-blue-400/50",
|
||||
selected && "outline outline-blue-500/25",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import {
|
||||
default as CodeMirror,
|
||||
type ReactCodeMirrorProps,
|
||||
EditorView,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
@@ -43,7 +47,7 @@ export default function CodeEditor({
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable={editable}
|
||||
basicSetup={_basicSetup}
|
||||
extensions={extensions}
|
||||
extensions={[...extensions, EditorView.lineWrapping]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -246,6 +246,7 @@ export const Switch = forwardRef<
|
||||
props.disabled && "opacity-50 !cursor-not-allowed",
|
||||
)}
|
||||
onCheckedChange={(bool) => {
|
||||
console.log("setting", bool);
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
{...(props as any)}
|
||||
@@ -271,7 +272,7 @@ export const Switch = forwardRef<
|
||||
export const Select = forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.ComponentProps<"select"> & {
|
||||
options?: { value: string; label: string; disabled?: boolean }[] | (string | number)[];
|
||||
options?: { value: string; label: string }[] | (string | number)[];
|
||||
}
|
||||
>(({ children, options, ...props }, ref) => (
|
||||
<div className="flex w-full relative">
|
||||
@@ -296,7 +297,7 @@ export const Select = forwardRef<
|
||||
return o;
|
||||
})
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -78,7 +78,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
required={schema.minItems > 0}
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
|
||||
@@ -19,6 +19,7 @@ export type DropdownItem =
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
@@ -142,6 +143,7 @@ export function Dropdown({
|
||||
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={item.title}
|
||||
>
|
||||
{space_for_icon && (
|
||||
<div className="size-[16px] text-left mr-1.5 opacity-80">
|
||||
|
||||
@@ -28,10 +28,10 @@ export function useRoutePathState(_path?: string, identifier?: string) {
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
function toggle(_open?: boolean) {
|
||||
const open = _open ?? !active;
|
||||
const open = _open ?? !localActive;
|
||||
|
||||
if (ctx) {
|
||||
ctx.setActiveIdentifier(open ? identifier! : "");
|
||||
ctx.setActiveIdentifier(identifier!);
|
||||
}
|
||||
|
||||
if (path) {
|
||||
@@ -58,7 +58,7 @@ export function useRoutePathState(_path?: string, identifier?: string) {
|
||||
}
|
||||
|
||||
type RoutePathStateContextType = {
|
||||
defaultIdentifier?: string;
|
||||
defaultIdentifier: string;
|
||||
path: string;
|
||||
activeIdentifier: string;
|
||||
setActiveIdentifier: (identifier: string) => void;
|
||||
@@ -72,9 +72,7 @@ export function RoutePathStateProvider({
|
||||
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
|
||||
const segment = extractPathSegment(path);
|
||||
const routeIdentifier = useParams()[segment];
|
||||
const [activeIdentifier, setActiveIdentifier] = useState(
|
||||
routeIdentifier ?? defaultIdentifier ?? "",
|
||||
);
|
||||
const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier);
|
||||
return (
|
||||
<RoutePathStateContext.Provider
|
||||
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
|
||||
|
||||
@@ -1,46 +1,42 @@
|
||||
import {
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
decodeSearch,
|
||||
encodeSearch,
|
||||
parseDecode,
|
||||
} from "core/utils";
|
||||
import { decodeSearch, encodeSearch, parseDecode } from "core/utils";
|
||||
import { isEqual, transform } from "lodash-es";
|
||||
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
||||
import { type s, parse } from "core/object/schema";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
export function useSearch<Schema extends TSchema = TSchema>(
|
||||
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
||||
schema: Schema,
|
||||
defaultValue?: Partial<StaticDecode<Schema>>,
|
||||
defaultValue?: Partial<s.StaticCoerced<Schema>>,
|
||||
) {
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
|
||||
|
||||
if (searchString.length > 0) {
|
||||
value = parseDecode(schema, decodeSearch(searchString));
|
||||
//console.log("search:decode", value);
|
||||
}
|
||||
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
|
||||
const value = parse(schema, initial, {
|
||||
withDefaults: true,
|
||||
clone: true,
|
||||
}) as s.StaticCoerced<Schema>;
|
||||
|
||||
// @todo: add option to set multiple keys at once
|
||||
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
|
||||
function set<Key extends keyof s.StaticCoerced<Schema>>(
|
||||
key: Key,
|
||||
value: s.StaticCoerced<Schema>[Key],
|
||||
): void {
|
||||
//console.log("set", key, value);
|
||||
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value });
|
||||
const update = parse(schema, { ...decodeSearch(searchString), [key]: value });
|
||||
const search = transform(
|
||||
update as any,
|
||||
(result, value, key) => {
|
||||
if (defaultValue && isEqual(value, defaultValue[key])) return;
|
||||
result[key] = value;
|
||||
},
|
||||
{} as Static<Schema>,
|
||||
{} as s.StaticCoerced<Schema>,
|
||||
);
|
||||
const encoded = encodeSearch(search, { encode: false });
|
||||
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
||||
}
|
||||
|
||||
return {
|
||||
value: value as Required<StaticDecode<Schema>>,
|
||||
value: value as Required<s.StaticCoerced<Schema>>,
|
||||
set,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import { IconApi, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
TbFingerprint,
|
||||
@@ -159,6 +159,11 @@ function UserMenu() {
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
||||
{
|
||||
label: "OpenAPI",
|
||||
onClick: () => window.open("/api/system/swagger", "_blank"),
|
||||
icon: IconApi,
|
||||
},
|
||||
];
|
||||
|
||||
if (config.auth.enabled) {
|
||||
@@ -166,7 +171,8 @@ function UserMenu() {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
} else {
|
||||
items.push({
|
||||
label: `Logout ${auth.user.email}`,
|
||||
label: "Logout",
|
||||
title: `Logout ${auth.user.email}`,
|
||||
onClick: handleLogout,
|
||||
icon: IconKeyOff,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useClipboard } from "@mantine/hooks";
|
||||
import { ButtonLink } from "ui/components/buttons/Button";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { JsonViewer } from "ui";
|
||||
|
||||
export type MediaInfoModalProps = {
|
||||
file: FileState;
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider, useReactFlow } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity, TAppDataField } from "data/data-schema";
|
||||
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
|
||||
import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Canvas, type CanvasProps } from "ui/components/canvas/Canvas";
|
||||
import { Canvas } from "ui/components/canvas/Canvas";
|
||||
import { layoutWithDagre } from "ui/components/canvas/layouts";
|
||||
import { Panels } from "ui/components/canvas/panels";
|
||||
import { EntityTableNode } from "./EntityTableNode";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { useCallback } from "react";
|
||||
import { mergeObject, transformObject } from "core/utils";
|
||||
|
||||
export interface TCanvasEntityField extends TAppDataField {
|
||||
name: string;
|
||||
indexed?: boolean;
|
||||
}
|
||||
|
||||
export interface TCanvasEntity extends TAppDataEntity {
|
||||
label: string;
|
||||
fields: Record<string, TCanvasEntityField>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function entitiesToNodes(
|
||||
entities: AppDataConfig["entities"],
|
||||
indices?: AppDataConfig["indices"],
|
||||
): Node<TCanvasEntity>[] {
|
||||
const indexed_fields = Object.entries(indices ?? {}).flatMap(([, index]) => index.fields);
|
||||
|
||||
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
|
||||
return Object.entries(entities ?? {}).map(([name, entity]) => {
|
||||
return {
|
||||
id: name,
|
||||
data: {
|
||||
...entity,
|
||||
label: name,
|
||||
fields: transformObject(entity.fields ?? {}, (f, name) => ({
|
||||
...f,
|
||||
name,
|
||||
indexed: indexed_fields.includes(name),
|
||||
})),
|
||||
},
|
||||
data: { label: name, ...entity },
|
||||
type: "entity",
|
||||
dragHandle: ".drag-handle",
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -91,29 +65,24 @@ const nodeTypes = {
|
||||
entity: EntityTableNode.Component,
|
||||
} as const;
|
||||
|
||||
const getEdgeStyle = (theme: string) => ({
|
||||
stroke: theme === "light" ? "#ccc" : "#666",
|
||||
});
|
||||
|
||||
const getMarkerEndStyle = (theme: string) => ({
|
||||
type: MarkerType.Arrow,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: theme === "light" ? "#aaa" : "#777",
|
||||
});
|
||||
|
||||
export function DataSchemaCanvas() {
|
||||
const {
|
||||
config: { data },
|
||||
} = useBknd();
|
||||
const { theme } = useTheme();
|
||||
const nodes = entitiesToNodes(data.entities, data.indices);
|
||||
console.log(nodes);
|
||||
const nodes = entitiesToNodes(data.entities);
|
||||
const edges = relationsToEdges(data.relations).map((e) => ({
|
||||
...e,
|
||||
style: getEdgeStyle(theme),
|
||||
style: {
|
||||
stroke: theme === "light" ? "#ccc" : "#666",
|
||||
},
|
||||
type: "smoothstep",
|
||||
markerEnd: getMarkerEndStyle(theme),
|
||||
markerEnd: {
|
||||
type: MarkerType.Arrow,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: theme === "light" ? "#aaa" : "#777",
|
||||
},
|
||||
}));
|
||||
|
||||
const nodeLayout = layoutWithDagre({
|
||||
@@ -138,7 +107,7 @@ export function DataSchemaCanvas() {
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<ActualCanvas
|
||||
<Canvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -150,49 +119,7 @@ export function DataSchemaCanvas() {
|
||||
}}
|
||||
>
|
||||
<Panels zoom minimap />
|
||||
</ActualCanvas>
|
||||
</Canvas>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function toRecord(nodes: Node<TAppDataEntity>[]): Record<string, Node<TAppDataEntity>> {
|
||||
return Object.fromEntries(nodes.map((n) => [n.id, n]));
|
||||
}
|
||||
|
||||
function ActualCanvas(props: CanvasProps) {
|
||||
const flow = useReactFlow();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const onNodesChange = useCallback((changes: any) => {
|
||||
const nodes = mergeObject<Record<string, Node<TAppDataEntity>>>(
|
||||
toRecord(flow.getNodes()),
|
||||
toRecord(changes),
|
||||
);
|
||||
const selected = Object.values(nodes).filter((n) => n.selected);
|
||||
const selected_names = selected.map((n) => n.id);
|
||||
flow.setEdges((edges) =>
|
||||
edges.map((edge) => {
|
||||
if (selected_names.includes(edge.source) || selected_names.includes(edge.target)) {
|
||||
return {
|
||||
...edge,
|
||||
animated: true,
|
||||
style: { stroke: "#6495c6" },
|
||||
markerEnd: {
|
||||
...getMarkerEndStyle(theme),
|
||||
color: "#6495c6",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
style: getEdgeStyle(theme),
|
||||
animated: false,
|
||||
markerEnd: getMarkerEndStyle(theme),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return <Canvas {...props} onNodesChange={onNodesChange as any} />;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { Handle, type Node, type NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
|
||||
import type { TAppDataEntity } from "data/data-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbBolt, TbDiamonds, TbKey } from "react-icons/tb";
|
||||
import { useState } from "react";
|
||||
import { TbDiamonds, TbKey } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import type { TCanvasEntity, TCanvasEntityField } from "./DataSchemaCanvas";
|
||||
|
||||
export type TableProps = {
|
||||
name: string;
|
||||
type?: string;
|
||||
fields: TCanvasEntityField[];
|
||||
fields: TableField[];
|
||||
};
|
||||
export type TableField = {
|
||||
name: string;
|
||||
type: string;
|
||||
primary?: boolean;
|
||||
foreign?: boolean;
|
||||
indexed?: boolean;
|
||||
};
|
||||
|
||||
function NodeComponent(props: NodeProps<Node<TCanvasEntity>>) {
|
||||
function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { data } = props;
|
||||
const fields = props.data.fields ?? {};
|
||||
const field_count = Object.keys(fields).length;
|
||||
@@ -27,11 +32,10 @@ function NodeComponent(props: NodeProps<Node<TCanvasEntity>>) {
|
||||
{Object.entries(fields).map(([name, field], index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
field={field}
|
||||
field={{ name, ...field }}
|
||||
table={data.label}
|
||||
index={index}
|
||||
last={field_count === index + 1}
|
||||
selected={props.selected && ["relation", "primary"].includes(field.type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -49,16 +53,13 @@ const TableRow = ({
|
||||
index,
|
||||
onHover,
|
||||
last,
|
||||
selected,
|
||||
}: {
|
||||
field: TCanvasEntityField;
|
||||
field: TableField;
|
||||
table: string;
|
||||
index: number;
|
||||
last?: boolean;
|
||||
selected?: boolean;
|
||||
onHover?: (hovered: boolean) => void;
|
||||
}) => {
|
||||
const [navigate] = useNavigate();
|
||||
const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2;
|
||||
const handles = true;
|
||||
const handleId = `${table}:${field.name}`;
|
||||
@@ -66,12 +67,10 @@ const TableRow = ({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-pointer",
|
||||
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-auto",
|
||||
last && "rounded-bl-lg rounded-br-lg",
|
||||
selected && "bg-primary/5",
|
||||
"hover:bg-primary/5",
|
||||
)}
|
||||
onClick={() => navigate(`/entity/${table}/fields/${field.name}`)}
|
||||
>
|
||||
{handles && (
|
||||
<Handle
|
||||
@@ -87,12 +86,7 @@ const TableRow = ({
|
||||
{field.type === "primary" && <TbKey className="text-yellow-700" />}
|
||||
{field.type === "relation" && <TbDiamonds className="text-sky-700" />}
|
||||
</div>
|
||||
<div className="flex flex-grow items-center gap-1">
|
||||
<span className={field.config?.required ? "font-bold" : "opacity-90"}>
|
||||
{field.name}
|
||||
</span>{" "}
|
||||
{field.indexed && <TbBolt className="text-warning-foreground/50" />}
|
||||
</div>
|
||||
<div className="flex flex-grow">{field.name}</div>
|
||||
<div className="flex opacity-60">{field.type}</div>
|
||||
|
||||
{handles && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isDebug } from "core";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { type ChangeEvent, useState } from "react";
|
||||
import {
|
||||
TbAt,
|
||||
TbBrandAppleFilled,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TbBrandX,
|
||||
TbSettings,
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
|
||||
@@ -83,6 +83,10 @@ export function DataRoot({ children }) {
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable initialOffset={96}>
|
||||
<div className="flex flex-col flex-grow py-3 gap-3">
|
||||
{/*<div className="pt-3 px-3">
|
||||
<SearchInput placeholder="Search entities" />
|
||||
</div>*/}
|
||||
|
||||
<EntityLinkList entities={entityList.regular} context={context} suggestCreate />
|
||||
<EntityLinkList entities={entityList.system} context={context} title="System" />
|
||||
<EntityLinkList
|
||||
|
||||
@@ -11,8 +11,7 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { EntityForm } from "ui/modules/data/components/EntityForm";
|
||||
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
export function DataEntityCreate({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
@@ -29,7 +28,7 @@ export function DataEntityCreate({ params }) {
|
||||
const $q = useEntityMutate(entity.name);
|
||||
|
||||
// @todo: use entity schema for prefilling
|
||||
const search = useSearch(Type.Object({}), {});
|
||||
const search = useSearch(s.object({}), {});
|
||||
|
||||
function goBack() {
|
||||
window.history.go(-1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Entity, querySchema } from "data";
|
||||
import { type Entity, repoQuery } from "data";
|
||||
import { Fragment } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
@@ -14,20 +14,14 @@ import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
|
||||
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "core/object/schema";
|
||||
import { pick } from "core/utils/objects";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
const searchSchema = Type.Composite(
|
||||
[
|
||||
Type.Pick(querySchema, ["select", "where", "sort"]),
|
||||
Type.Object({
|
||||
page: Type.Optional(Type.Number({ default: 1 })),
|
||||
perPage: Type.Optional(Type.Number({ default: 10 })),
|
||||
}),
|
||||
],
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
const searchSchema = s.partialObject({
|
||||
...pick(repoQuery.properties, ["select", "where", "sort"]),
|
||||
page: s.number({ default: 1 }).optional(),
|
||||
perPage: s.number({ default: 10 }).optional(),
|
||||
});
|
||||
|
||||
const PER_PAGE_OPTIONS = [5, 10, 25];
|
||||
|
||||
@@ -74,8 +68,6 @@ export function DataEntityList({ params }) {
|
||||
const sort = search.value.sort!;
|
||||
const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" };
|
||||
|
||||
// // @ts-expect-error - somehow all search keys are optional
|
||||
console.log("new sort", newSort);
|
||||
search.set("sort", newSort as any);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
import { extractSchema } from "../settings/utils/schema";
|
||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||
import { EntityIndicesForm } from "./forms/entity.indices.form";
|
||||
import type { TAppDataIndex } from "data/data-schema";
|
||||
|
||||
export function DataSchemaEntity({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
@@ -44,100 +42,106 @@ export function DataSchemaEntity({ params }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoutePathStateProvider
|
||||
path={`/entity/${entity.name}/:setting?`}
|
||||
defaultIdentifier="fields"
|
||||
<RoutePathStateProvider path={`/entity/${entity.name}/:setting?`} defaultIdentifier="fields">
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Data",
|
||||
onClick: () =>
|
||||
navigate(routes.data.root() + routes.data.entity.list(entity.name), {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () => $data.modals.createRelation(entity.name),
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name),
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Data",
|
||||
onClick: () =>
|
||||
navigate(
|
||||
routes.data.root() + routes.data.entity.list(entity.name),
|
||||
{
|
||||
absolute: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "entities", entity.name]), {
|
||||
absolute: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
icon: TbCirclesRelation,
|
||||
label: "Add relation",
|
||||
onClick: () => $data.modals.createRelation(entity.name),
|
||||
},
|
||||
{
|
||||
icon: TbPhoto,
|
||||
label: "Add media",
|
||||
onClick: () => $data.modals.createMedia(entity.name),
|
||||
},
|
||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||
{
|
||||
icon: TbDatabasePlus,
|
||||
label: "Create Entity",
|
||||
onClick: () => $data.modals.createEntity(),
|
||||
},
|
||||
]}
|
||||
position="bottom-end"
|
||||
>
|
||||
<Button IconRight={TbPlus}>Add</Button>
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
className="pl-3"
|
||||
>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Breadcrumbs2
|
||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||
backTo="/"
|
||||
/>
|
||||
<Link to="/" className="hidden md:inline">
|
||||
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-col h-full" key={entity.name}>
|
||||
<Fields entity={entity} />
|
||||
|
||||
<BasicSettings entity={entity} />
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="relations"
|
||||
title="Relations"
|
||||
ActiveIcon={IconCirclesRelation}
|
||||
>
|
||||
<Empty
|
||||
title="Relations"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "relations"]), {
|
||||
absolute: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
<Indices entity={entity} />
|
||||
<div className="flex flex-row gap-4">
|
||||
<Breadcrumbs2
|
||||
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
|
||||
backTo="/"
|
||||
/>
|
||||
<Link to="/" className="hidden md:inline">
|
||||
<Button IconLeft={TbSitemap}>Overview</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</RoutePathStateProvider>
|
||||
</>
|
||||
</AppShell.SectionHeader>
|
||||
<div className="flex flex-col h-full" key={entity.name}>
|
||||
<Fields entity={entity} />
|
||||
|
||||
<BasicSettings entity={entity} />
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="relations"
|
||||
title="Relations"
|
||||
ActiveIcon={IconCirclesRelation}
|
||||
>
|
||||
<Empty
|
||||
title="Relations"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
|
||||
}}
|
||||
/>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="indices"
|
||||
title="Indices"
|
||||
ActiveIcon={IconBolt}
|
||||
>
|
||||
<Empty
|
||||
title="Indices"
|
||||
description="This will soon be available here. Meanwhile, check advanced settings."
|
||||
primary={{
|
||||
children: "Advanced Settings",
|
||||
onClick: () =>
|
||||
navigate(routes.settings.path(["data", "indices"]), {
|
||||
absolute: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
</div>
|
||||
</RoutePathStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,37 +281,3 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Indices = ({ entity }: { entity: Entity }) => {
|
||||
const [navigate] = useNavigate();
|
||||
const [data, setData] = useState<Record<string, TAppDataIndex>>({});
|
||||
const d = useBkndData();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
async function handleUpdate() {
|
||||
console.log("update", data);
|
||||
return;
|
||||
/*if (submitting) return;
|
||||
setSubmitting(true);
|
||||
await d.actions.entity.patch(entity.name).indices.set(data);
|
||||
setSubmitting(false);*/
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell.RouteAwareSectionHeaderAccordionItem
|
||||
identifier="indices"
|
||||
title="Indices"
|
||||
ActiveIcon={IconBolt}
|
||||
renderHeaderRight={({ open }) =>
|
||||
open ? (
|
||||
<Button variant="primary" disabled={!open || submitting} onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col flex-grow py-3 px-4 max-w-4xl gap-3 relative">
|
||||
<EntityIndicesForm entity={entity} onChange={setData} />
|
||||
</div>
|
||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { TbBolt, TbTrash } from "react-icons/tb";
|
||||
import type { Entity } from "data/entities";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { CollapsibleList } from "ui/components/list/CollapsibleList";
|
||||
import { EntityIndex } from "data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import type { TAppDataIndex } from "data/data-schema";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
export interface EntityIndicesFormProps {
|
||||
entity: Entity;
|
||||
onChange?: (indices: Record<string, TAppDataIndex>) => void;
|
||||
}
|
||||
|
||||
export function EntityIndicesForm({ entity, onChange }: EntityIndicesFormProps) {
|
||||
const { $data } = useBkndData();
|
||||
const [indices, setIndices] = useState<EntityIndex[]>($data.indicesOf(entity.name));
|
||||
const [create, setCreate] = useState<TAppDataIndex>({
|
||||
entity: entity.name,
|
||||
fields: [],
|
||||
unique: false,
|
||||
});
|
||||
const indexed_fields = indices.flatMap((i) => i.fields).map((f) => f.name);
|
||||
const required_fields = indices
|
||||
.flatMap((i) => i.fields)
|
||||
.filter((f) => f.isRequired())
|
||||
.map((f) => f.name);
|
||||
|
||||
const fields = entity.fields.filter(
|
||||
(f) => !["primary", "relation", "media"].includes(f.type) && !indexed_fields.includes(f.name),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.(Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])));
|
||||
}, [indices]);
|
||||
|
||||
function handleAdd() {
|
||||
setIndices((prev) => [
|
||||
...prev,
|
||||
new EntityIndex(
|
||||
entity,
|
||||
create.fields.map((f) => entity.fields.find((f2) => f2.name === f)!),
|
||||
create.unique,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
//console.log("indices", { indices, schema, config });
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{indices.map((index) => (
|
||||
<CollapsibleList.Item key={index.name} title={index.name}>
|
||||
<CollapsibleList.Preview
|
||||
left={<TbBolt />}
|
||||
right={<IconButton size="lg" Icon={TbTrash} />}
|
||||
>
|
||||
<span>{index.fields.map((f) => f.name).join(", ")}</span>
|
||||
<span className="opacity-50">{index.name}</span>
|
||||
</CollapsibleList.Preview>
|
||||
</CollapsibleList.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-row gap-7 items-center">
|
||||
<span className="font-bold">Add Index</span>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Formy.Label className="opacity-70">Unique</Formy.Label>
|
||||
<Formy.Switch
|
||||
checked={create.unique}
|
||||
size="sm"
|
||||
onCheckedChange={(checked) => {
|
||||
setCreate((prev) => ({
|
||||
...prev,
|
||||
unique: checked,
|
||||
fields: prev.fields.some((f) => required_fields.includes(f))
|
||||
? prev.fields
|
||||
: [],
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-2 items-center">
|
||||
<Formy.Label className="opacity-70">Field</Formy.Label>
|
||||
<div className="min-w-0">
|
||||
<Formy.Select
|
||||
className="h-9 py-1.5 pl-3 pr-8"
|
||||
options={fields.map((f) => ({
|
||||
label: f.getLabel()!,
|
||||
value: f.name,
|
||||
disabled: create.unique && !required_fields.includes(f.name),
|
||||
}))}
|
||||
value={create.fields[0]}
|
||||
onChange={(e) => {
|
||||
setCreate((prev) => ({ ...prev, fields: [e.target.value] }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow" />
|
||||
<Button variant="primary" disabled={create.fields.length === 0} onClick={handleAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<JsonViewer
|
||||
json={{
|
||||
create,
|
||||
data: Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])),
|
||||
}}
|
||||
expand={9}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
.cm-editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["./node_modules", "./__test__", "./e2e"]
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": ".",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["./node_modules", "./__test__", "./e2e"]
|
||||
}
|
||||
|
||||
+4
-4
@@ -26,12 +26,11 @@
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"outDir": "./dist/types",
|
||||
"paths": {
|
||||
"*": ["./src/*"],
|
||||
"bknd": ["./src/*"]
|
||||
"*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -39,6 +38,7 @@
|
||||
"./src/**/*.tsx",
|
||||
"vite.dev.ts",
|
||||
"build.ts",
|
||||
"build.cli.ts",
|
||||
"__test__",
|
||||
"e2e/**/*.ts"
|
||||
],
|
||||
|
||||
+6
-1
@@ -4,11 +4,13 @@ import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__isDev: process.env.NODE_ENV === "production" ? "0" : "1",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
},
|
||||
clearScreen: false,
|
||||
publicDir: "./src/ui/assets",
|
||||
@@ -21,7 +23,10 @@ export default defineConfig({
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
tsconfigPaths({
|
||||
// otherwise it'll throw an error because of examples/astro
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
devServer({
|
||||
...devServerConfig,
|
||||
entry: "./vite.dev.ts",
|
||||
|
||||
@@ -27,18 +27,18 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "bknd",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"bin": "./dist/cli/index.js",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-liquid": "^6.2.2",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hono/swagger-ui": "^0.5.1",
|
||||
"@libsql/client": "^0.15.2",
|
||||
"@mantine/core": "^7.17.1",
|
||||
"@mantine/hooks": "^7.17.1",
|
||||
"@sinclair/typebox": "^0.34.30",
|
||||
"@sinclair/typebox": "0.34.30",
|
||||
"@tanstack/react-form": "^1.0.5",
|
||||
"@uiw/react-codemirror": "^4.23.10",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
@@ -48,17 +48,14 @@
|
||||
"fast-xml-parser": "^5.0.8",
|
||||
"hono": "^4.7.4",
|
||||
"json-schema-form-react": "^0.0.2",
|
||||
"json-schema-library": "^10.0.0-rc7",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"kysely": "^0.27.6",
|
||||
"liquidjs": "^10.21.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"radix-ui": "^1.1.3",
|
||||
"swr": "^2.3.3",
|
||||
"wrangler": "^4.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
@@ -87,9 +84,11 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"jotai": "^2.12.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsonv-ts": "^0.0.14-alpha.6",
|
||||
"kysely-d1": "^0.3.0",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
@@ -514,8 +513,6 @@
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250311.0" }, "optionalPeers": ["workerd"] }, "sha512-AaKYnbFpHaVDZGh3Hjy3oLYd12+LZw9aupAOudYJ+tjekahxcIqlSAr0zK9kPOdtgn10tzaqH7QJFUWcLE+k7g=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250224.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-sBbaAF2vgQ9+T50ik1ihekdepStBp0w4fvNghBfXIw1iWqfNWnypcjDMmi/7JhXJt2uBxBrSlXCvE5H7Gz+kbw=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250224.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-naetGefgjAaDbEacpwaVruJXNwxmRRL7v3ppStgEiqAlPmTpQ/Edjn2SQ284QwOw3MvaVPHrWcaTBupUpkqCyg=="],
|
||||
@@ -542,8 +539,6 @@
|
||||
|
||||
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ=="],
|
||||
|
||||
"@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.2.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-7Dm841fk37+JQW6j2rI1/uGkJyESrjzyhiIkaLjbbR0U6aFFQvMrJn35WxQreRMADMhzkyVkZM4467OR7GR8nQ=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.10.8", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.8.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A=="],
|
||||
@@ -646,6 +641,8 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.13.8", "", { "peerDependencies": { "hono": "^4" } }, "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg=="],
|
||||
|
||||
"@hono/swagger-ui": ["@hono/swagger-ui@0.5.1", "", { "peerDependencies": { "hono": "*" } }, "sha512-XpUCfszLJ9b1rtFdzqOSHfdg9pfBiC2J5piEjuSanYpDDTIwpMz0ciiv5N3WWUaQpz9fEgH8lttQqL41vIFuDA=="],
|
||||
|
||||
"@hono/typebox-validator": ["@hono/typebox-validator@0.3.2", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-MIxYk80vtuFnkvbNreMubZ/vLoNCCQivLH8n3vNDY5dFNsZ12BFaZV3FmsLJHGibNMMpmkO6y4w5gNWY4KzSdg=="],
|
||||
|
||||
"@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="],
|
||||
@@ -2038,8 +2035,6 @@
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@5.5.1", "", {}, "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.4", "", {}, "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="],
|
||||
@@ -2526,6 +2521,8 @@
|
||||
|
||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
||||
|
||||
"jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
||||
|
||||
"jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="],
|
||||
@@ -2586,8 +2583,6 @@
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"liquidjs": ["liquidjs@10.21.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-DouqxNU2jfoZzb1LinVjOc/f6ssitGIxiDJT+kEKyYqPSSSd+WmGOAhtWbVm1/n75svu4aQ+FyQ3ctd3wh1bbw=="],
|
||||
|
||||
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
|
||||
|
||||
"locate-app": ["locate-app@2.5.0", "", { "dependencies": { "@promptbook/utils": "0.69.5", "type-fest": "4.26.0", "userhome": "1.0.1" } }, "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q=="],
|
||||
@@ -3858,8 +3853,6 @@
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
|
||||
|
||||
"@cloudflare/unenv-preset/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||
@@ -4164,8 +4157,6 @@
|
||||
|
||||
"bknd/vitest": ["vitest@3.0.9", "", { "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", "@vitest/pretty-format": "^3.0.9", "@vitest/runner": "3.0.9", "@vitest/snapshot": "3.0.9", "@vitest/spy": "3.0.9", "@vitest/utils": "3.0.9", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.9", "@vitest/ui": "3.0.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ=="],
|
||||
|
||||
"bknd/wrangler": ["wrangler@4.4.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.0", "blake3-wasm": "2.1.5", "esbuild": "0.24.2", "miniflare": "4.20250321.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.15", "workerd": "1.20250321.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250321.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EFwr7hiVeAmPOuOGQ7HFfeaLKLxEXQMJ86kyn6RFB8pGjMEUtvZMsVa9cPubKkKgNi3WcDEFeFLalclGyq+tGA=="],
|
||||
|
||||
"bknd-cli/@libsql/client": ["@libsql/client@0.14.0", "", { "dependencies": { "@libsql/core": "^0.14.0", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.4.4", "promise-limit": "^2.7.0" } }, "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -4428,8 +4419,6 @@
|
||||
|
||||
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||
|
||||
"liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"locate-app/type-fest": ["type-fest@4.26.0", "", {}, "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw=="],
|
||||
|
||||
"log-update/ansi-escapes": ["ansi-escapes@3.2.0", "", {}, "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ=="],
|
||||
@@ -4708,8 +4697,6 @@
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
|
||||
|
||||
"@cloudflare/unenv-preset/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
@@ -4790,16 +4777,6 @@
|
||||
|
||||
"bknd-cli/@libsql/client/libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="],
|
||||
|
||||
"bknd/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
|
||||
|
||||
"bknd/wrangler/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="],
|
||||
|
||||
"bknd/wrangler/miniflare": ["miniflare@4.20250321.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250321.0", "ws": "8.18.0", "youch": "3.2.3", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-os+NJA7Eqi00BJHdVhzIa+3PMotnCtZg3hiUIRYcsZF5W7He8SK2EkV8csAb+npZq3jZ4SNpDebO01swM5dcWw=="],
|
||||
|
||||
"bknd/wrangler/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="],
|
||||
|
||||
"bknd/wrangler/workerd": ["workerd@1.20250321.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250321.0", "@cloudflare/workerd-darwin-arm64": "1.20250321.0", "@cloudflare/workerd-linux-64": "1.20250321.0", "@cloudflare/workerd-linux-arm64": "1.20250321.0", "@cloudflare/workerd-windows-64": "1.20250321.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vyuz9pdJ+7o1lC79vQ2UVRLXPARa2Lq94PbTfqEcYQeSxeR9X+YqhNq2yysv8Zs5vpokmexLCtMniPp9u+2LVQ=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"class-utils/define-property/is-descriptor": ["is-descriptor@0.1.7", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg=="],
|
||||
@@ -5304,68 +5281,6 @@
|
||||
|
||||
"bknd-cli/@libsql/client/libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
|
||||
|
||||
"bknd/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
|
||||
|
||||
"bknd/wrangler/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"bknd/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250321.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-y273GfLaNCxkL8hTfo0c8FZKkOPdq+CPZAKJXPWB+YpS1JCOULu6lNTptpD7ZtF14dTYPkn5Weug31TTlviJmw=="],
|
||||
|
||||
"bknd/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250321.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qvf7/gkkQq7fAsoMlntJSimN/WfwQqxi2oL0aWZMGodTvs/yRHO2I4oE0eOihVdK1BXyBHJXNxEvNDBjF0+Yuw=="],
|
||||
|
||||
"bknd/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250321.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AEp3xjWFrNPO/h0StCOgOb0bWCcNThnkESpy91Wf4mfUF2p7tOCdp37Nk/1QIRqSxnfv4Hgxyi7gcWud9cJuMw=="],
|
||||
|
||||
"bknd/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250321.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wRWyMIoPIS1UBXCisW0FYTgGsfZD4AVS0hXA5nuLc0c21CvzZpmmTjqEWMcwPFenwy/MNL61NautVOC4qJqQ3Q=="],
|
||||
|
||||
"bknd/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250321.0", "", { "os": "win32", "cpu": "x64" }, "sha512-8vYP3QYO0zo2faUDfWl88jjfUvz7Si9GS3mUYaTh/TR9LcAUtsO7muLxPamqEyoxNFtbQgy08R4rTid94KRi3w=="],
|
||||
|
||||
"eslint/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||
|
||||
"eslint/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
|
||||
# define bknd version to be used as:
|
||||
# `docker build --build-arg VERSION=<version> -t bknd .`
|
||||
ARG VERSION=0.10.2
|
||||
ARG VERSION=0.13.0
|
||||
|
||||
# Install & copy required cli
|
||||
RUN npm install --omit=dev bknd@${VERSION}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: bknd.config.ts
|
||||
---
|
||||
|
||||
The central configuration file to extend bknd should be placed in the root of your project, so that the [CLI](/usage/cli#using-configuration-file-bknd-config) can automatically pick it up. It allows to:
|
||||
* define your database connection centrally
|
||||
* pass in initial configuration or data seeds when booting the first time
|
||||
* add plugins to the app
|
||||
* hook into system events
|
||||
* define custom routes and endpoints
|
||||
|
||||
|
||||
A simple example of a `bknd.config.ts` file:
|
||||
|
||||
```typescript bknd.config.ts
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db",
|
||||
}
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The `BkndConfig` extends the [`CreateAppConfig`](/usage/introduction#configuration-createappconfig) type with the following properties:
|
||||
|
||||
```typescript
|
||||
export type BkndConfig = CreateAppConfig & {
|
||||
// return the app configuration as object or from a function
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
// called before the app is built
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
// called after the app has been built
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
// passed as the first argument to the `App.build` method
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
// force the app to be recreated
|
||||
force?: boolean;
|
||||
// the id of the app, defaults to `app`
|
||||
id?: string;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
The supported configuration file extensions are `js`, `ts`, `mjs`, `cjs` and `json`. Throughout the documentation, we'll use `ts` for the file extension.
|
||||
|
||||
### `app` (CreateAppConfig)
|
||||
The `app` property is a function that returns a `CreateAppConfig` object. It allows to pass in the environment variables to the configuration object.
|
||||
|
||||
```typescript
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
|
||||
export default {
|
||||
app: ({ env }) => ({
|
||||
connection: {
|
||||
url: env.DB_URL,
|
||||
}
|
||||
})
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
See [Database](/usage/database) for more information on how to configure the database connection.
|
||||
|
||||
### `beforeBuild`
|
||||
The `beforeBuild` property is an async function that is called before the app is built. It allows to modify the app instance that may influence the build process.
|
||||
|
||||
```typescript
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
|
||||
export default {
|
||||
beforeBuild: async (app: App) => {
|
||||
// do something that has to happen before the app is built
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `onBuilt`
|
||||
The `onBuilt` property is an async function that is called after the app has been built. It allows to hook into the app after it has been built. This is useful for defining event listeners or register custom routes, as both the event manager and the server are recreated during the build process.
|
||||
|
||||
```typescript
|
||||
import { type App, AppEvents } from "bknd";
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
|
||||
export default {
|
||||
onBuilt: async (app: App) => {
|
||||
console.log("App built", app.version());
|
||||
|
||||
// registering an event listener
|
||||
app.emgr.onEvent(AppEvents.AppRequest, (event) => {
|
||||
console.log("Request received", event.request.url);
|
||||
})
|
||||
|
||||
// registering a custom route
|
||||
app.server.get("/hello", (c) => c.text("Hello World"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `force` & `id`
|
||||
The `force` property is a boolean that forces the app to be recreated. This is mainly useful for serverless environments where the execution environment is re-used, and you may or may not want to recreate the app on every request.
|
||||
|
||||
The `id` property is the reference in a cache map. You may create multiple instances of apps in the same process by using different ids (e.g. multi tenant applications).
|
||||
|
||||
## Framework & Runtime configuration
|
||||
|
||||
Depending on which framework or runtime you're using to run bknd, the configuration object will extend the `BkndConfig` type with additional properties.
|
||||
|
||||
### `RuntimeBkndConfig`
|
||||
|
||||
[Runtime adapters](/integration/runtime) need additional configuration to serve static assets for the admin UI.
|
||||
|
||||
```typescript
|
||||
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||
// the path to the dist folder to serve static assets for the admin UI
|
||||
distPath?: string;
|
||||
// custom middleware to serve static assets for the admin UI
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
// options for the admin UI
|
||||
adminOptions?: AdminControllerOptions | false;
|
||||
};
|
||||
```
|
||||
|
||||
### `FrameworkBkndConfig`
|
||||
|
||||
[Framework adapters](/integration/framework) may need additional configuration based on the framework's requirements. For example, the `NextjsBkndConfig` type extends the `BkndConfig` type with the following additional properties:
|
||||
|
||||
```typescript
|
||||
type NextjsEnv = NextApiRequest["env"];
|
||||
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
cleanRequest?: { searchParams?: string[] };
|
||||
};
|
||||
```
|
||||
|
||||
Next.js adds the mounted path to the request object, so that the `cleanRequest` property can be used to remove the mounted path from the request URL. See other frameworks for more information on how to configure them.
|
||||
|
||||
|
||||
## Using the configuration file
|
||||
|
||||
The configuration file is automatically picked up if you're using the [CLI](/usage/cli). This allows interacting with your application using the `bknd` command. For example, you can run the following command in the root of your project to start an instance:
|
||||
|
||||
```bash
|
||||
npx bknd run
|
||||
```
|
||||
|
||||
When serving your application, you need to make sure to import the contents of your configuration file. If you're using Next.js for example, it's recommended to follow these steps:
|
||||
|
||||
1. create a `bknd.config.ts` file in the root of your project which defines the connection to the database, adds event listeners and custom routes.
|
||||
2. create a `bknd.ts` file inside your app folder which exports helper functions to instantiate the bknd instance and retrieve the API.
|
||||
3. create a catch-all route file at `src/api/[[...bknd]]/route.ts` which serves the bknd API.
|
||||
|
||||
This way, your application and the CLI are using the same configuration.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Events & Hooks
|
||||
---
|
||||
|
||||
bknd comes with a powerful built-in event system that allows you to hook into the app lifecycle and extend its functionality. You can hook into these events in two ways:
|
||||
|
||||
- `async`: Your listener is not blocking the main execution flow. E.g. on Cloudflare Workers, by default, the `ExecutionContext`'s `waitUntil` method is used so that the listeners runs after the response is sent.
|
||||
- `sync`: Your listener is blocking the main execution flow. This allows to abort the request in your custom conditions. Some events also allow to return a modified event payload.
|
||||
|
||||
<Note>
|
||||
Don't mistake `async` with JavaScript's `async` keyword. The `async` keyword is used to indicate that the listener is not blocking the main execution flow.
|
||||
</Note>
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
### Listening to events
|
||||
|
||||
You can listen to events by using the `EventManager` exposed at `app.emgr`. To register a listener, you can use either of these methods:
|
||||
|
||||
- `onEvent`: Register a listener for a specific event with a typed event.
|
||||
- `on`: Register a listener for an event by its slug.
|
||||
- `onAny`: Register a listener for all events.
|
||||
|
||||
```typescript
|
||||
import { createApp, AppEvents } from "bknd";
|
||||
const app = createApp();
|
||||
|
||||
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
||||
// ^? AppRequest
|
||||
console.log("Request received", event.request.url);
|
||||
});
|
||||
|
||||
app.emgr.on("app-request", async (event) => {
|
||||
console.log("Request received", event.request.url);
|
||||
});
|
||||
|
||||
app.emgr.onAny(async (event) => {
|
||||
console.log("Event received", event.slug);
|
||||
});
|
||||
```
|
||||
|
||||
You may want to register your listeners inside [`bknd.config.ts`](/extending/config) to make sure they are registered before the app is built:
|
||||
|
||||
```typescript bknd.config.ts
|
||||
import { AppEvents } from "bknd";
|
||||
|
||||
export default {
|
||||
onBuilt: (app) => {
|
||||
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
||||
console.log("Request received", event.request.url);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
### Setting a mode
|
||||
By default, listeners are registered as `async` listeners, meaning that the listener is not blocking the main execution flow. You can change this by passing the mode as the third argument:
|
||||
|
||||
```typescript
|
||||
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
||||
console.log("Request received", event.request.url);
|
||||
}, { mode: "sync" });
|
||||
```
|
||||
|
||||
This works for all three methods.
|
||||
|
||||
## App Events
|
||||
These events are emitted by the `App` class and are available on the `AppEvents` object.
|
||||
|
||||
```typescript
|
||||
import { AppEvents } from "bknd";
|
||||
```
|
||||
|
||||
Available events:
|
||||
|
||||
| Event | Params | Description |
|
||||
|:------|:--------|:------------|
|
||||
| `AppConfigUpdatedEvent` | `{ app: App }` | Emitted when the app configuration is updated |
|
||||
| `AppBuiltEvent` | `{ app: App }` | Emitted when the app is built |
|
||||
| `AppFirstBoot` | `{ app: App }` | Emitted when the app is first booted |
|
||||
| `AppRequest` | `{ app: App, request: Request }` | Emitted when a request is received |
|
||||
| `AppBeforeResponse` | `{ app: App, request: Request, response: Response }` | Emitted before a response is sent |
|
||||
|
||||
## Database Events
|
||||
These events are emitted by the `Database` class and are available on the `DatabaseEvents` object. These are divided by events triggered by the `Mutator` and `Repository` classes.
|
||||
|
||||
```typescript
|
||||
import { DatabaseEvents } from "bknd/data";
|
||||
```
|
||||
|
||||
### Mutator Events
|
||||
These events are emitted during database mutations (insert, update, delete operations).
|
||||
|
||||
| Event | Params | Description |
|
||||
|:------|:--------|:------------|
|
||||
| `MutatorInsertBefore` | `{ entity: Entity, data: EntityData }` | Emitted before inserting a new record. Can modify the data. |
|
||||
| `MutatorInsertAfter` | `{ entity: Entity, data: EntityData, changed: EntityData }` | Emitted after inserting a new record. |
|
||||
| `MutatorUpdateBefore` | `{ entity: Entity, entityId: PrimaryFieldType, data: EntityData }` | Emitted before updating a record. Can modify the data. |
|
||||
| `MutatorUpdateAfter` | `{ entity: Entity, entityId: PrimaryFieldType, data: EntityData, changed: EntityData }` | Emitted after updating a record. |
|
||||
| `MutatorDeleteBefore` | `{ entity: Entity, entityId: PrimaryFieldType }` | Emitted before deleting a record. |
|
||||
| `MutatorDeleteAfter` | `{ entity: Entity, entityId: PrimaryFieldType, data: EntityData }` | Emitted after deleting a record. |
|
||||
|
||||
### Repository Events
|
||||
These events are emitted during database queries (find operations).
|
||||
|
||||
| Event | Params | Description |
|
||||
|:------|:--------|:------------|
|
||||
| `RepositoryFindOneBefore` | `{ entity: Entity, options: RepoQuery }` | Emitted before finding a single record. |
|
||||
| `RepositoryFindOneAfter` | `{ entity: Entity, options: RepoQuery, data: EntityData }` | Emitted after finding a single record. |
|
||||
| `RepositoryFindManyBefore` | `{ entity: Entity, options: RepoQuery }` | Emitted before finding multiple records. |
|
||||
| `RepositoryFindManyAfter` | `{ entity: Entity, options: RepoQuery, data: EntityData }` | Emitted after finding multiple records. |
|
||||
|
||||
## Media Events
|
||||
These events are emitted by the `Storage` class and are available on the `MediaEvents` object.
|
||||
|
||||
```typescript
|
||||
import { MediaEvents } from "bknd/media";
|
||||
```
|
||||
|
||||
| Event | Params | Description |
|
||||
|:------|:--------|:------------|
|
||||
| `FileUploadedEvent` | `{ file: FileBody } & FileUploadPayload` | Emitted when a file is successfully uploaded. Can modify the event data. |
|
||||
| `FileDeletedEvent` | `{ name: string }` | Emitted when a file is deleted. |
|
||||
| `FileAccessEvent` | `{ name: string }` | Emitted when a file is accessed. |
|
||||
|
||||
## Auth Events
|
||||
Coming soon.
|
||||
@@ -28,14 +28,14 @@ To get started with Astro and bknd you can either install the package manually,
|
||||
npx astro add react
|
||||
```
|
||||
|
||||
You also need to make sure to set the output to `hybrid` in your Astro config:
|
||||
You also need to make sure to set the output to `server` in your Astro config:
|
||||
```js {6}
|
||||
// astro.config.mjs
|
||||
import { defineConfig } from "astro/config";
|
||||
import react from "@astrojs/react";
|
||||
|
||||
export default defineConfig({
|
||||
output: "hybrid",
|
||||
output: "server",
|
||||
integrations: [react()]
|
||||
});
|
||||
```
|
||||
@@ -48,8 +48,58 @@ To get started with Astro and bknd you can either install the package manually,
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Configuration
|
||||
Now create a `bknd.config.ts` file in the root of your project. If you created the project using the CLI starter, this file is already created for you.
|
||||
|
||||
```typescript bknd.config.ts
|
||||
import type { AstroBkndConfig } from "bknd/adapter/astro";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db"
|
||||
},
|
||||
} satisfies AstroBkndConfig;
|
||||
```
|
||||
|
||||
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `AstroBkndConfig` type extends the `BkndConfig` type with the following additional properties:
|
||||
|
||||
```typescript
|
||||
type AstroEnv = NodeJS.ProcessEnv;
|
||||
export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
||||
```
|
||||
|
||||
## Serve the API
|
||||
Create a new catch-all route at `src/pages/api/[...api].ts`:
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||
|
||||
```ts src/bknd.ts
|
||||
import type { AstroGlobal } from "astro";
|
||||
import { getApp as getBkndApp } from "bknd/adapter/astro";
|
||||
import config from "../bknd.config";
|
||||
|
||||
export { config };
|
||||
|
||||
export async function getApp() {
|
||||
return await getBkndApp(config);
|
||||
}
|
||||
|
||||
export async function getApi(
|
||||
astro: AstroGlobal,
|
||||
opts?: { mode: "static" } | { mode?: "dynamic"; verify?: boolean },
|
||||
) {
|
||||
const app = await getApp();
|
||||
if (opts?.mode !== "static" && opts?.verify) {
|
||||
const api = app.getApi({ headers: astro.request.headers });
|
||||
await api.verifyAuth();
|
||||
return api;
|
||||
}
|
||||
|
||||
return app.getApi();
|
||||
}
|
||||
```
|
||||
|
||||
Create a new catch-all route at `src/pages/api/[...api].ts`.
|
||||
|
||||
```ts src/pages/api/[...api].ts
|
||||
import { serve } from "bknd/adapter/astro";
|
||||
|
||||
@@ -63,6 +113,7 @@ export const ALL = serve({
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
For more information about the connection object, refer to the [Database](/usage/database) guide. In the
|
||||
special case of astro, you may also use your Astro DB credentials since it's also using LibSQL
|
||||
under the hood. Refer to the [Astro DB documentation](https://docs.astro.build/en/guides/astro-db/) for more information.
|
||||
|
||||
@@ -22,21 +22,42 @@ To get started with Next.js and bknd you can either install the package manually
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
Now create a `bknd.config.ts` file in the root of your project. If you created the project using the CLI starter, this file is already created for you.
|
||||
|
||||
|
||||
|
||||
```typescript bknd.config.ts
|
||||
import type { NextjsBkndConfig } from "bknd/adapter/nextjs";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db"
|
||||
},
|
||||
} satisfies NextjsBkndConfig;
|
||||
```
|
||||
|
||||
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `NextjsBkndConfig` type extends the `BkndConfig` type with the following additional properties:
|
||||
|
||||
```typescript
|
||||
type NextjsEnv = NextApiRequest["env"];
|
||||
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
cleanRequest?: { searchParams?: string[] };
|
||||
};
|
||||
```
|
||||
|
||||
## Serve the API
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API:
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||
|
||||
```ts src/bknd.ts
|
||||
import { type NextjsBkndConfig, getApp as getBkndApp } from "bknd/adapter/nextjs";
|
||||
import { headers } from "next/headers";
|
||||
import config from "../bknd.config";
|
||||
|
||||
export const config = {
|
||||
connection: {
|
||||
url: "file:data.db"
|
||||
},
|
||||
} as const satisfies NextjsBkndConfig;
|
||||
export { config };
|
||||
|
||||
export async function getApp() {
|
||||
return await getBkndApp(config);
|
||||
return await getBkndApp(config, process.env);
|
||||
}
|
||||
|
||||
export async function getApi(opts?: { verify?: boolean }) {
|
||||
|
||||
@@ -22,21 +22,44 @@ To get started with React Router and bknd you can either install the package man
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Configuration
|
||||
Now create a `bknd.config.ts` file in the root of your project. If you created the project using the CLI starter, this file is already created for you.
|
||||
|
||||
```typescript bknd.config.ts
|
||||
import type { ReactRouterBkndConfig } from "bknd/adapter/react-router";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db"
|
||||
},
|
||||
} satisfies ReactRouterBkndConfig;
|
||||
```
|
||||
|
||||
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `ReactRouterBkndConfig` type extends the `BkndConfig` type with the following additional properties:
|
||||
|
||||
```typescript
|
||||
type ReactRouterEnv = NodeJS.ProcessEnv;
|
||||
type ReactRouterFunctionArgs = {
|
||||
request: Request;
|
||||
};
|
||||
export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<Env>;
|
||||
```
|
||||
|
||||
|
||||
## Serve the API
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API:
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||
|
||||
```ts app/bknd.ts
|
||||
import { type ReactRouterBkndConfig, getApp as getBkndApp } from "bknd/adapter/react-router";
|
||||
import config from "../bknd.config";
|
||||
|
||||
const config = {
|
||||
connection: {
|
||||
url: "file:data.db"
|
||||
}
|
||||
} as const satisfies ReactRouterBkndConfig;
|
||||
export { config };
|
||||
|
||||
export async function getApp(args?: { request: Request }) {
|
||||
return await getBkndApp(config, args);
|
||||
// you may adjust this function depending on your runtime environment.
|
||||
// e.g. when deploying to cloudflare workers, you'd want the FunctionArgs to be passed in
|
||||
// to resolve environment variables
|
||||
export async function getApp() {
|
||||
return await getBkndApp(config, process.env as any);
|
||||
}
|
||||
|
||||
export async function getApi(
|
||||
@@ -60,7 +83,7 @@ Create a new api splat route file at `app/routes/api.$.ts`:
|
||||
import { getApp } from "~/bknd";
|
||||
|
||||
const handler = async (args: { request: Request }) => {
|
||||
const app = await getApp(args);
|
||||
const app = await getApp();
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
|
||||
+46
-19
@@ -16,25 +16,42 @@ Additionally, install required dependencies:
|
||||
npm install @hono/vite-dev-server
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Now create a `bknd.config.ts` file in the root of your project.
|
||||
|
||||
```typescript bknd.config.ts
|
||||
import type { ViteBkndConfig } from "bknd/adapter/vite";
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db"
|
||||
}
|
||||
} satisfies ViteBkndConfig;
|
||||
```
|
||||
|
||||
See [bknd.config.ts](/extending/config) for more information on how to configure bknd.
|
||||
The `ViteBkndConfig` type extends the `BkndConfig` type with the following additional properties:
|
||||
|
||||
```typescript
|
||||
export type ViteEnv = NodeJS.ProcessEnv;
|
||||
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
|
||||
```
|
||||
|
||||
## Serve the API
|
||||
To serve the **bknd** API, you first have to create a local server file for you vite environment.
|
||||
Create a `server.ts` file:
|
||||
```ts
|
||||
|
||||
```typescript
|
||||
import { serve } from "bknd/adapter/vite";
|
||||
import config from "./bknd.config";
|
||||
|
||||
// the configuration given is optional
|
||||
export default serve({
|
||||
mode: "cached", // that's the default
|
||||
connection: {
|
||||
url: ":memory:"
|
||||
}
|
||||
})
|
||||
export default serve(config)
|
||||
```
|
||||
For more information about the connection object, refer to the [Database](/usage/database) guide.
|
||||
|
||||
You can also run your vite server in `mode: "fresh"`, this will re-create the app on every fetch.
|
||||
This is only useful for when working on the `bknd` repository directly.
|
||||
|
||||
For more information about the connection object, refer to the [Database](/usage/database) guide.
|
||||
|
||||
Next, adjust your `vite.config.ts` to look like the following:
|
||||
```ts
|
||||
import { devServer } from "bknd/adapter/vite";
|
||||
@@ -82,17 +99,22 @@ options you can make use of to adjust it according to your setup.
|
||||
There might be cases you want to be sure to be in control over the HTML that is being used.
|
||||
`bknd` generates it automatically, but you use your own one as follows:
|
||||
|
||||
```ts server.ts
|
||||
```typescript server.ts
|
||||
import { serve, addViteScript } from "bknd/adapter/vite";
|
||||
import { readFile } from "node:fs/promises"
|
||||
import config from "./bknd.config";
|
||||
|
||||
let html = await readFile("./index.html", "utf-8");
|
||||
|
||||
// add vite scripts
|
||||
html = addViteScript(html);
|
||||
|
||||
// then add it as an option
|
||||
export default serve({ html })
|
||||
export default serve({
|
||||
...config,
|
||||
adminOptions: {
|
||||
html: addViteScript(html),
|
||||
// optionally, you can change the base path for the admin UI
|
||||
adminBasePath: "/admin"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
The vite scripts has to be added manually currently, as adding them automatically with
|
||||
@@ -101,13 +123,18 @@ The vite scripts has to be added manually currently, as adding them automaticall
|
||||
### Use a custom entry point
|
||||
By default, the entry point `/src/main.tsx` is used and should fit most cases. If that's not you,
|
||||
you can supply a different one like so:
|
||||
```ts server.ts
|
||||
|
||||
```typescript server.ts
|
||||
import { serve } from "bknd/adapter/vite";
|
||||
import config from "./bknd.config";
|
||||
|
||||
// the configuration given is optional
|
||||
export default serve({
|
||||
forceDev: {
|
||||
mainPath: "/src/special.tsx"
|
||||
}
|
||||
...config,
|
||||
adminOptions: {
|
||||
forceDev: {
|
||||
mainPath: "/src/special.tsx"
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -74,6 +74,10 @@
|
||||
"usage/elements"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Extending",
|
||||
"pages": ["extending/config", "extending/events"]
|
||||
},
|
||||
{
|
||||
"group": "Integration",
|
||||
"pages": [
|
||||
|
||||
+76
-8
@@ -14,20 +14,21 @@ Here is the output:
|
||||
$ npx bknd
|
||||
Usage: bknd [options] [command]
|
||||
|
||||
⚡ bknd cli v0.10.3-rc.1
|
||||
⚡ bknd cli v0.13.0
|
||||
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
-h, --help display help for command
|
||||
|
||||
Commands:
|
||||
config [options] get default config
|
||||
copy-assets [options] copy static assets
|
||||
create [options] create a new project
|
||||
debug <subject> debug bknd
|
||||
run [options] run an instance
|
||||
user <action> create/update users, or generate a token (auth)
|
||||
types [options] generate types
|
||||
schema [options] get schema
|
||||
user <action> create and update user (auth)
|
||||
run [options] run an instance
|
||||
debug <subject> debug bknd
|
||||
create [options] create a new project
|
||||
copy-assets [options] copy static assets
|
||||
config [options] get default config
|
||||
help [command] display help for command
|
||||
```
|
||||
|
||||
@@ -123,4 +124,71 @@ To start an instance with an ephemeral in-memory database, run the following:
|
||||
```
|
||||
npx bknd run --memory
|
||||
```
|
||||
Keep in mind that the database is not persisted and will be lost when the process is terminated.
|
||||
Keep in mind that the database is not persisted and will be lost when the process is terminated.
|
||||
|
||||
## Generating types (`types`)
|
||||
To see all available `types` options, execute `npx bknd types --help`.
|
||||
|
||||
```
|
||||
$ npx bknd types --help
|
||||
Usage: bknd types [options]
|
||||
|
||||
generate types
|
||||
|
||||
Options:
|
||||
-o, --outfile <outfile> output file (default: "bknd-types.d.ts")
|
||||
--no-write do not write to file
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
To generate types for the database, run the following:
|
||||
```
|
||||
npx bknd types
|
||||
```
|
||||
|
||||
This will generate types for your database schema in `bknd-types.d.ts`. The generated file could look like this:
|
||||
|
||||
```typescript bknd-types.d.ts
|
||||
import type { DB } from "bknd/core";
|
||||
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
|
||||
|
||||
declare global {
|
||||
type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;
|
||||
type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;
|
||||
type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;
|
||||
}
|
||||
|
||||
export interface Todos {
|
||||
id: Generated<number>;
|
||||
title?: string;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
interface Database {
|
||||
todos: Todos;
|
||||
}
|
||||
|
||||
declare module "bknd/core" {
|
||||
interface DB extends Database {}
|
||||
}
|
||||
```
|
||||
|
||||
Make sure to add the generated file in your `tsconfig.json` file:
|
||||
|
||||
```json tsconfig.json
|
||||
{
|
||||
"include": [
|
||||
"bknd-types.d.ts"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You can then use the types by importing them from `bknd/core`:
|
||||
|
||||
```typescript
|
||||
import type { DB } from "bknd/core";
|
||||
|
||||
type Todo = DB["todos"];
|
||||
```
|
||||
|
||||
All bknd methods that involve your database schema will be automatically typed.
|
||||
@@ -18,7 +18,7 @@ There is also a fourth option, which is running it inside a
|
||||
Regardless of the method you choose, at the end all adapters come down to the actual
|
||||
instantiation of the `App`, which in raw looks like this:
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import { createApp, type CreateAppConfig } from "bknd";
|
||||
|
||||
// create the app
|
||||
@@ -38,7 +38,7 @@ implements the `Fetch` API.
|
||||
## Configuration (`CreateAppConfig`)
|
||||
The `CreateAppConfig` type is the main configuration object for the `createApp` function. It has
|
||||
the following properties:
|
||||
```ts
|
||||
```typescript
|
||||
import type { App, InitialModuleConfigs, ModuleBuildContext } from "bknd";
|
||||
import type { Connection } from "bknd/data";
|
||||
import type { Config } from "@libsql/client";
|
||||
|
||||
Reference in New Issue
Block a user