mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 906f80b11e | |||
| 7a3691a1a7 | |||
| 12c955155d | |||
| 15af9f5261 | |||
| 39efb911e9 | |||
| 4729203d47 | |||
| 0db052acca | |||
| 6e08f45857 | |||
| 7bff84d601 | |||
| e66e05b2b0 | |||
| 5b318ce485 | |||
| 22de16fe17 | |||
| 5d26673bc6 | |||
| dfa4bc0e33 | |||
| e51b89a18a | |||
| 63988e0c5f | |||
| 297fd85a4f | |||
| a82fbe7400 | |||
| 74e1e9a03f | |||
| 05a81b5bdc | |||
| 9ff49103fb | |||
| a5b59c004e | |||
| a0edcf483b | |||
| 9a18e354cd | |||
| 58c7aba1a4 | |||
| f8aa242d2b | |||
| 3da9570abe | |||
| 6ee898e606 | |||
| abbd372ddf | |||
| 3e77982996 | |||
| 3fbea8ace7 | |||
| a41f943b43 | |||
| 15a9c549e7 | |||
| 50cadbaa8e | |||
| 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
|
||||
@@ -1,5 +1,4 @@
|
||||
[](https://npmjs.org/package/bknd)
|
||||
[](https://www.npmjs.com/package/bknd)
|
||||
|
||||

|
||||
|
||||
@@ -18,14 +17,14 @@ bknd simplifies app development by providing a fully functional backend for data
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
## Size
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
|
||||
|
||||
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
|
||||
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker).
|
||||
|
||||
## Motivation
|
||||
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -110,4 +110,18 @@ describe("some tests", async () => {
|
||||
new EntityManager([entity, entity2], connection);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("primary uuid", async () => {
|
||||
const entity = new Entity("users", [
|
||||
new PrimaryField("id", { format: "uuid" }),
|
||||
new TextField("username"),
|
||||
]);
|
||||
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
const mutator = em.mutator(entity);
|
||||
const data = await mutator.insertOne({ username: "test" });
|
||||
expect(data.data.id).toBeDefined();
|
||||
expect(data.data.id).toBeString();
|
||||
});
|
||||
});
|
||||
|
||||
+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" } });
|
||||
});
|
||||
});
|
||||
@@ -39,4 +39,28 @@ describe("[data] PrimaryField", async () => {
|
||||
expect(field.transformPersist(1)).rejects.toThrow();
|
||||
expect(field.transformRetrieve(1)).toBe(1);
|
||||
});
|
||||
|
||||
test("format", () => {
|
||||
const uuid = new PrimaryField("uuid", { format: "uuid" });
|
||||
expect(uuid.format).toBe("uuid");
|
||||
expect(uuid.fieldType).toBe("text");
|
||||
expect(uuid.getNewValue()).toBeString();
|
||||
expect(uuid.toType()).toEqual({
|
||||
required: true,
|
||||
comment: undefined,
|
||||
type: "Generated<string>",
|
||||
import: [{ package: "kysely", name: "Generated" }],
|
||||
});
|
||||
|
||||
const integer = new PrimaryField("integer", { format: "integer" });
|
||||
expect(integer.format).toBe("integer");
|
||||
expect(integer.fieldType).toBe("integer");
|
||||
expect(integer.getNewValue()).toBeUndefined();
|
||||
expect(integer.toType()).toEqual({
|
||||
required: true,
|
||||
comment: undefined,
|
||||
type: "Generated<number>",
|
||||
import: [{ package: "kysely", name: "Generated" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -153,6 +153,7 @@ describe("AppAuth", () => {
|
||||
});
|
||||
|
||||
await app.build();
|
||||
app.registerAdminController();
|
||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||
|
||||
// register custom route
|
||||
@@ -162,6 +163,10 @@ describe("AppAuth", () => {
|
||||
await app.server.request("/api/system/ping");
|
||||
await app.server.request("/test");
|
||||
|
||||
expect(spy.mock.calls.length).toBe(0);
|
||||
|
||||
// admin route
|
||||
await app.server.request("/");
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+15
-11
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0-rc.2",
|
||||
"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",
|
||||
@@ -59,24 +61,24 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"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-to-ts": "^3.1.1",
|
||||
"kysely": "^0.27.6",
|
||||
"hono": "^4.7.11",
|
||||
"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"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@bluwy/giget-core": "^0.1.2",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hono/typebox-validator": "^0.3.2",
|
||||
"@hono/vite-dev-server": "^0.19.0",
|
||||
"@hono/typebox-validator": "^0.3.3",
|
||||
"@hono/vite-dev-server": "^0.19.1",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@libsql/kysely-libsql": "^0.4.1",
|
||||
"@mantine/modals": "^7.17.1",
|
||||
@@ -98,14 +100,15 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"jotai": "^2.12.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsonv-ts": "^0.1.0",
|
||||
"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",
|
||||
@@ -118,13 +121,14 @@
|
||||
"tsc-alias": "^1.8.11",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"vite": "^6.2.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.9",
|
||||
"wouter": "^3.6.0"
|
||||
"wouter": "^3.6.0",
|
||||
"@cloudflare/workers-types": "^4.20250606.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.13.8"
|
||||
"@hono/node-server": "^1.14.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -151,7 +151,7 @@ export class App {
|
||||
}
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
return this.server.fetch;
|
||||
return this.server.fetch as any;
|
||||
}
|
||||
|
||||
get module() {
|
||||
@@ -253,6 +253,11 @@ export class App {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// call server init if set
|
||||
if (this.options?.manager?.onServerInit) {
|
||||
this.options.manager.onServerInit(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { makeApp } from "./modes/fresh";
|
||||
import { makeConfig } from "./config";
|
||||
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
@@ -23,7 +23,7 @@ describe("cf adapter", () => {
|
||||
{
|
||||
connection: { url: DB_URL },
|
||||
},
|
||||
{},
|
||||
$ctx({ DB_URL }),
|
||||
),
|
||||
).toEqual({ connection: { url: DB_URL } });
|
||||
|
||||
@@ -34,15 +34,15 @@ describe("cf adapter", () => {
|
||||
connection: { url: env.DB_URL },
|
||||
}),
|
||||
},
|
||||
{
|
||||
DB_URL,
|
||||
},
|
||||
$ctx({ DB_URL }),
|
||||
),
|
||||
).toEqual({ connection: { url: DB_URL } });
|
||||
});
|
||||
|
||||
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
|
||||
makeApp,
|
||||
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
||||
makeApp: async (c, a, o) => {
|
||||
return await makeApp(c, { env: a } as any, o);
|
||||
},
|
||||
makeHandler: (c, a, o) => {
|
||||
return async (request: any) => {
|
||||
const app = await makeApp(
|
||||
@@ -50,7 +50,7 @@ describe("cf adapter", () => {
|
||||
c ?? {
|
||||
connection: { url: DB_URL },
|
||||
},
|
||||
a,
|
||||
a!,
|
||||
o,
|
||||
);
|
||||
return app.fetch(request);
|
||||
|
||||
@@ -9,7 +9,13 @@ import { getDurable } from "./modes/durable";
|
||||
import type { App } from "bknd";
|
||||
import { $console } from "core";
|
||||
|
||||
export type CloudflareEnv = object;
|
||||
declare global {
|
||||
namespace Cloudflare {
|
||||
interface Env {}
|
||||
}
|
||||
}
|
||||
|
||||
export type CloudflareEnv = Cloudflare.Env;
|
||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (args: Env) => {
|
||||
@@ -17,6 +23,11 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
||||
dobj?: DurableObjectNamespace;
|
||||
db?: D1Database;
|
||||
};
|
||||
d1?: {
|
||||
session?: boolean;
|
||||
transport?: "header" | "cookie";
|
||||
first?: D1SessionConstraint;
|
||||
};
|
||||
static?: "kv" | "assets";
|
||||
key?: string;
|
||||
keepAliveSeconds?: number;
|
||||
|
||||
@@ -1,47 +1,148 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||
import { getBinding } from "./bindings";
|
||||
import { D1Connection } from "./D1Connection";
|
||||
import { D1Connection } from "./connection/D1Connection";
|
||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||
import { App } from "bknd";
|
||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||
import type { ExecutionContext } from "hono";
|
||||
import type { Context, ExecutionContext } from "hono";
|
||||
import { $console } from "core";
|
||||
import { setCookie } from "hono/cookie";
|
||||
|
||||
export const constants = {
|
||||
exec_async_event_id: "cf_register_waituntil",
|
||||
cache_endpoint: "/__bknd/cache",
|
||||
do_endpoint: "/__bknd/do",
|
||||
d1_session: {
|
||||
cookie: "cf_d1_session",
|
||||
header: "x-cf-d1-session",
|
||||
},
|
||||
};
|
||||
|
||||
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
|
||||
env: Env;
|
||||
ctx?: ExecutionContext;
|
||||
request?: Request;
|
||||
};
|
||||
|
||||
function getCookieValue(cookies: string | null, name: string) {
|
||||
if (!cookies) return null;
|
||||
|
||||
for (const cookie of cookies.split("; ")) {
|
||||
const [key, value] = cookie.split("=");
|
||||
if (key === name && value) {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
||||
const headerKey = constants.d1_session.header;
|
||||
const cookieKey = constants.d1_session.cookie;
|
||||
const transport = config.d1?.transport;
|
||||
|
||||
return {
|
||||
get: (request?: Request): D1SessionBookmark | undefined => {
|
||||
if (!request || !config.d1?.session) return undefined;
|
||||
|
||||
if (!transport || transport === "cookie") {
|
||||
const cookies = request.headers.get("Cookie");
|
||||
if (cookies) {
|
||||
const cookie = getCookieValue(cookies, cookieKey);
|
||||
if (cookie) {
|
||||
return cookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!transport || transport === "header") {
|
||||
if (request.headers.has(headerKey)) {
|
||||
return request.headers.get(headerKey) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
set: (c: Context, d1?: D1DatabaseSession) => {
|
||||
if (!d1 || !config.d1?.session) return;
|
||||
|
||||
const session = d1.getBookmark();
|
||||
if (session) {
|
||||
if (!transport || transport === "header") {
|
||||
c.header(headerKey, session);
|
||||
}
|
||||
if (!transport || transport === "cookie") {
|
||||
setCookie(c, cookieKey, session, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let media_registered: boolean = false;
|
||||
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
) {
|
||||
if (!media_registered) {
|
||||
registerMedia(args as any);
|
||||
media_registered = true;
|
||||
}
|
||||
|
||||
const appConfig = makeAdapterConfig(config, args);
|
||||
const bindings = config.bindings?.(args);
|
||||
if (!appConfig.connection) {
|
||||
let db: D1Database | undefined;
|
||||
if (bindings?.db) {
|
||||
$console.log("Using database from bindings");
|
||||
db = bindings.db;
|
||||
} else if (Object.keys(args).length > 0) {
|
||||
const binding = getBinding(args, "D1Database");
|
||||
if (binding) {
|
||||
$console.log(`Using database from env "${binding.key}"`);
|
||||
db = binding.value;
|
||||
const appConfig = makeAdapterConfig(config, args?.env);
|
||||
|
||||
if (args?.env) {
|
||||
const bindings = config.bindings?.(args?.env);
|
||||
|
||||
const sessionHelper = d1SessionHelper(config);
|
||||
const sessionId = sessionHelper.get(args.request);
|
||||
let session: D1DatabaseSession | undefined;
|
||||
|
||||
if (!appConfig.connection) {
|
||||
let db: D1Database | undefined;
|
||||
if (bindings?.db) {
|
||||
$console.log("Using database from bindings");
|
||||
db = bindings.db;
|
||||
} else if (Object.keys(args).length > 0) {
|
||||
const binding = getBinding(args.env, "D1Database");
|
||||
if (binding) {
|
||||
$console.log(`Using database from env "${binding.key}"`);
|
||||
db = binding.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (db) {
|
||||
if (config.d1?.session) {
|
||||
session = db.withSession(sessionId ?? config.d1?.first);
|
||||
appConfig.connection = new D1Connection({ binding: session });
|
||||
} else {
|
||||
appConfig.connection = new D1Connection({ binding: db });
|
||||
}
|
||||
} else {
|
||||
throw new Error("No database connection given");
|
||||
}
|
||||
}
|
||||
|
||||
if (db) {
|
||||
appConfig.connection = new D1Connection({ binding: db });
|
||||
} else {
|
||||
throw new Error("No database connection given");
|
||||
if (config.d1?.session) {
|
||||
appConfig.options = {
|
||||
...appConfig.options,
|
||||
manager: {
|
||||
...appConfig.options?.manager,
|
||||
onServerInit: (server) => {
|
||||
server.use(async (c, next) => {
|
||||
sessionHelper.set(c, session);
|
||||
await next();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -5,8 +5,8 @@ import type { QB } from "data/connection/Connection";
|
||||
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||
import { D1Dialect } from "kysely-d1";
|
||||
|
||||
export type D1ConnectionConfig = {
|
||||
binding: D1Database;
|
||||
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
|
||||
binding: DB;
|
||||
};
|
||||
|
||||
class CustomD1Dialect extends D1Dialect {
|
||||
@@ -17,22 +17,25 @@ class CustomD1Dialect extends D1Dialect {
|
||||
}
|
||||
}
|
||||
|
||||
export class D1Connection extends SqliteConnection {
|
||||
export class D1Connection<
|
||||
DB extends D1Database | D1DatabaseSession = D1Database,
|
||||
> extends SqliteConnection {
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
counts: false,
|
||||
};
|
||||
|
||||
constructor(private config: D1ConnectionConfig) {
|
||||
constructor(private config: D1ConnectionConfig<DB>) {
|
||||
const plugins = [new ParseJSONResultsPlugin()];
|
||||
|
||||
const kysely = new Kysely({
|
||||
dialect: new CustomD1Dialect({ database: config.binding }),
|
||||
dialect: new CustomD1Dialect({ database: config.binding as D1Database }),
|
||||
plugins,
|
||||
});
|
||||
super(kysely, {}, plugins);
|
||||
}
|
||||
|
||||
get client(): D1Database {
|
||||
get client(): DB {
|
||||
return this.config.binding;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
|
||||
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh } from "./modes/fresh";
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
type GetBindingType,
|
||||
type BindingMap,
|
||||
} from "./bindings";
|
||||
export { constants } from "./config";
|
||||
|
||||
export function d1(config: D1ConnectionConfig) {
|
||||
return new D1Connection(config);
|
||||
|
||||
@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
|
||||
|
||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
{ env, ctx, ...args }: Context<Env>,
|
||||
args: Context<Env>,
|
||||
) {
|
||||
const { env, ctx } = args;
|
||||
const { kv } = config.bindings?.(env)!;
|
||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
@@ -20,7 +21,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
|
||||
const app = await createRuntimeApp(
|
||||
{
|
||||
...makeConfig(config, env),
|
||||
...makeConfig(config, args),
|
||||
initialConfig,
|
||||
onBuilt: async (app) => {
|
||||
registerAsyncsExecutionContext(app, ctx);
|
||||
@@ -41,7 +42,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
await config.beforeBuild?.(app);
|
||||
},
|
||||
},
|
||||
{ env, ctx, ...args },
|
||||
args,
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||
import { makeConfig, registerAsyncsExecutionContext } from "../config";
|
||||
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
|
||||
|
||||
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
|
||||
}
|
||||
|
||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
},
|
||||
ctx.env,
|
||||
ctx,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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);
|
||||
await this.auth.authenticator?.requestCookieRefresh(c);
|
||||
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 +175,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
async logout(c: Context<ServerEnv>) {
|
||||
$console.info("Logging out");
|
||||
c.set("auth", undefined);
|
||||
|
||||
const cookie = await this.getAuthCookie(c);
|
||||
|
||||
@@ -60,11 +60,7 @@ export const auth = (options?: {
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
if (!skipped) {
|
||||
// renew cookie if applicable
|
||||
authenticator?.requestCookieRefresh(c);
|
||||
}
|
||||
// @todo: potentially add cookie refresh if content-type html and about to expire
|
||||
|
||||
// release
|
||||
authCtx.skip = false;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
|
||||
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
|
||||
|
||||
export interface AppEntity<IdType extends number = number> {
|
||||
export interface AppEntity<IdType = number | string> {
|
||||
id: PrimaryFieldType<IdType>;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ function hasColors() {
|
||||
}
|
||||
|
||||
const __consoles = {
|
||||
critical: {
|
||||
prefix: "CRT",
|
||||
color: colors.red,
|
||||
args_color: colors.red,
|
||||
original: console.error,
|
||||
},
|
||||
error: {
|
||||
prefix: "ERR",
|
||||
color: colors.red,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
export 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";
|
||||
@@ -117,7 +117,9 @@ async function detectMimeType(
|
||||
return;
|
||||
}
|
||||
|
||||
export async function getFileFromContext(c: Context<any>): Promise<File> {
|
||||
type HonoAnyContext = Context<any, any, any>;
|
||||
|
||||
export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
|
||||
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
if (
|
||||
@@ -149,7 +151,7 @@ export async function getFileFromContext(c: Context<any>): Promise<File> {
|
||||
throw new Error("No file found in request");
|
||||
}
|
||||
|
||||
export async function getBodyFromContext(c: Context<any>): Promise<ReadableStream | File> {
|
||||
export async function getBodyFromContext(c: HonoAnyContext): Promise<ReadableStream | File> {
|
||||
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
if (
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
$console.setLevel("error");
|
||||
$console.setLevel("critical");
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { v4, v7 } from "uuid";
|
||||
|
||||
// generates v4
|
||||
export function uuid(): string {
|
||||
return crypto.randomUUID();
|
||||
return v4();
|
||||
}
|
||||
|
||||
export function uuidv7(): string {
|
||||
return v7();
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+222
-109
@@ -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,9 @@ 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);
|
||||
// @todo: make dynamic based on entity
|
||||
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any });
|
||||
|
||||
/**
|
||||
* Function endpoints
|
||||
@@ -205,14 +243,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 +265,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,18 +287,36 @@ 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);
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
const result = await this.em.repo(entity).findMany(options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -259,22 +325,27 @@ 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: idType,
|
||||
}),
|
||||
),
|
||||
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);
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findId(Number(id), options);
|
||||
const result = await this.em.repository(entity).findId(id, options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -283,18 +354,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: idType,
|
||||
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);
|
||||
}
|
||||
@@ -302,24 +378,40 @@ export class DataController extends Controller {
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em
|
||||
.repository(entity)
|
||||
.findManyByReference(Number(id), reference, options);
|
||||
.findManyByReference(id, reference, options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
},
|
||||
);
|
||||
|
||||
// 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 +424,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 +451,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,15 +482,20 @@ 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: idType })),
|
||||
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);
|
||||
}
|
||||
const body = (await c.req.json()) as EntityData;
|
||||
const result = await this.em.mutator(entity).updateOne(Number(id), body);
|
||||
const result = await this.em.mutator(entity).updateOne(id, body);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
},
|
||||
@@ -399,14 +504,18 @@ 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: idType })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const result = await this.em.mutator(entity).deleteOne(Number(id));
|
||||
const result = await this.em.mutator(entity).deleteOne(id);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
},
|
||||
@@ -415,15 +524,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));
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
type AliasableExpression,
|
||||
type ColumnBuilderCallback,
|
||||
type ColumnDataType,
|
||||
type DatabaseIntrospector,
|
||||
type Dialect,
|
||||
type Expression,
|
||||
type Kysely,
|
||||
type KyselyPlugin,
|
||||
@@ -12,7 +14,8 @@ import {
|
||||
type Simplify,
|
||||
sql,
|
||||
} from "kysely";
|
||||
import type { BaseIntrospector } from "./BaseIntrospector";
|
||||
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
||||
import type { Constructor } from "core";
|
||||
|
||||
export type QB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
@@ -79,6 +82,7 @@ export abstract class Connection<DB = any> {
|
||||
kysely: Kysely<DB>;
|
||||
protected readonly supported = {
|
||||
batching: false,
|
||||
counts: true,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -159,3 +163,19 @@ export abstract class Connection<DB = any> {
|
||||
// no-op by default
|
||||
}
|
||||
}
|
||||
|
||||
export function customIntrospector<T extends Constructor<Dialect>>(
|
||||
dialect: T,
|
||||
introspector: Constructor<DatabaseIntrospector>,
|
||||
options: BaseIntrospectorConfig = {},
|
||||
) {
|
||||
return {
|
||||
create(...args: ConstructorParameters<T>) {
|
||||
return new (class extends dialect {
|
||||
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new introspector(db, options);
|
||||
}
|
||||
})(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Connection, type FieldSpec, type SchemaResponse } from "./Connection";
|
||||
export class DummyConnection extends Connection {
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
counts: true,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
type IndexSpec,
|
||||
type DbFunctions,
|
||||
type SchemaResponse,
|
||||
customIntrospector,
|
||||
} from "./Connection";
|
||||
|
||||
// sqlite
|
||||
|
||||
@@ -28,13 +28,13 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
private client: Client;
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
counts: false,
|
||||
};
|
||||
|
||||
constructor(client: Client);
|
||||
constructor(credentials: LibSqlCredentials);
|
||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||
let client: Client;
|
||||
let batching_enabled = true;
|
||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||
let { url, authToken, protocol } = clientOrCredentials;
|
||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -63,7 +56,6 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
|
||||
super(kysely, {}, plugins);
|
||||
this.client = client;
|
||||
this.supported.batching = batching_enabled;
|
||||
}
|
||||
|
||||
getClient(): Client {
|
||||
|
||||
@@ -31,7 +31,11 @@ export class SqliteConnection extends Connection {
|
||||
type,
|
||||
(col: ColumnDefinitionBuilder) => {
|
||||
if (spec.primary) {
|
||||
return col.primaryKey().notNull().autoIncrement();
|
||||
if (spec.type === "integer") {
|
||||
return col.primaryKey().notNull().autoIncrement();
|
||||
}
|
||||
|
||||
return col.primaryKey().notNull();
|
||||
}
|
||||
if (spec.references) {
|
||||
let relCol = col.references(spec.references);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Static, StringRecord, objectTransform } from "core/utils";
|
||||
import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
import {
|
||||
FieldClassMap,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
entityTypes,
|
||||
} from "data";
|
||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||
import { primaryFieldTypes } from "./fields";
|
||||
|
||||
export const FIELDS = {
|
||||
...FieldClassMap,
|
||||
@@ -68,11 +69,13 @@ export const indicesSchema = tb.Type.Object(
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export type TAppDataIndex = Static<(typeof indicesSchema)[number]>;
|
||||
|
||||
export const dataConfigSchema = tb.Type.Object(
|
||||
{
|
||||
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
|
||||
default_primary_format: tb.Type.Optional(
|
||||
StringEnum(primaryFieldTypes, { default: "integer" }),
|
||||
),
|
||||
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
|
||||
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
|
||||
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
snakeToPascalWithSpaces,
|
||||
transformObject,
|
||||
} from "core/utils";
|
||||
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
|
||||
import {
|
||||
type Field,
|
||||
PrimaryField,
|
||||
primaryFieldTypes,
|
||||
type TActionContext,
|
||||
type TRenderContext,
|
||||
} from "../fields";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
@@ -18,6 +24,7 @@ export const entityConfigSchema = Type.Object(
|
||||
description: Type.Optional(Type.String()),
|
||||
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
||||
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
|
||||
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
@@ -68,7 +75,14 @@ export class Entity<
|
||||
if (primary_count > 1) {
|
||||
throw new Error(`Entity "${name}" has more than one primary field`);
|
||||
}
|
||||
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
|
||||
this.fields =
|
||||
primary_count === 1
|
||||
? []
|
||||
: [
|
||||
new PrimaryField(undefined, {
|
||||
format: this.config.primary_format,
|
||||
}),
|
||||
];
|
||||
|
||||
if (fields) {
|
||||
fields.forEach((field) => this.addField(field));
|
||||
|
||||
@@ -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>
|
||||
@@ -143,7 +143,7 @@ export class Mutator<
|
||||
|
||||
// if listener returned, take what's returned
|
||||
const _data = result.returned ? result.params.data : data;
|
||||
const validatedData = {
|
||||
let validatedData = {
|
||||
...entity.getDefaultObject(),
|
||||
...(await this.getValidatedData(_data, "create")),
|
||||
};
|
||||
@@ -159,6 +159,16 @@ export class Mutator<
|
||||
}
|
||||
}
|
||||
|
||||
// primary
|
||||
const primary = entity.getPrimaryField();
|
||||
const primary_value = primary.getNewValue();
|
||||
if (primary_value) {
|
||||
validatedData = {
|
||||
[primary.name]: primary_value,
|
||||
...validatedData,
|
||||
};
|
||||
}
|
||||
|
||||
const query = this.conn
|
||||
.insertInto(entity.name)
|
||||
.values(validatedData)
|
||||
@@ -175,7 +185,7 @@ export class Mutator<
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
if (!id) {
|
||||
throw new Error("ID must be provided for update");
|
||||
}
|
||||
|
||||
@@ -212,7 +222,7 @@ export class Mutator<
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
if (!id) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -28,6 +27,7 @@ export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
|
||||
data: T;
|
||||
meta: {
|
||||
items: number;
|
||||
has_more?: boolean;
|
||||
total?: number;
|
||||
count?: number;
|
||||
time?: number;
|
||||
@@ -62,6 +62,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
public entity: Entity,
|
||||
protected options: RepositoryOptions = {},
|
||||
) {
|
||||
this.options = {
|
||||
...options,
|
||||
includeCounts: options?.includeCounts ?? this.em.connection.supports("counts"),
|
||||
};
|
||||
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
|
||||
}
|
||||
|
||||
@@ -84,14 +88,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 +103,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) {
|
||||
@@ -177,6 +184,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
if (options.limit) validated.limit = options.limit;
|
||||
if (options.offset) validated.offset = options.offset;
|
||||
|
||||
// if counts disabled, add +1 to limit
|
||||
if (this.options?.includeCounts === false) {
|
||||
validated.limit = (validated.limit ?? 10) + 1;
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
@@ -208,6 +220,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
|
||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||
const entity = this.entity;
|
||||
|
||||
const compiled = qb.compile();
|
||||
|
||||
const payload = {
|
||||
@@ -442,6 +455,16 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
await this.triggerFindBefore(this.entity, options);
|
||||
|
||||
const res = await this.performQuery(qb);
|
||||
if (this.options?.includeCounts === false) {
|
||||
const hasMore = res.data.length === (options.limit ?? 10);
|
||||
res.meta.has_more = hasMore;
|
||||
|
||||
if (hasMore) {
|
||||
res.data = res.data.slice(0, -1);
|
||||
res.result = res.result.slice(0, -1);
|
||||
res.meta.items = res.data.length;
|
||||
}
|
||||
}
|
||||
|
||||
await this.triggerFindAfter(this.entity, options, res.data);
|
||||
return res as any;
|
||||
@@ -505,7 +528,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 +536,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";
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { config } from "core";
|
||||
import type { Static } from "core/utils";
|
||||
import { StringEnum, uuidv7, type Static } from "core/utils";
|
||||
import { Field, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const primaryFieldTypes = ["integer", "uuid"] as const;
|
||||
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
|
||||
|
||||
export const primaryFieldConfigSchema = Type.Composite([
|
||||
Type.Omit(baseFieldConfigSchema, ["required"]),
|
||||
Type.Object({
|
||||
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
|
||||
required: Type.Optional(Type.Literal(false)),
|
||||
}),
|
||||
]);
|
||||
@@ -21,8 +25,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
> {
|
||||
override readonly type = "primary";
|
||||
|
||||
constructor(name: string = config.data.default_primary_field) {
|
||||
super(name, { fillable: false, required: false });
|
||||
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
|
||||
super(name, { fillable: false, required: false, ...cfg });
|
||||
}
|
||||
|
||||
override isRequired(): boolean {
|
||||
@@ -30,32 +34,53 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
return baseFieldConfigSchema;
|
||||
return primaryFieldConfigSchema;
|
||||
}
|
||||
|
||||
get format() {
|
||||
return this.config.format ?? "integer";
|
||||
}
|
||||
|
||||
get fieldType() {
|
||||
return this.format === "integer" ? "integer" : "text";
|
||||
}
|
||||
|
||||
override schema() {
|
||||
return Object.freeze({
|
||||
type: "integer",
|
||||
type: this.fieldType,
|
||||
name: this.name,
|
||||
primary: true,
|
||||
nullable: false,
|
||||
});
|
||||
}
|
||||
|
||||
getNewValue(): any {
|
||||
if (this.format === "uuid") {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override async transformPersist(value: any): Promise<number> {
|
||||
throw new Error("PrimaryField: This function should not be called");
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
if (this.format === "uuid") {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ writeOnly: undefined }));
|
||||
}
|
||||
|
||||
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
const type = this.format === "integer" ? "number" : "string";
|
||||
return {
|
||||
...super.toType(),
|
||||
required: true,
|
||||
import: [{ package: "kysely", name: "Generated" }],
|
||||
type: "Generated<number>",
|
||||
type: `Generated<${type}>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +6,10 @@ 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";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const directions = ["source", "target"] as const;
|
||||
@@ -72,7 +73,7 @@ export abstract class EntityRelation<
|
||||
reference: string,
|
||||
): KyselyQueryBuilder;
|
||||
|
||||
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { type Static, StringEnum } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, baseFieldConfigSchema } from "../fields";
|
||||
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
|
||||
import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
@@ -15,6 +15,7 @@ export const relationFieldConfigSchema = Type.Composite([
|
||||
reference: Type.String(),
|
||||
target: Type.String(), // @todo: potentially has to be an instance!
|
||||
target_field: Type.Optional(Type.String({ default: "id" })),
|
||||
target_field_type: Type.Optional(StringEnum(["integer", "text"], { default: "integer" })),
|
||||
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
|
||||
}),
|
||||
]);
|
||||
@@ -45,6 +46,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
reference: target.reference,
|
||||
target: target.entity.name,
|
||||
target_field: target.entity.getPrimaryField().name,
|
||||
target_field_type: target.entity.getPrimaryField().fieldType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
override schema() {
|
||||
return Object.freeze({
|
||||
...super.schema()!,
|
||||
type: "integer",
|
||||
type: this.config.target_field_type ?? "integer",
|
||||
references: `${this.config.target}.${this.config.target_field}`,
|
||||
onDelete: this.config.on_delete ?? "set null",
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CompiledQuery, TableMetadata } from "kysely";
|
||||
import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { PrimaryField } from "../fields";
|
||||
import { $console } from "core";
|
||||
|
||||
type IntrospectedTable = TableMetadata & {
|
||||
indices: IndexMetadata[];
|
||||
@@ -332,6 +333,7 @@ export class SchemaManager {
|
||||
|
||||
if (config.force) {
|
||||
try {
|
||||
$console.log("[SchemaManager]", sql);
|
||||
await qb.execute();
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export class ModuleManager {
|
||||
const context = this.ctx(true);
|
||||
|
||||
for (const key in MODULES) {
|
||||
const moduleConfig = key in initial ? initial[key] : {};
|
||||
const moduleConfig = initial && key in initial ? initial[key] : {};
|
||||
const module = new MODULES[key](moduleConfig, context) as Module;
|
||||
module.setListener(async (c) => {
|
||||
await this.onModuleConfigUpdated(key, c);
|
||||
|
||||
@@ -54,7 +54,7 @@ export class AdminController extends Controller {
|
||||
}
|
||||
|
||||
private withBasePath(route: string = "") {
|
||||
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||
return (this.options.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||
}
|
||||
|
||||
private withAdminBasePath(route: string = "") {
|
||||
@@ -80,25 +80,51 @@ export class AdminController extends Controller {
|
||||
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
|
||||
login: this.withAdminBasePath("/auth/login"),
|
||||
register: this.withAdminBasePath("/auth/register"),
|
||||
logout: this.withAdminBasePath("/auth/logout"),
|
||||
logout: "/api/auth/logout",
|
||||
};
|
||||
|
||||
hono.use("*", async (c, next) => {
|
||||
const obj = {
|
||||
user: c.get("auth")?.user,
|
||||
logout_route: this.withAdminBasePath(authRoutes.logout),
|
||||
admin_basepath: this.options.adminBasepath,
|
||||
};
|
||||
const html = await this.getHtml(obj);
|
||||
if (!html) {
|
||||
console.warn("Couldn't generate HTML for admin UI");
|
||||
// re-casting to void as a return is not required
|
||||
return c.notFound() as unknown as void;
|
||||
}
|
||||
c.set("html", html);
|
||||
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
|
||||
if (isDebug()) {
|
||||
paths.push("/test/*");
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
for (const path of paths) {
|
||||
hono.get(
|
||||
path,
|
||||
permission(SystemPermissions.accessAdmin, {
|
||||
onDenied: async (c) => {
|
||||
if (!path.startsWith("/auth")) {
|
||||
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||
|
||||
$console.log("redirecting", authRoutes.login);
|
||||
return c.redirect(authRoutes.login);
|
||||
}
|
||||
return;
|
||||
},
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const obj = {
|
||||
user: c.get("auth")?.user,
|
||||
logout_route: authRoutes.logout,
|
||||
admin_basepath: this.options.adminBasepath,
|
||||
};
|
||||
const html = await this.getHtml(obj);
|
||||
if (!html) {
|
||||
console.warn("Couldn't generate HTML for admin UI");
|
||||
// re-casting to void as a return is not required
|
||||
return c.notFound() as unknown as void;
|
||||
}
|
||||
|
||||
await auth.authenticator?.requestCookieRefresh(c);
|
||||
return c.html(html);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (auth_enabled) {
|
||||
const redirectRouteParams = [
|
||||
@@ -126,27 +152,6 @@ export class AdminController extends Controller {
|
||||
});
|
||||
}
|
||||
|
||||
// @todo: only load known paths
|
||||
hono.get(
|
||||
"/*",
|
||||
permission(SystemPermissions.accessAdmin, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||
|
||||
$console.log("redirecting");
|
||||
return c.redirect(authRoutes.login);
|
||||
},
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.html(c.get("html")!);
|
||||
},
|
||||
);
|
||||
|
||||
return hono;
|
||||
}
|
||||
|
||||
@@ -194,9 +199,13 @@ export class AdminController extends Controller {
|
||||
}).then((res) => res.default);
|
||||
}
|
||||
|
||||
// @todo: load all marked as entry (incl. css)
|
||||
assets.js = manifest["src/ui/main.tsx"].file;
|
||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||
try {
|
||||
// @todo: load all marked as entry (incl. css)
|
||||
assets.js = manifest["src/ui/main.tsx"].file;
|
||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||
} catch (e) {
|
||||
$console.warn("Couldn't find assets in manifest", e);
|
||||
}
|
||||
}
|
||||
|
||||
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
|
||||
|
||||
@@ -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,26 +292,45 @@ 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("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||
|
||||
hono.get("/openapi.json", async (c) => {
|
||||
const config = getDefaultConfig();
|
||||
return c.json(generateOpenAPI(config));
|
||||
});
|
||||
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
return hono;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -34,6 +34,7 @@ export type DataTableProps<Data> = {
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: Data) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
hasMore?: boolean;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@@ -59,6 +60,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
hasMore,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
@@ -75,7 +77,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
page = page || 1;
|
||||
|
||||
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const pages = hasMore === undefined ? Math.max(Math.ceil(total / perPage), 1) : undefined;
|
||||
const CellRender = renderValue || CellValue;
|
||||
|
||||
return (
|
||||
@@ -196,12 +198,14 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="hidden md:flex text-primary/40">
|
||||
<TableDisplay
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
items={data?.length || 0}
|
||||
total={total}
|
||||
/>
|
||||
{hasMore === undefined ? (
|
||||
<TableDisplay
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
items={data?.length || 0}
|
||||
total={total}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
@@ -220,11 +224,16 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} of {pages}
|
||||
Page {page} {pages ? `of ${pages}` : ""}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
<TableNav
|
||||
current={page}
|
||||
total={pages}
|
||||
hasMore={hasMore}
|
||||
onClick={onClickPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -274,41 +283,59 @@ const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
const text = `Showing ${perPage * (page - 1) + 1}-${perPage * (page - 1) + items}`;
|
||||
if (total) {
|
||||
return `${text} of ${total} rows`;
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const TableNav: React.FC<TableNavProps> = ({
|
||||
current,
|
||||
total: _total,
|
||||
hasMore: _hasMore,
|
||||
onClick,
|
||||
}: TableNavProps) => {
|
||||
const total = _total !== undefined ? _total : _hasMore !== undefined ? current + 1 : current;
|
||||
const hasMore = _hasMore !== undefined ? _hasMore : current < (total ?? 0);
|
||||
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: !hasMore },
|
||||
{
|
||||
value: _hasMore === undefined ? total : undefined,
|
||||
Icon: TbChevronsRight,
|
||||
disabled: !hasMore,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
));
|
||||
return navMap.map((nav, key) => {
|
||||
const page = nav.value;
|
||||
if (page === undefined) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
onClick?.(safePage);
|
||||
}}
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Api } from "bknd/client";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { RepoQueryIn } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId, useEffect, useRef, useState } from "react";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
@@ -14,7 +15,7 @@ export type DropzoneContainerProps = {
|
||||
infinite?: boolean;
|
||||
entity?: {
|
||||
name: string;
|
||||
id: number;
|
||||
id: PrimaryFieldType;
|
||||
field: string;
|
||||
};
|
||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||
|
||||
@@ -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,47 @@
|
||||
import {
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
decodeSearch,
|
||||
encodeSearch,
|
||||
parseDecode,
|
||||
} from "core/utils";
|
||||
import { decodeSearch, encodeSearch, mergeObject, parseDecode } from "core/utils";
|
||||
import { isEqual, transform } from "lodash-es";
|
||||
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
||||
import { type s, parse, cloneSchema } from "core/object/schema";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
export function useSearch<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
defaultValue?: Partial<StaticDecode<Schema>>,
|
||||
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
||||
_schema: Schema,
|
||||
defaultValue?: Partial<s.StaticCoerced<Schema>>,
|
||||
) {
|
||||
const schema = cloneSchema(_schema as any) as s.TSchema;
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
|
||||
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
|
||||
const value = parse(schema, initial, {
|
||||
withDefaults: true,
|
||||
clone: true,
|
||||
}) as s.StaticCoerced<Schema>;
|
||||
|
||||
if (searchString.length > 0) {
|
||||
value = parseDecode(schema, decodeSearch(searchString));
|
||||
//console.log("search:decode", value);
|
||||
}
|
||||
// @ts-ignore
|
||||
const _defaults = mergeObject(schema.template({ withOptional: true }), defaultValue ?? {});
|
||||
|
||||
// @todo: add option to set multiple keys at once
|
||||
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
|
||||
//console.log("set", key, value);
|
||||
const update = parseDecode(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>,
|
||||
);
|
||||
const encoded = encodeSearch(search, { encode: false });
|
||||
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
||||
function set<Update extends Partial<s.StaticCoerced<Schema>>>(update: Update): void {
|
||||
// @ts-ignore
|
||||
if (schema.validate(update).valid) {
|
||||
const search = getWithoutDefaults(mergeObject(value, update), _defaults);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function getWithoutDefaults(value: object, defaultValue: object) {
|
||||
return transform(
|
||||
value as any,
|
||||
(result, value, key) => {
|
||||
if (defaultValue && isEqual(value, defaultValue[key])) return;
|
||||
result[key] = value;
|
||||
},
|
||||
{} as object,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
TbFingerprint,
|
||||
@@ -24,6 +24,7 @@ import { useLocation } from "wouter";
|
||||
import { NavLink } from "./AppShell";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { getVersion } from "core/env";
|
||||
|
||||
export function HeaderNavigation() {
|
||||
const [location, navigate] = useLocation();
|
||||
@@ -159,6 +160,16 @@ function UserMenu() {
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
||||
{
|
||||
label: "OpenAPI",
|
||||
onClick: () => window.open("/api/system/swagger", "_blank"),
|
||||
icon: IconApi,
|
||||
},
|
||||
{
|
||||
label: "Docs",
|
||||
onClick: () => window.open("https://docs.bknd.io", "_blank"),
|
||||
icon: IconBook,
|
||||
},
|
||||
];
|
||||
|
||||
if (config.auth.enabled) {
|
||||
@@ -166,7 +177,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,
|
||||
});
|
||||
@@ -176,6 +188,11 @@ function UserMenu() {
|
||||
if (!options.theme) {
|
||||
items.push(() => <UserMenuThemeToggler />);
|
||||
}
|
||||
items.push(() => (
|
||||
<div className="font-mono leading-none text-xs text-primary/50 text-center pb-1 pt-2 mt-1 border-t border-primary/5">
|
||||
{getVersion()}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { bkndModals } from "ui/modals";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
|
||||
// simplify react form types 🤦
|
||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
||||
@@ -30,7 +31,7 @@ export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, an
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
entityId?: PrimaryFieldType;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
@@ -225,7 +226,7 @@ function EntityMediaFormField({
|
||||
formApi: FormApi;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
entityId?: PrimaryFieldType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
type EntityFieldsFormRef,
|
||||
} from "ui/routes/data/forms/entity.fields.form";
|
||||
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
|
||||
const schema = entitiesSchema;
|
||||
type Schema = Static<typeof schema>;
|
||||
|
||||
export function StepEntityFields() {
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const { config } = useBkndData();
|
||||
const entity = state.entities?.create?.[0]!;
|
||||
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
@@ -82,6 +84,8 @@ export function StepEntityFields() {
|
||||
ref={ref}
|
||||
fields={initial.fields as any}
|
||||
onChange={updateListener}
|
||||
defaultPrimaryFormat={config?.default_primary_format}
|
||||
isNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
entitySchema,
|
||||
useStepContext,
|
||||
} from "./CreateModal";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
|
||||
export function StepEntity() {
|
||||
const focusTrapRef = useFocusTrap();
|
||||
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const { register, handleSubmit, formState, watch } = useForm({
|
||||
const { register, handleSubmit, formState, watch, control } = useForm({
|
||||
mode: "onTouched",
|
||||
resolver: typeboxResolver(entitySchema),
|
||||
defaultValues: state.entities?.create?.[0] ?? {},
|
||||
@@ -56,7 +57,6 @@ export function StepEntity() {
|
||||
label="What's the name of the entity?"
|
||||
description="Use plural form, and all lowercase. It will be used as the database table."
|
||||
/>
|
||||
{/*<input type="submit" value="submit" />*/}
|
||||
<TextInput
|
||||
{...register("config.name")}
|
||||
error={formState.errors.config?.name?.message}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { ucFirst } from "core/utils";
|
||||
import type { Entity, EntityData, EntityRelation } from "data";
|
||||
import { Fragment, useState } from "react";
|
||||
@@ -24,7 +25,7 @@ export function DataEntityUpdate({ params }) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
}
|
||||
|
||||
const entityId = Number.parseInt(params.id as string);
|
||||
const entityId = params.id as PrimaryFieldType;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [navigate] = useNavigate();
|
||||
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
|
||||
@@ -202,7 +203,7 @@ function EntityDetailRelations({
|
||||
entity,
|
||||
relations,
|
||||
}: {
|
||||
id: number;
|
||||
id: PrimaryFieldType;
|
||||
entity: Entity;
|
||||
relations: EntityRelation[];
|
||||
}) {
|
||||
@@ -250,7 +251,7 @@ function EntityDetailInner({
|
||||
entity,
|
||||
relation,
|
||||
}: {
|
||||
id: number;
|
||||
id: PrimaryFieldType;
|
||||
entity: Entity;
|
||||
relation: EntityRelation;
|
||||
}) {
|
||||
|
||||
@@ -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,22 +14,16 @@ 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];
|
||||
const PER_PAGE_OPTIONS = [5, 10, 25, 50, 100];
|
||||
|
||||
export function DataEntityList({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
@@ -67,21 +61,18 @@ export function DataEntityList({ params }) {
|
||||
}
|
||||
|
||||
function handleClickPage(page: number) {
|
||||
search.set("page", page);
|
||||
search.set({ page });
|
||||
}
|
||||
|
||||
function handleSortClick(name: string) {
|
||||
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);
|
||||
search.set({ sort: newSort as any });
|
||||
}
|
||||
|
||||
function handleClickPerPage(perPage: number) {
|
||||
// @todo: also reset page to 1
|
||||
search.set("perPage", perPage);
|
||||
search.set({ perPage, page: 1 });
|
||||
}
|
||||
|
||||
const isUpdating = $q.isLoading || $q.isValidating;
|
||||
@@ -140,6 +131,7 @@ export function DataEntityList({ params }) {
|
||||
perPage={search.value.perPage}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
total={meta?.count}
|
||||
hasMore={meta?.has_more}
|
||||
onClickPage={handleClickPage}
|
||||
onClickPerPage={handleClickPerPage}
|
||||
/>
|
||||
|
||||
@@ -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,107 +42,113 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
const Fields = ({ entity }: { entity: Entity }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updates, setUpdates] = useState(0);
|
||||
const { actions, $data } = useBkndData();
|
||||
const { actions, $data, config } = useBkndData();
|
||||
const [res, setRes] = useState<any>();
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
async function handleUpdate() {
|
||||
@@ -197,6 +201,8 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
}
|
||||
},
|
||||
}))}
|
||||
defaultPrimaryFormat={config?.default_primary_format}
|
||||
isNew={false}
|
||||
/>
|
||||
|
||||
{isDebug() && (
|
||||
@@ -277,37 +283,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-s
|
||||
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||
@@ -65,6 +67,8 @@ export type EntityFieldsFormProps = {
|
||||
sortable?: boolean;
|
||||
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
||||
routePattern?: string;
|
||||
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
export type EntityFieldsFormRef = {
|
||||
@@ -77,7 +81,7 @@ export type EntityFieldsFormRef = {
|
||||
|
||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||
function EntityFieldsForm(
|
||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props },
|
||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
|
||||
ref,
|
||||
) {
|
||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||
@@ -172,6 +176,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
remove={remove}
|
||||
dnd={dnd}
|
||||
routePattern={routePattern}
|
||||
primary={{
|
||||
defaultFormat: props.defaultPrimaryFormat,
|
||||
editable: isNew,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -186,6 +194,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
routePattern={routePattern}
|
||||
primary={{
|
||||
defaultFormat: props.defaultPrimaryFormat,
|
||||
editable: isNew,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -281,6 +293,7 @@ function EntityField({
|
||||
errors,
|
||||
dnd,
|
||||
routePattern,
|
||||
primary,
|
||||
}: {
|
||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||
index: number;
|
||||
@@ -292,6 +305,10 @@ function EntityField({
|
||||
errors: any;
|
||||
dnd?: SortableItemProps;
|
||||
routePattern?: string;
|
||||
primary?: {
|
||||
defaultFormat?: TPrimaryFieldFormat;
|
||||
editable?: boolean;
|
||||
};
|
||||
}) {
|
||||
const prefix = `fields.${index}.field` as const;
|
||||
const type = field.field.type;
|
||||
@@ -363,15 +380,29 @@ function EntityField({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-col gap-1 hidden md:flex">
|
||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||
{is_primary ? (
|
||||
<Switch size="sm" defaultChecked disabled />
|
||||
<>
|
||||
<MantineSelect
|
||||
data={["integer", "uuid"]}
|
||||
defaultValue={primary?.defaultFormat}
|
||||
disabled={!primary?.editable}
|
||||
placeholder="Select format"
|
||||
name={`${prefix}.config.format`}
|
||||
allowDeselect={false}
|
||||
control={control}
|
||||
size="xs"
|
||||
className="w-22"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<MantineSwitch
|
||||
size="sm"
|
||||
name={`${prefix}.config.required`}
|
||||
control={control}
|
||||
/>
|
||||
<>
|
||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||
<MantineSwitch
|
||||
size="sm"
|
||||
name={`${prefix}.config.required`}
|
||||
control={control}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user