mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ca52a25c | |||
| 1ef2287d34 | |||
| eec8b64bae | |||
| b60fd01960 | |||
| f04239332a | |||
| 3da257e843 | |||
| a762789e93 | |||
| 11f76d788a | |||
| 3e58a17769 | |||
| a3b33a0312 | |||
| c7c1811267 | |||
| a1be1b4bf4 | |||
| 5763a6e150 | |||
| e246396225 | |||
| 8823b94eb7 | |||
| 79ca2a9939 | |||
| edb5d5f4a9 | |||
| 600b55fada | |||
| 95a8289afa | |||
| a55eca75d0 | |||
| b94dec65d2 | |||
| e5d6ed536c | |||
| 4e718a063d | |||
| 3859b9ba00 |
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "1.2.14"
|
||||
bun-version: "1.2.5"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./app
|
||||
|
||||
+1
-3
@@ -29,6 +29,4 @@ packages/media/.env
|
||||
.idea
|
||||
.vscode
|
||||
.git_old
|
||||
docker/tmp
|
||||
.debug
|
||||
.history
|
||||
docker/tmp
|
||||
@@ -1,4 +1,5 @@
|
||||
[](https://npmjs.org/package/bknd)
|
||||
[](https://www.npmjs.com/package/bknd)
|
||||
|
||||

|
||||
|
||||
@@ -17,14 +18,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 300 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 212 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.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# ===== 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 "core/object/query/object-query";
|
||||
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
|
||||
|
||||
describe("object-query", () => {
|
||||
const q: ObjectQuery = { name: "Michael" };
|
||||
|
||||
+80
-13
@@ -1,18 +1,25 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { type WhereQuery, WhereBuilder } from "data";
|
||||
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";
|
||||
|
||||
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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
describe("WhereBuilder", () => {
|
||||
test("single validation", () => {
|
||||
const tests: [WhereQuery, string, any[]][] = [
|
||||
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
||||
@@ -87,4 +94,64 @@ describe("WhereBuilder", () => {
|
||||
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" } });
|
||||
});
|
||||
});
|
||||
@@ -110,18 +110,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Entity, NumberField, TextField } from "data";
|
||||
import * as p from "data/prototype";
|
||||
import { Entity, NumberField, TextField } from "../../../src/data";
|
||||
|
||||
describe("[data] Entity", async () => {
|
||||
const entity = new Entity("test", [
|
||||
@@ -48,7 +47,14 @@ describe("[data] Entity", async () => {
|
||||
expect(entity.getField("new_field")).toBe(field);
|
||||
});
|
||||
|
||||
test.only("types", async () => {
|
||||
console.log(entity.toTypes());
|
||||
});
|
||||
// @todo: move this to ClientApp
|
||||
/*test("serialize and deserialize", async () => {
|
||||
const json = entity.toJSON();
|
||||
//sconsole.log("json", json.fields);
|
||||
const newEntity = Entity.deserialize(json);
|
||||
//console.log("newEntity", newEntity.toJSON().fields);
|
||||
expect(newEntity).toBeInstanceOf(Entity);
|
||||
expect(json).toEqual(newEntity.toJSON());
|
||||
expect(json.fields).toEqual(newEntity.toJSON().fields);
|
||||
});*/
|
||||
});
|
||||
|
||||
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
|
||||
em.addRelation(new ManyToOneRelation(posts, users));
|
||||
expect(em.relations.all.length).toBe(1);
|
||||
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
|
||||
expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
|
||||
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
|
||||
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
|
||||
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
|
||||
expect(em.hasRelations("users")).toBe(true);
|
||||
expect(em.hasRelations("posts")).toBe(true);
|
||||
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
|
||||
|
||||
@@ -39,28 +39,4 @@ 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,9 +43,8 @@ beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("MediaController", () => {
|
||||
test("accepts direct", async () => {
|
||||
test.only("accepts direct", async () => {
|
||||
const app = await makeApp();
|
||||
console.log("app", app);
|
||||
|
||||
const file = Bun.file(path);
|
||||
const name = makeName("png");
|
||||
|
||||
@@ -153,7 +153,6 @@ describe("AppAuth", () => {
|
||||
});
|
||||
|
||||
await app.build();
|
||||
app.registerAdminController();
|
||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||
|
||||
// register custom route
|
||||
@@ -163,10 +162,6 @@ 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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 + "/", "")),
|
||||
);
|
||||
}
|
||||
+8
-17
@@ -1,6 +1,5 @@
|
||||
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");
|
||||
@@ -9,13 +8,8 @@ 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.info("Cleaning dist (w/o static)");
|
||||
console.log("Cleaning dist (w/o static)");
|
||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||
}
|
||||
|
||||
@@ -27,11 +21,11 @@ function buildTypes() {
|
||||
Bun.spawn(["bun", "build:types"], {
|
||||
stdout: "inherit",
|
||||
onExit: () => {
|
||||
console.info("Types built");
|
||||
console.log("Types built");
|
||||
Bun.spawn(["bun", "tsc-alias"], {
|
||||
stdout: "inherit",
|
||||
onExit: () => {
|
||||
console.info("Types aliased");
|
||||
console.log("Types aliased");
|
||||
types_running = false;
|
||||
},
|
||||
});
|
||||
@@ -53,10 +47,10 @@ if (types && !watch) {
|
||||
}
|
||||
|
||||
function banner(title: string) {
|
||||
console.info("");
|
||||
console.info("=".repeat(40));
|
||||
console.info(title.toUpperCase());
|
||||
console.info("-".repeat(40));
|
||||
console.log("");
|
||||
console.log("=".repeat(40));
|
||||
console.log(title.toUpperCase());
|
||||
console.log("-".repeat(40));
|
||||
}
|
||||
|
||||
// collection of always-external packages
|
||||
@@ -71,7 +65,6 @@ async function buildApi() {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
"src/core/index.ts",
|
||||
@@ -108,7 +101,6 @@ async function buildUi() {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
external: [
|
||||
...external,
|
||||
"react",
|
||||
@@ -168,7 +160,6 @@ async function buildUiElements() {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
entry: ["src/ui/elements/index.ts"],
|
||||
outDir: "dist/ui/elements",
|
||||
external: [
|
||||
@@ -220,7 +211,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
||||
},
|
||||
...overrides,
|
||||
define: {
|
||||
...define,
|
||||
__isDev: "0",
|
||||
...overrides.define,
|
||||
},
|
||||
external: [
|
||||
|
||||
+11
-15
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.14.0-rc.2",
|
||||
"version": "0.11.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 run build.cli.ts",
|
||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
|
||||
"build:static": "vite build",
|
||||
"watch": "bun run build.ts --types --watch",
|
||||
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
||||
@@ -49,11 +49,9 @@
|
||||
"@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",
|
||||
@@ -61,24 +59,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",
|
||||
"uuid": "^11.1.0"
|
||||
"lodash-es": "^4.17.21",
|
||||
"@sinclair/typebox": "0.34.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@bluwy/giget-core": "^0.1.2",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hono/typebox-validator": "^0.3.3",
|
||||
"@hono/vite-dev-server": "^0.19.1",
|
||||
"@hono/typebox-validator": "^0.3.2",
|
||||
"@hono/vite-dev-server": "^0.19.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@libsql/kysely-libsql": "^0.4.1",
|
||||
"@mantine/modals": "^7.17.1",
|
||||
@@ -100,15 +98,14 @@
|
||||
"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",
|
||||
@@ -121,14 +118,13 @@
|
||||
"tsc-alias": "^1.8.11",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^6.2.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.9",
|
||||
"wouter": "^3.6.0",
|
||||
"@cloudflare/workers-types": "^4.20250606.0"
|
||||
"wouter": "^3.6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.14.3"
|
||||
"@hono/node-server": "^1.13.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
+21
-71
@@ -1,11 +1,10 @@
|
||||
import type { SafeUser } from "auth";
|
||||
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
|
||||
import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
||||
import { AuthApi } from "auth/api/AuthApi";
|
||||
import { DataApi } from "data/api/DataApi";
|
||||
import { decode } from "hono/jwt";
|
||||
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
|
||||
import { MediaApi } from "media/api/MediaApi";
|
||||
import { SystemApi } from "modules/SystemApi";
|
||||
import { omitKeys } from "core/utils";
|
||||
import type { BaseModuleApiOptions } from "modules";
|
||||
|
||||
export type TApiUser = SafeUser;
|
||||
|
||||
@@ -22,24 +21,14 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
|
||||
|
||||
export type ApiOptions = {
|
||||
host?: string;
|
||||
headers?: Headers;
|
||||
key?: string;
|
||||
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;
|
||||
localStorage?: boolean;
|
||||
fetcher?: ApiFetcher;
|
||||
verbose?: boolean;
|
||||
verified?: boolean;
|
||||
data?: SubApiOptions<DataApiOptions>;
|
||||
auth?: SubApiOptions<AuthApiOptions>;
|
||||
media?: SubApiOptions<MediaApiOptions>;
|
||||
} & (
|
||||
| {
|
||||
token?: string;
|
||||
@@ -72,18 +61,18 @@ export class Api {
|
||||
this.verified = options.verified === true;
|
||||
|
||||
// prefer request if given
|
||||
if ("request" in options && options.request) {
|
||||
if ("request" in options) {
|
||||
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 && options.token) {
|
||||
} else if ("token" in options) {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(options.token, { trigger: false });
|
||||
this.updateToken(options.token);
|
||||
|
||||
// then check for an user object
|
||||
} else if ("user" in options && options.user) {
|
||||
} else if ("user" in options) {
|
||||
this.token_transport = "none";
|
||||
this.user = options.user;
|
||||
this.verified = options.verified !== false;
|
||||
@@ -126,30 +115,16 @@ export class Api {
|
||||
this.updateToken(headerToken);
|
||||
return;
|
||||
}
|
||||
} else if (this.storage) {
|
||||
this.storage.getItem(this.tokenKey).then((token) => {
|
||||
} else if (this.options.localStorage) {
|
||||
const token = localStorage.getItem(this.tokenKey);
|
||||
if (token) {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(token ? String(token) : undefined);
|
||||
});
|
||||
this.updateToken(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }) {
|
||||
updateToken(token?: string, rebuild?: boolean) {
|
||||
this.token = token;
|
||||
this.verified = false;
|
||||
|
||||
@@ -159,25 +134,17 @@ export class Api {
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
if (this.storage) {
|
||||
if (this.options.localStorage) {
|
||||
const key = this.tokenKey;
|
||||
|
||||
if (token) {
|
||||
this.storage.setItem(key, token).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
localStorage.setItem(key, token);
|
||||
} else {
|
||||
this.storage.removeItem(key).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (opts?.trigger !== false) {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts?.rebuild) this.buildApis();
|
||||
if (rebuild) this.buildApis();
|
||||
}
|
||||
|
||||
private markAuthVerified(verfied: boolean) {
|
||||
@@ -247,32 +214,15 @@ export class Api {
|
||||
const fetcher = this.options.fetcher;
|
||||
|
||||
this.system = new SystemApi(baseParams, fetcher);
|
||||
this.data = new DataApi(
|
||||
{
|
||||
...baseParams,
|
||||
...this.options.data,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
this.data = new DataApi(baseParams, 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,
|
||||
onTokenUpdate: (token) => this.updateToken(token, true),
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
this.media = new MediaApi(baseParams, fetcher);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-6
@@ -151,7 +151,7 @@ export class App {
|
||||
}
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
return this.server.fetch as any;
|
||||
return this.server.fetch;
|
||||
}
|
||||
|
||||
get module() {
|
||||
@@ -253,11 +253,6 @@ export class App {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// call server init if set
|
||||
if (this.options?.manager?.onServerInit) {
|
||||
this.options.manager.onServerInit(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-9
@@ -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<DB extends D1Database | D1DatabaseSession = D1Database> = {
|
||||
binding: DB;
|
||||
export type D1ConnectionConfig = {
|
||||
binding: D1Database;
|
||||
};
|
||||
|
||||
class CustomD1Dialect extends D1Dialect {
|
||||
@@ -17,25 +17,22 @@ class CustomD1Dialect extends D1Dialect {
|
||||
}
|
||||
}
|
||||
|
||||
export class D1Connection<
|
||||
DB extends D1Database | D1DatabaseSession = D1Database,
|
||||
> extends SqliteConnection {
|
||||
export class D1Connection extends SqliteConnection {
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
counts: false,
|
||||
};
|
||||
|
||||
constructor(private config: D1ConnectionConfig<DB>) {
|
||||
constructor(private config: D1ConnectionConfig) {
|
||||
const plugins = [new ParseJSONResultsPlugin()];
|
||||
|
||||
const kysely = new Kysely({
|
||||
dialect: new CustomD1Dialect({ database: config.binding as D1Database }),
|
||||
dialect: new CustomD1Dialect({ database: config.binding }),
|
||||
plugins,
|
||||
});
|
||||
super(kysely, {}, plugins);
|
||||
}
|
||||
|
||||
get client(): DB {
|
||||
get client(): D1Database {
|
||||
return this.config.binding;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { makeApp } from "./modes/fresh";
|
||||
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
||||
import { makeConfig } 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 },
|
||||
}),
|
||||
},
|
||||
$ctx({ DB_URL }),
|
||||
{
|
||||
DB_URL,
|
||||
},
|
||||
),
|
||||
).toEqual({ connection: { url: DB_URL } });
|
||||
});
|
||||
|
||||
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
||||
makeApp: async (c, a, o) => {
|
||||
return await makeApp(c, { env: a } as any, o);
|
||||
},
|
||||
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
|
||||
makeApp,
|
||||
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,13 +9,7 @@ import { getDurable } from "./modes/durable";
|
||||
import type { App } from "bknd";
|
||||
import { $console } from "core";
|
||||
|
||||
declare global {
|
||||
namespace Cloudflare {
|
||||
interface Env {}
|
||||
}
|
||||
}
|
||||
|
||||
export type CloudflareEnv = Cloudflare.Env;
|
||||
export type CloudflareEnv = object;
|
||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (args: Env) => {
|
||||
@@ -23,11 +17,6 @@ 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,148 +1,47 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||
import { getBinding } from "./bindings";
|
||||
import { D1Connection } from "./connection/D1Connection";
|
||||
import { D1Connection } from "./D1Connection";
|
||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||
import { App } from "bknd";
|
||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||
import type { Context, ExecutionContext } from "hono";
|
||||
import type { 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?: CfMakeConfigArgs<Env>,
|
||||
args: Env = {} as Env,
|
||||
) {
|
||||
if (!media_registered) {
|
||||
registerMedia(args as any);
|
||||
media_registered = true;
|
||||
}
|
||||
|
||||
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");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
if (db) {
|
||||
appConfig.connection = new D1Connection({ binding: db });
|
||||
} else {
|
||||
throw new Error("No database connection given");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
|
||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh } from "./modes/fresh";
|
||||
@@ -12,7 +12,6 @@ export {
|
||||
type GetBindingType,
|
||||
type BindingMap,
|
||||
} from "./bindings";
|
||||
export { constants } from "./config";
|
||||
|
||||
export function d1(config: D1ConnectionConfig) {
|
||||
return new D1Connection(config);
|
||||
|
||||
@@ -5,9 +5,8 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
|
||||
|
||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args: Context<Env>,
|
||||
{ env, ctx, ...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";
|
||||
@@ -21,7 +20,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
|
||||
const app = await createRuntimeApp(
|
||||
{
|
||||
...makeConfig(config, args),
|
||||
...makeConfig(config, env),
|
||||
initialConfig,
|
||||
onBuilt: async (app) => {
|
||||
registerAsyncsExecutionContext(app, ctx);
|
||||
@@ -42,7 +41,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
await config.beforeBuild?.(app);
|
||||
},
|
||||
},
|
||||
args,
|
||||
{ env, ctx, ...args },
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
|
||||
import { makeConfig, registerAsyncsExecutionContext } from "../config";
|
||||
|
||||
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args, 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,
|
||||
ctx.env,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isNode } from "bknd/utils";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
type NextjsEnv = NextApiRequest["env"];
|
||||
|
||||
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
cleanRequest?: { searchParams?: string[] };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { $console, type DB } from "core";
|
||||
import { $console, type DB, type PrimaryFieldType } from "core";
|
||||
import { secureRandomString, transformObject } from "core/utils";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
|
||||
@@ -8,13 +8,11 @@ import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
||||
import { AppUserPool } from "auth/AppUserPool";
|
||||
import type { AppEntity } from "core/config";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare module "core" {
|
||||
interface Users extends AppEntity, UserFieldSchema {}
|
||||
interface DB {
|
||||
users: Users;
|
||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,21 +4,19 @@ 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>;
|
||||
credentials?: "include" | "same-origin" | "omit";
|
||||
onTokenUpdate?: (token: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
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: this.options.credentials,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
@@ -29,7 +27,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
|
||||
async register(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||
credentials: this.options.credentials,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok && res.body.token) {
|
||||
@@ -70,7 +68,5 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.options.onTokenUpdate?.(undefined);
|
||||
}
|
||||
async logout() {}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { describeRoute, jsc, s } from "core/object/schema";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
@@ -12,6 +14,10 @@ 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();
|
||||
@@ -50,10 +56,6 @@ 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);
|
||||
@@ -91,16 +93,9 @@ export class AuthController extends Controller {
|
||||
}
|
||||
},
|
||||
);
|
||||
hono.get(
|
||||
"create/schema.json",
|
||||
describeRoute({
|
||||
summary: "Get the schema for creating a user",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(create.schema);
|
||||
},
|
||||
);
|
||||
hono.get("create/schema.json", async (c) => {
|
||||
return c.json(create.schema);
|
||||
});
|
||||
}
|
||||
|
||||
mainHono.route(`/${name}/actions`, hono);
|
||||
@@ -109,55 +104,42 @@ export class AuthController extends Controller {
|
||||
override getController() {
|
||||
const { auth } = this.middlewares;
|
||||
const hono = this.create();
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
|
||||
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 });
|
||||
}
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||
|
||||
return c.json({ user: null }, 403);
|
||||
},
|
||||
);
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
this.registerStrategyActions(strategy, hono);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
const referer = c.req.header("referer");
|
||||
if (referer) {
|
||||
return c.redirect(referer);
|
||||
}
|
||||
return c.json({ user: null }, 403);
|
||||
});
|
||||
|
||||
return c.redirect("/");
|
||||
},
|
||||
);
|
||||
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("/");
|
||||
});
|
||||
|
||||
hono.get(
|
||||
"/strategies",
|
||||
describeRoute({
|
||||
summary: "Get the available authentication strategies",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
||||
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
|
||||
async (c) => {
|
||||
const { include_disabled } = c.req.valid("query");
|
||||
const { strategies, basepath } = this.auth.toJSON(false);
|
||||
@@ -175,15 +157,6 @@ 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,7 +347,6 @@ 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,7 +60,11 @@ export const auth = (options?: {
|
||||
}
|
||||
|
||||
await next();
|
||||
// @todo: potentially add cookie refresh if content-type html and about to expire
|
||||
|
||||
if (!skipped) {
|
||||
// renew cookie if applicable
|
||||
authenticator?.requestCookieRefresh(c);
|
||||
}
|
||||
|
||||
// release
|
||||
authCtx.skip = false;
|
||||
|
||||
@@ -5,4 +5,3 @@ export { debug } from "./debug";
|
||||
export { user } from "./user";
|
||||
export { create } from "./create";
|
||||
export { copyAssets } from "./copy-assets";
|
||||
export { types } from "./types";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./types";
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { makeAppFromEnv } from "cli/commands/run";
|
||||
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||
import { writeFile } from "cli/utils/sys";
|
||||
import c from "picocolors";
|
||||
|
||||
export const types: CliCommand = (program) => {
|
||||
program
|
||||
.command("types")
|
||||
.description("generate types")
|
||||
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
|
||||
.addOption(new Option("--no-write", "do not write to file").default(true))
|
||||
.action(action);
|
||||
};
|
||||
|
||||
async function action({
|
||||
outfile,
|
||||
write,
|
||||
}: {
|
||||
outfile: string;
|
||||
write: boolean;
|
||||
}) {
|
||||
const app = await makeAppFromEnv({
|
||||
server: "node",
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const et = new EntityTypescript(app.em);
|
||||
|
||||
if (write) {
|
||||
await writeFile(outfile, et.toString());
|
||||
console.info(`\nTypes written to ${c.cyan(outfile)}`);
|
||||
} else {
|
||||
console.info(et.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { $console } from "core";
|
||||
import { execSync, exec as nodeExec } from "node:child_process";
|
||||
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
@@ -49,16 +48,6 @@ export async function fileExists(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeFile(filePath: string, content: string) {
|
||||
try {
|
||||
await nodeWriteFile(path.resolve(process.cwd(), filePath), content);
|
||||
return true;
|
||||
} catch (e) {
|
||||
$console.error("Failed to write file", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
|
||||
const stdio = opts?.silent ? "pipe" : "inherit";
|
||||
const output = execSync(command, {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
|
||||
|
||||
export interface AppEntity<IdType = number | string> {
|
||||
id: PrimaryFieldType<IdType>;
|
||||
}
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
|
||||
export interface DB {
|
||||
// make sure to make unknown as "any"
|
||||
|
||||
@@ -23,12 +23,6 @@ function hasColors() {
|
||||
}
|
||||
|
||||
const __consoles = {
|
||||
critical: {
|
||||
prefix: "CRT",
|
||||
color: colors.red,
|
||||
args_color: colors.red,
|
||||
original: console.error,
|
||||
},
|
||||
error: {
|
||||
prefix: "ERR",
|
||||
color: colors.red,
|
||||
|
||||
@@ -13,15 +13,6 @@ 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: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug, env } from "./env";
|
||||
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config";
|
||||
export { type PrimaryFieldType, config, type DB } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
@@ -26,7 +26,6 @@ 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,8 +34,6 @@ 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>];
|
||||
@@ -197,7 +195,5 @@ 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>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
@@ -1 +0,0 @@
|
||||
export { tbValidator } from "./tbValidator";
|
||||
@@ -117,9 +117,7 @@ async function detectMimeType(
|
||||
return;
|
||||
}
|
||||
|
||||
type HonoAnyContext = Context<any, any, any>;
|
||||
|
||||
export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
|
||||
export async function getFileFromContext(c: Context<any>): Promise<File> {
|
||||
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
if (
|
||||
@@ -151,7 +149,7 @@ export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
|
||||
throw new Error("No file found in request");
|
||||
}
|
||||
|
||||
export async function getBodyFromContext(c: HonoAnyContext): Promise<ReadableStream | File> {
|
||||
export async function getBodyFromContext(c: Context<any>): Promise<ReadableStream | File> {
|
||||
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
if (
|
||||
|
||||
@@ -359,63 +359,3 @@ export function getPath(
|
||||
throw new Error(`Invalid path: ${path.join(".")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
|
||||
const nl = indent ? "\n" : "";
|
||||
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
|
||||
const openPad = pad(_level + 1);
|
||||
const closePad = pad(_level);
|
||||
|
||||
// primitives
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
const t = typeof value;
|
||||
if (t === "string") return JSON.stringify(value); // handles escapes
|
||||
if (t === "number" || t === "boolean") return String(value);
|
||||
|
||||
// arrays
|
||||
if (Array.isArray(value)) {
|
||||
const out = value
|
||||
.map((v) => objectToJsLiteral(v, indent, _level + 1))
|
||||
.join(", " + (indent ? nl + openPad : ""));
|
||||
return (
|
||||
"[" +
|
||||
(indent && value.length ? nl + openPad : "") +
|
||||
out +
|
||||
(indent && value.length ? nl + closePad : "") +
|
||||
"]"
|
||||
);
|
||||
}
|
||||
|
||||
// objects
|
||||
if (t === "object") {
|
||||
const entries = Object.entries(value).map(([k, v]) => {
|
||||
const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier?
|
||||
const key = idOk ? k : JSON.stringify(k); // quote if needed
|
||||
return key + ": " + objectToJsLiteral(v, indent, _level + 1);
|
||||
});
|
||||
const out = entries.join(", " + (indent ? nl + openPad : ""));
|
||||
return (
|
||||
"{" +
|
||||
(indent && entries.length ? nl + openPad : "") +
|
||||
out +
|
||||
(indent && entries.length ? nl + closePad : "") +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
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("critical");
|
||||
$console.setLevel("error");
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { v4, v7 } from "uuid";
|
||||
|
||||
// generates v4
|
||||
export function uuid(): string {
|
||||
return v4();
|
||||
}
|
||||
|
||||
export function uuidv7(): string {
|
||||
return v7();
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
+27
-30
@@ -1,6 +1,5 @@
|
||||
import type { DB } from "core";
|
||||
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
|
||||
|
||||
@@ -26,23 +25,21 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
}
|
||||
}
|
||||
|
||||
readOne<E extends keyof DB | string>(
|
||||
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
||||
["entity", entity as any, id],
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
readOneBy<E extends keyof DB | string>(
|
||||
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
|
||||
return this.readMany(entity, {
|
||||
...query,
|
||||
@@ -51,8 +48,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
|
||||
}
|
||||
|
||||
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: RepoQueryIn = {},
|
||||
) {
|
||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||
|
||||
const input = query ?? this.options.defaultQuery;
|
||||
@@ -65,70 +64,68 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
return this.post<T>(["entity", entity as any, "query"], input);
|
||||
}
|
||||
|
||||
readManyByReference<E extends keyof DB | string, R extends keyof DB | string>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
reference: R,
|
||||
query: RepoQueryIn = {},
|
||||
) {
|
||||
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
|
||||
readManyByReference<
|
||||
E extends keyof DB | string,
|
||||
R extends keyof DB | string,
|
||||
Data = R extends keyof DB ? DB[R] : EntityData,
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
["entity", entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery,
|
||||
);
|
||||
}
|
||||
|
||||
createOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Insertable<Input>,
|
||||
input: Omit<Data, "id">,
|
||||
) {
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Insertable<Input>[],
|
||||
input: Omit<Data, "id">[],
|
||||
) {
|
||||
if (!input || !Array.isArray(input) || input.length === 0) {
|
||||
throw new Error("input is required");
|
||||
}
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
input: Updateable<Input>,
|
||||
input: Partial<Omit<Data, "id">>,
|
||||
) {
|
||||
if (!id) throw new Error("ID is required");
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
|
||||
}
|
||||
|
||||
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
|
||||
updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
where: RepoQueryIn["where"],
|
||||
update: Updateable<Input>,
|
||||
update: Partial<Omit<Data, "id">>,
|
||||
) {
|
||||
this.requireObjectSet(where);
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
|
||||
update,
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
|
||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
) {
|
||||
if (!id) throw new Error("ID is required");
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
|
||||
}
|
||||
|
||||
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
|
||||
deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
where: RepoQueryIn["where"],
|
||||
) {
|
||||
this.requireObjectSet(where);
|
||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
|
||||
}
|
||||
|
||||
|
||||
+109
-222
@@ -1,4 +1,6 @@
|
||||
import { $console, isDebug } from "core";
|
||||
import { $console, isDebug, tbValidator as tb } from "core";
|
||||
import { StringEnum } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
@@ -6,15 +8,14 @@ import {
|
||||
type MutatorResponse,
|
||||
type RepoQuery,
|
||||
type RepositoryResponse,
|
||||
repoQuery,
|
||||
querySchema,
|
||||
} 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";
|
||||
import { omitKeys } from "core/utils";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export class DataController extends Controller {
|
||||
constructor(
|
||||
@@ -70,7 +71,6 @@ 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,10 +83,6 @@ 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());
|
||||
@@ -94,75 +90,49 @@ export class DataController extends Controller {
|
||||
);
|
||||
|
||||
// sync endpoint
|
||||
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 });
|
||||
},
|
||||
);
|
||||
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 });
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema endpoints
|
||||
*/
|
||||
// read entity schema
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
// read schema
|
||||
hono.get(
|
||||
"/schemas/:entity/:context?",
|
||||
permission(DataPermissions.entityRead),
|
||||
describeRoute({
|
||||
summary: "Retrieve entity schema",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc(
|
||||
tb(
|
||||
"param",
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
context: Type.Optional(StringEnum(["create", "update"])),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -191,39 +161,30 @@ export class DataController extends Controller {
|
||||
/**
|
||||
* Info endpoints
|
||||
*/
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
}));
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -232,9 +193,10 @@ export class DataController extends Controller {
|
||||
const { permission } = this.middlewares;
|
||||
const hono = this.create();
|
||||
|
||||
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 });
|
||||
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);
|
||||
|
||||
/**
|
||||
* Function endpoints
|
||||
@@ -243,19 +205,14 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/fn/count",
|
||||
permission(DataPermissions.entityRead),
|
||||
describeRoute({
|
||||
summary: "Count entities",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
const where = c.req.valid("json") as any;
|
||||
const where = (await c.req.json()) as any;
|
||||
const result = await this.em.repository(entity).count(where);
|
||||
return c.json({ entity, count: result.count });
|
||||
},
|
||||
@@ -265,19 +222,14 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/fn/exists",
|
||||
permission(DataPermissions.entityRead),
|
||||
describeRoute({
|
||||
summary: "Check if entity exists",
|
||||
tags: ["data"],
|
||||
}),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
const where = c.req.valid("json") as any;
|
||||
const where = c.req.json() as any;
|
||||
const result = await this.em.repository(entity).exists(where);
|
||||
return c.json({ entity, exists: result.exists });
|
||||
},
|
||||
@@ -287,36 +239,18 @@ 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),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em.repo(entity).findMany(options);
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -325,27 +259,22 @@ export class DataController extends Controller {
|
||||
// read one
|
||||
hono.get(
|
||||
"/:entity/:id",
|
||||
describeRoute({
|
||||
summary: "Read one",
|
||||
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
jsc(
|
||||
tb(
|
||||
"param",
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: idType,
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
}),
|
||||
),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.valid("param");
|
||||
const { entity, id } = c.req.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(id, options);
|
||||
const result = await this.em.repository(entity).findId(Number(id), options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
},
|
||||
@@ -354,23 +283,18 @@ 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),
|
||||
jsc(
|
||||
tb(
|
||||
"param",
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: idType,
|
||||
reference: s.string(),
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
reference: Type.String(),
|
||||
}),
|
||||
),
|
||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
const { entity, id, reference } = c.req.valid("param");
|
||||
const { entity, id, reference } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -378,40 +302,24 @@ export class DataController extends Controller {
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em
|
||||
.repository(entity)
|
||||
.findManyByReference(id, reference, options);
|
||||
.findManyByReference(Number(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),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery, { skipOpenAPI: true }),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const options = (await c.req.json()) as RepoQuery;
|
||||
const options = (await c.req.valid("json")) as RepoQuery;
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
@@ -424,15 +332,11 @@ export class DataController extends Controller {
|
||||
// insert one
|
||||
hono.post(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Insert one or many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityCreate),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -451,17 +355,13 @@ export class DataController extends Controller {
|
||||
// update many
|
||||
hono.patch(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Update many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc(
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb(
|
||||
"json",
|
||||
s.object({
|
||||
update: s.object({}),
|
||||
where: repoQuery.properties.where,
|
||||
Type.Object({
|
||||
update: Type.Object({}),
|
||||
where: querySchema.properties.where,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -482,20 +382,15 @@ export class DataController extends Controller {
|
||||
// update one
|
||||
hono.patch(
|
||||
"/:entity/:id",
|
||||
describeRoute({
|
||||
summary: "Update one",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||
jsc("json", s.object({})),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.valid("param");
|
||||
const { entity, id } = c.req.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(id, body);
|
||||
const result = await this.em.mutator(entity).updateOne(Number(id), body);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
},
|
||||
@@ -504,18 +399,14 @@ export class DataController extends Controller {
|
||||
// delete one
|
||||
hono.delete(
|
||||
"/:entity/:id",
|
||||
describeRoute({
|
||||
summary: "Delete one",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.valid("param");
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const result = await this.em.mutator(entity).deleteOne(id);
|
||||
const result = await this.em.mutator(entity).deleteOne(Number(id));
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
},
|
||||
@@ -524,19 +415,15 @@ export class DataController extends Controller {
|
||||
// delete many
|
||||
hono.delete(
|
||||
"/:entity",
|
||||
describeRoute({
|
||||
summary: "Delete many",
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
const where = (await c.req.json()) as RepoQuery["where"];
|
||||
const where = c.req.valid("json") as RepoQuery["where"];
|
||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
type AliasableExpression,
|
||||
type ColumnBuilderCallback,
|
||||
type ColumnDataType,
|
||||
type DatabaseIntrospector,
|
||||
type Dialect,
|
||||
type Expression,
|
||||
type Kysely,
|
||||
type KyselyPlugin,
|
||||
@@ -14,8 +12,7 @@ import {
|
||||
type Simplify,
|
||||
sql,
|
||||
} from "kysely";
|
||||
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
||||
import type { Constructor } from "core";
|
||||
import type { BaseIntrospector } from "./BaseIntrospector";
|
||||
|
||||
export type QB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
@@ -82,7 +79,6 @@ export abstract class Connection<DB = any> {
|
||||
kysely: Kysely<DB>;
|
||||
protected readonly supported = {
|
||||
batching: false,
|
||||
counts: true,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -163,19 +159,3 @@ 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,7 +3,6 @@ import { Connection, type FieldSpec, type SchemaResponse } from "./Connection";
|
||||
export class DummyConnection extends Connection {
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
counts: true,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,6 +44,13 @@ 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;
|
||||
}
|
||||
@@ -56,6 +63,7 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
|
||||
super(kysely, {}, plugins);
|
||||
this.client = client;
|
||||
this.supported.batching = batching_enabled;
|
||||
}
|
||||
|
||||
getClient(): Client {
|
||||
|
||||
@@ -31,11 +31,7 @@ export class SqliteConnection extends Connection {
|
||||
type,
|
||||
(col: ColumnDefinitionBuilder) => {
|
||||
if (spec.primary) {
|
||||
if (spec.type === "integer") {
|
||||
return col.primaryKey().notNull().autoIncrement();
|
||||
}
|
||||
|
||||
return col.primaryKey().notNull();
|
||||
return col.primaryKey().notNull().autoIncrement();
|
||||
}
|
||||
if (spec.references) {
|
||||
let relCol = col.references(spec.references);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils";
|
||||
import { type Static, StringRecord, objectTransform } from "core/utils";
|
||||
import * as tb from "@sinclair/typebox";
|
||||
import {
|
||||
FieldClassMap,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
entityTypes,
|
||||
} from "data";
|
||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||
import { primaryFieldTypes } from "./fields";
|
||||
|
||||
export const FIELDS = {
|
||||
...FieldClassMap,
|
||||
@@ -73,9 +72,6 @@ export const indicesSchema = tb.Type.Object(
|
||||
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,13 +6,7 @@ import {
|
||||
snakeToPascalWithSpaces,
|
||||
transformObject,
|
||||
} from "core/utils";
|
||||
import {
|
||||
type Field,
|
||||
PrimaryField,
|
||||
primaryFieldTypes,
|
||||
type TActionContext,
|
||||
type TRenderContext,
|
||||
} from "../fields";
|
||||
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
@@ -24,7 +18,6 @@ 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,
|
||||
@@ -75,14 +68,7 @@ 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(undefined, {
|
||||
format: this.config.primary_format,
|
||||
}),
|
||||
];
|
||||
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
|
||||
|
||||
if (fields) {
|
||||
fields.forEach((field) => this.addField(field));
|
||||
@@ -294,15 +280,6 @@ export class Entity<
|
||||
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
toTypes() {
|
||||
return {
|
||||
name: this.name,
|
||||
type: this.type,
|
||||
comment: this.config.description,
|
||||
fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])),
|
||||
};
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type,
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import type { Entity, EntityManager, EntityRelation, TEntityType } from "data";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { AppAuth, AppMedia } from "modules";
|
||||
|
||||
export type TEntityTSType = {
|
||||
name: string;
|
||||
type: TEntityType;
|
||||
comment?: string;
|
||||
fields: Record<string, TFieldTSType>;
|
||||
};
|
||||
|
||||
// [select, insert, update]
|
||||
type TFieldContextType = boolean | [boolean, boolean, boolean];
|
||||
|
||||
export type TFieldTSType = {
|
||||
required?: TFieldContextType;
|
||||
fillable?: TFieldContextType;
|
||||
type: "PrimaryFieldType" | string;
|
||||
comment?: string;
|
||||
import?: {
|
||||
package: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type EntityTypescriptOptions = {
|
||||
indentWidth?: number;
|
||||
indentChar?: string;
|
||||
entityCommentMultiline?: boolean;
|
||||
fieldCommentMultiline?: boolean;
|
||||
};
|
||||
|
||||
// keep a local copy here until properties have a type
|
||||
const systemEntities = {
|
||||
users: AppAuth.usersFields,
|
||||
media: AppMedia.mediaFields,
|
||||
};
|
||||
|
||||
export class EntityTypescript {
|
||||
constructor(
|
||||
protected em: EntityManager,
|
||||
protected _options: EntityTypescriptOptions = {},
|
||||
) {}
|
||||
|
||||
get options() {
|
||||
return {
|
||||
...this._options,
|
||||
indentWidth: 2,
|
||||
indentChar: " ",
|
||||
entityCommentMultiline: true,
|
||||
fieldCommentMultiline: false,
|
||||
};
|
||||
}
|
||||
|
||||
toTypes() {
|
||||
return this.em.entities.map((e) => e.toTypes());
|
||||
}
|
||||
|
||||
protected getTab(count = 1) {
|
||||
return this.options.indentChar.repeat(this.options.indentWidth).repeat(count);
|
||||
}
|
||||
|
||||
collectImports(
|
||||
type: TEntityTSType,
|
||||
imports: Record<string, string[]> = {},
|
||||
): Record<string, string[]> {
|
||||
for (const [, entity_type] of Object.entries(type.fields)) {
|
||||
for (const imp of entity_type.import ?? []) {
|
||||
const name = imp.name;
|
||||
const pkg = imp.package;
|
||||
if (!imports[pkg]) {
|
||||
imports[pkg] = [];
|
||||
}
|
||||
if (!imports[pkg].includes(name)) {
|
||||
imports[pkg].push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
typeName(name: string) {
|
||||
return autoFormatString(name);
|
||||
}
|
||||
|
||||
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {
|
||||
let string = "";
|
||||
const coment_multiline = this.options.fieldCommentMultiline;
|
||||
const indent = opts?.indent ?? 1;
|
||||
for (const [field_name, field_type] of Object.entries(type.fields)) {
|
||||
if (opts?.ignore_fields?.includes(field_name)) continue;
|
||||
|
||||
let f = "";
|
||||
f += this.commentString(field_type.comment, indent, coment_multiline);
|
||||
f += `${this.getTab(indent)}${field_name}${field_type.required ? "" : "?"}: `;
|
||||
f += field_type.type + ";";
|
||||
f += "\n";
|
||||
string += f;
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
relationToFieldType(relation: EntityRelation, entity: Entity) {
|
||||
const other = relation.other(entity);
|
||||
const listable = relation.isListableFor(entity);
|
||||
const name = this.typeName(other.entity.name);
|
||||
|
||||
let type = name;
|
||||
if (other.entity.type === "system") {
|
||||
type = `DB["${other.entity.name}"]`;
|
||||
}
|
||||
|
||||
return {
|
||||
fields: {
|
||||
[other.reference]: {
|
||||
required: false,
|
||||
type: `${type}${listable ? "[]" : ""}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
importsToString(imports: Record<string, string[]>) {
|
||||
const strings: string[] = [];
|
||||
for (const [pkg, names] of Object.entries(imports)) {
|
||||
strings.push(`import type { ${names.join(", ")} } from "${pkg}";`);
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
commentString(comment?: string, indents = 0, multiline = true) {
|
||||
if (!comment) return "";
|
||||
const indent = this.getTab(indents);
|
||||
if (!multiline) return `${indent}// ${comment}\n`;
|
||||
return `${indent}/**\n${indent} * ${comment}\n${indent} */\n`;
|
||||
}
|
||||
|
||||
entityToTypeString(
|
||||
entity: Entity,
|
||||
opts?: { ignore_fields?: string[]; indent?: number; export?: boolean },
|
||||
) {
|
||||
const type = entity.toTypes();
|
||||
const name = this.typeName(type.name);
|
||||
const indent = opts?.indent ?? 1;
|
||||
const min_indent = Math.max(0, indent - 1);
|
||||
|
||||
let s = this.commentString(type.comment, min_indent, this.options.entityCommentMultiline);
|
||||
s += `${opts?.export ? "export " : ""}interface ${name} {\n`;
|
||||
s += this.fieldTypesToString(type, opts);
|
||||
|
||||
// add listable relations
|
||||
const relations = this.em.relations.relationsOf(entity);
|
||||
const rel_types = relations.map((r) =>
|
||||
this.relationToFieldType(r, entity),
|
||||
) as TEntityTSType[];
|
||||
for (const rel_type of rel_types) {
|
||||
s += this.fieldTypesToString(rel_type, {
|
||||
indent,
|
||||
});
|
||||
}
|
||||
s += `${this.getTab(min_indent)}}`;
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const strings: string[] = [];
|
||||
const tables: Record<string, string> = {};
|
||||
const imports: Record<string, string[]> = {
|
||||
"bknd/core": ["DB"],
|
||||
kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
|
||||
};
|
||||
|
||||
// add global types
|
||||
let g = "declare global {\n";
|
||||
g += `${this.getTab(1)}type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;\n`;
|
||||
g += `${this.getTab(1)}type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;\n`;
|
||||
g += `${this.getTab(1)}type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;\n`;
|
||||
g += "}";
|
||||
strings.push(g);
|
||||
|
||||
const system_entities = this.em.entities.filter((e) => e.type === "system");
|
||||
|
||||
for (const entity of this.em.entities) {
|
||||
// skip system entities, declare addtional props in the DB interface
|
||||
if (system_entities.includes(entity)) continue;
|
||||
|
||||
const type = entity.toTypes();
|
||||
if (!type) continue;
|
||||
this.collectImports(type, imports);
|
||||
tables[type.name] = this.typeName(type.name);
|
||||
const s = this.entityToTypeString(entity, {
|
||||
export: true,
|
||||
});
|
||||
strings.push(s);
|
||||
}
|
||||
|
||||
// write tables
|
||||
let tables_string = "interface Database {\n";
|
||||
for (const [name, type] of Object.entries(tables)) {
|
||||
tables_string += `${this.getTab(1)}${name}: ${type};\n`;
|
||||
}
|
||||
tables_string += "}";
|
||||
strings.push(tables_string);
|
||||
|
||||
// merge
|
||||
let merge = `declare module "bknd/core" {\n`;
|
||||
for (const systemEntity of system_entities) {
|
||||
const system_fields = Object.keys(systemEntities[systemEntity.name]);
|
||||
const additional_fields = systemEntity.fields
|
||||
.filter((f) => !system_fields.includes(f.name) && f.type !== "primary")
|
||||
.map((f) => f.name);
|
||||
if (additional_fields.length === 0) continue;
|
||||
|
||||
merge += `${this.getTab(1)}${this.entityToTypeString(systemEntity, {
|
||||
ignore_fields: ["id", ...system_fields],
|
||||
indent: 2,
|
||||
})}\n\n`;
|
||||
}
|
||||
|
||||
merge += `${this.getTab(1)}interface DB extends Database {}\n}`;
|
||||
strings.push(merge);
|
||||
|
||||
const final = [this.importsToString(imports).join("\n"), strings.join("\n\n")];
|
||||
return final.join("\n\n");
|
||||
}
|
||||
}
|
||||
@@ -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/query";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
|
||||
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;
|
||||
let validatedData = {
|
||||
const validatedData = {
|
||||
...entity.getDefaultObject(),
|
||||
...(await this.getValidatedData(_data, "create")),
|
||||
};
|
||||
@@ -159,16 +159,6 @@ 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)
|
||||
@@ -185,7 +175,7 @@ export class Mutator<
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!id) {
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for update");
|
||||
}
|
||||
|
||||
@@ -222,7 +212,7 @@ export class Mutator<
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!id) {
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ 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, getRepoQueryTemplate } from "data/server/query";
|
||||
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
@@ -27,7 +28,6 @@ export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
|
||||
data: T;
|
||||
meta: {
|
||||
items: number;
|
||||
has_more?: boolean;
|
||||
total?: number;
|
||||
count?: number;
|
||||
time?: number;
|
||||
@@ -62,10 +62,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -88,14 +84,14 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
}
|
||||
}
|
||||
|
||||
getValidOptions(options?: RepoQuery): RepoQuery {
|
||||
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
const entity = this.entity;
|
||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||
const validated = {
|
||||
...structuredClone(getRepoQueryTemplate()),
|
||||
...cloneDeep(defaultQuerySchema),
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect(),
|
||||
} satisfies Required<RepoQuery>;
|
||||
};
|
||||
|
||||
if (!options) return validated;
|
||||
|
||||
@@ -103,15 +99,12 @@ 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 = {
|
||||
dir: "asc",
|
||||
...options.sort,
|
||||
};
|
||||
validated.sort = options.sort;
|
||||
}
|
||||
|
||||
if (options.select && options.select.length > 0) {
|
||||
@@ -184,11 +177,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -220,7 +208,6 @@ 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 = {
|
||||
@@ -455,16 +442,6 @@ 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;
|
||||
@@ -528,7 +505,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 });
|
||||
|
||||
@@ -536,7 +513,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,7 +90,6 @@ 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 "data/server/query";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
|
||||
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
|
||||
static override slug = "mutator-insert-before";
|
||||
|
||||
@@ -88,11 +88,4 @@ export class BooleanField<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType() {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "boolean",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "../entities";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import { $console } from "core";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const dateFieldConfigSchema = Type.Composite(
|
||||
@@ -145,11 +144,4 @@ export class DateField<Required extends true | false = false> extends Field<
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "Date | string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const enumFieldConfigSchema = Type.Composite(
|
||||
@@ -141,14 +140,4 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
const union = this.getOptions().map(({ value }) =>
|
||||
typeof value === "string" ? `"${value}"` : value,
|
||||
);
|
||||
return {
|
||||
...super.toType(),
|
||||
type: union.length > 0 ? union.join(" | ") : "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { EntityManager } from "../entities";
|
||||
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
||||
import type { FieldSpec } from "data/connection/Connection";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
// @todo: contexts need to be reworked
|
||||
@@ -236,14 +235,6 @@ export abstract class Field<
|
||||
return this.toSchemaWrapIfRequired(Type.Any());
|
||||
}
|
||||
|
||||
toType(): TFieldTSType {
|
||||
return {
|
||||
required: this.isRequired(),
|
||||
comment: this.getDescription(),
|
||||
type: "any",
|
||||
};
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
// @todo: current workaround because of fixed string type
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
||||
@@ -99,11 +98,4 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "any",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
||||
import { Default, FromSchema, objectToJsLiteral, type Static } from "core/utils";
|
||||
import { Default, FromSchema, type Static } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const jsonSchemaFieldConfigSchema = Type.Composite(
|
||||
@@ -122,12 +121,4 @@ export class JsonSchemaField<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
import: [{ package: "json-schema-to-ts", name: "FromSchema" }],
|
||||
type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
export const numberFieldConfigSchema = Type.Composite(
|
||||
@@ -103,11 +102,4 @@ export class NumberField<Required extends true | false = false> extends Field<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { config } from "core";
|
||||
import { StringEnum, uuidv7, type Static } from "core/utils";
|
||||
import 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)),
|
||||
}),
|
||||
]);
|
||||
@@ -25,8 +20,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
> {
|
||||
override readonly type = "primary";
|
||||
|
||||
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
|
||||
super(name, { fillable: false, required: false, ...cfg });
|
||||
constructor(name: string = config.data.default_primary_field) {
|
||||
super(name, { fillable: false, required: false });
|
||||
}
|
||||
|
||||
override isRequired(): boolean {
|
||||
@@ -34,53 +29,23 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
return primaryFieldConfigSchema;
|
||||
}
|
||||
|
||||
get format() {
|
||||
return this.config.format ?? "integer";
|
||||
}
|
||||
|
||||
get fieldType() {
|
||||
return this.format === "integer" ? "integer" : "text";
|
||||
return baseFieldConfigSchema;
|
||||
}
|
||||
|
||||
override schema() {
|
||||
return Object.freeze({
|
||||
type: this.fieldType,
|
||||
type: "integer",
|
||||
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<${type}>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,11 +121,4 @@ export class TextField<Required extends true | false = false> extends Field<
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType() {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "string",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,10 @@ export * from "./connection";
|
||||
export {
|
||||
type RepoQuery,
|
||||
type RepoQueryIn,
|
||||
getRepoQueryTemplate,
|
||||
repoQuery,
|
||||
} from "./server/query";
|
||||
|
||||
export type { WhereQuery } from "./entities/query/WhereBuilder";
|
||||
defaultQuerySchema,
|
||||
querySchema,
|
||||
whereSchema,
|
||||
} from "./server/data-query-impl";
|
||||
|
||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||
|
||||
|
||||
@@ -6,15 +6,11 @@ import {
|
||||
type MutationInstructionResponse,
|
||||
RelationHelper,
|
||||
} from "../relations";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
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;
|
||||
export type TDirection = (typeof directions)[number];
|
||||
|
||||
export type KyselyJsonFrom = any;
|
||||
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
@@ -31,7 +27,7 @@ export abstract class EntityRelation<
|
||||
|
||||
// @todo: add unit tests
|
||||
// allowed directions, used in RelationAccessor for visibility
|
||||
directions: TDirection[] = ["source", "target"];
|
||||
directions: ("source" | "target")[] = ["source", "target"];
|
||||
|
||||
static schema = Type.Object({
|
||||
mappedBy: Type.Optional(Type.String()),
|
||||
@@ -73,7 +69,7 @@ export abstract class EntityRelation<
|
||||
reference: string,
|
||||
): KyselyQueryBuilder;
|
||||
|
||||
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
|
||||
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -106,10 +102,6 @@ export abstract class EntityRelation<
|
||||
);
|
||||
}
|
||||
|
||||
self(entity: Entity | string): EntityRelationAnchor {
|
||||
return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source;
|
||||
}
|
||||
|
||||
ref(reference: string): EntityRelationAnchor {
|
||||
return this.source.reference === reference ? this.source : this.target;
|
||||
}
|
||||
|
||||
@@ -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/query";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
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/query";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
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/query";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type Static, StringEnum } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
|
||||
import { Field, baseFieldConfigSchema } from "../fields";
|
||||
import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||
@@ -15,7 +14,6 @@ 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" })),
|
||||
}),
|
||||
]);
|
||||
@@ -46,7 +44,6 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +62,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
override schema() {
|
||||
return Object.freeze({
|
||||
...super.schema()!,
|
||||
type: this.config.target_field_type ?? "integer",
|
||||
type: "integer",
|
||||
references: `${this.config.target}.${this.config.target_field}`,
|
||||
onDelete: this.config.on_delete ?? "set null",
|
||||
});
|
||||
@@ -86,11 +83,4 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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[];
|
||||
@@ -333,7 +332,6 @@ 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}`);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
@@ -1,184 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
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>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $console, type AppEntity } from "core";
|
||||
import { $console, type PrimaryFieldType } from "core";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
|
||||
import { Module } from "modules/Module";
|
||||
@@ -17,9 +17,8 @@ import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-sche
|
||||
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
declare module "core" {
|
||||
interface Media extends AppEntity, MediaFieldSchema {}
|
||||
interface DB {
|
||||
media: Media;
|
||||
media: { id: PrimaryFieldType } & MediaFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@ import { DataPermissions } from "data";
|
||||
import { Controller } from "modules/Controller";
|
||||
import type { AppMedia } from "../AppMedia";
|
||||
import { MediaField } from "../MediaField";
|
||||
import { jsc, s, describeRoute } from "core/object/schema";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
|
||||
export class MediaController extends Controller {
|
||||
constructor(private readonly media: AppMedia) {
|
||||
@@ -26,165 +31,90 @@ 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",
|
||||
describeRoute({
|
||||
summary: "Get the list of files",
|
||||
tags: ["media"],
|
||||
}),
|
||||
permission(MediaPermissions.listFiles),
|
||||
async (c) => {
|
||||
const files = await this.getStorageAdapter().listObjects();
|
||||
return c.json(files);
|
||||
},
|
||||
);
|
||||
hono.get("/files", 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",
|
||||
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");
|
||||
}
|
||||
hono.get("/file/:filename", 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",
|
||||
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);
|
||||
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);
|
||||
|
||||
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",
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
hono.post("/inspect", 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?",
|
||||
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");
|
||||
hono.post("/upload/:filename?", 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",
|
||||
describeRoute({
|
||||
summary: "Add a file to an entity",
|
||||
tags: ["media"],
|
||||
requestBody,
|
||||
}),
|
||||
jsc(
|
||||
"param",
|
||||
s.object({
|
||||
entity: entitiesEnum,
|
||||
id: s.number(),
|
||||
field: s.string(),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
overwrite: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||
async (c) => {
|
||||
const entity_name = c.req.param("entity");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { App } from "App";
|
||||
import { type Context, type Env, Hono } from "hono";
|
||||
import { type Context, 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 = Env & {
|
||||
export type ServerEnv = {
|
||||
Variables: {
|
||||
app: App;
|
||||
// to prevent resolving auth multiple times
|
||||
@@ -48,9 +46,4 @@ 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 = initial && key in initial ? initial[key] : {};
|
||||
const moduleConfig = 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.options.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||
}
|
||||
|
||||
private withAdminBasePath(route: string = "") {
|
||||
@@ -80,51 +80,25 @@ export class AdminController extends Controller {
|
||||
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
|
||||
login: this.withAdminBasePath("/auth/login"),
|
||||
register: this.withAdminBasePath("/auth/register"),
|
||||
logout: "/api/auth/logout",
|
||||
logout: this.withAdminBasePath("/auth/logout"),
|
||||
};
|
||||
|
||||
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
|
||||
if (isDebug()) {
|
||||
paths.push("/test/*");
|
||||
}
|
||||
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);
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
if (auth_enabled) {
|
||||
const redirectRouteParams = [
|
||||
@@ -152,6 +126,27 @@ 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;
|
||||
}
|
||||
|
||||
@@ -199,13 +194,9 @@ export class AdminController extends Controller {
|
||||
}).then((res) => res.default);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// @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;
|
||||
}
|
||||
|
||||
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import { getRuntimeKey } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
import { openAPISpecs } from "jsonv-ts/hono";
|
||||
import { swaggerUI } from "@hono/swagger-ui";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
import {
|
||||
MODULE_NAMES,
|
||||
type ModuleConfigs,
|
||||
@@ -23,8 +24,12 @@ import {
|
||||
getDefaultConfig,
|
||||
} from "modules/ModuleManager";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { jsc, s, describeRoute } from "core/object/schema";
|
||||
import { getVersion } from "core/env";
|
||||
import { generateOpenAPI } from "modules/server/openapi";
|
||||
|
||||
const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
|
||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||
success: true;
|
||||
module: Key;
|
||||
@@ -56,27 +61,20 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.use(permission(SystemPermissions.configRead));
|
||||
|
||||
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("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
|
||||
// @ts-expect-error "fetch" is private
|
||||
return c.json(await this.app.modules.fetch());
|
||||
});
|
||||
|
||||
hono.get(
|
||||
"/:module?",
|
||||
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() })),
|
||||
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
secrets: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
// @todo: allow secrets if authenticated user is admin
|
||||
const { secrets } = c.req.valid("query");
|
||||
@@ -121,7 +119,12 @@ export class SystemController extends Controller {
|
||||
hono.post(
|
||||
"/set/:module",
|
||||
permission(SystemPermissions.configWrite),
|
||||
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
force: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as any;
|
||||
const { force } = c.req.valid("query");
|
||||
@@ -227,17 +230,13 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/schema/:module?",
|
||||
describeRoute({
|
||||
summary: "Get the schema for a module",
|
||||
tags: ["system"],
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead),
|
||||
jsc(
|
||||
tb(
|
||||
"query",
|
||||
s.partialObject({
|
||||
config: s.boolean(),
|
||||
secrets: s.boolean(),
|
||||
fresh: s.boolean(),
|
||||
Type.Object({
|
||||
config: Type.Optional(booleanLike),
|
||||
secrets: Type.Optional(booleanLike),
|
||||
fresh: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -275,11 +274,13 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.post(
|
||||
"/build",
|
||||
describeRoute({
|
||||
summary: "Build the app",
|
||||
tags: ["system"],
|
||||
}),
|
||||
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
sync: Type.Optional(booleanLike),
|
||||
fetch: Type.Optional(booleanLike),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const options = c.req.valid("query") as Record<string, boolean>;
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||
@@ -292,45 +293,26 @@ export class SystemController extends Controller {
|
||||
},
|
||||
);
|
||||
|
||||
hono.get(
|
||||
"/ping",
|
||||
describeRoute({
|
||||
summary: "Ping the server",
|
||||
tags: ["system"],
|
||||
}),
|
||||
(c) => c.json({ pong: true }),
|
||||
);
|
||||
hono.get("/ping", (c) => c.json({ pong: true }));
|
||||
|
||||
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("/info", (c) =>
|
||||
c.json({
|
||||
version: c.get("app")?.version(),
|
||||
runtime: getRuntimeKey(),
|
||||
timezone: {
|
||||
name: getTimezone(),
|
||||
offset: getTimezoneOffset(),
|
||||
local: datetimeStringLocal(),
|
||||
utc: datetimeStringUTC(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||
|
||||
return hono;
|
||||
hono.get("/openapi.json", async (c) => {
|
||||
const config = getDefaultConfig();
|
||||
return c.json(generateOpenAPI(config));
|
||||
});
|
||||
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,52 @@
|
||||
import { Api, type ApiOptions, type AuthState } from "Api";
|
||||
import { Api, type ApiOptions, type TApiUser } from "Api";
|
||||
import { isDebug } from "core";
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { AdminBkndWindowContext } from "modules/server/AdminController";
|
||||
|
||||
export type BkndClientContext = {
|
||||
baseUrl: string;
|
||||
api: Api;
|
||||
authState?: Partial<AuthState>;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<BkndClientContext>(undefined!);
|
||||
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
|
||||
baseUrl: undefined,
|
||||
} as any);
|
||||
|
||||
export type ClientProviderProps = {
|
||||
children?: ReactNode;
|
||||
baseUrl?: string;
|
||||
} & ApiOptions;
|
||||
} & (
|
||||
| { baseUrl?: string; user?: TApiUser | null | undefined }
|
||||
| {
|
||||
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;
|
||||
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
|
||||
let api: Api;
|
||||
|
||||
if (winCtx) {
|
||||
user = winCtx.user;
|
||||
}
|
||||
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 ?? "";
|
||||
|
||||
if (!actualBaseUrl) {
|
||||
try {
|
||||
actualBaseUrl = window.location.origin;
|
||||
} catch (e) {}
|
||||
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() });
|
||||
}
|
||||
|
||||
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, authState }}>
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
);
|
||||
@@ -73,16 +61,12 @@ 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 = useClientContext();
|
||||
return context?.baseUrl;
|
||||
const context = useContext(ClientContext);
|
||||
return context.baseUrl;
|
||||
};
|
||||
|
||||
export function useBkndWindowContext(): AdminBkndWindowContext {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { DB, PrimaryFieldType } from "core";
|
||||
import { objectTransform } from "core/utils/objects";
|
||||
import { encodeSearch } from "core/utils/reqres";
|
||||
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||
import type { EntityData, RepoQueryIn } from "data";
|
||||
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
|
||||
export class UseEntityApiError<Payload = any> extends Error {
|
||||
@@ -24,26 +23,6 @@ export class UseEntityApiError<Payload = any> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface UseEntityReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
Response = ResponseObject<RepositoryResponse<Selectable<Data>>>,
|
||||
> {
|
||||
create: (input: Insertable<Data>) => Promise<Response>;
|
||||
read: (
|
||||
query?: RepoQueryIn,
|
||||
) => Promise<
|
||||
ResponseObject<
|
||||
RepositoryResponse<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>
|
||||
>
|
||||
>;
|
||||
update: Id extends undefined
|
||||
? (input: Updateable<Data>, id: Id) => Promise<Response>
|
||||
: (input: Updateable<Data>) => Promise<Response>;
|
||||
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
@@ -51,26 +30,28 @@ export const useEntity = <
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
): UseEntityReturn<Entity, Id, Data> => {
|
||||
) => {
|
||||
const api = useApi().data;
|
||||
|
||||
return {
|
||||
create: async (input: Insertable<Data>) => {
|
||||
const res = await api.createOne(entity, input as any);
|
||||
create: async (input: Omit<Data, "id">) => {
|
||||
const res = await api.createOne(entity, input);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
return res;
|
||||
},
|
||||
read: async (query?: RepoQueryIn) => {
|
||||
read: async (query: RepoQueryIn = {}) => {
|
||||
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
// must be manually typed
|
||||
return res as unknown as Id extends undefined
|
||||
? ResponseObject<Data[]>
|
||||
: ResponseObject<Data>;
|
||||
},
|
||||
// @ts-ignore
|
||||
update: async (input: Updateable<Data>, _id: PrimaryFieldType | undefined = id) => {
|
||||
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
|
||||
if (!_id) {
|
||||
throw new Error("id is required");
|
||||
}
|
||||
@@ -78,9 +59,8 @@ export const useEntity = <
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
return res;
|
||||
},
|
||||
// @ts-ignore
|
||||
_delete: async (_id: PrimaryFieldType | undefined = id) => {
|
||||
if (!_id) {
|
||||
throw new Error("id is required");
|
||||
@@ -90,7 +70,7 @@ export const useEntity = <
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
|
||||
}
|
||||
return res as any;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -111,19 +91,6 @@ export function makeKey(
|
||||
);
|
||||
}
|
||||
|
||||
interface UseEntityQueryReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? Selectable<DB[Entity]> : EntityData,
|
||||
Return = Id extends undefined ? ResponseObject<Data[]> : ResponseObject<Data>,
|
||||
> extends Omit<SWRResponse<Return>, "mutate">,
|
||||
Omit<ReturnType<typeof useEntity<Entity, Id>>, "read"> {
|
||||
mutate: (id?: PrimaryFieldType) => Promise<any>;
|
||||
mutateRaw: SWRResponse<Return>["mutate"];
|
||||
api: Api["data"];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useEntityQuery = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
@@ -132,11 +99,11 @@ export const useEntityQuery = <
|
||||
id?: Id,
|
||||
query?: RepoQueryIn,
|
||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean },
|
||||
): UseEntityQueryReturn<Entity, Id> => {
|
||||
) => {
|
||||
const api = useApi().data;
|
||||
const key = makeKey(api, entity as string, id, query);
|
||||
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
||||
const fetcher = () => read(query ?? {});
|
||||
const fetcher = () => read(query);
|
||||
|
||||
type T = Awaited<ReturnType<typeof fetcher>>;
|
||||
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
||||
@@ -169,7 +136,6 @@ export const useEntityQuery = <
|
||||
...swr,
|
||||
...mapped,
|
||||
mutate: mutateFn,
|
||||
// @ts-ignore
|
||||
mutateRaw: swr.mutate,
|
||||
api,
|
||||
key,
|
||||
@@ -178,8 +144,8 @@ export const useEntityQuery = <
|
||||
|
||||
export async function mutateEntityCache<
|
||||
Entity extends keyof DB | string,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Selectable<Data>>) {
|
||||
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
|
||||
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
|
||||
function update(prev: any, partialNext: any) {
|
||||
if (
|
||||
typeof prev !== "undefined" &&
|
||||
@@ -210,37 +176,28 @@ export async function mutateEntityCache<
|
||||
);
|
||||
}
|
||||
|
||||
interface UseEntityMutateReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
> extends Omit<ReturnType<typeof useEntityQuery<Entity, Id>>, "mutate"> {
|
||||
mutate: Id extends undefined
|
||||
? (id: PrimaryFieldType, data: Partial<Selectable<Data>>) => Promise<void>
|
||||
: (data: Partial<Selectable<Data>>) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useEntityMutate = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
||||
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
options?: SWRConfiguration,
|
||||
): UseEntityMutateReturn<Entity, Id, Data> => {
|
||||
) => {
|
||||
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
|
||||
...options,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const _mutate = id
|
||||
? (data: Partial<Selectable<Data>>) => mutateEntityCache($q.api, entity, id, data)
|
||||
: (id: PrimaryFieldType, data: Partial<Selectable<Data>>) =>
|
||||
mutateEntityCache($q.api, entity, id, data);
|
||||
? (data) => mutateEntityCache($q.api, entity, id, data)
|
||||
: (id, data) => mutateEntityCache($q.api, entity, id, data);
|
||||
|
||||
return {
|
||||
...$q,
|
||||
mutate: _mutate,
|
||||
} as any;
|
||||
mutate: _mutate as unknown as Id extends undefined
|
||||
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
|
||||
: (data: Partial<Data>) => Promise<void>,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: Partial<AuthState> | undefined;
|
||||
data: AuthState | undefined;
|
||||
user: AuthState["user"] | undefined;
|
||||
token: AuthState["token"] | undefined;
|
||||
verified: boolean;
|
||||
@@ -24,36 +24,46 @@ type UseAuth = {
|
||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
const api = useApi(options?.baseUrl);
|
||||
const invalidate = useInvalidate();
|
||||
const { authState } = useClientContext();
|
||||
const authState = api.getAuthState();
|
||||
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
||||
const verified = authState?.verified ?? false;
|
||||
|
||||
function updateAuthState() {
|
||||
setAuthData(api.getAuthState());
|
||||
}
|
||||
|
||||
async function login(input: LoginData) {
|
||||
const res = await api.auth.login("password", input);
|
||||
const res = await api.auth.loginWithPassword(input);
|
||||
updateAuthState();
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function register(input: LoginData) {
|
||||
const res = await api.auth.register("password", input);
|
||||
const res = await api.auth.registerWithPassword(input);
|
||||
updateAuthState();
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
api.updateToken(token);
|
||||
updateAuthState();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
api.updateToken(undefined);
|
||||
await api.updateToken(undefined);
|
||||
setAuthData(undefined);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await api.verifyAuth();
|
||||
updateAuthState();
|
||||
}
|
||||
|
||||
return {
|
||||
data: authState,
|
||||
user: authState?.user,
|
||||
token: authState?.token,
|
||||
data: authData,
|
||||
user: authData?.user,
|
||||
token: authData?.token,
|
||||
verified,
|
||||
login,
|
||||
register,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
default as CodeMirror,
|
||||
type ReactCodeMirrorProps,
|
||||
EditorView,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
@@ -47,7 +43,7 @@ export default function CodeEditor({
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable={editable}
|
||||
basicSetup={_basicSetup}
|
||||
extensions={[...extensions, EditorView.lineWrapping]}
|
||||
extensions={extensions}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface CollapsibleListRootProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const Root = ({ className, ...props }: CollapsibleListRootProps) => (
|
||||
<div className={twMerge("flex flex-col gap-2 max-w-4xl", className)} {...props} />
|
||||
);
|
||||
|
||||
export interface CollapsibleListItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
hasError?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Item = ({ className, hasError, disabled, ...props }: CollapsibleListItemProps) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col border border-muted rounded bg-background",
|
||||
hasError && "border-error",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface CollapsibleListPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}
|
||||
|
||||
const Preview = ({ className, left, right, children, ...props }: CollapsibleListPreviewProps) => (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge("flex flex-row justify-between p-3 gap-3 items-center", className)}
|
||||
>
|
||||
{left && <div className="flex flex-row items-center p-2 bg-primary/5 rounded">{left}</div>}
|
||||
<div className="font-mono flex-grow flex flex-row gap-3">{children}</div>
|
||||
{right && <div className="flex flex-row gap-4 items-center">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface CollapsibleListDetailProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
const Detail = ({ className, open, ...props }: CollapsibleListDetailProps) =>
|
||||
open && (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CollapsibleList = {
|
||||
Root,
|
||||
Item,
|
||||
Preview,
|
||||
Detail,
|
||||
};
|
||||
@@ -19,7 +19,6 @@ export type DropdownItem =
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
@@ -143,7 +142,6 @@ 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,7 +34,6 @@ export type DataTableProps<Data> = {
|
||||
checkable?: boolean;
|
||||
onClickRow?: (row: Data) => void;
|
||||
onClickPage?: (page: number) => void;
|
||||
hasMore?: boolean;
|
||||
total?: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@@ -60,7 +59,6 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
onClickRow,
|
||||
onClickPage,
|
||||
onClickSort,
|
||||
hasMore,
|
||||
total,
|
||||
sort,
|
||||
page = 1,
|
||||
@@ -77,7 +75,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 = hasMore === undefined ? Math.max(Math.ceil(total / perPage), 1) : undefined;
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const CellRender = renderValue || CellValue;
|
||||
|
||||
return (
|
||||
@@ -198,14 +196,12 @@ 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">
|
||||
{hasMore === undefined ? (
|
||||
<TableDisplay
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
items={data?.length || 0}
|
||||
total={total}
|
||||
/>
|
||||
) : null}
|
||||
<TableDisplay
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
items={data?.length || 0}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
{perPageOptions && (
|
||||
@@ -224,16 +220,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page} {pages ? `of ${pages}` : ""}
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav
|
||||
current={page}
|
||||
total={pages}
|
||||
hasMore={hasMore}
|
||||
onClick={onClickPage}
|
||||
/>
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -283,59 +274,41 @@ const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
const text = `Showing ${perPage * (page - 1) + 1}-${perPage * (page - 1) + items}`;
|
||||
if (total) {
|
||||
return `${text} of ${total} rows`;
|
||||
}
|
||||
|
||||
return text;
|
||||
return (
|
||||
<>
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TableNavProps = {
|
||||
current: number;
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
};
|
||||
|
||||
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 TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: !hasMore },
|
||||
{
|
||||
value: _hasMore === undefined ? total : undefined,
|
||||
Icon: TbChevronsRight,
|
||||
disabled: !hasMore,
|
||||
},
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||
] as const;
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
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>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 "bknd/client";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
@@ -15,7 +14,7 @@ export type DropzoneContainerProps = {
|
||||
infinite?: boolean;
|
||||
entity?: {
|
||||
name: string;
|
||||
id: PrimaryFieldType;
|
||||
id: number;
|
||||
field: string;
|
||||
};
|
||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { use, createContext, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useParams } from "wouter";
|
||||
|
||||
// extract path segment from path, e.g. /auth/strategies/:strategy? -> "strategy"
|
||||
function extractPathSegment(path: string): string {
|
||||
const match = path.match(/:(\w+)\??/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
// get url by replacing path segment with identifier
|
||||
// e.g. /auth/strategies/:strategy? -> /auth/strategies/x
|
||||
function getPath(path: string, identifier?: string) {
|
||||
if (!identifier) {
|
||||
return path.replace(/\/:\w+\??/, "");
|
||||
}
|
||||
return path.replace(/:\w+\??/, identifier);
|
||||
}
|
||||
|
||||
export function useRoutePathState(_path?: string, identifier?: string) {
|
||||
const ctx = useRoutePathContext(_path ?? "");
|
||||
const path = _path ?? ctx?.path ?? "";
|
||||
const segment = extractPathSegment(path);
|
||||
const routeIdentifier = useParams()[segment];
|
||||
const [localActive, setLocalActive] = useState(routeIdentifier === identifier);
|
||||
const active = ctx ? identifier === ctx.activeIdentifier : localActive;
|
||||
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
function toggle(_open?: boolean) {
|
||||
const open = _open ?? !localActive;
|
||||
|
||||
if (ctx) {
|
||||
ctx.setActiveIdentifier(identifier!);
|
||||
}
|
||||
|
||||
if (path) {
|
||||
if (open) {
|
||||
navigate(getPath(path, identifier));
|
||||
} else {
|
||||
navigate(getPath(path));
|
||||
}
|
||||
} else {
|
||||
setLocalActive(open);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx && _path && identifier) {
|
||||
setLocalActive(routeIdentifier === identifier);
|
||||
}
|
||||
}, [routeIdentifier, identifier, _path]);
|
||||
|
||||
return {
|
||||
active,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
|
||||
type RoutePathStateContextType = {
|
||||
defaultIdentifier: string;
|
||||
path: string;
|
||||
activeIdentifier: string;
|
||||
setActiveIdentifier: (identifier: string) => void;
|
||||
};
|
||||
const RoutePathStateContext = createContext<RoutePathStateContextType>(undefined!);
|
||||
|
||||
export function RoutePathStateProvider({
|
||||
children,
|
||||
defaultIdentifier,
|
||||
path,
|
||||
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
|
||||
const segment = extractPathSegment(path);
|
||||
const routeIdentifier = useParams()[segment];
|
||||
const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier);
|
||||
return (
|
||||
<RoutePathStateContext.Provider
|
||||
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
|
||||
>
|
||||
{children}
|
||||
</RoutePathStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useRoutePathContext(path?: string) {
|
||||
const ctx = use(RoutePathStateContext);
|
||||
if (ctx && (!path || ctx.path === path)) {
|
||||
return ctx;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,47 +1,46 @@
|
||||
import { decodeSearch, encodeSearch, mergeObject, parseDecode } from "core/utils";
|
||||
import {
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
decodeSearch,
|
||||
encodeSearch,
|
||||
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 s.TAnySchema = s.TAnySchema>(
|
||||
_schema: Schema,
|
||||
defaultValue?: Partial<s.StaticCoerced<Schema>>,
|
||||
export function useSearch<Schema extends TSchema = TSchema>(
|
||||
schema: Schema,
|
||||
defaultValue?: Partial<StaticDecode<Schema>>,
|
||||
) {
|
||||
const schema = cloneSchema(_schema as any) as s.TSchema;
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
|
||||
const value = parse(schema, initial, {
|
||||
withDefaults: true,
|
||||
clone: true,
|
||||
}) as s.StaticCoerced<Schema>;
|
||||
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
|
||||
|
||||
// @ts-ignore
|
||||
const _defaults = mergeObject(schema.template({ withOptional: true }), defaultValue ?? {});
|
||||
if (searchString.length > 0) {
|
||||
value = parseDecode(schema, decodeSearch(searchString));
|
||||
//console.log("search:decode", value);
|
||||
}
|
||||
|
||||
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 : ""));
|
||||
}
|
||||
// @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 : ""));
|
||||
}
|
||||
|
||||
return {
|
||||
value: value as Required<s.StaticCoerced<Schema>>,
|
||||
value: value as Required<StaticDecode<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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import type { IconType } from "react-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
||||
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { useLocation } from "wouter";
|
||||
@@ -377,15 +376,6 @@ export function Scrollable({
|
||||
);
|
||||
}
|
||||
|
||||
type SectionHeaderAccordionItemProps = {
|
||||
title: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const SectionHeaderAccordionItem = ({
|
||||
title,
|
||||
open,
|
||||
@@ -393,7 +383,14 @@ export const SectionHeaderAccordionItem = ({
|
||||
ActiveIcon = IconChevronUp,
|
||||
children,
|
||||
renderHeaderRight,
|
||||
}: SectionHeaderAccordionItemProps) => (
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
ActiveIcon?: any;
|
||||
children?: React.ReactNode;
|
||||
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{ minHeight: 49 }}
|
||||
className={twMerge(
|
||||
@@ -425,19 +422,6 @@ export const SectionHeaderAccordionItem = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RouteAwareSectionHeaderAccordionItem = ({
|
||||
routePattern,
|
||||
identifier,
|
||||
...props
|
||||
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
|
||||
// it's optional because it could be provided using the context
|
||||
routePattern?: string;
|
||||
identifier: string;
|
||||
}) => {
|
||||
const { active, toggle } = useRoutePathState(routePattern, identifier);
|
||||
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
|
||||
};
|
||||
|
||||
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
||||
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
TbDatabase,
|
||||
TbFingerprint,
|
||||
@@ -24,7 +24,6 @@ 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();
|
||||
@@ -160,16 +159,6 @@ 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) {
|
||||
@@ -177,8 +166,7 @@ function UserMenu() {
|
||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||
} else {
|
||||
items.push({
|
||||
label: "Logout",
|
||||
title: `Logout ${auth.user.email}`,
|
||||
label: `Logout ${auth.user.email}`,
|
||||
onClick: handleLogout,
|
||||
icon: IconKeyOff,
|
||||
});
|
||||
@@ -188,11 +176,6 @@ 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 (
|
||||
<>
|
||||
|
||||
@@ -22,7 +22,6 @@ 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>;
|
||||
@@ -31,7 +30,7 @@ export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, an
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: PrimaryFieldType;
|
||||
entityId?: number;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
@@ -226,7 +225,7 @@ function EntityMediaFormField({
|
||||
formApi: FormApi;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: PrimaryFieldType;
|
||||
entityId?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
@@ -11,14 +11,12 @@ 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);
|
||||
@@ -84,8 +82,6 @@ export function StepEntityFields() {
|
||||
ref={ref}
|
||||
fields={initial.fields as any}
|
||||
onChange={updateListener}
|
||||
defaultPrimaryFormat={config?.default_primary_format}
|
||||
isNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user