mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +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
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: "1.2.14"
|
bun-version: "1.2.5"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
+1
-3
@@ -29,6 +29,4 @@ packages/media/.env
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.git_old
|
.git_old
|
||||||
docker/tmp
|
docker/tmp
|
||||||
.debug
|
|
||||||
.history
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
[](https://npmjs.org/package/bknd)
|
[](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.
|
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||||
|
|
||||||
## Size
|
## 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.
|
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
|
## 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.
|
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 { 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", () => {
|
describe("object-query", () => {
|
||||||
const q: ObjectQuery = { name: "Michael" };
|
const q: ObjectQuery = { name: "Michael" };
|
||||||
|
|||||||
+80
-13
@@ -1,18 +1,25 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { getDummyConnection } from "../helper";
|
import { Value, _jsonp } from "../../src/core/utils";
|
||||||
import { type WhereQuery, WhereBuilder } from "data";
|
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 decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
||||||
const c = getDummyConnection();
|
const result = Value.Decode(querySchema, input);
|
||||||
const kysely = c.dummyConnection.kysely;
|
expect(result).toEqual(expected);
|
||||||
return kysely.selectFrom("t").selectAll();
|
};
|
||||||
}
|
|
||||||
function compile(q: WhereQuery) {
|
describe("data-query-impl", () => {
|
||||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
function qb() {
|
||||||
return { sql, parameters };
|
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", () => {
|
test("single validation", () => {
|
||||||
const tests: [WhereQuery, string, any[]][] = [
|
const tests: [WhereQuery, string, any[]][] = [
|
||||||
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
||||||
@@ -87,4 +94,64 @@ describe("WhereBuilder", () => {
|
|||||||
expect(keys).toEqual(expectedKeys);
|
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);
|
new EntityManager([entity, entity2], connection);
|
||||||
}).toThrow();
|
}).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 { describe, expect, test } from "bun:test";
|
||||||
import { Entity, NumberField, TextField } from "data";
|
import { Entity, NumberField, TextField } from "../../../src/data";
|
||||||
import * as p from "data/prototype";
|
|
||||||
|
|
||||||
describe("[data] Entity", async () => {
|
describe("[data] Entity", async () => {
|
||||||
const entity = new Entity("test", [
|
const entity = new Entity("test", [
|
||||||
@@ -48,7 +47,14 @@ describe("[data] Entity", async () => {
|
|||||||
expect(entity.getField("new_field")).toBe(field);
|
expect(entity.getField("new_field")).toBe(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only("types", async () => {
|
// @todo: move this to ClientApp
|
||||||
console.log(entity.toTypes());
|
/*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));
|
em.addRelation(new ManyToOneRelation(posts, users));
|
||||||
expect(em.relations.all.length).toBe(1);
|
expect(em.relations.all.length).toBe(1);
|
||||||
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
|
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
|
||||||
expect(em.relationsOf("users")).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.relationsOf("posts")).toEqual([em.relations.all[0]]);
|
||||||
expect(em.hasRelations("users")).toBe(true);
|
expect(em.hasRelations("users")).toBe(true);
|
||||||
expect(em.hasRelations("posts")).toBe(true);
|
expect(em.hasRelations("posts")).toBe(true);
|
||||||
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
|
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
|
||||||
|
|||||||
@@ -39,28 +39,4 @@ describe("[data] PrimaryField", async () => {
|
|||||||
expect(field.transformPersist(1)).rejects.toThrow();
|
expect(field.transformPersist(1)).rejects.toThrow();
|
||||||
expect(field.transformRetrieve(1)).toBe(1);
|
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);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("MediaController", () => {
|
describe("MediaController", () => {
|
||||||
test("accepts direct", async () => {
|
test.only("accepts direct", async () => {
|
||||||
const app = await makeApp();
|
const app = await makeApp();
|
||||||
console.log("app", app);
|
|
||||||
|
|
||||||
const file = Bun.file(path);
|
const file = Bun.file(path);
|
||||||
const name = makeName("png");
|
const name = makeName("png");
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ describe("AppAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
app.registerAdminController();
|
|
||||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
// register custom route
|
// register custom route
|
||||||
@@ -163,10 +162,6 @@ describe("AppAuth", () => {
|
|||||||
await app.server.request("/api/system/ping");
|
await app.server.request("/api/system/ping");
|
||||||
await app.server.request("/test");
|
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);
|
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 { $ } from "bun";
|
||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import pkg from "./package.json" with { type: "json" };
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
@@ -9,13 +8,8 @@ const types = args.includes("--types");
|
|||||||
const sourcemap = args.includes("--sourcemap");
|
const sourcemap = args.includes("--sourcemap");
|
||||||
const clean = args.includes("--clean");
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
const define = {
|
|
||||||
__isDev: "0",
|
|
||||||
__version: JSON.stringify(pkg.version),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (clean) {
|
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 {} +`;
|
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"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.info("Types built");
|
console.log("Types built");
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.info("Types aliased");
|
console.log("Types aliased");
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,10 +47,10 @@ if (types && !watch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function banner(title: string) {
|
function banner(title: string) {
|
||||||
console.info("");
|
console.log("");
|
||||||
console.info("=".repeat(40));
|
console.log("=".repeat(40));
|
||||||
console.info(title.toUpperCase());
|
console.log(title.toUpperCase());
|
||||||
console.info("-".repeat(40));
|
console.log("-".repeat(40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
@@ -71,7 +65,6 @@ async function buildApi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
define,
|
|
||||||
entry: [
|
entry: [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/core/index.ts",
|
"src/core/index.ts",
|
||||||
@@ -108,7 +101,6 @@ async function buildUi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
define,
|
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
@@ -168,7 +160,6 @@ async function buildUiElements() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
define,
|
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
@@ -220,7 +211,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
|||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
define: {
|
define: {
|
||||||
...define,
|
__isDev: "0",
|
||||||
...overrides.define,
|
...overrides.define,
|
||||||
},
|
},
|
||||||
external: [
|
external: [
|
||||||
|
|||||||
+11
-15
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"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.",
|
"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",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
"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: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: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",
|
"build:static": "vite build",
|
||||||
"watch": "bun run build.ts --types --watch",
|
"watch": "bun run build.ts --types --watch",
|
||||||
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
||||||
@@ -49,11 +49,9 @@
|
|||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hono/swagger-ui": "^0.5.1",
|
|
||||||
"@libsql/client": "^0.15.2",
|
"@libsql/client": "^0.15.2",
|
||||||
"@mantine/core": "^7.17.1",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.1",
|
"@mantine/hooks": "^7.17.1",
|
||||||
"@sinclair/typebox": "0.34.30",
|
|
||||||
"@tanstack/react-form": "^1.0.5",
|
"@tanstack/react-form": "^1.0.5",
|
||||||
"@uiw/react-codemirror": "^4.23.10",
|
"@uiw/react-codemirror": "^4.23.10",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
@@ -61,24 +59,24 @@
|
|||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fast-xml-parser": "^5.0.8",
|
"fast-xml-parser": "^5.0.8",
|
||||||
|
"hono": "^4.7.4",
|
||||||
"json-schema-form-react": "^0.0.2",
|
"json-schema-form-react": "^0.0.2",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"kysely": "^0.27.6",
|
"kysely": "^0.27.6",
|
||||||
"hono": "^4.7.11",
|
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
"uuid": "^11.1.0"
|
"lodash-es": "^4.17.21",
|
||||||
|
"@sinclair/typebox": "0.34.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
"@bluwy/giget-core": "^0.1.2",
|
"@bluwy/giget-core": "^0.1.2",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hono/typebox-validator": "^0.3.3",
|
"@hono/typebox-validator": "^0.3.2",
|
||||||
"@hono/vite-dev-server": "^0.19.1",
|
"@hono/vite-dev-server": "^0.19.0",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@mantine/modals": "^7.17.1",
|
"@mantine/modals": "^7.17.1",
|
||||||
@@ -100,15 +98,14 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonv-ts": "^0.1.0",
|
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"posthog-js-lite": "^3.4.2",
|
"posthog-js-lite": "^3.4.2",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -121,14 +118,13 @@
|
|||||||
"tsc-alias": "^1.8.11",
|
"tsc-alias": "^1.8.11",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.2.1",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.9",
|
"vitest": "^3.0.9",
|
||||||
"wouter": "^3.6.0",
|
"wouter": "^3.6.0"
|
||||||
"@cloudflare/workers-types": "^4.20250606.0"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.14.3"
|
"@hono/node-server": "^1.13.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=19",
|
"react": ">=19",
|
||||||
|
|||||||
+21
-71
@@ -1,11 +1,10 @@
|
|||||||
import type { SafeUser } from "auth";
|
import type { SafeUser } from "auth";
|
||||||
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
|
import { AuthApi } from "auth/api/AuthApi";
|
||||||
import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
import { DataApi } from "data/api/DataApi";
|
||||||
import { decode } from "hono/jwt";
|
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 { SystemApi } from "modules/SystemApi";
|
||||||
import { omitKeys } from "core/utils";
|
import { omitKeys } from "core/utils";
|
||||||
import type { BaseModuleApiOptions } from "modules";
|
|
||||||
|
|
||||||
export type TApiUser = SafeUser;
|
export type TApiUser = SafeUser;
|
||||||
|
|
||||||
@@ -22,24 +21,14 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
|
|
||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
key?: string;
|
key?: string;
|
||||||
storage?: {
|
localStorage?: boolean;
|
||||||
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
|
|
||||||
setItem: (key: string, value: string) => void | Promise<void>;
|
|
||||||
removeItem: (key: string) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
onAuthStateChange?: (state: AuthState) => void;
|
|
||||||
fetcher?: ApiFetcher;
|
fetcher?: ApiFetcher;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
data?: SubApiOptions<DataApiOptions>;
|
|
||||||
auth?: SubApiOptions<AuthApiOptions>;
|
|
||||||
media?: SubApiOptions<MediaApiOptions>;
|
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -72,18 +61,18 @@ export class Api {
|
|||||||
this.verified = options.verified === true;
|
this.verified = options.verified === true;
|
||||||
|
|
||||||
// prefer request if given
|
// 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.host = options.host ?? new URL(options.request.url).origin;
|
||||||
this.options.headers = options.headers ?? options.request.headers;
|
this.options.headers = options.headers ?? options.request.headers;
|
||||||
this.extractToken();
|
this.extractToken();
|
||||||
|
|
||||||
// then check for a token
|
// then check for a token
|
||||||
} else if ("token" in options && options.token) {
|
} else if ("token" in options) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token, { trigger: false });
|
this.updateToken(options.token);
|
||||||
|
|
||||||
// then check for an user object
|
// then check for an user object
|
||||||
} else if ("user" in options && options.user) {
|
} else if ("user" in options) {
|
||||||
this.token_transport = "none";
|
this.token_transport = "none";
|
||||||
this.user = options.user;
|
this.user = options.user;
|
||||||
this.verified = options.verified !== false;
|
this.verified = options.verified !== false;
|
||||||
@@ -126,30 +115,16 @@ export class Api {
|
|||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (this.storage) {
|
} else if (this.options.localStorage) {
|
||||||
this.storage.getItem(this.tokenKey).then((token) => {
|
const token = localStorage.getItem(this.tokenKey);
|
||||||
|
if (token) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(token ? String(token) : undefined);
|
this.updateToken(token);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get storage() {
|
updateToken(token?: string, rebuild?: boolean) {
|
||||||
if (!this.options.storage) return null;
|
|
||||||
return {
|
|
||||||
getItem: async (key: string) => {
|
|
||||||
return await this.options.storage!.getItem(key);
|
|
||||||
},
|
|
||||||
setItem: async (key: string, value: string) => {
|
|
||||||
return await this.options.storage!.setItem(key, value);
|
|
||||||
},
|
|
||||||
removeItem: async (key: string) => {
|
|
||||||
return await this.options.storage!.removeItem(key);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.verified = false;
|
this.verified = false;
|
||||||
|
|
||||||
@@ -159,25 +134,17 @@ export class Api {
|
|||||||
this.user = undefined;
|
this.user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.storage) {
|
if (this.options.localStorage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
this.storage.setItem(key, token).then(() => {
|
localStorage.setItem(key, token);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.storage.removeItem(key).then(() => {
|
localStorage.removeItem(key);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (opts?.trigger !== false) {
|
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts?.rebuild) this.buildApis();
|
if (rebuild) this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
private markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
@@ -247,32 +214,15 @@ export class Api {
|
|||||||
const fetcher = this.options.fetcher;
|
const fetcher = this.options.fetcher;
|
||||||
|
|
||||||
this.system = new SystemApi(baseParams, fetcher);
|
this.system = new SystemApi(baseParams, fetcher);
|
||||||
this.data = new DataApi(
|
this.data = new DataApi(baseParams, fetcher);
|
||||||
{
|
|
||||||
...baseParams,
|
|
||||||
...this.options.data,
|
|
||||||
},
|
|
||||||
fetcher,
|
|
||||||
);
|
|
||||||
this.auth = new AuthApi(
|
this.auth = new AuthApi(
|
||||||
{
|
{
|
||||||
...baseParams,
|
...baseParams,
|
||||||
credentials: this.options.storage ? "omit" : "include",
|
onTokenUpdate: (token) => this.updateToken(token, true),
|
||||||
...this.options.auth,
|
|
||||||
onTokenUpdate: (token) => {
|
|
||||||
this.updateToken(token, { rebuild: true });
|
|
||||||
this.options.auth?.onTokenUpdate?.(token);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fetcher,
|
|
||||||
);
|
|
||||||
this.media = new MediaApi(
|
|
||||||
{
|
|
||||||
...baseParams,
|
|
||||||
...this.options.media,
|
|
||||||
},
|
},
|
||||||
fetcher,
|
fetcher,
|
||||||
);
|
);
|
||||||
|
this.media = new MediaApi(baseParams, fetcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -151,7 +151,7 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get fetch(): Hono["fetch"] {
|
get fetch(): Hono["fetch"] {
|
||||||
return this.server.fetch as any;
|
return this.server.fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
get module() {
|
get module() {
|
||||||
@@ -253,11 +253,6 @@ export class App {
|
|||||||
break;
|
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 { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||||
import { D1Dialect } from "kysely-d1";
|
import { D1Dialect } from "kysely-d1";
|
||||||
|
|
||||||
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
|
export type D1ConnectionConfig = {
|
||||||
binding: DB;
|
binding: D1Database;
|
||||||
};
|
};
|
||||||
|
|
||||||
class CustomD1Dialect extends D1Dialect {
|
class CustomD1Dialect extends D1Dialect {
|
||||||
@@ -17,25 +17,22 @@ class CustomD1Dialect extends D1Dialect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class D1Connection<
|
export class D1Connection extends SqliteConnection {
|
||||||
DB extends D1Database | D1DatabaseSession = D1Database,
|
|
||||||
> extends SqliteConnection {
|
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private config: D1ConnectionConfig<DB>) {
|
constructor(private config: D1ConnectionConfig) {
|
||||||
const plugins = [new ParseJSONResultsPlugin()];
|
const plugins = [new ParseJSONResultsPlugin()];
|
||||||
|
|
||||||
const kysely = new Kysely({
|
const kysely = new Kysely({
|
||||||
dialect: new CustomD1Dialect({ database: config.binding as D1Database }),
|
dialect: new CustomD1Dialect({ database: config.binding }),
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
super(kysely, {}, plugins);
|
super(kysely, {}, plugins);
|
||||||
}
|
}
|
||||||
|
|
||||||
get client(): DB {
|
get client(): D1Database {
|
||||||
return this.config.binding;
|
return this.config.binding;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { makeApp } from "./modes/fresh";
|
import { makeApp } from "./modes/fresh";
|
||||||
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
import { makeConfig } from "./config";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
@@ -23,7 +23,7 @@ describe("cf adapter", () => {
|
|||||||
{
|
{
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
$ctx({ DB_URL }),
|
{},
|
||||||
),
|
),
|
||||||
).toEqual({ connection: { url: DB_URL } });
|
).toEqual({ connection: { url: DB_URL } });
|
||||||
|
|
||||||
@@ -34,15 +34,15 @@ describe("cf adapter", () => {
|
|||||||
connection: { url: env.DB_URL },
|
connection: { url: env.DB_URL },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
$ctx({ DB_URL }),
|
{
|
||||||
|
DB_URL,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
).toEqual({ connection: { url: DB_URL } });
|
).toEqual({ connection: { url: DB_URL } });
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
|
||||||
makeApp: async (c, a, o) => {
|
makeApp,
|
||||||
return await makeApp(c, { env: a } as any, o);
|
|
||||||
},
|
|
||||||
makeHandler: (c, a, o) => {
|
makeHandler: (c, a, o) => {
|
||||||
return async (request: any) => {
|
return async (request: any) => {
|
||||||
const app = await makeApp(
|
const app = await makeApp(
|
||||||
@@ -50,7 +50,7 @@ describe("cf adapter", () => {
|
|||||||
c ?? {
|
c ?? {
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
a!,
|
a,
|
||||||
o,
|
o,
|
||||||
);
|
);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
|
|||||||
@@ -9,13 +9,7 @@ import { getDurable } from "./modes/durable";
|
|||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { $console } from "core";
|
import { $console } from "core";
|
||||||
|
|
||||||
declare global {
|
export type CloudflareEnv = object;
|
||||||
namespace Cloudflare {
|
|
||||||
interface Env {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CloudflareEnv = Cloudflare.Env;
|
|
||||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
bindings?: (args: Env) => {
|
bindings?: (args: Env) => {
|
||||||
@@ -23,11 +17,6 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
|||||||
dobj?: DurableObjectNamespace;
|
dobj?: DurableObjectNamespace;
|
||||||
db?: D1Database;
|
db?: D1Database;
|
||||||
};
|
};
|
||||||
d1?: {
|
|
||||||
session?: boolean;
|
|
||||||
transport?: "header" | "cookie";
|
|
||||||
first?: D1SessionConstraint;
|
|
||||||
};
|
|
||||||
static?: "kv" | "assets";
|
static?: "kv" | "assets";
|
||||||
key?: string;
|
key?: string;
|
||||||
keepAliveSeconds?: number;
|
keepAliveSeconds?: number;
|
||||||
|
|||||||
@@ -1,148 +1,47 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
|
||||||
|
|
||||||
import { registerMedia } from "./storage/StorageR2Adapter";
|
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||||
import { getBinding } from "./bindings";
|
import { getBinding } from "./bindings";
|
||||||
import { D1Connection } from "./connection/D1Connection";
|
import { D1Connection } from "./D1Connection";
|
||||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||||
import type { Context, ExecutionContext } from "hono";
|
import type { ExecutionContext } from "hono";
|
||||||
import { $console } from "core";
|
import { $console } from "core";
|
||||||
import { setCookie } from "hono/cookie";
|
|
||||||
|
|
||||||
export const constants = {
|
export const constants = {
|
||||||
exec_async_event_id: "cf_register_waituntil",
|
exec_async_event_id: "cf_register_waituntil",
|
||||||
cache_endpoint: "/__bknd/cache",
|
cache_endpoint: "/__bknd/cache",
|
||||||
do_endpoint: "/__bknd/do",
|
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;
|
let media_registered: boolean = false;
|
||||||
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args?: CfMakeConfigArgs<Env>,
|
args: Env = {} as Env,
|
||||||
) {
|
) {
|
||||||
if (!media_registered) {
|
if (!media_registered) {
|
||||||
registerMedia(args as any);
|
registerMedia(args as any);
|
||||||
media_registered = true;
|
media_registered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = makeAdapterConfig(config, args?.env);
|
const appConfig = makeAdapterConfig(config, args);
|
||||||
|
const bindings = config.bindings?.(args);
|
||||||
if (args?.env) {
|
if (!appConfig.connection) {
|
||||||
const bindings = config.bindings?.(args?.env);
|
let db: D1Database | undefined;
|
||||||
|
if (bindings?.db) {
|
||||||
const sessionHelper = d1SessionHelper(config);
|
$console.log("Using database from bindings");
|
||||||
const sessionId = sessionHelper.get(args.request);
|
db = bindings.db;
|
||||||
let session: D1DatabaseSession | undefined;
|
} else if (Object.keys(args).length > 0) {
|
||||||
|
const binding = getBinding(args, "D1Database");
|
||||||
if (!appConfig.connection) {
|
if (binding) {
|
||||||
let db: D1Database | undefined;
|
$console.log(`Using database from env "${binding.key}"`);
|
||||||
if (bindings?.db) {
|
db = binding.value;
|
||||||
$console.log("Using database from bindings");
|
|
||||||
db = bindings.db;
|
|
||||||
} else if (Object.keys(args).length > 0) {
|
|
||||||
const binding = getBinding(args.env, "D1Database");
|
|
||||||
if (binding) {
|
|
||||||
$console.log(`Using database from env "${binding.key}"`);
|
|
||||||
db = binding.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db) {
|
|
||||||
if (config.d1?.session) {
|
|
||||||
session = db.withSession(sessionId ?? config.d1?.first);
|
|
||||||
appConfig.connection = new D1Connection({ binding: session });
|
|
||||||
} else {
|
|
||||||
appConfig.connection = new D1Connection({ binding: db });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error("No database connection given");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.d1?.session) {
|
if (db) {
|
||||||
appConfig.options = {
|
appConfig.connection = new D1Connection({ binding: db });
|
||||||
...appConfig.options,
|
} else {
|
||||||
manager: {
|
throw new Error("No database connection given");
|
||||||
...appConfig.options?.manager,
|
|
||||||
onServerInit: (server) => {
|
|
||||||
server.use(async (c, next) => {
|
|
||||||
sessionHelper.set(c, session);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
|
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||||
|
|
||||||
export * from "./cloudflare-workers.adapter";
|
export * from "./cloudflare-workers.adapter";
|
||||||
export { makeApp, getFresh } from "./modes/fresh";
|
export { makeApp, getFresh } from "./modes/fresh";
|
||||||
@@ -12,7 +12,6 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
export { constants } from "./config";
|
|
||||||
|
|
||||||
export function d1(config: D1ConnectionConfig) {
|
export function d1(config: D1ConnectionConfig) {
|
||||||
return new D1Connection(config);
|
return new D1Connection(config);
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
|
|||||||
|
|
||||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args: Context<Env>,
|
{ env, ctx, ...args }: Context<Env>,
|
||||||
) {
|
) {
|
||||||
const { env, ctx } = args;
|
|
||||||
const { kv } = config.bindings?.(env)!;
|
const { kv } = config.bindings?.(env)!;
|
||||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||||
const key = config.key ?? "app";
|
const key = config.key ?? "app";
|
||||||
@@ -21,7 +20,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
|
|
||||||
const app = await createRuntimeApp(
|
const app = await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...makeConfig(config, args),
|
...makeConfig(config, env),
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
registerAsyncsExecutionContext(app, ctx);
|
registerAsyncsExecutionContext(app, ctx);
|
||||||
@@ -42,7 +41,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
await config.beforeBuild?.(app);
|
await config.beforeBuild?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
args,
|
{ env, ctx, ...args },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!cachedConfig) {
|
if (!cachedConfig) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
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>(
|
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args?: CfMakeConfigArgs<Env>,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
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>(
|
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
await config.onBuilt?.(app);
|
await config.onBuilt?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ctx,
|
ctx.env,
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { isNode } from "bknd/utils";
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
type NextjsEnv = NextApiRequest["env"];
|
type NextjsEnv = NextApiRequest["env"];
|
||||||
|
|
||||||
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||||
cleanRequest?: { searchParams?: string[] };
|
cleanRequest?: { searchParams?: string[] };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
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 { secureRandomString, transformObject } from "core/utils";
|
||||||
import type { Entity, EntityManager } from "data";
|
import type { Entity, EntityManager } from "data";
|
||||||
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
|
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 { AuthController } from "./api/AuthController";
|
||||||
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
||||||
import { AppUserPool } from "auth/AppUserPool";
|
import { AppUserPool } from "auth/AppUserPool";
|
||||||
import type { AppEntity } from "core/config";
|
|
||||||
|
|
||||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||||
declare module "core" {
|
declare module "core" {
|
||||||
interface Users extends AppEntity, UserFieldSchema {}
|
|
||||||
interface DB {
|
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";
|
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||||
|
|
||||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||||
onTokenUpdate?: (token?: string) => void | Promise<void>;
|
onTokenUpdate?: (token: string) => void | Promise<void>;
|
||||||
credentials?: "include" | "same-origin" | "omit";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||||
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
||||||
return {
|
return {
|
||||||
basepath: "/api/auth",
|
basepath: "/api/auth",
|
||||||
credentials: "include",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(strategy: string, input: any) {
|
async login(strategy: string, input: any) {
|
||||||
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
||||||
credentials: this.options.credentials,
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
@@ -29,7 +27,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
|
|
||||||
async register(strategy: string, input: any) {
|
async register(strategy: string, input: any) {
|
||||||
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||||
credentials: this.options.credentials,
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
@@ -70,7 +68,5 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {}
|
||||||
await this.options.onTokenUpdate?.(undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||||
|
import { tbValidator as tb } from "core";
|
||||||
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||||
import { DataPermissions } from "data";
|
import { DataPermissions } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { Controller, type ServerEnv } from "modules/Controller";
|
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 = {
|
export type AuthActionResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -12,6 +14,10 @@ export type AuthActionResponse = {
|
|||||||
errors?: any;
|
errors?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const booleanLike = Type.Transform(Type.String())
|
||||||
|
.Decode((v) => v === "1")
|
||||||
|
.Encode((v) => (v ? "1" : "0"));
|
||||||
|
|
||||||
export class AuthController extends Controller {
|
export class AuthController extends Controller {
|
||||||
constructor(private auth: AppAuth) {
|
constructor(private auth: AppAuth) {
|
||||||
super();
|
super();
|
||||||
@@ -50,10 +56,6 @@ export class AuthController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/create",
|
"/create",
|
||||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||||
describeRoute({
|
|
||||||
summary: "Create a new user",
|
|
||||||
tags: ["auth"],
|
|
||||||
}),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await this.auth.authenticator.getBody(c);
|
const body = await this.auth.authenticator.getBody(c);
|
||||||
@@ -91,16 +93,9 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
hono.get(
|
hono.get("create/schema.json", async (c) => {
|
||||||
"create/schema.json",
|
return c.json(create.schema);
|
||||||
describeRoute({
|
});
|
||||||
summary: "Get the schema for creating a user",
|
|
||||||
tags: ["auth"],
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
return c.json(create.schema);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mainHono.route(`/${name}/actions`, hono);
|
mainHono.route(`/${name}/actions`, hono);
|
||||||
@@ -109,55 +104,42 @@ export class AuthController extends Controller {
|
|||||||
override getController() {
|
override getController() {
|
||||||
const { auth } = this.middlewares;
|
const { auth } = this.middlewares;
|
||||||
const hono = this.create();
|
const hono = this.create();
|
||||||
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
|
|
||||||
hono.get(
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
"/me",
|
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
},
|
this.registerStrategyActions(strategy, hono);
|
||||||
);
|
}
|
||||||
|
|
||||||
hono.get(
|
hono.get("/me", auth(), async (c) => {
|
||||||
"/logout",
|
const claims = c.get("auth")?.user;
|
||||||
describeRoute({
|
if (claims) {
|
||||||
summary: "Logout the current user",
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
tags: ["auth"],
|
return c.json({ user });
|
||||||
}),
|
}
|
||||||
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");
|
return c.json({ user: null }, 403);
|
||||||
if (referer) {
|
});
|
||||||
return c.redirect(referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
hono.get(
|
||||||
"/strategies",
|
"/strategies",
|
||||||
describeRoute({
|
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
|
||||||
summary: "Get the available authentication strategies",
|
|
||||||
tags: ["auth"],
|
|
||||||
}),
|
|
||||||
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { include_disabled } = c.req.valid("query");
|
const { include_disabled } = c.req.valid("query");
|
||||||
const { strategies, basepath } = this.auth.toJSON(false);
|
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());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async logout(c: Context<ServerEnv>) {
|
async logout(c: Context<ServerEnv>) {
|
||||||
$console.info("Logging out");
|
|
||||||
c.set("auth", undefined);
|
c.set("auth", undefined);
|
||||||
|
|
||||||
const cookie = await this.getAuthCookie(c);
|
const cookie = await this.getAuthCookie(c);
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export const auth = (options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
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
|
// release
|
||||||
authCtx.skip = false;
|
authCtx.skip = false;
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ export { debug } from "./debug";
|
|||||||
export { user } from "./user";
|
export { user } from "./user";
|
||||||
export { create } from "./create";
|
export { create } from "./create";
|
||||||
export { copyAssets } from "./copy-assets";
|
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 { 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 path from "node:path";
|
||||||
import url from "node:url";
|
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> }) {
|
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
|
||||||
const stdio = opts?.silent ? "pipe" : "inherit";
|
const stdio = opts?.silent ? "pipe" : "inherit";
|
||||||
const output = execSync(command, {
|
const output = execSync(command, {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { Generated } from "kysely";
|
import type { Generated } from "kysely";
|
||||||
|
|
||||||
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
|
export type PrimaryFieldType = number | Generated<number>;
|
||||||
|
|
||||||
export interface AppEntity<IdType = number | string> {
|
|
||||||
id: PrimaryFieldType<IdType>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
// make sure to make unknown as "any"
|
// make sure to make unknown as "any"
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ function hasColors() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const __consoles = {
|
const __consoles = {
|
||||||
critical: {
|
|
||||||
prefix: "CRT",
|
|
||||||
color: colors.red,
|
|
||||||
args_color: colors.red,
|
|
||||||
original: console.error,
|
|
||||||
},
|
|
||||||
error: {
|
error: {
|
||||||
prefix: "ERR",
|
prefix: "ERR",
|
||||||
color: colors.red,
|
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 = {
|
const envs = {
|
||||||
// used in $console to determine the log level
|
// used in $console to determine the log level
|
||||||
cli_log_level: {
|
cli_log_level: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
|
|||||||
export { tbValidator } from "./server/lib/tbValidator";
|
export { tbValidator } from "./server/lib/tbValidator";
|
||||||
export { Exception, BkndError } from "./errors";
|
export { Exception, BkndError } from "./errors";
|
||||||
export { isDebug, env } from "./env";
|
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 { AwsClient } from "./clients/aws/AwsClient";
|
||||||
export {
|
export {
|
||||||
SimpleRenderer,
|
SimpleRenderer,
|
||||||
@@ -26,7 +26,6 @@ export {
|
|||||||
} from "./object/query/query";
|
} from "./object/query/query";
|
||||||
export { Registry, type Constructor } from "./registry/Registry";
|
export { Registry, type Constructor } from "./registry/Registry";
|
||||||
export { getFlashMessage } from "./server/flash";
|
export { getFlashMessage } from "./server/flash";
|
||||||
export { s, jsc, describeRoute } from "./object/schema";
|
|
||||||
|
|
||||||
export * from "./console";
|
export * from "./console";
|
||||||
export * from "./events";
|
export * from "./events";
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ type ExpressionMap<Exps extends Expressions> = {
|
|||||||
? E
|
? E
|
||||||
: never;
|
: never;
|
||||||
};
|
};
|
||||||
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
|
|
||||||
|
|
||||||
type ExpressionCondition<Exps extends Expressions> = {
|
type ExpressionCondition<Exps extends Expressions> = {
|
||||||
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
|
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
|
||||||
}[keyof ExpressionMap<Exps>];
|
}[keyof ExpressionMap<Exps>];
|
||||||
@@ -197,7 +195,5 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
|||||||
const fns = _build(query, expressions, options);
|
const fns = _build(query, expressions, options);
|
||||||
return _validate(fns);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HonoAnyContext = Context<any, any, any>;
|
export async function getFileFromContext(c: Context<any>): Promise<File> {
|
||||||
|
|
||||||
export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
|
|
||||||
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -151,7 +149,7 @@ export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
|
|||||||
throw new Error("No file found in request");
|
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";
|
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -359,63 +359,3 @@ export function getPath(
|
|||||||
throw new Error(`Invalid path: ${path.join(".")}`);
|
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) => {
|
severities.forEach((severity) => {
|
||||||
console[severity] = () => null;
|
console[severity] = () => null;
|
||||||
});
|
});
|
||||||
$console.setLevel("critical");
|
$console.setLevel("error");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enableConsoleLog() {
|
export function enableConsoleLog() {
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { v4, v7 } from "uuid";
|
|
||||||
|
|
||||||
// generates v4
|
// generates v4
|
||||||
export function uuid(): string {
|
export function uuid(): string {
|
||||||
return v4();
|
return crypto.randomUUID();
|
||||||
}
|
|
||||||
|
|
||||||
export function uuidv7(): string {
|
|
||||||
return v7();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-30
@@ -1,6 +1,5 @@
|
|||||||
import type { DB } from "core";
|
import type { DB } from "core";
|
||||||
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
||||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
|
||||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||||
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
|
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,
|
entity: E,
|
||||||
id: PrimaryFieldType,
|
id: PrimaryFieldType,
|
||||||
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
|
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
|
||||||
) {
|
) {
|
||||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
|
||||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
||||||
["entity", entity as any, id],
|
["entity", entity as any, id],
|
||||||
query,
|
query,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
readOneBy<E extends keyof DB | string>(
|
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||||
entity: E,
|
entity: E,
|
||||||
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
|
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
|
||||||
) {
|
) {
|
||||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
|
||||||
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
|
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
|
||||||
return this.readMany(entity, {
|
return this.readMany(entity, {
|
||||||
...query,
|
...query,
|
||||||
@@ -51,8 +48,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
|
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
|
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
entity: E,
|
||||||
|
query: RepoQueryIn = {},
|
||||||
|
) {
|
||||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||||
|
|
||||||
const input = query ?? this.options.defaultQuery;
|
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);
|
return this.post<T>(["entity", entity as any, "query"], input);
|
||||||
}
|
}
|
||||||
|
|
||||||
readManyByReference<E extends keyof DB | string, R extends keyof DB | string>(
|
readManyByReference<
|
||||||
entity: E,
|
E extends keyof DB | string,
|
||||||
id: PrimaryFieldType,
|
R extends keyof DB | string,
|
||||||
reference: R,
|
Data = R extends keyof DB ? DB[R] : EntityData,
|
||||||
query: RepoQueryIn = {},
|
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||||
) {
|
|
||||||
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
|
|
||||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||||
["entity", entity as any, id, reference],
|
["entity", entity as any, id, reference],
|
||||||
query ?? this.options.defaultQuery,
|
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,
|
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);
|
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,
|
entity: E,
|
||||||
input: Insertable<Input>[],
|
input: Omit<Data, "id">[],
|
||||||
) {
|
) {
|
||||||
if (!input || !Array.isArray(input) || input.length === 0) {
|
if (!input || !Array.isArray(input) || input.length === 0) {
|
||||||
throw new Error("input is required");
|
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);
|
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,
|
entity: E,
|
||||||
id: PrimaryFieldType,
|
id: PrimaryFieldType,
|
||||||
input: Updateable<Input>,
|
input: Partial<Omit<Data, "id">>,
|
||||||
) {
|
) {
|
||||||
if (!id) throw new Error("ID is required");
|
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);
|
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,
|
entity: E,
|
||||||
where: RepoQueryIn["where"],
|
where: RepoQueryIn["where"],
|
||||||
update: Updateable<Input>,
|
update: Partial<Omit<Data, "id">>,
|
||||||
) {
|
) {
|
||||||
this.requireObjectSet(where);
|
this.requireObjectSet(where);
|
||||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
|
||||||
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
|
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
|
||||||
update,
|
update,
|
||||||
where,
|
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");
|
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]);
|
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);
|
this.requireObjectSet(where);
|
||||||
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
|
|
||||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
|
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 {
|
import {
|
||||||
DataPermissions,
|
DataPermissions,
|
||||||
type EntityData,
|
type EntityData,
|
||||||
@@ -6,15 +8,14 @@ import {
|
|||||||
type MutatorResponse,
|
type MutatorResponse,
|
||||||
type RepoQuery,
|
type RepoQuery,
|
||||||
type RepositoryResponse,
|
type RepositoryResponse,
|
||||||
repoQuery,
|
querySchema,
|
||||||
} from "data";
|
} from "data";
|
||||||
import type { Handler } from "hono/types";
|
import type { Handler } from "hono/types";
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
|
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import type { AppDataConfig } from "../data-schema";
|
import type { AppDataConfig } from "../data-schema";
|
||||||
import { omitKeys } from "core/utils";
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export class DataController extends Controller {
|
export class DataController extends Controller {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -70,7 +71,6 @@ export class DataController extends Controller {
|
|||||||
override getController() {
|
override getController() {
|
||||||
const { permission, auth } = this.middlewares;
|
const { permission, auth } = this.middlewares;
|
||||||
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
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
|
// @todo: sample implementation how to augment handler with additional info
|
||||||
function handler<HH extends Handler>(name: string, h: HH): any {
|
function handler<HH extends Handler>(name: string, h: HH): any {
|
||||||
@@ -83,10 +83,6 @@ export class DataController extends Controller {
|
|||||||
// info
|
// info
|
||||||
hono.get(
|
hono.get(
|
||||||
"/",
|
"/",
|
||||||
describeRoute({
|
|
||||||
summary: "Retrieve data configuration",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
handler("data info", (c) => {
|
handler("data info", (c) => {
|
||||||
// sample implementation
|
// sample implementation
|
||||||
return c.json(this.em.toJSON());
|
return c.json(this.em.toJSON());
|
||||||
@@ -94,75 +90,49 @@ export class DataController extends Controller {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// sync endpoint
|
// sync endpoint
|
||||||
hono.get(
|
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
|
||||||
"/sync",
|
const force = c.req.query("force") === "1";
|
||||||
permission(DataPermissions.databaseSync),
|
const drop = c.req.query("drop") === "1";
|
||||||
describeRoute({
|
//console.log("force", force);
|
||||||
summary: "Sync database schema",
|
const tables = await this.em.schema().introspect();
|
||||||
tags: ["data"],
|
//console.log("tables", tables);
|
||||||
}),
|
const changes = await this.em.schema().sync({
|
||||||
jsc(
|
force,
|
||||||
"query",
|
drop,
|
||||||
s.partialObject({
|
});
|
||||||
force: s.boolean(),
|
return c.json({ tables: tables.map((t) => t.name), changes });
|
||||||
drop: s.boolean(),
|
});
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const { force, drop } = c.req.valid("query");
|
|
||||||
//console.log("force", force);
|
|
||||||
const tables = await this.em.schema().introspect();
|
|
||||||
//console.log("tables", tables);
|
|
||||||
const changes = await this.em.schema().sync({
|
|
||||||
force,
|
|
||||||
drop,
|
|
||||||
});
|
|
||||||
return c.json({ tables: tables.map((t) => t.name), changes });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema endpoints
|
* Schema endpoints
|
||||||
*/
|
*/
|
||||||
// read entity schema
|
// read entity schema
|
||||||
hono.get(
|
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
||||||
"/schema.json",
|
const $id = `${this.config.basepath}/schema.json`;
|
||||||
permission(DataPermissions.entityRead),
|
const schemas = Object.fromEntries(
|
||||||
describeRoute({
|
this.em.entities.map((e) => [
|
||||||
summary: "Retrieve data schema",
|
e.name,
|
||||||
tags: ["data"],
|
{
|
||||||
}),
|
$ref: `${this.config.basepath}/schemas/${e.name}`,
|
||||||
async (c) => {
|
},
|
||||||
const $id = `${this.config.basepath}/schema.json`;
|
]),
|
||||||
const schemas = Object.fromEntries(
|
);
|
||||||
this.em.entities.map((e) => [
|
return c.json({
|
||||||
e.name,
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||||
{
|
$id,
|
||||||
$ref: `${this.config.basepath}/schemas/${e.name}`,
|
properties: schemas,
|
||||||
},
|
});
|
||||||
]),
|
});
|
||||||
);
|
|
||||||
return c.json({
|
|
||||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
$id,
|
|
||||||
properties: schemas,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// read schema
|
// read schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schemas/:entity/:context?",
|
"/schemas/:entity/:context?",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
describeRoute({
|
tb(
|
||||||
summary: "Retrieve entity schema",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
jsc(
|
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
Type.Object({
|
||||||
entity: entitiesEnum,
|
entity: Type.String(),
|
||||||
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
|
context: Type.Optional(StringEnum(["create", "update"])),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -191,39 +161,30 @@ export class DataController extends Controller {
|
|||||||
/**
|
/**
|
||||||
* Info endpoints
|
* Info endpoints
|
||||||
*/
|
*/
|
||||||
hono.get(
|
hono.get("/info/:entity", async (c) => {
|
||||||
"/info/:entity",
|
const { entity } = c.req.param();
|
||||||
permission(DataPermissions.entityRead),
|
if (!this.entityExists(entity)) {
|
||||||
describeRoute({
|
return this.notFound(c);
|
||||||
summary: "Retrieve entity info",
|
}
|
||||||
tags: ["data"],
|
const _entity = this.em.entity(entity);
|
||||||
}),
|
const fields = _entity.fields.map((f) => f.name);
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
const $rels = (r: any) =>
|
||||||
async (c) => {
|
r.map((r: any) => ({
|
||||||
const { entity } = c.req.param();
|
entity: r.other(_entity).entity.name,
|
||||||
if (!this.entityExists(entity)) {
|
ref: r.other(_entity).reference,
|
||||||
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({
|
return c.json({
|
||||||
name: _entity.name,
|
name: _entity.name,
|
||||||
fields,
|
fields,
|
||||||
relations: {
|
relations: {
|
||||||
all: $rels(this.em.relations.relationsOf(_entity)),
|
all: $rels(this.em.relations.relationsOf(_entity)),
|
||||||
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
|
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
|
||||||
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
|
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
|
||||||
target: $rels(this.em.relations.targetRelationsOf(_entity)),
|
target: $rels(this.em.relations.targetRelationsOf(_entity)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return hono.all("*", (c) => c.notFound());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
@@ -232,9 +193,10 @@ export class DataController extends Controller {
|
|||||||
const { permission } = this.middlewares;
|
const { permission } = this.middlewares;
|
||||||
const hono = this.create();
|
const hono = this.create();
|
||||||
|
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
const definedEntities = this.em.entities.map((e) => e.name);
|
||||||
// @todo: make dynamic based on entity
|
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
||||||
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any });
|
.Decode(Number.parseInt)
|
||||||
|
.Encode(String);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function endpoints
|
* Function endpoints
|
||||||
@@ -243,19 +205,14 @@ export class DataController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/fn/count",
|
"/:entity/fn/count",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
describeRoute({
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
summary: "Count entities",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
|
||||||
jsc("json", repoQuery.properties.where),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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);
|
const result = await this.em.repository(entity).count(where);
|
||||||
return c.json({ entity, count: result.count });
|
return c.json({ entity, count: result.count });
|
||||||
},
|
},
|
||||||
@@ -265,19 +222,14 @@ export class DataController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/fn/exists",
|
"/:entity/fn/exists",
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
describeRoute({
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
summary: "Check if entity exists",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
|
||||||
jsc("json", repoQuery.properties.where),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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);
|
const result = await this.em.repository(entity).exists(where);
|
||||||
return c.json({ entity, exists: result.exists });
|
return c.json({ entity, exists: result.exists });
|
||||||
},
|
},
|
||||||
@@ -287,36 +239,18 @@ export class DataController extends Controller {
|
|||||||
* Read endpoints
|
* Read endpoints
|
||||||
*/
|
*/
|
||||||
// read many
|
// 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(
|
hono.get(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
describeRoute({
|
|
||||||
summary: "Read many",
|
|
||||||
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
tb("query", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
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 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -325,27 +259,22 @@ export class DataController extends Controller {
|
|||||||
// read one
|
// read one
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
describeRoute({
|
|
||||||
summary: "Read one",
|
|
||||||
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc(
|
tb(
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
Type.Object({
|
||||||
entity: entitiesEnum,
|
entity: Type.String(),
|
||||||
id: idType,
|
id: tbNumber,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
tb("query", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
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 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -354,23 +283,18 @@ export class DataController extends Controller {
|
|||||||
// read many by reference
|
// read many by reference
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:entity/:id/:reference",
|
"/:entity/:id/:reference",
|
||||||
describeRoute({
|
|
||||||
summary: "Read many by reference",
|
|
||||||
parameters: saveRepoQueryParams(),
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc(
|
tb(
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
Type.Object({
|
||||||
entity: entitiesEnum,
|
entity: Type.String(),
|
||||||
id: idType,
|
id: tbNumber,
|
||||||
reference: s.string(),
|
reference: Type.String(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
tb("query", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id, reference } = c.req.valid("param");
|
const { entity, id, reference } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
@@ -378,40 +302,24 @@ export class DataController extends Controller {
|
|||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
const result = await this.em
|
const result = await this.em
|
||||||
.repository(entity)
|
.repository(entity)
|
||||||
.findManyByReference(id, reference, options);
|
.findManyByReference(Number(id), reference, options);
|
||||||
|
|
||||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// func query
|
// func query
|
||||||
const fnQuery = s.partialObject({
|
|
||||||
...saveRepoQuery.properties,
|
|
||||||
with: s.object({}),
|
|
||||||
});
|
|
||||||
hono.post(
|
hono.post(
|
||||||
"/:entity/query",
|
"/:entity/query",
|
||||||
describeRoute({
|
|
||||||
summary: "Query entities",
|
|
||||||
requestBody: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: fnQuery.toJSON(),
|
|
||||||
example: fnQuery.template({ withOptional: true }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
jsc("json", repoQuery, { skipOpenAPI: true }),
|
tb("json", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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);
|
const result = await this.em.repository(entity).findMany(options);
|
||||||
|
|
||||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
@@ -424,15 +332,11 @@ export class DataController extends Controller {
|
|||||||
// insert one
|
// insert one
|
||||||
hono.post(
|
hono.post(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
describeRoute({
|
|
||||||
summary: "Insert one or many",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityCreate),
|
permission(DataPermissions.entityCreate),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
@@ -451,17 +355,13 @@ export class DataController extends Controller {
|
|||||||
// update many
|
// update many
|
||||||
hono.patch(
|
hono.patch(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
describeRoute({
|
|
||||||
summary: "Update many",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
jsc(
|
tb(
|
||||||
"json",
|
"json",
|
||||||
s.object({
|
Type.Object({
|
||||||
update: s.object({}),
|
update: Type.Object({}),
|
||||||
where: repoQuery.properties.where,
|
where: querySchema.properties.where,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -482,20 +382,15 @@ export class DataController extends Controller {
|
|||||||
// update one
|
// update one
|
||||||
hono.patch(
|
hono.patch(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
describeRoute({
|
|
||||||
summary: "Update one",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||||
jsc("json", s.object({})),
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const body = (await c.req.json()) as EntityData;
|
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));
|
return c.json(this.mutatorResult(result));
|
||||||
},
|
},
|
||||||
@@ -504,18 +399,14 @@ export class DataController extends Controller {
|
|||||||
// delete one
|
// delete one
|
||||||
hono.delete(
|
hono.delete(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
describeRoute({
|
|
||||||
summary: "Delete one",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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));
|
return c.json(this.mutatorResult(result));
|
||||||
},
|
},
|
||||||
@@ -524,19 +415,15 @@ export class DataController extends Controller {
|
|||||||
// delete many
|
// delete many
|
||||||
hono.delete(
|
hono.delete(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
describeRoute({
|
|
||||||
summary: "Delete many",
|
|
||||||
tags: ["data"],
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
jsc("json", repoQuery.properties.where),
|
tb("json", querySchema.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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);
|
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||||
|
|
||||||
return c.json(this.mutatorResult(result));
|
return c.json(this.mutatorResult(result));
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {
|
|||||||
type AliasableExpression,
|
type AliasableExpression,
|
||||||
type ColumnBuilderCallback,
|
type ColumnBuilderCallback,
|
||||||
type ColumnDataType,
|
type ColumnDataType,
|
||||||
type DatabaseIntrospector,
|
|
||||||
type Dialect,
|
|
||||||
type Expression,
|
type Expression,
|
||||||
type Kysely,
|
type Kysely,
|
||||||
type KyselyPlugin,
|
type KyselyPlugin,
|
||||||
@@ -14,8 +12,7 @@ import {
|
|||||||
type Simplify,
|
type Simplify,
|
||||||
sql,
|
sql,
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
|
import type { BaseIntrospector } from "./BaseIntrospector";
|
||||||
import type { Constructor } from "core";
|
|
||||||
|
|
||||||
export type QB = SelectQueryBuilder<any, any, any>;
|
export type QB = SelectQueryBuilder<any, any, any>;
|
||||||
|
|
||||||
@@ -82,7 +79,6 @@ export abstract class Connection<DB = any> {
|
|||||||
kysely: Kysely<DB>;
|
kysely: Kysely<DB>;
|
||||||
protected readonly supported = {
|
protected readonly supported = {
|
||||||
batching: false,
|
batching: false,
|
||||||
counts: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -163,19 +159,3 @@ export abstract class Connection<DB = any> {
|
|||||||
// no-op by default
|
// 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 {
|
export class DummyConnection extends Connection {
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export {
|
|||||||
type IndexSpec,
|
type IndexSpec,
|
||||||
type DbFunctions,
|
type DbFunctions,
|
||||||
type SchemaResponse,
|
type SchemaResponse,
|
||||||
customIntrospector,
|
|
||||||
} from "./Connection";
|
} from "./Connection";
|
||||||
|
|
||||||
// sqlite
|
// sqlite
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
private client: Client;
|
private client: Client;
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
counts: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(client: Client);
|
constructor(client: Client);
|
||||||
constructor(credentials: LibSqlCredentials);
|
constructor(credentials: LibSqlCredentials);
|
||||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||||
let client: Client;
|
let client: Client;
|
||||||
|
let batching_enabled = true;
|
||||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||||
let { url, authToken, protocol } = clientOrCredentials;
|
let { url, authToken, protocol } = clientOrCredentials;
|
||||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||||
@@ -44,6 +44,13 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client = createClient({ url, authToken });
|
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 {
|
} else {
|
||||||
client = clientOrCredentials;
|
client = clientOrCredentials;
|
||||||
}
|
}
|
||||||
@@ -56,6 +63,7 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
|
|
||||||
super(kysely, {}, plugins);
|
super(kysely, {}, plugins);
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
this.supported.batching = batching_enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient(): Client {
|
getClient(): Client {
|
||||||
|
|||||||
@@ -31,11 +31,7 @@ export class SqliteConnection extends Connection {
|
|||||||
type,
|
type,
|
||||||
(col: ColumnDefinitionBuilder) => {
|
(col: ColumnDefinitionBuilder) => {
|
||||||
if (spec.primary) {
|
if (spec.primary) {
|
||||||
if (spec.type === "integer") {
|
return col.primaryKey().notNull().autoIncrement();
|
||||||
return col.primaryKey().notNull().autoIncrement();
|
|
||||||
}
|
|
||||||
|
|
||||||
return col.primaryKey().notNull();
|
|
||||||
}
|
}
|
||||||
if (spec.references) {
|
if (spec.references) {
|
||||||
let relCol = col.references(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 * as tb from "@sinclair/typebox";
|
||||||
import {
|
import {
|
||||||
FieldClassMap,
|
FieldClassMap,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
entityTypes,
|
entityTypes,
|
||||||
} from "data";
|
} from "data";
|
||||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||||
import { primaryFieldTypes } from "./fields";
|
|
||||||
|
|
||||||
export const FIELDS = {
|
export const FIELDS = {
|
||||||
...FieldClassMap,
|
...FieldClassMap,
|
||||||
@@ -73,9 +72,6 @@ export const indicesSchema = tb.Type.Object(
|
|||||||
export const dataConfigSchema = tb.Type.Object(
|
export const dataConfigSchema = tb.Type.Object(
|
||||||
{
|
{
|
||||||
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
|
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: {} })),
|
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
|
||||||
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
|
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
|
||||||
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
|
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ import {
|
|||||||
snakeToPascalWithSpaces,
|
snakeToPascalWithSpaces,
|
||||||
transformObject,
|
transformObject,
|
||||||
} from "core/utils";
|
} from "core/utils";
|
||||||
import {
|
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
|
||||||
type Field,
|
|
||||||
PrimaryField,
|
|
||||||
primaryFieldTypes,
|
|
||||||
type TActionContext,
|
|
||||||
type TRenderContext,
|
|
||||||
} from "../fields";
|
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
@@ -24,7 +18,6 @@ export const entityConfigSchema = Type.Object(
|
|||||||
description: Type.Optional(Type.String()),
|
description: Type.Optional(Type.String()),
|
||||||
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
||||||
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
|
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
|
||||||
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -75,14 +68,7 @@ export class Entity<
|
|||||||
if (primary_count > 1) {
|
if (primary_count > 1) {
|
||||||
throw new Error(`Entity "${name}" has more than one primary field`);
|
throw new Error(`Entity "${name}" has more than one primary field`);
|
||||||
}
|
}
|
||||||
this.fields =
|
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
|
||||||
primary_count === 1
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
new PrimaryField(undefined, {
|
|
||||||
format: this.config.primary_format,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (fields) {
|
if (fields) {
|
||||||
fields.forEach((field) => this.addField(field));
|
fields.forEach((field) => this.addField(field));
|
||||||
@@ -294,15 +280,6 @@ export class Entity<
|
|||||||
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
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() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
type: this.type,
|
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 { InvalidSearchParamsException } from "../errors";
|
||||||
import { MutatorEvents } from "../events";
|
import { MutatorEvents } from "../events";
|
||||||
import { RelationMutator } from "../relations";
|
import { RelationMutator } from "../relations";
|
||||||
import type { RepoQuery } from "../server/query";
|
import type { RepoQuery } from "../server/data-query-impl";
|
||||||
|
|
||||||
type MutatorQB =
|
type MutatorQB =
|
||||||
| InsertQueryBuilder<any, any, any>
|
| InsertQueryBuilder<any, any, any>
|
||||||
@@ -143,7 +143,7 @@ export class Mutator<
|
|||||||
|
|
||||||
// if listener returned, take what's returned
|
// if listener returned, take what's returned
|
||||||
const _data = result.returned ? result.params.data : data;
|
const _data = result.returned ? result.params.data : data;
|
||||||
let validatedData = {
|
const validatedData = {
|
||||||
...entity.getDefaultObject(),
|
...entity.getDefaultObject(),
|
||||||
...(await this.getValidatedData(_data, "create")),
|
...(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
|
const query = this.conn
|
||||||
.insertInto(entity.name)
|
.insertInto(entity.name)
|
||||||
.values(validatedData)
|
.values(validatedData)
|
||||||
@@ -185,7 +175,7 @@ export class Mutator<
|
|||||||
|
|
||||||
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
if (!id) {
|
if (!Number.isInteger(id)) {
|
||||||
throw new Error("ID must be provided for update");
|
throw new Error("ID must be provided for update");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +212,7 @@ export class Mutator<
|
|||||||
|
|
||||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
if (!id) {
|
if (!Number.isInteger(id)) {
|
||||||
throw new Error("ID must be provided for deletion");
|
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 { $console } from "core";
|
||||||
import { type EmitsEvents, EventManager } from "core/events";
|
import { type EmitsEvents, EventManager } from "core/events";
|
||||||
import { type SelectQueryBuilder, sql } from "kysely";
|
import { type SelectQueryBuilder, sql } from "kysely";
|
||||||
|
import { cloneDeep } from "lodash-es";
|
||||||
import { InvalidSearchParamsException } from "../../errors";
|
import { InvalidSearchParamsException } from "../../errors";
|
||||||
import { MutatorEvents, RepositoryEvents } from "../../events";
|
import { MutatorEvents, RepositoryEvents } from "../../events";
|
||||||
import { type RepoQuery, getRepoQueryTemplate } from "data/server/query";
|
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
|
||||||
import {
|
import {
|
||||||
type Entity,
|
type Entity,
|
||||||
type EntityData,
|
type EntityData,
|
||||||
@@ -27,7 +28,6 @@ export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
|
|||||||
data: T;
|
data: T;
|
||||||
meta: {
|
meta: {
|
||||||
items: number;
|
items: number;
|
||||||
has_more?: boolean;
|
|
||||||
total?: number;
|
total?: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
time?: number;
|
time?: number;
|
||||||
@@ -62,10 +62,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
public entity: Entity,
|
public entity: Entity,
|
||||||
protected options: RepositoryOptions = {},
|
protected options: RepositoryOptions = {},
|
||||||
) {
|
) {
|
||||||
this.options = {
|
|
||||||
...options,
|
|
||||||
includeCounts: options?.includeCounts ?? this.em.connection.supports("counts"),
|
|
||||||
};
|
|
||||||
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
|
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;
|
const entity = this.entity;
|
||||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||||
const validated = {
|
const validated = {
|
||||||
...structuredClone(getRepoQueryTemplate()),
|
...cloneDeep(defaultQuerySchema),
|
||||||
sort: entity.getDefaultSort(),
|
sort: entity.getDefaultSort(),
|
||||||
select: entity.getSelect(),
|
select: entity.getSelect(),
|
||||||
} satisfies Required<RepoQuery>;
|
};
|
||||||
|
|
||||||
if (!options) return validated;
|
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)) {
|
if (!validated.select.includes(options.sort.by)) {
|
||||||
throw new InvalidSearchParamsException(`Invalid sort field "${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}"`);
|
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.checkIndex(entity.name, options.sort.by, "sort");
|
this.checkIndex(entity.name, options.sort.by, "sort");
|
||||||
validated.sort = {
|
validated.sort = options.sort;
|
||||||
dir: "asc",
|
|
||||||
...options.sort,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.select && options.select.length > 0) {
|
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.limit) validated.limit = options.limit;
|
||||||
if (options.offset) validated.offset = options.offset;
|
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;
|
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> {
|
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
|
|
||||||
const compiled = qb.compile();
|
const compiled = qb.compile();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -455,16 +442,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
await this.triggerFindBefore(this.entity, options);
|
await this.triggerFindBefore(this.entity, options);
|
||||||
|
|
||||||
const res = await this.performQuery(qb);
|
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);
|
await this.triggerFindAfter(this.entity, options, res.data);
|
||||||
return res as any;
|
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 entity = this.entity;
|
||||||
const options = this.getValidOptions({ where });
|
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);
|
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||||
|
|
||||||
// add mandatory where
|
// 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);
|
const { result, ...compiled } = await this.executeQb(qb);
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ const expressions = [
|
|||||||
export type WhereQuery = FilterQuery<typeof expressions>;
|
export type WhereQuery = FilterQuery<typeof expressions>;
|
||||||
|
|
||||||
const validator = makeValidator(expressions);
|
const validator = makeValidator(expressions);
|
||||||
export const expressionKeys = validator.expressionKeys;
|
|
||||||
|
|
||||||
export class WhereBuilder {
|
export class WhereBuilder {
|
||||||
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
|
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { $console, type PrimaryFieldType } from "core";
|
import { $console, type PrimaryFieldType } from "core";
|
||||||
import { Event, InvalidEventReturn } from "core/events";
|
import { Event, InvalidEventReturn } from "core/events";
|
||||||
import type { Entity, EntityData } from "../entities";
|
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> {
|
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
|
||||||
static override slug = "mutator-insert-before";
|
static override slug = "mutator-insert-before";
|
||||||
|
|||||||
@@ -88,11 +88,4 @@ export class BooleanField<Required extends true | false = false> extends Field<
|
|||||||
override toJsonSchema() {
|
override toJsonSchema() {
|
||||||
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
|
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 { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import { $console } from "core";
|
import { $console } from "core";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const dateFieldConfigSchema = Type.Composite(
|
export const dateFieldConfigSchema = Type.Composite(
|
||||||
@@ -145,11 +144,4 @@ export class DateField<Required extends true | false = false> extends Field<
|
|||||||
override toJsonSchema() {
|
override toJsonSchema() {
|
||||||
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
|
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 { TransformPersistFailedException } from "../errors";
|
||||||
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const enumFieldConfigSchema = Type.Composite(
|
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 { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
||||||
import type { FieldSpec } from "data/connection/Connection";
|
import type { FieldSpec } from "data/connection/Connection";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
// @todo: contexts need to be reworked
|
// @todo: contexts need to be reworked
|
||||||
@@ -236,14 +235,6 @@ export abstract class Field<
|
|||||||
return this.toSchemaWrapIfRequired(Type.Any());
|
return this.toSchemaWrapIfRequired(Type.Any());
|
||||||
}
|
}
|
||||||
|
|
||||||
toType(): TFieldTSType {
|
|
||||||
return {
|
|
||||||
required: this.isRequired(),
|
|
||||||
comment: this.getDescription(),
|
|
||||||
type: "any",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
// @todo: current workaround because of fixed string type
|
// @todo: current workaround because of fixed string type
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { EntityManager } from "data";
|
|||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
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);
|
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 { 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 type { EntityManager } from "data";
|
||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const jsonSchemaFieldConfigSchema = Type.Composite(
|
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 { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const numberFieldConfigSchema = Type.Composite(
|
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 { config } from "core";
|
||||||
import { StringEnum, uuidv7, type Static } from "core/utils";
|
import type { Static } from "core/utils";
|
||||||
import { Field, baseFieldConfigSchema } from "./Field";
|
import { Field, baseFieldConfigSchema } from "./Field";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const primaryFieldTypes = ["integer", "uuid"] as const;
|
|
||||||
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
|
|
||||||
|
|
||||||
export const primaryFieldConfigSchema = Type.Composite([
|
export const primaryFieldConfigSchema = Type.Composite([
|
||||||
Type.Omit(baseFieldConfigSchema, ["required"]),
|
Type.Omit(baseFieldConfigSchema, ["required"]),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
|
|
||||||
required: Type.Optional(Type.Literal(false)),
|
required: Type.Optional(Type.Literal(false)),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -25,8 +20,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
|||||||
> {
|
> {
|
||||||
override readonly type = "primary";
|
override readonly type = "primary";
|
||||||
|
|
||||||
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
|
constructor(name: string = config.data.default_primary_field) {
|
||||||
super(name, { fillable: false, required: false, ...cfg });
|
super(name, { fillable: false, required: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
override isRequired(): boolean {
|
override isRequired(): boolean {
|
||||||
@@ -34,53 +29,23 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getSchema() {
|
protected getSchema() {
|
||||||
return primaryFieldConfigSchema;
|
return baseFieldConfigSchema;
|
||||||
}
|
|
||||||
|
|
||||||
get format() {
|
|
||||||
return this.config.format ?? "integer";
|
|
||||||
}
|
|
||||||
|
|
||||||
get fieldType() {
|
|
||||||
return this.format === "integer" ? "integer" : "text";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override schema() {
|
override schema() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
type: this.fieldType,
|
type: "integer",
|
||||||
name: this.name,
|
name: this.name,
|
||||||
primary: true,
|
primary: true,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getNewValue(): any {
|
|
||||||
if (this.format === "uuid") {
|
|
||||||
return uuidv7();
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async transformPersist(value: any): Promise<number> {
|
override async transformPersist(value: any): Promise<number> {
|
||||||
throw new Error("PrimaryField: This function should not be called");
|
throw new Error("PrimaryField: This function should not be called");
|
||||||
}
|
}
|
||||||
|
|
||||||
override toJsonSchema() {
|
override toJsonSchema() {
|
||||||
if (this.format === "uuid") {
|
|
||||||
return this.toSchemaWrapIfRequired(Type.String({ writeOnly: undefined }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.toSchemaWrapIfRequired(Type.Number({ 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 {
|
export {
|
||||||
type RepoQuery,
|
type RepoQuery,
|
||||||
type RepoQueryIn,
|
type RepoQueryIn,
|
||||||
getRepoQueryTemplate,
|
defaultQuerySchema,
|
||||||
repoQuery,
|
querySchema,
|
||||||
} from "./server/query";
|
whereSchema,
|
||||||
|
} from "./server/data-query-impl";
|
||||||
export type { WhereQuery } from "./entities/query/WhereBuilder";
|
|
||||||
|
|
||||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ import {
|
|||||||
type MutationInstructionResponse,
|
type MutationInstructionResponse,
|
||||||
RelationHelper,
|
RelationHelper,
|
||||||
} from "../relations";
|
} from "../relations";
|
||||||
import type { RepoQuery } from "../server/query";
|
import type { RepoQuery } from "../server/data-query-impl";
|
||||||
import type { RelationType } from "./relation-types";
|
import type { RelationType } from "./relation-types";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { PrimaryFieldType } from "core";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
const directions = ["source", "target"] as const;
|
|
||||||
export type TDirection = (typeof directions)[number];
|
|
||||||
|
|
||||||
export type KyselyJsonFrom = any;
|
export type KyselyJsonFrom = any;
|
||||||
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
||||||
|
|
||||||
@@ -31,7 +27,7 @@ export abstract class EntityRelation<
|
|||||||
|
|
||||||
// @todo: add unit tests
|
// @todo: add unit tests
|
||||||
// allowed directions, used in RelationAccessor for visibility
|
// allowed directions, used in RelationAccessor for visibility
|
||||||
directions: TDirection[] = ["source", "target"];
|
directions: ("source" | "target")[] = ["source", "target"];
|
||||||
|
|
||||||
static schema = Type.Object({
|
static schema = Type.Object({
|
||||||
mappedBy: Type.Optional(Type.String()),
|
mappedBy: Type.Optional(Type.String()),
|
||||||
@@ -73,7 +69,7 @@ export abstract class EntityRelation<
|
|||||||
reference: string,
|
reference: string,
|
||||||
): KyselyQueryBuilder;
|
): KyselyQueryBuilder;
|
||||||
|
|
||||||
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
|
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||||
return {};
|
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 {
|
ref(reference: string): EntityRelationAnchor {
|
||||||
return this.source.reference === reference ? this.source : this.target;
|
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 type { ExpressionBuilder } from "kysely";
|
||||||
import { Entity, type EntityManager } from "../entities";
|
import { Entity, type EntityManager } from "../entities";
|
||||||
import { type Field, PrimaryField } from "../fields";
|
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 { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import { RelationField } from "./RelationField";
|
import { RelationField } from "./RelationField";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { snakeToPascalWithSpaces } from "core/utils";
|
|||||||
import type { Static } from "core/utils";
|
import type { Static } from "core/utils";
|
||||||
import type { ExpressionBuilder } from "kysely";
|
import type { ExpressionBuilder } from "kysely";
|
||||||
import type { Entity, EntityManager } from "../entities";
|
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 { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
|
|||||||
import type { ExpressionBuilder } from "kysely";
|
import type { ExpressionBuilder } from "kysely";
|
||||||
import type { Entity, EntityManager } from "../entities";
|
import type { Entity, EntityManager } from "../entities";
|
||||||
import { NumberField, TextField } from "../fields";
|
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 { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import { type RelationType, RelationTypes } from "./relation-types";
|
import { type RelationType, RelationTypes } from "./relation-types";
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { type Static, StringEnum } from "core/utils";
|
import { type Static, StringEnum } from "core/utils";
|
||||||
import type { EntityManager } from "../entities";
|
import type { EntityManager } from "../entities";
|
||||||
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
|
import { Field, baseFieldConfigSchema } from "../fields";
|
||||||
import type { EntityRelation } from "./EntityRelation";
|
import type { EntityRelation } from "./EntityRelation";
|
||||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||||
@@ -15,7 +14,6 @@ export const relationFieldConfigSchema = Type.Composite([
|
|||||||
reference: Type.String(),
|
reference: Type.String(),
|
||||||
target: Type.String(), // @todo: potentially has to be an instance!
|
target: Type.String(), // @todo: potentially has to be an instance!
|
||||||
target_field: Type.Optional(Type.String({ default: "id" })),
|
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" })),
|
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -46,7 +44,6 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
reference: target.reference,
|
reference: target.reference,
|
||||||
target: target.entity.name,
|
target: target.entity.name,
|
||||||
target_field: target.entity.getPrimaryField().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() {
|
override schema() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...super.schema()!,
|
...super.schema()!,
|
||||||
type: this.config.target_field_type ?? "integer",
|
type: "integer",
|
||||||
references: `${this.config.target}.${this.config.target_field}`,
|
references: `${this.config.target}.${this.config.target_field}`,
|
||||||
onDelete: this.config.on_delete ?? "set null",
|
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 { IndexMetadata, SchemaResponse } from "../connection/Connection";
|
||||||
import type { Entity, EntityManager } from "../entities";
|
import type { Entity, EntityManager } from "../entities";
|
||||||
import { PrimaryField } from "../fields";
|
import { PrimaryField } from "../fields";
|
||||||
import { $console } from "core";
|
|
||||||
|
|
||||||
type IntrospectedTable = TableMetadata & {
|
type IntrospectedTable = TableMetadata & {
|
||||||
indices: IndexMetadata[];
|
indices: IndexMetadata[];
|
||||||
@@ -333,7 +332,6 @@ export class SchemaManager {
|
|||||||
|
|
||||||
if (config.force) {
|
if (config.force) {
|
||||||
try {
|
try {
|
||||||
$console.log("[SchemaManager]", sql);
|
|
||||||
await qb.execute();
|
await qb.execute();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
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 { Entity, EntityManager } from "data";
|
||||||
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
|
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
@@ -17,9 +17,8 @@ import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-sche
|
|||||||
|
|
||||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||||
declare module "core" {
|
declare module "core" {
|
||||||
interface Media extends AppEntity, MediaFieldSchema {}
|
|
||||||
interface DB {
|
interface DB {
|
||||||
media: Media;
|
media: { id: PrimaryFieldType } & MediaFieldSchema;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { DataPermissions } from "data";
|
|||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import type { AppMedia } from "../AppMedia";
|
import type { AppMedia } from "../AppMedia";
|
||||||
import { MediaField } from "../MediaField";
|
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 {
|
export class MediaController extends Controller {
|
||||||
constructor(private readonly media: AppMedia) {
|
constructor(private readonly media: AppMedia) {
|
||||||
@@ -26,165 +31,90 @@ export class MediaController extends Controller {
|
|||||||
// @todo: implement range requests
|
// @todo: implement range requests
|
||||||
const { auth, permission } = this.middlewares;
|
const { auth, permission } = this.middlewares;
|
||||||
const hono = this.create().use(auth());
|
const hono = this.create().use(auth());
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.media.em);
|
|
||||||
|
|
||||||
// get files list (temporary)
|
// get files list (temporary)
|
||||||
hono.get(
|
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
|
||||||
"/files",
|
const files = await this.getStorageAdapter().listObjects();
|
||||||
describeRoute({
|
return c.json(files);
|
||||||
summary: "Get the list of files",
|
});
|
||||||
tags: ["media"],
|
|
||||||
}),
|
|
||||||
permission(MediaPermissions.listFiles),
|
|
||||||
async (c) => {
|
|
||||||
const files = await this.getStorageAdapter().listObjects();
|
|
||||||
return c.json(files);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// get file by name
|
// get file by name
|
||||||
// @todo: implement more aggressive cache? (configurable)
|
// @todo: implement more aggressive cache? (configurable)
|
||||||
hono.get(
|
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => {
|
||||||
"/file/:filename",
|
const { filename } = c.req.param();
|
||||||
describeRoute({
|
if (!filename) {
|
||||||
summary: "Get a file by name",
|
throw new Error("No file name provided");
|
||||||
tags: ["media"],
|
}
|
||||||
}),
|
|
||||||
permission(MediaPermissions.readFile),
|
|
||||||
async (c) => {
|
|
||||||
const { filename } = c.req.param();
|
|
||||||
if (!filename) {
|
|
||||||
throw new Error("No file name provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.getStorage().emgr.emit(
|
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
|
||||||
new StorageEvents.FileAccessEvent({ name: filename }),
|
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
||||||
);
|
|
||||||
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
|
||||||
|
|
||||||
const headers = new Headers(res.headers);
|
const headers = new Headers(res.headers);
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// delete a file by name
|
// delete a file by name
|
||||||
hono.delete(
|
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => {
|
||||||
"/file/:filename",
|
const { filename } = c.req.param();
|
||||||
describeRoute({
|
if (!filename) {
|
||||||
summary: "Delete a file by name",
|
throw new Error("No file name provided");
|
||||||
tags: ["media"],
|
}
|
||||||
}),
|
await this.getStorage().deleteFile(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;
|
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
if (isDebug()) {
|
if (isDebug()) {
|
||||||
hono.post(
|
hono.post("/inspect", async (c) => {
|
||||||
"/inspect",
|
const file = await getFileFromContext(c);
|
||||||
describeRoute({
|
return c.json({
|
||||||
summary: "Inspect a file",
|
type: file?.type,
|
||||||
tags: ["media"],
|
name: file?.name,
|
||||||
}),
|
size: file?.size,
|
||||||
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
|
// upload file
|
||||||
// @todo: add required type for "upload endpoints"
|
// @todo: add required type for "upload endpoints"
|
||||||
hono.post(
|
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => {
|
||||||
"/upload/:filename?",
|
const reqname = c.req.param("filename");
|
||||||
describeRoute({
|
|
||||||
summary: "Upload a file",
|
|
||||||
tags: ["media"],
|
|
||||||
requestBody,
|
|
||||||
}),
|
|
||||||
jsc("param", s.object({ filename: s.string().optional() })),
|
|
||||||
permission(MediaPermissions.uploadFile),
|
|
||||||
async (c) => {
|
|
||||||
const reqname = c.req.param("filename");
|
|
||||||
|
|
||||||
const body = await getFileFromContext(c);
|
const body = await getFileFromContext(c);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
|
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
if (body.size > maxSize) {
|
if (body.size > maxSize) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: `Max size (${maxSize} bytes) exceeded` },
|
{ error: `Max size (${maxSize} bytes) exceeded` },
|
||||||
HttpStatus.PAYLOAD_TOO_LARGE,
|
HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = reqname ?? getRandomizedFilename(body as File);
|
const filename = reqname ?? getRandomizedFilename(body as File);
|
||||||
const res = await this.getStorage().uploadFile(body, filename);
|
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
|
// add upload file to entity
|
||||||
// @todo: add required type for "upload endpoints"
|
// @todo: add required type for "upload endpoints"
|
||||||
hono.post(
|
hono.post(
|
||||||
"/entity/:entity/:id/:field",
|
"/entity/:entity/:id/:field",
|
||||||
describeRoute({
|
tb(
|
||||||
summary: "Add a file to an entity",
|
"query",
|
||||||
tags: ["media"],
|
Type.Object({
|
||||||
requestBody,
|
overwrite: Type.Optional(booleanLike),
|
||||||
}),
|
|
||||||
jsc(
|
|
||||||
"param",
|
|
||||||
s.object({
|
|
||||||
entity: entitiesEnum,
|
|
||||||
id: s.number(),
|
|
||||||
field: s.string(),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
|
||||||
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const entity_name = c.req.param("entity");
|
const entity_name = c.req.param("entity");
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { App } from "App";
|
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 * as middlewares from "modules/middlewares";
|
||||||
import type { SafeUser } from "auth";
|
import type { SafeUser } from "auth";
|
||||||
import type { EntityManager } from "data";
|
|
||||||
import { s } from "core/object/schema";
|
|
||||||
|
|
||||||
export type ServerEnv = Env & {
|
export type ServerEnv = {
|
||||||
Variables: {
|
Variables: {
|
||||||
app: App;
|
app: App;
|
||||||
// to prevent resolving auth multiple times
|
// to prevent resolving auth multiple times
|
||||||
@@ -48,9 +46,4 @@ export class Controller {
|
|||||||
|
|
||||||
return c.notFound();
|
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);
|
const context = this.ctx(true);
|
||||||
|
|
||||||
for (const key in MODULES) {
|
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;
|
const module = new MODULES[key](moduleConfig, context) as Module;
|
||||||
module.setListener(async (c) => {
|
module.setListener(async (c) => {
|
||||||
await this.onModuleConfigUpdated(key, c);
|
await this.onModuleConfigUpdated(key, c);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class AdminController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private withBasePath(route: string = "") {
|
private withBasePath(route: string = "") {
|
||||||
return (this.options.basepath + route).replace(/(?<!:)\/+/g, "/");
|
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
private withAdminBasePath(route: string = "") {
|
private withAdminBasePath(route: string = "") {
|
||||||
@@ -80,51 +80,25 @@ export class AdminController extends Controller {
|
|||||||
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
|
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
|
||||||
login: this.withAdminBasePath("/auth/login"),
|
login: this.withAdminBasePath("/auth/login"),
|
||||||
register: this.withAdminBasePath("/auth/register"),
|
register: this.withAdminBasePath("/auth/register"),
|
||||||
logout: "/api/auth/logout",
|
logout: this.withAdminBasePath("/auth/logout"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
|
hono.use("*", async (c, next) => {
|
||||||
if (isDebug()) {
|
const obj = {
|
||||||
paths.push("/test/*");
|
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) {
|
await next();
|
||||||
hono.get(
|
});
|
||||||
path,
|
|
||||||
permission(SystemPermissions.accessAdmin, {
|
|
||||||
onDenied: async (c) => {
|
|
||||||
if (!path.startsWith("/auth")) {
|
|
||||||
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
|
||||||
|
|
||||||
$console.log("redirecting", authRoutes.login);
|
|
||||||
return c.redirect(authRoutes.login);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
onDenied: async (c) => {
|
|
||||||
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const obj = {
|
|
||||||
user: c.get("auth")?.user,
|
|
||||||
logout_route: authRoutes.logout,
|
|
||||||
admin_basepath: this.options.adminBasepath,
|
|
||||||
};
|
|
||||||
const html = await this.getHtml(obj);
|
|
||||||
if (!html) {
|
|
||||||
console.warn("Couldn't generate HTML for admin UI");
|
|
||||||
// re-casting to void as a return is not required
|
|
||||||
return c.notFound() as unknown as void;
|
|
||||||
}
|
|
||||||
|
|
||||||
await auth.authenticator?.requestCookieRefresh(c);
|
|
||||||
return c.html(html);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth_enabled) {
|
if (auth_enabled) {
|
||||||
const redirectRouteParams = [
|
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;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,13 +194,9 @@ export class AdminController extends Controller {
|
|||||||
}).then((res) => res.default);
|
}).then((res) => res.default);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// @todo: load all marked as entry (incl. css)
|
||||||
// @todo: load all marked as entry (incl. css)
|
assets.js = manifest["src/ui/main.tsx"].file;
|
||||||
assets.js = manifest["src/ui/main.tsx"].file;
|
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
|
||||||
} catch (e) {
|
|
||||||
$console.warn("Couldn't find assets in manifest", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
|
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
import { getRuntimeKey } from "core/utils";
|
import { getRuntimeKey } from "core/utils";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import { openAPISpecs } from "jsonv-ts/hono";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import { swaggerUI } from "@hono/swagger-ui";
|
const { Type } = tbbox;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MODULE_NAMES,
|
MODULE_NAMES,
|
||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
@@ -23,8 +24,12 @@ import {
|
|||||||
getDefaultConfig,
|
getDefaultConfig,
|
||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { jsc, s, describeRoute } from "core/object/schema";
|
import { generateOpenAPI } from "modules/server/openapi";
|
||||||
import { getVersion } from "core/env";
|
|
||||||
|
const booleanLike = Type.Transform(Type.String())
|
||||||
|
.Decode((v) => v === "1")
|
||||||
|
.Encode((v) => (v ? "1" : "0"));
|
||||||
|
|
||||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||||
success: true;
|
success: true;
|
||||||
module: Key;
|
module: Key;
|
||||||
@@ -56,27 +61,20 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.use(permission(SystemPermissions.configRead));
|
hono.use(permission(SystemPermissions.configRead));
|
||||||
|
|
||||||
hono.get(
|
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
|
||||||
"/raw",
|
// @ts-expect-error "fetch" is private
|
||||||
describeRoute({
|
return c.json(await this.app.modules.fetch());
|
||||||
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(
|
hono.get(
|
||||||
"/:module?",
|
"/:module?",
|
||||||
describeRoute({
|
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
||||||
summary: "Get the config for a module",
|
tb(
|
||||||
tags: ["system"],
|
"query",
|
||||||
}),
|
Type.Object({
|
||||||
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
|
secrets: Type.Optional(booleanLike),
|
||||||
jsc("query", s.object({ secrets: s.boolean().optional() })),
|
}),
|
||||||
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// @todo: allow secrets if authenticated user is admin
|
// @todo: allow secrets if authenticated user is admin
|
||||||
const { secrets } = c.req.valid("query");
|
const { secrets } = c.req.valid("query");
|
||||||
@@ -121,7 +119,12 @@ export class SystemController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/set/:module",
|
"/set/:module",
|
||||||
permission(SystemPermissions.configWrite),
|
permission(SystemPermissions.configWrite),
|
||||||
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
tb(
|
||||||
|
"query",
|
||||||
|
Type.Object({
|
||||||
|
force: Type.Optional(booleanLike),
|
||||||
|
}),
|
||||||
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const module = c.req.param("module") as any;
|
const module = c.req.param("module") as any;
|
||||||
const { force } = c.req.valid("query");
|
const { force } = c.req.valid("query");
|
||||||
@@ -227,17 +230,13 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schema/:module?",
|
"/schema/:module?",
|
||||||
describeRoute({
|
|
||||||
summary: "Get the schema for a module",
|
|
||||||
tags: ["system"],
|
|
||||||
}),
|
|
||||||
permission(SystemPermissions.schemaRead),
|
permission(SystemPermissions.schemaRead),
|
||||||
jsc(
|
tb(
|
||||||
"query",
|
"query",
|
||||||
s.partialObject({
|
Type.Object({
|
||||||
config: s.boolean(),
|
config: Type.Optional(booleanLike),
|
||||||
secrets: s.boolean(),
|
secrets: Type.Optional(booleanLike),
|
||||||
fresh: s.boolean(),
|
fresh: Type.Optional(booleanLike),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -275,11 +274,13 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.post(
|
hono.post(
|
||||||
"/build",
|
"/build",
|
||||||
describeRoute({
|
tb(
|
||||||
summary: "Build the app",
|
"query",
|
||||||
tags: ["system"],
|
Type.Object({
|
||||||
}),
|
sync: Type.Optional(booleanLike),
|
||||||
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
fetch: Type.Optional(booleanLike),
|
||||||
|
}),
|
||||||
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const options = c.req.valid("query") as Record<string, boolean>;
|
const options = c.req.valid("query") as Record<string, boolean>;
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||||
@@ -292,45 +293,26 @@ export class SystemController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
hono.get(
|
hono.get("/ping", (c) => c.json({ pong: true }));
|
||||||
"/ping",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Ping the server",
|
|
||||||
tags: ["system"],
|
|
||||||
}),
|
|
||||||
(c) => c.json({ pong: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
hono.get(
|
hono.get("/info", (c) =>
|
||||||
"/info",
|
c.json({
|
||||||
describeRoute({
|
version: c.get("app")?.version(),
|
||||||
summary: "Get the server info",
|
runtime: getRuntimeKey(),
|
||||||
tags: ["system"],
|
timezone: {
|
||||||
}),
|
name: getTimezone(),
|
||||||
(c) =>
|
offset: getTimezoneOffset(),
|
||||||
c.json({
|
local: datetimeStringLocal(),
|
||||||
version: c.get("app")?.version(),
|
utc: datetimeStringUTC(),
|
||||||
runtime: getRuntimeKey(),
|
|
||||||
timezone: {
|
|
||||||
name: getTimezone(),
|
|
||||||
offset: getTimezoneOffset(),
|
|
||||||
local: datetimeStringLocal(),
|
|
||||||
utc: datetimeStringUTC(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
hono.get(
|
|
||||||
"/openapi.json",
|
|
||||||
openAPISpecs(this.ctx.server, {
|
|
||||||
info: {
|
|
||||||
title: "bknd API",
|
|
||||||
version: getVersion(),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
|
||||||
|
|
||||||
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 { 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";
|
import type { AdminBkndWindowContext } from "modules/server/AdminController";
|
||||||
|
|
||||||
export type BkndClientContext = {
|
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
|
||||||
baseUrl: string;
|
baseUrl: undefined,
|
||||||
api: Api;
|
} as any);
|
||||||
authState?: Partial<AuthState>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClientContext = createContext<BkndClientContext>(undefined!);
|
|
||||||
|
|
||||||
export type ClientProviderProps = {
|
export type ClientProviderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
baseUrl?: string;
|
} & (
|
||||||
} & ApiOptions;
|
| { baseUrl?: string; user?: TApiUser | null | undefined }
|
||||||
|
| {
|
||||||
|
api: Api;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const ClientProvider = ({
|
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
|
||||||
children,
|
let api: Api;
|
||||||
host,
|
|
||||||
baseUrl: _baseUrl = host,
|
|
||||||
...props
|
|
||||||
}: ClientProviderProps) => {
|
|
||||||
const winCtx = useBkndWindowContext();
|
|
||||||
const _ctx = useClientContext();
|
|
||||||
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
|
|
||||||
let user: any = undefined;
|
|
||||||
|
|
||||||
if (winCtx) {
|
if (props && "api" in props) {
|
||||||
user = winCtx.user;
|
api = props.api;
|
||||||
}
|
} else {
|
||||||
|
const winCtx = useBkndWindowContext();
|
||||||
|
const _ctx_baseUrl = useBaseUrl();
|
||||||
|
const { baseUrl, user } = props;
|
||||||
|
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||||
|
|
||||||
if (!actualBaseUrl) {
|
|
||||||
try {
|
try {
|
||||||
actualBaseUrl = window.location.origin;
|
if (!baseUrl) {
|
||||||
} catch (e) {}
|
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 (
|
return (
|
||||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
|
||||||
{children}
|
{children}
|
||||||
</ClientContext.Provider>
|
</ClientContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -73,16 +61,12 @@ export const useApi = (host?: ApiOptions["host"]): Api => {
|
|||||||
return context.api;
|
return context.api;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useClientContext = () => {
|
|
||||||
return useContext(ClientContext);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated use useApi().baseUrl instead
|
* @deprecated use useApi().baseUrl instead
|
||||||
*/
|
*/
|
||||||
export const useBaseUrl = () => {
|
export const useBaseUrl = () => {
|
||||||
const context = useClientContext();
|
const context = useContext(ClientContext);
|
||||||
return context?.baseUrl;
|
return context.baseUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useBkndWindowContext(): AdminBkndWindowContext {
|
export function useBkndWindowContext(): AdminBkndWindowContext {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { DB, PrimaryFieldType } from "core";
|
import type { DB, PrimaryFieldType } from "core";
|
||||||
import { objectTransform } from "core/utils/objects";
|
import { objectTransform } from "core/utils/objects";
|
||||||
import { encodeSearch } from "core/utils/reqres";
|
import { encodeSearch } from "core/utils/reqres";
|
||||||
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
import type { EntityData, RepoQueryIn } from "data";
|
||||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
|
||||||
import { type Api, useApi } from "ui/client";
|
import { type Api, useApi } from "ui/client";
|
||||||
|
|
||||||
export class UseEntityApiError<Payload = any> extends Error {
|
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 = <
|
export const useEntity = <
|
||||||
Entity extends keyof DB | string,
|
Entity extends keyof DB | string,
|
||||||
Id extends PrimaryFieldType | undefined = undefined,
|
Id extends PrimaryFieldType | undefined = undefined,
|
||||||
@@ -51,26 +30,28 @@ export const useEntity = <
|
|||||||
>(
|
>(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
id?: Id,
|
id?: Id,
|
||||||
): UseEntityReturn<Entity, Id, Data> => {
|
) => {
|
||||||
const api = useApi().data;
|
const api = useApi().data;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
create: async (input: Insertable<Data>) => {
|
create: async (input: Omit<Data, "id">) => {
|
||||||
const res = await api.createOne(entity, input as any);
|
const res = await api.createOne(entity, input);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
|
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);
|
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
|
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: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
|
||||||
update: async (input: Updateable<Data>, _id: PrimaryFieldType | undefined = id) => {
|
|
||||||
if (!_id) {
|
if (!_id) {
|
||||||
throw new Error("id is required");
|
throw new Error("id is required");
|
||||||
}
|
}
|
||||||
@@ -78,9 +59,8 @@ export const useEntity = <
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
|
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
|
||||||
}
|
}
|
||||||
return res as any;
|
return res;
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
|
||||||
_delete: async (_id: PrimaryFieldType | undefined = id) => {
|
_delete: async (_id: PrimaryFieldType | undefined = id) => {
|
||||||
if (!_id) {
|
if (!_id) {
|
||||||
throw new Error("id is required");
|
throw new Error("id is required");
|
||||||
@@ -90,7 +70,7 @@ export const useEntity = <
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
|
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 = <
|
export const useEntityQuery = <
|
||||||
Entity extends keyof DB | string,
|
Entity extends keyof DB | string,
|
||||||
Id extends PrimaryFieldType | undefined = undefined,
|
Id extends PrimaryFieldType | undefined = undefined,
|
||||||
@@ -132,11 +99,11 @@ export const useEntityQuery = <
|
|||||||
id?: Id,
|
id?: Id,
|
||||||
query?: RepoQueryIn,
|
query?: RepoQueryIn,
|
||||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean },
|
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean },
|
||||||
): UseEntityQueryReturn<Entity, Id> => {
|
) => {
|
||||||
const api = useApi().data;
|
const api = useApi().data;
|
||||||
const key = makeKey(api, entity as string, id, query);
|
const key = makeKey(api, entity as string, id, query);
|
||||||
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
||||||
const fetcher = () => read(query ?? {});
|
const fetcher = () => read(query);
|
||||||
|
|
||||||
type T = Awaited<ReturnType<typeof fetcher>>;
|
type T = Awaited<ReturnType<typeof fetcher>>;
|
||||||
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
||||||
@@ -169,7 +136,6 @@ export const useEntityQuery = <
|
|||||||
...swr,
|
...swr,
|
||||||
...mapped,
|
...mapped,
|
||||||
mutate: mutateFn,
|
mutate: mutateFn,
|
||||||
// @ts-ignore
|
|
||||||
mutateRaw: swr.mutate,
|
mutateRaw: swr.mutate,
|
||||||
api,
|
api,
|
||||||
key,
|
key,
|
||||||
@@ -178,8 +144,8 @@ export const useEntityQuery = <
|
|||||||
|
|
||||||
export async function mutateEntityCache<
|
export async function mutateEntityCache<
|
||||||
Entity extends keyof DB | string,
|
Entity extends keyof DB | string,
|
||||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
|
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
|
||||||
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Selectable<Data>>) {
|
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
|
||||||
function update(prev: any, partialNext: any) {
|
function update(prev: any, partialNext: any) {
|
||||||
if (
|
if (
|
||||||
typeof prev !== "undefined" &&
|
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 = <
|
export const useEntityMutate = <
|
||||||
Entity extends keyof DB | string,
|
Entity extends keyof DB | string,
|
||||||
Id extends PrimaryFieldType | undefined = undefined,
|
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,
|
entity: Entity,
|
||||||
id?: Id,
|
id?: Id,
|
||||||
options?: SWRConfiguration,
|
options?: SWRConfiguration,
|
||||||
): UseEntityMutateReturn<Entity, Id, Data> => {
|
) => {
|
||||||
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
|
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
|
||||||
...options,
|
...options,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const _mutate = id
|
const _mutate = id
|
||||||
? (data: Partial<Selectable<Data>>) => mutateEntityCache($q.api, entity, id, data)
|
? (data) => mutateEntityCache($q.api, entity, id, data)
|
||||||
: (id: PrimaryFieldType, data: Partial<Selectable<Data>>) =>
|
: (id, data) => mutateEntityCache($q.api, entity, id, data);
|
||||||
mutateEntityCache($q.api, entity, id, data);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...$q,
|
...$q,
|
||||||
mutate: _mutate,
|
mutate: _mutate as unknown as Id extends undefined
|
||||||
} as any;
|
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
|
||||||
|
: (data: Partial<Data>) => Promise<void>,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AuthState } from "Api";
|
import type { AuthState } from "Api";
|
||||||
import type { AuthResponse } from "auth";
|
import type { AuthResponse } from "auth";
|
||||||
|
import { useState } from "react";
|
||||||
import { useApi, useInvalidate } from "ui/client";
|
import { useApi, useInvalidate } from "ui/client";
|
||||||
import { useClientContext } from "ui/client/ClientProvider";
|
|
||||||
|
|
||||||
type LoginData = {
|
type LoginData = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -10,7 +10,7 @@ type LoginData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UseAuth = {
|
type UseAuth = {
|
||||||
data: Partial<AuthState> | undefined;
|
data: AuthState | undefined;
|
||||||
user: AuthState["user"] | undefined;
|
user: AuthState["user"] | undefined;
|
||||||
token: AuthState["token"] | undefined;
|
token: AuthState["token"] | undefined;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
@@ -24,36 +24,46 @@ type UseAuth = {
|
|||||||
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||||
const api = useApi(options?.baseUrl);
|
const api = useApi(options?.baseUrl);
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
const { authState } = useClientContext();
|
const authState = api.getAuthState();
|
||||||
|
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
|
||||||
const verified = authState?.verified ?? false;
|
const verified = authState?.verified ?? false;
|
||||||
|
|
||||||
|
function updateAuthState() {
|
||||||
|
setAuthData(api.getAuthState());
|
||||||
|
}
|
||||||
|
|
||||||
async function login(input: LoginData) {
|
async function login(input: LoginData) {
|
||||||
const res = await api.auth.login("password", input);
|
const res = await api.auth.loginWithPassword(input);
|
||||||
|
updateAuthState();
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(input: LoginData) {
|
async function register(input: LoginData) {
|
||||||
const res = await api.auth.register("password", input);
|
const res = await api.auth.registerWithPassword(input);
|
||||||
|
updateAuthState();
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToken(token: string) {
|
function setToken(token: string) {
|
||||||
api.updateToken(token);
|
api.updateToken(token);
|
||||||
|
updateAuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
api.updateToken(undefined);
|
await api.updateToken(undefined);
|
||||||
|
setAuthData(undefined);
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verify() {
|
async function verify() {
|
||||||
await api.verifyAuth();
|
await api.verifyAuth();
|
||||||
|
updateAuthState();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: authState,
|
data: authData,
|
||||||
user: authState?.user,
|
user: authData?.user,
|
||||||
token: authState?.token,
|
token: authData?.token,
|
||||||
verified,
|
verified,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||||
default as CodeMirror,
|
|
||||||
type ReactCodeMirrorProps,
|
|
||||||
EditorView,
|
|
||||||
} from "@uiw/react-codemirror";
|
|
||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { html } from "@codemirror/lang-html";
|
import { html } from "@codemirror/lang-html";
|
||||||
import { useTheme } from "ui/client/use-theme";
|
import { useTheme } from "ui/client/use-theme";
|
||||||
@@ -47,7 +43,7 @@ export default function CodeEditor({
|
|||||||
theme={theme === "dark" ? "dark" : "light"}
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
basicSetup={_basicSetup}
|
basicSetup={_basicSetup}
|
||||||
extensions={[...extensions, EditorView.lineWrapping]}
|
extensions={extensions}
|
||||||
{...props}
|
{...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;
|
onClick?: () => void;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
title?: string;
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +142,6 @@ export function Dropdown({
|
|||||||
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
|
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={item.title}
|
|
||||||
>
|
>
|
||||||
{space_for_icon && (
|
{space_for_icon && (
|
||||||
<div className="size-[16px] text-left mr-1.5 opacity-80">
|
<div className="size-[16px] text-left mr-1.5 opacity-80">
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export type DataTableProps<Data> = {
|
|||||||
checkable?: boolean;
|
checkable?: boolean;
|
||||||
onClickRow?: (row: Data) => void;
|
onClickRow?: (row: Data) => void;
|
||||||
onClickPage?: (page: number) => void;
|
onClickPage?: (page: number) => void;
|
||||||
hasMore?: boolean;
|
|
||||||
total?: number;
|
total?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
@@ -60,7 +59,6 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
onClickRow,
|
onClickRow,
|
||||||
onClickPage,
|
onClickPage,
|
||||||
onClickSort,
|
onClickSort,
|
||||||
hasMore,
|
|
||||||
total,
|
total,
|
||||||
sort,
|
sort,
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -77,7 +75,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
page = page || 1;
|
page = page || 1;
|
||||||
|
|
||||||
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
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;
|
const CellRender = renderValue || CellValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,14 +196,12 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-between">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="hidden md:flex text-primary/40">
|
<div className="hidden md:flex text-primary/40">
|
||||||
{hasMore === undefined ? (
|
<TableDisplay
|
||||||
<TableDisplay
|
perPage={perPage}
|
||||||
perPage={perPage}
|
page={page}
|
||||||
page={page}
|
items={data?.length || 0}
|
||||||
items={data?.length || 0}
|
total={total}
|
||||||
total={total}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||||
{perPageOptions && (
|
{perPageOptions && (
|
||||||
@@ -224,16 +220,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-primary/40">
|
<div className="text-primary/40">
|
||||||
Page {page} {pages ? `of ${pages}` : ""}
|
Page {page} of {pages}
|
||||||
</div>
|
</div>
|
||||||
{onClickPage && (
|
{onClickPage && (
|
||||||
<div className="flex flex-row gap-1.5">
|
<div className="flex flex-row gap-1.5">
|
||||||
<TableNav
|
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||||
current={page}
|
|
||||||
total={pages}
|
|
||||||
hasMore={hasMore}
|
|
||||||
onClick={onClickPage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,59 +274,41 @@ const TableDisplay = ({ perPage, page, items, total }) => {
|
|||||||
return <>Showing 1 row</>;
|
return <>Showing 1 row</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `Showing ${perPage * (page - 1) + 1}-${perPage * (page - 1) + items}`;
|
return (
|
||||||
if (total) {
|
<>
|
||||||
return `${text} of ${total} rows`;
|
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||||
}
|
</>
|
||||||
|
);
|
||||||
return text;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableNavProps = {
|
type TableNavProps = {
|
||||||
current: number;
|
current: number;
|
||||||
total?: number;
|
total: number;
|
||||||
hasMore?: boolean;
|
|
||||||
onClick?: (page: number) => void;
|
onClick?: (page: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableNav: React.FC<TableNavProps> = ({
|
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||||
current,
|
|
||||||
total: _total,
|
|
||||||
hasMore: _hasMore,
|
|
||||||
onClick,
|
|
||||||
}: TableNavProps) => {
|
|
||||||
const total = _total !== undefined ? _total : _hasMore !== undefined ? current + 1 : current;
|
|
||||||
const hasMore = _hasMore !== undefined ? _hasMore : current < (total ?? 0);
|
|
||||||
|
|
||||||
const navMap = [
|
const navMap = [
|
||||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||||
{ value: current + 1, Icon: TbChevronRight, disabled: !hasMore },
|
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||||
{
|
{ value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||||
value: _hasMore === undefined ? total : undefined,
|
|
||||||
Icon: TbChevronsRight,
|
|
||||||
disabled: !hasMore,
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return navMap.map((nav, key) => {
|
return navMap.map((nav, key) => (
|
||||||
const page = nav.value;
|
<button
|
||||||
if (page === undefined) return null;
|
role="button"
|
||||||
|
type="button"
|
||||||
return (
|
key={key}
|
||||||
<button
|
disabled={nav.disabled}
|
||||||
role="button"
|
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"
|
||||||
type="button"
|
onClick={() => {
|
||||||
key={key}
|
const page = nav.value;
|
||||||
disabled={nav.disabled}
|
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||||
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?.(safePage);
|
||||||
onClick={() => {
|
}}
|
||||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
>
|
||||||
onClick?.(safePage);
|
<nav.Icon />
|
||||||
}}
|
</button>
|
||||||
>
|
));
|
||||||
<nav.Icon />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Api } from "bknd/client";
|
import type { Api } from "bknd/client";
|
||||||
import type { PrimaryFieldType } from "core";
|
|
||||||
import type { RepoQueryIn } from "data";
|
import type { RepoQueryIn } from "data";
|
||||||
import type { MediaFieldSchema } from "media/AppMedia";
|
import type { MediaFieldSchema } from "media/AppMedia";
|
||||||
import type { TAppMediaConfig } from "media/media-schema";
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
import { useId, useEffect, useRef, useState } from "react";
|
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 { useEvent } from "ui/hooks/use-event";
|
||||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||||
import { mediaItemsToFileStates } from "./helper";
|
import { mediaItemsToFileStates } from "./helper";
|
||||||
@@ -15,7 +14,7 @@ export type DropzoneContainerProps = {
|
|||||||
infinite?: boolean;
|
infinite?: boolean;
|
||||||
entity?: {
|
entity?: {
|
||||||
name: string;
|
name: string;
|
||||||
id: PrimaryFieldType;
|
id: number;
|
||||||
field: string;
|
field: string;
|
||||||
};
|
};
|
||||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
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 { isEqual, transform } from "lodash-es";
|
||||||
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
||||||
import { type s, parse, cloneSchema } from "core/object/schema";
|
|
||||||
|
|
||||||
// @todo: migrate to Typebox
|
// @todo: migrate to Typebox
|
||||||
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
export function useSearch<Schema extends TSchema = TSchema>(
|
||||||
_schema: Schema,
|
schema: Schema,
|
||||||
defaultValue?: Partial<s.StaticCoerced<Schema>>,
|
defaultValue?: Partial<StaticDecode<Schema>>,
|
||||||
) {
|
) {
|
||||||
const schema = cloneSchema(_schema as any) as s.TSchema;
|
|
||||||
const searchString = useWouterSearch();
|
const searchString = useWouterSearch();
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
|
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
|
||||||
const value = parse(schema, initial, {
|
|
||||||
withDefaults: true,
|
|
||||||
clone: true,
|
|
||||||
}) as s.StaticCoerced<Schema>;
|
|
||||||
|
|
||||||
// @ts-ignore
|
if (searchString.length > 0) {
|
||||||
const _defaults = mergeObject(schema.template({ withOptional: true }), defaultValue ?? {});
|
value = parseDecode(schema, decodeSearch(searchString));
|
||||||
|
//console.log("search:decode", value);
|
||||||
|
}
|
||||||
|
|
||||||
function set<Update extends Partial<s.StaticCoerced<Schema>>>(update: Update): void {
|
// @todo: add option to set multiple keys at once
|
||||||
// @ts-ignore
|
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
|
||||||
if (schema.validate(update).valid) {
|
//console.log("set", key, value);
|
||||||
const search = getWithoutDefaults(mergeObject(value, update), _defaults);
|
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value });
|
||||||
const encoded = encodeSearch(search, { encode: false });
|
const search = transform(
|
||||||
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
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 {
|
return {
|
||||||
value: value as Required<s.StaticCoerced<Schema>>,
|
value: value as Required<StaticDecode<Schema>>,
|
||||||
set,
|
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 type { IconType } from "react-icons";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
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 { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
import { useLocation } from "wouter";
|
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 = ({
|
export const SectionHeaderAccordionItem = ({
|
||||||
title,
|
title,
|
||||||
open,
|
open,
|
||||||
@@ -393,7 +383,14 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
ActiveIcon = IconChevronUp,
|
ActiveIcon = IconChevronUp,
|
||||||
children,
|
children,
|
||||||
renderHeaderRight,
|
renderHeaderRight,
|
||||||
}: SectionHeaderAccordionItemProps) => (
|
}: {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
ActiveIcon?: any;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
|
||||||
|
}) => (
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 49 }}
|
style={{ minHeight: 49 }}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
@@ -425,19 +422,6 @@ export const SectionHeaderAccordionItem = ({
|
|||||||
</div>
|
</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">) => (
|
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
|
||||||
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
<hr {...props} className={twMerge("border-muted my-3", className)} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SegmentedControl, Tooltip } from "@mantine/core";
|
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 {
|
import {
|
||||||
TbDatabase,
|
TbDatabase,
|
||||||
TbFingerprint,
|
TbFingerprint,
|
||||||
@@ -24,7 +24,6 @@ import { useLocation } from "wouter";
|
|||||||
import { NavLink } from "./AppShell";
|
import { NavLink } from "./AppShell";
|
||||||
import { autoFormatString } from "core/utils";
|
import { autoFormatString } from "core/utils";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
import { getVersion } from "core/env";
|
|
||||||
|
|
||||||
export function HeaderNavigation() {
|
export function HeaderNavigation() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
@@ -160,16 +159,6 @@ function UserMenu() {
|
|||||||
|
|
||||||
const items: DropdownItem[] = [
|
const items: DropdownItem[] = [
|
||||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
|
{ 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) {
|
if (config.auth.enabled) {
|
||||||
@@ -177,8 +166,7 @@ function UserMenu() {
|
|||||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
label: "Logout",
|
label: `Logout ${auth.user.email}`,
|
||||||
title: `Logout ${auth.user.email}`,
|
|
||||||
onClick: handleLogout,
|
onClick: handleLogout,
|
||||||
icon: IconKeyOff,
|
icon: IconKeyOff,
|
||||||
});
|
});
|
||||||
@@ -188,11 +176,6 @@ function UserMenu() {
|
|||||||
if (!options.theme) {
|
if (!options.theme) {
|
||||||
items.push(() => <UserMenuThemeToggler />);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
|||||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||||
import { Alert } from "ui/components/display/Alert";
|
import { Alert } from "ui/components/display/Alert";
|
||||||
import { bkndModals } from "ui/modals";
|
import { bkndModals } from "ui/modals";
|
||||||
import type { PrimaryFieldType } from "core";
|
|
||||||
|
|
||||||
// simplify react form types 🤦
|
// simplify react form types 🤦
|
||||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
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 = {
|
type EntityFormProps = {
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
entityId?: PrimaryFieldType;
|
entityId?: number;
|
||||||
data?: EntityData;
|
data?: EntityData;
|
||||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
fieldsDisabled: boolean;
|
fieldsDisabled: boolean;
|
||||||
@@ -226,7 +225,7 @@ function EntityMediaFormField({
|
|||||||
formApi: FormApi;
|
formApi: FormApi;
|
||||||
field: MediaField;
|
field: MediaField;
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
entityId?: PrimaryFieldType;
|
entityId?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ import {
|
|||||||
type EntityFieldsFormRef,
|
type EntityFieldsFormRef,
|
||||||
} from "ui/routes/data/forms/entity.fields.form";
|
} from "ui/routes/data/forms/entity.fields.form";
|
||||||
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
|
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
|
||||||
|
|
||||||
const schema = entitiesSchema;
|
const schema = entitiesSchema;
|
||||||
type Schema = Static<typeof schema>;
|
type Schema = Static<typeof schema>;
|
||||||
|
|
||||||
export function StepEntityFields() {
|
export function StepEntityFields() {
|
||||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||||
const { config } = useBkndData();
|
|
||||||
const entity = state.entities?.create?.[0]!;
|
const entity = state.entities?.create?.[0]!;
|
||||||
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
||||||
const ref = useRef<EntityFieldsFormRef>(null);
|
const ref = useRef<EntityFieldsFormRef>(null);
|
||||||
@@ -84,8 +82,6 @@ export function StepEntityFields() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
fields={initial.fields as any}
|
fields={initial.fields as any}
|
||||||
onChange={updateListener}
|
onChange={updateListener}
|
||||||
defaultPrimaryFormat={config?.default_primary_format}
|
|
||||||
isNew={true}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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