Compare commits

..

1 Commits

Author SHA1 Message Date
dswbx 66959fe789 extended data canvas and init indices management 2025-05-03 17:58:58 +02:00
137 changed files with 1746 additions and 3469 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.14"
bun-version: "1.2.5"
- name: Install dependencies
working-directory: ./app
+1 -3
View File
@@ -29,6 +29,4 @@ packages/media/.env
.idea
.vscode
.git_old
docker/tmp
.debug
.history
docker/tmp
-1
View File
@@ -1 +0,0 @@
22
+3 -2
View File
@@ -1,4 +1,5 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
@@ -17,14 +18,14 @@ bknd simplifies app development by providing a fully functional backend for data
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Size
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui)
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker).
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
-42
View File
@@ -1,42 +0,0 @@
# ===== DB Settings =====
VITE_DB_URL=:memory:
# you can set a location for a database here, it'll overwrite the previous setting
# ideally use the ".db" folder (create it first), it's git ignored
VITE_DB_URL=file:.db/dev.db
# alternatively, you can use url/token combination
#VITE_DB_URL=
#VITE_DB_TOKEN=
# ===== DEV Server =====
# restart the dev server on every change (enable with "1")
VITE_APP_FRESH=
# displays react-scan widget (enable with "1")
VITE_DEBUG_RERENDERS=
# console logs registered routes on start (enable with "1")
VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_ACCESS_KEY=
R2_URL=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_S3_URL=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=
# ===== Internals =====
BKND_CLI_CREATE_REF=main
BKND_CLI_LOG_LEVEL=debug
BKND_MODULES_DEBUG=1
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
@@ -1,18 +1,25 @@
import { describe, test, expect } from "bun:test";
import { getDummyConnection } from "../helper";
import { type WhereQuery, WhereBuilder } from "data";
import { describe, expect, test } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -87,4 +94,64 @@ describe("WhereBuilder", () => {
expect(keys).toEqual(expectedKeys);
}
});
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
});
-14
View File
@@ -110,18 +110,4 @@ describe("some tests", async () => {
new EntityManager([entity, entity2], connection);
}).toThrow();
});
test("primary uuid", async () => {
const entity = new Entity("users", [
new PrimaryField("id", { format: "uuid" }),
new TextField("username"),
]);
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
const mutator = em.mutator(entity);
const data = await mutator.insertOne({ username: "test" });
expect(data.data.id).toBeDefined();
expect(data.data.id).toBeString();
});
});
@@ -39,28 +39,4 @@ describe("[data] PrimaryField", async () => {
expect(field.transformPersist(1)).rejects.toThrow();
expect(field.transformRetrieve(1)).toBe(1);
});
test("format", () => {
const uuid = new PrimaryField("uuid", { format: "uuid" });
expect(uuid.format).toBe("uuid");
expect(uuid.fieldType).toBe("text");
expect(uuid.getNewValue()).toBeString();
expect(uuid.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<string>",
import: [{ package: "kysely", name: "Generated" }],
});
const integer = new PrimaryField("integer", { format: "integer" });
expect(integer.format).toBe("integer");
expect(integer.fieldType).toBe("integer");
expect(integer.getNewValue()).toBeUndefined();
expect(integer.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<number>",
import: [{ package: "kysely", name: "Generated" }],
});
});
});
+1 -2
View File
@@ -43,9 +43,8 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
test.only("accepts direct", async () => {
const app = await makeApp();
console.log("app", app);
const file = Bun.file(path);
const name = makeName("png");
-5
View File
@@ -153,7 +153,6 @@ describe("AppAuth", () => {
});
await app.build();
app.registerAdminController();
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
// register custom route
@@ -163,10 +162,6 @@ describe("AppAuth", () => {
await app.server.request("/api/system/ping");
await app.server.request("/test");
expect(spy.mock.calls.length).toBe(0);
// admin route
await app.server.request("/");
expect(spy.mock.calls.length).toBe(1);
});
-23
View File
@@ -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
View File
@@ -1,6 +1,5 @@
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -9,13 +8,8 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) {
console.info("Cleaning dist (w/o static)");
console.log("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
@@ -27,11 +21,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.info("Types built");
console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.info("Types aliased");
console.log("Types aliased");
types_running = false;
},
});
@@ -53,10 +47,10 @@ if (types && !watch) {
}
function banner(title: string) {
console.info("");
console.info("=".repeat(40));
console.info(title.toUpperCase());
console.info("-".repeat(40));
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
}
// collection of always-external packages
@@ -71,7 +65,6 @@ async function buildApi() {
minify,
sourcemap,
watch,
define,
entry: [
"src/index.ts",
"src/core/index.ts",
@@ -108,7 +101,6 @@ async function buildUi() {
minify,
sourcemap,
watch,
define,
external: [
...external,
"react",
@@ -168,7 +160,6 @@ async function buildUiElements() {
minify,
sourcemap,
watch,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
@@ -220,7 +211,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
},
...overrides,
define: {
...define,
__isDev: "0",
...overrides.define,
},
external: [
+11 -15
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.14.0-rc.2",
"version": "0.12.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -18,7 +18,7 @@
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun run build.cli.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -49,11 +49,9 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
@@ -61,24 +59,24 @@
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.4",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6",
"hono": "^4.7.11",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"uuid": "^11.1.0"
"lodash-es": "^4.17.21",
"@sinclair/typebox": "0.34.30"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.3.3",
"@hono/vite-dev-server": "^0.19.1",
"@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.19.0",
"@hookform/resolvers": "^4.1.3",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
@@ -100,15 +98,14 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.1.0",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2",
"picocolors": "^1.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
@@ -121,14 +118,13 @@
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"vite": "^6.3.5",
"vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0",
"@cloudflare/workers-types": "^4.20250606.0"
"wouter": "^3.6.0"
},
"optionalDependencies": {
"@hono/node-server": "^1.14.3"
"@hono/node-server": "^1.13.8"
},
"peerDependencies": {
"react": ">=19",
+21 -71
View File
@@ -1,11 +1,10 @@
import type { SafeUser } from "auth";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser;
@@ -22,24 +21,14 @@ declare global {
}
}
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = {
host?: string;
headers?: Headers;
key?: string;
storage?: {
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
localStorage?: boolean;
fetcher?: ApiFetcher;
verbose?: boolean;
verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & (
| {
token?: string;
@@ -72,18 +61,18 @@ export class Api {
this.verified = options.verified === true;
// prefer request if given
if ("request" in options && options.request) {
if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options && options.token) {
} else if ("token" in options) {
this.token_transport = "header";
this.updateToken(options.token, { trigger: false });
this.updateToken(options.token);
// then check for an user object
} else if ("user" in options && options.user) {
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
@@ -126,30 +115,16 @@ export class Api {
this.updateToken(headerToken);
return;
}
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
} else if (this.options.localStorage) {
const token = localStorage.getItem(this.tokenKey);
if (token) {
this.token_transport = "header";
this.updateToken(token ? String(token) : undefined);
});
this.updateToken(token);
}
}
}
private get storage() {
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
}
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.verified = false;
@@ -159,25 +134,17 @@ export class Api {
this.user = undefined;
}
if (this.storage) {
if (this.options.localStorage) {
const key = this.tokenKey;
if (token) {
this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
localStorage.setItem(key, token);
} else {
this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
localStorage.removeItem(key);
}
}
if (opts?.rebuild) this.buildApis();
if (rebuild) this.buildApis();
}
private markAuthVerified(verfied: boolean) {
@@ -247,32 +214,15 @@ export class Api {
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(
{
...baseParams,
...this.options.data,
},
fetcher,
);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
onTokenUpdate: (token) => this.updateToken(token, true),
},
fetcher,
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+1 -6
View File
@@ -151,7 +151,7 @@ export class App {
}
get fetch(): Hono["fetch"] {
return this.server.fetch as any;
return this.server.fetch;
}
get module() {
@@ -253,11 +253,6 @@ export class App {
break;
}
});
// call server init if set
if (this.options?.manager?.onServerInit) {
this.options.manager.onServerInit(server);
}
}
}
@@ -5,8 +5,8 @@ import type { QB } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB;
export type D1ConnectionConfig = {
binding: D1Database;
};
class CustomD1Dialect extends D1Dialect {
@@ -17,25 +17,22 @@ class CustomD1Dialect extends D1Dialect {
}
}
export class D1Connection<
DB extends D1Database | D1DatabaseSession = D1Database,
> extends SqliteConnection {
export class D1Connection extends SqliteConnection {
protected override readonly supported = {
batching: true,
counts: false,
};
constructor(private config: D1ConnectionConfig<DB>) {
constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomD1Dialect({ database: config.binding as D1Database }),
dialect: new CustomD1Dialect({ database: config.binding }),
plugins,
});
super(kysely, {}, plugins);
}
get client(): DB {
get client(): D1Database {
return this.config.binding;
}
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig, type CfMakeConfigArgs } from "./config";
import { makeConfig } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
@@ -23,7 +23,7 @@ describe("cf adapter", () => {
{
connection: { url: DB_URL },
},
$ctx({ DB_URL }),
{},
),
).toEqual({ connection: { url: DB_URL } });
@@ -34,15 +34,15 @@ describe("cf adapter", () => {
connection: { url: env.DB_URL },
}),
},
$ctx({ DB_URL }),
{
DB_URL,
},
),
).toEqual({ connection: { url: DB_URL } });
});
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await makeApp(c, { env: a } as any, o);
},
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
makeHandler: (c, a, o) => {
return async (request: any) => {
const app = await makeApp(
@@ -50,7 +50,7 @@ describe("cf adapter", () => {
c ?? {
connection: { url: DB_URL },
},
a!,
a,
o,
);
return app.fetch(request);
@@ -9,13 +9,7 @@ import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core";
declare global {
namespace Cloudflare {
interface Env {}
}
}
export type CloudflareEnv = Cloudflare.Env;
export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => {
@@ -23,11 +17,6 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
dobj?: DurableObjectNamespace;
db?: D1Database;
};
d1?: {
session?: boolean;
transport?: "header" | "cookie";
first?: D1SessionConstraint;
};
static?: "kv" | "assets";
key?: string;
keepAliveSeconds?: number;
+19 -120
View File
@@ -1,148 +1,47 @@
/// <reference types="@cloudflare/workers-types" />
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { D1Connection } from "./connection/D1Connection";
import { D1Connection } from "./D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { Context, ExecutionContext } from "hono";
import type { ExecutionContext } from "hono";
import { $console } from "core";
import { setCookie } from "hono/cookie";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
d1_session: {
cookie: "cf_d1_session",
header: "x-cf-d1-session",
},
};
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
env: Env;
ctx?: ExecutionContext;
request?: Request;
};
function getCookieValue(cookies: string | null, name: string) {
if (!cookies) return null;
for (const cookie of cookies.split("; ")) {
const [key, value] = cookie.split("=");
if (key === name && value) {
return decodeURIComponent(value);
}
}
return null;
}
export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
const headerKey = constants.d1_session.header;
const cookieKey = constants.d1_session.cookie;
const transport = config.d1?.transport;
return {
get: (request?: Request): D1SessionBookmark | undefined => {
if (!request || !config.d1?.session) return undefined;
if (!transport || transport === "cookie") {
const cookies = request.headers.get("Cookie");
if (cookies) {
const cookie = getCookieValue(cookies, cookieKey);
if (cookie) {
return cookie;
}
}
}
if (!transport || transport === "header") {
if (request.headers.has(headerKey)) {
return request.headers.get(headerKey) as any;
}
}
return undefined;
},
set: (c: Context, d1?: D1DatabaseSession) => {
if (!d1 || !config.d1?.session) return;
const session = d1.getBookmark();
if (session) {
if (!transport || transport === "header") {
c.header(headerKey, session);
}
if (!transport || transport === "cookie") {
setCookie(c, cookieKey, session, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 60 * 5, // 5 minutes
});
}
}
},
};
}
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
args: Env = {} as Env,
) {
if (!media_registered) {
registerMedia(args as any);
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args?.env);
if (args?.env) {
const bindings = config.bindings?.(args?.env);
const sessionHelper = d1SessionHelper(config);
const sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined;
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
$console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args.env, "D1Database");
if (binding) {
$console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
if (config.d1?.session) {
session = db.withSession(sessionId ?? config.d1?.first);
appConfig.connection = new D1Connection({ binding: session });
} else {
appConfig.connection = new D1Connection({ binding: db });
}
} else {
throw new Error("No database connection given");
const appConfig = makeAdapterConfig(config, args);
const bindings = config.bindings?.(args);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
$console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
if (binding) {
$console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (config.d1?.session) {
appConfig.options = {
...appConfig.options,
manager: {
...appConfig.options?.manager,
onServerInit: (server) => {
server.use(async (c, next) => {
sessionHelper.set(c, session);
await next();
});
},
},
};
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
+1 -2
View File
@@ -1,4 +1,4 @@
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
@@ -12,7 +12,6 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export function d1(config: D1ConnectionConfig) {
return new D1Connection(config);
+3 -4
View File
@@ -5,9 +5,8 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Context<Env>,
{ env, ctx, ...args }: Context<Env>,
) {
const { env, ctx } = args;
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -21,7 +20,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
const app = await createRuntimeApp(
{
...makeConfig(config, args),
...makeConfig(config, env),
initialConfig,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx);
@@ -42,7 +41,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
await config.beforeBuild?.(app);
},
},
args,
{ env, ctx, ...args },
);
if (!cachedConfig) {
+4 -4
View File
@@ -1,13 +1,13 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
import { makeConfig, registerAsyncsExecutionContext } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
await config.onBuilt?.(app);
},
},
ctx,
ctx.env,
opts,
);
}
+1
View File
@@ -3,6 +3,7 @@ import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] };
};
+4 -8
View File
@@ -4,21 +4,19 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
onTokenUpdate?: (token: string) => void | Promise<void>;
};
export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> {
return {
basepath: "/api/auth",
credentials: "include",
};
}
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: this.options.credentials,
credentials: "include",
});
if (res.ok && res.body.token) {
@@ -29,7 +27,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: this.options.credentials,
credentials: "include",
});
if (res.ok && res.body.token) {
@@ -70,7 +68,5 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
}
async logout() {
await this.options.onTokenUpdate?.(undefined);
}
async logout() {}
}
+38 -65
View File
@@ -1,9 +1,11 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s } from "core/object/schema";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export type AuthActionResponse = {
success: boolean;
@@ -12,6 +14,10 @@ export type AuthActionResponse = {
errors?: any;
};
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
@@ -50,10 +56,6 @@ export class AuthController extends Controller {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
@@ -91,16 +93,9 @@ export class AuthController extends Controller {
}
},
);
hono.get(
"create/schema.json",
describeRoute({
summary: "Get the schema for creating a user",
tags: ["auth"],
}),
async (c) => {
return c.json(create.schema);
},
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
}
mainHono.route(`/${name}/actions`, hono);
@@ -109,55 +104,42 @@ export class AuthController extends Controller {
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
hono.get(
"/me",
describeRoute({
summary: "Get the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
await this.auth.authenticator?.requestCookieRefresh(c);
return c.json({ user });
}
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
return c.json({ user: null }, 403);
},
);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
hono.get(
"/logout",
describeRoute({
summary: "Logout the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
hono.get("/me", auth(), async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.json({ user: null }, 403);
});
return c.redirect("/");
},
);
hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.redirect("/");
});
hono.get(
"/strategies",
describeRoute({
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
async (c) => {
const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false);
@@ -175,15 +157,6 @@ export class AuthController extends Controller {
},
);
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return hono.all("*", (c) => c.notFound());
}
}
@@ -347,7 +347,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
async logout(c: Context<ServerEnv>) {
$console.info("Logging out");
c.set("auth", undefined);
const cookie = await this.getAuthCookie(c);
+5 -1
View File
@@ -60,7 +60,11 @@ export const auth = (options?: {
}
await next();
// @todo: potentially add cookie refresh if content-type html and about to expire
if (!skipped) {
// renew cookie if applicable
authenticator?.requestCookieRefresh(c);
}
// release
authCtx.skip = false;
+2 -2
View File
@@ -3,9 +3,9 @@
*/
import type { Generated } from "kysely";
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
export interface AppEntity<IdType = number | string> {
export interface AppEntity<IdType extends number = number> {
id: PrimaryFieldType<IdType>;
}
-6
View File
@@ -23,12 +23,6 @@ function hasColors() {
}
const __consoles = {
critical: {
prefix: "CRT",
color: colors.red,
args_color: colors.red,
original: console.error,
},
error: {
prefix: "ERR",
color: colors.red,
-9
View File
@@ -13,15 +13,6 @@ export function isDebug(): boolean {
}
}
export function getVersion(): string {
try {
// @ts-expect-error - this is a global variable in dev
return __version;
} catch (e) {
return "0.0.0";
}
}
const envs = {
// used in $console to determine the log level
cli_log_level: {
-1
View File
@@ -26,7 +26,6 @@ export {
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash";
export { s, jsc, describeRoute } from "./object/schema";
export * from "./console";
export * from "./events";
-4
View File
@@ -34,8 +34,6 @@ type ExpressionMap<Exps extends Expressions> = {
? E
: never;
};
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
type ExpressionCondition<Exps extends Expressions> = {
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
}[keyof ExpressionMap<Exps>];
@@ -197,7 +195,5 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
const fns = _build(query, expressions, options);
return _validate(fns);
},
expressions,
expressionKeys: expressions.map((e) => e.key) as ExpressionKeys<Exps>,
};
}
-52
View File
@@ -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;
}
-63
View File
@@ -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
View File
@@ -1 +0,0 @@
export { tbValidator } from "./tbValidator";
+2 -4
View File
@@ -117,9 +117,7 @@ async function detectMimeType(
return;
}
type HonoAnyContext = Context<any, any, any>;
export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
export async function getFileFromContext(c: Context<any>): Promise<File> {
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
if (
@@ -151,7 +149,7 @@ export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
throw new Error("No file found in request");
}
export async function getBodyFromContext(c: HonoAnyContext): Promise<ReadableStream | File> {
export async function getBodyFromContext(c: Context<any>): Promise<ReadableStream | File> {
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
if (
+1 -14
View File
@@ -218,7 +218,7 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
* @param object
* @param sources
*/
export function mergeObject(object, ...sources) {
export function mergeObject<R = unknown>(object, ...sources): R {
for (const source of sources) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
@@ -406,16 +406,3 @@ export function objectToJsLiteral(value: object, indent: number = 0, _level: num
throw new TypeError(`Unsupported data type: ${t}`);
}
// lodash-es compatible `pick` with perfect type inference
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce(
(acc, key) => {
if (key in obj) {
acc[key] = obj[key];
}
return acc;
},
{} as Pick<T, K>,
);
}
+1 -1
View File
@@ -36,7 +36,7 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
severities.forEach((severity) => {
console[severity] = () => null;
});
$console.setLevel("critical");
$console.setLevel("error");
}
export function enableConsoleLog() {
+1 -7
View File
@@ -1,10 +1,4 @@
import { v4, v7 } from "uuid";
// generates v4
export function uuid(): string {
return v4();
}
export function uuidv7(): string {
return v7();
return crypto.randomUUID();
}
+2 -3
View File
@@ -6,6 +6,7 @@ import {
type EntityManager,
constructEntity,
constructRelation,
constructIndex,
} from "data";
import { Module } from "modules/Module";
import { DataController } from "./api/DataController";
@@ -35,9 +36,7 @@ export class AppData extends Module<typeof dataConfigSchema> {
);
const indices = transformObject(_indices, (index, name) => {
const entity = _entity(index.entity)!;
const fields = index.fields.map((f) => entity.field(f)!);
return new EntityIndex(entity, fields, index.unique, name);
return constructIndex(index, _entity, name);
});
for (const entity of Object.values(entities)) {
+109 -222
View File
@@ -1,4 +1,6 @@
import { $console, isDebug } from "core";
import { $console, isDebug, tbValidator as tb } from "core";
import { StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import {
DataPermissions,
type EntityData,
@@ -6,15 +8,14 @@ import {
type MutatorResponse,
type RepoQuery,
type RepositoryResponse,
repoQuery,
querySchema,
} from "data";
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
import { omitKeys } from "core/utils";
const { Type } = tbbox;
export class DataController extends Controller {
constructor(
@@ -70,7 +71,6 @@ export class DataController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
@@ -83,10 +83,6 @@ export class DataController extends Controller {
// info
hono.get(
"/",
describeRoute({
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
@@ -94,75 +90,49 @@ export class DataController extends Controller {
);
// sync endpoint
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
describeRoute({
summary: "Sync database schema",
tags: ["data"],
}),
jsc(
"query",
s.partialObject({
force: s.boolean(),
drop: s.boolean(),
}),
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
});
return c.json({ tables: tables.map((t) => t.name), changes });
},
);
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
const force = c.req.query("force") === "1";
const drop = c.req.query("drop") === "1";
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
});
return c.json({ tables: tables.map((t) => t.name), changes });
});
/**
* Schema endpoints
*/
// read entity schema
hono.get(
"/schema.json",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
}),
async (c) => {
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `${this.config.basepath}/schemas/${e.name}`,
},
]),
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas,
});
},
);
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `${this.config.basepath}/schemas/${e.name}`,
},
]),
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas,
});
});
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
}),
jsc(
tb(
"param",
s.object({
entity: entitiesEnum,
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"])),
}),
),
async (c) => {
@@ -191,39 +161,30 @@ export class DataController extends Controller {
/**
* Info endpoints
*/
hono.get(
"/info/:entity",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name);
const $rels = (r: any) =>
r.map((r: any) => ({
entity: r.other(_entity).entity.name,
ref: r.other(_entity).reference,
}));
hono.get("/info/:entity", async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name);
const $rels = (r: any) =>
r.map((r: any) => ({
entity: r.other(_entity).entity.name,
ref: r.other(_entity).reference,
}));
return c.json({
name: _entity.name,
fields,
relations: {
all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity)),
},
});
},
);
return c.json({
name: _entity.name,
fields,
relations: {
all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity)),
},
});
});
return hono.all("*", (c) => c.notFound());
}
@@ -232,9 +193,10 @@ export class DataController extends Controller {
const { permission } = this.middlewares;
const hono = this.create();
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any });
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
.Encode(String);
/**
* Function endpoints
@@ -243,19 +205,14 @@ export class DataController extends Controller {
hono.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Count entities",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.valid("json") as any;
const where = (await c.req.json()) as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
},
@@ -265,19 +222,14 @@ export class DataController extends Controller {
hono.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Check if entity exists",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.valid("json") as any;
const where = c.req.json() as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists });
},
@@ -287,36 +239,18 @@ export class DataController extends Controller {
* Read endpoints
*/
// read many
const saveRepoQuery = s.partialObject({
...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }),
select: s.array(s.string()),
join: s.array(s.string()),
});
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
// @ts-ignore
(p) => pick.includes(p.name),
) as any),
];
hono.get(
"/:entity",
describeRoute({
summary: "Read many",
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
tb("param", Type.Object({ entity: Type.String() })),
tb("query", querySchema),
async (c) => {
const { entity } = c.req.valid("param");
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repo(entity).findMany(options);
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
},
@@ -325,27 +259,22 @@ export class DataController extends Controller {
// read one
hono.get(
"/:entity/:id",
describeRoute({
summary: "Read one",
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc(
tb(
"param",
s.object({
entity: entitiesEnum,
id: idType,
Type.Object({
entity: Type.String(),
id: tbNumber,
}),
),
jsc("query", repoQuery, { skipOpenAPI: true }),
tb("query", querySchema),
async (c) => {
const { entity, id } = c.req.valid("param");
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(id, options);
const result = await this.em.repository(entity).findId(Number(id), options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
},
@@ -354,23 +283,18 @@ export class DataController extends Controller {
// read many by reference
hono.get(
"/:entity/:id/:reference",
describeRoute({
summary: "Read many by reference",
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc(
tb(
"param",
s.object({
entity: entitiesEnum,
id: idType,
reference: s.string(),
Type.Object({
entity: Type.String(),
id: tbNumber,
reference: Type.String(),
}),
),
jsc("query", repoQuery, { skipOpenAPI: true }),
tb("query", querySchema),
async (c) => {
const { entity, id, reference } = c.req.valid("param");
const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -378,40 +302,24 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery;
const result = await this.em
.repository(entity)
.findManyByReference(id, reference, options);
.findManyByReference(Number(id), reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
},
);
// func query
const fnQuery = s.partialObject({
...saveRepoQuery.properties,
with: s.object({}),
});
hono.post(
"/:entity/query",
describeRoute({
summary: "Query entities",
requestBody: {
content: {
"application/json": {
schema: fnQuery.toJSON(),
example: fnQuery.template({ withOptional: true }),
},
},
},
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery, { skipOpenAPI: true }),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema),
async (c) => {
const { entity } = c.req.valid("param");
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = (await c.req.json()) as RepoQuery;
const options = (await c.req.valid("json")) as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
@@ -424,15 +332,11 @@ export class DataController extends Controller {
// insert one
hono.post(
"/:entity",
describeRoute({
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
async (c) => {
const { entity } = c.req.valid("param");
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -451,17 +355,13 @@ export class DataController extends Controller {
// update many
hono.patch(
"/:entity",
describeRoute({
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum })),
jsc(
tb("param", Type.Object({ entity: Type.String() })),
tb(
"json",
s.object({
update: s.object({}),
where: repoQuery.properties.where,
Type.Object({
update: Type.Object({}),
where: querySchema.properties.where,
}),
),
async (c) => {
@@ -482,20 +382,15 @@ export class DataController extends Controller {
// update one
hono.patch(
"/:entity/:id",
describeRoute({
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
const { entity, id } = c.req.valid("param");
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(id, body);
const result = await this.em.mutator(entity).updateOne(Number(id), body);
return c.json(this.mutatorResult(result));
},
@@ -504,18 +399,14 @@ export class DataController extends Controller {
// delete one
hono.delete(
"/:entity/:id",
describeRoute({
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
const { entity, id } = c.req.valid("param");
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const result = await this.em.mutator(entity).deleteOne(id);
const result = await this.em.mutator(entity).deleteOne(Number(id));
return c.json(this.mutatorResult(result));
},
@@ -524,19 +415,15 @@ export class DataController extends Controller {
// delete many
hono.delete(
"/:entity",
describeRoute({
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
async (c) => {
const { entity } = c.req.valid("param");
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = (await c.req.json()) as RepoQuery["where"];
const where = c.req.valid("json") as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));
+1 -21
View File
@@ -2,8 +2,6 @@ import {
type AliasableExpression,
type ColumnBuilderCallback,
type ColumnDataType,
type DatabaseIntrospector,
type Dialect,
type Expression,
type Kysely,
type KyselyPlugin,
@@ -14,8 +12,7 @@ import {
type Simplify,
sql,
} from "kysely";
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
import type { Constructor } from "core";
import type { BaseIntrospector } from "./BaseIntrospector";
export type QB = SelectQueryBuilder<any, any, any>;
@@ -82,7 +79,6 @@ export abstract class Connection<DB = any> {
kysely: Kysely<DB>;
protected readonly supported = {
batching: false,
counts: true,
};
constructor(
@@ -163,19 +159,3 @@ export abstract class Connection<DB = any> {
// no-op by default
}
}
export function customIntrospector<T extends Constructor<Dialect>>(
dialect: T,
introspector: Constructor<DatabaseIntrospector>,
options: BaseIntrospectorConfig = {},
) {
return {
create(...args: ConstructorParameters<T>) {
return new (class extends dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new introspector(db, options);
}
})(...args);
},
};
}
@@ -3,7 +3,6 @@ import { Connection, type FieldSpec, type SchemaResponse } from "./Connection";
export class DummyConnection extends Connection {
protected override readonly supported = {
batching: true,
counts: true,
};
constructor() {
-1
View File
@@ -5,7 +5,6 @@ export {
type IndexSpec,
type DbFunctions,
type SchemaResponse,
customIntrospector,
} from "./Connection";
// sqlite
@@ -28,13 +28,13 @@ export class LibsqlConnection extends SqliteConnection {
private client: Client;
protected override readonly supported = {
batching: true,
counts: false,
};
constructor(client: Client);
constructor(credentials: LibSqlCredentials);
constructor(clientOrCredentials: Client | LibSqlCredentials) {
let client: Client;
let batching_enabled = true;
if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
@@ -44,6 +44,13 @@ export class LibsqlConnection extends SqliteConnection {
}
client = createClient({ url, authToken });
// currently there is an issue in limbo implementation
// that prevents batching from working correctly
if (/\.aws.*turso\.io$/.test(url)) {
$console.warn("Using an Turso AWS endpoint currently disables batching support");
batching_enabled = false;
}
} else {
client = clientOrCredentials;
}
@@ -56,6 +63,7 @@ export class LibsqlConnection extends SqliteConnection {
super(kysely, {}, plugins);
this.client = client;
this.supported.batching = batching_enabled;
}
getClient(): Client {
@@ -31,11 +31,7 @@ export class SqliteConnection extends Connection {
type,
(col: ColumnDefinitionBuilder) => {
if (spec.primary) {
if (spec.type === "integer") {
return col.primaryKey().notNull().autoIncrement();
}
return col.primaryKey().notNull();
return col.primaryKey().notNull().autoIncrement();
}
if (spec.references) {
let relCol = col.references(spec.references);
+2 -5
View File
@@ -1,4 +1,4 @@
import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils";
import { type Static, StringRecord, objectTransform } from "core/utils";
import * as tb from "@sinclair/typebox";
import {
FieldClassMap,
@@ -8,7 +8,6 @@ import {
entityTypes,
} from "data";
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
import { primaryFieldTypes } from "./fields";
export const FIELDS = {
...FieldClassMap,
@@ -69,13 +68,11 @@ export const indicesSchema = tb.Type.Object(
additionalProperties: false,
},
);
export type TAppDataIndex = Static<(typeof indicesSchema)[number]>;
export const dataConfigSchema = tb.Type.Object(
{
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
default_primary_format: tb.Type.Optional(
StringEnum(primaryFieldTypes, { default: "integer" }),
),
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
+2 -16
View File
@@ -6,13 +6,7 @@ import {
snakeToPascalWithSpaces,
transformObject,
} from "core/utils";
import {
type Field,
PrimaryField,
primaryFieldTypes,
type TActionContext,
type TRenderContext,
} from "../fields";
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
@@ -24,7 +18,6 @@ export const entityConfigSchema = Type.Object(
description: Type.Optional(Type.String()),
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
},
{
additionalProperties: false,
@@ -75,14 +68,7 @@ export class Entity<
if (primary_count > 1) {
throw new Error(`Entity "${name}" has more than one primary field`);
}
this.fields =
primary_count === 1
? []
: [
new PrimaryField(undefined, {
format: this.config.primary_format,
}),
];
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
if (fields) {
fields.forEach((field) => this.addField(field));
+4 -14
View File
@@ -6,7 +6,7 @@ import type { Entity, EntityData, EntityManager } from "../entities";
import { InvalidSearchParamsException } from "../errors";
import { MutatorEvents } from "../events";
import { RelationMutator } from "../relations";
import type { RepoQuery } from "../server/query";
import type { RepoQuery } from "../server/data-query-impl";
type MutatorQB =
| InsertQueryBuilder<any, any, any>
@@ -143,7 +143,7 @@ export class Mutator<
// if listener returned, take what's returned
const _data = result.returned ? result.params.data : data;
let validatedData = {
const validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(_data, "create")),
};
@@ -159,16 +159,6 @@ export class Mutator<
}
}
// primary
const primary = entity.getPrimaryField();
const primary_value = primary.getNewValue();
if (primary_value) {
validatedData = {
[primary.name]: primary_value,
...validatedData,
};
}
const query = this.conn
.insertInto(entity.name)
.values(validatedData)
@@ -185,7 +175,7 @@ export class Mutator<
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!id) {
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update");
}
@@ -222,7 +212,7 @@ export class Mutator<
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!id) {
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion");
}
+9 -32
View File
@@ -2,9 +2,10 @@ import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { $console } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es";
import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents, RepositoryEvents } from "../../events";
import { type RepoQuery, getRepoQueryTemplate } from "data/server/query";
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
import {
type Entity,
type EntityData,
@@ -27,7 +28,6 @@ export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
data: T;
meta: {
items: number;
has_more?: boolean;
total?: number;
count?: number;
time?: number;
@@ -62,10 +62,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
public entity: Entity,
protected options: RepositoryOptions = {},
) {
this.options = {
...options,
includeCounts: options?.includeCounts ?? this.em.connection.supports("counts"),
};
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
}
@@ -88,14 +84,14 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}
}
getValidOptions(options?: RepoQuery): RepoQuery {
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
const entity = this.entity;
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
const validated = {
...structuredClone(getRepoQueryTemplate()),
...cloneDeep(defaultQuerySchema),
sort: entity.getDefaultSort(),
select: entity.getSelect(),
} satisfies Required<RepoQuery>;
};
if (!options) return validated;
@@ -103,15 +99,12 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
if (!validated.select.includes(options.sort.by)) {
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
}
if (!["asc", "desc"].includes(options.sort.dir!)) {
if (!["asc", "desc"].includes(options.sort.dir)) {
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
}
this.checkIndex(entity.name, options.sort.by, "sort");
validated.sort = {
dir: "asc",
...options.sort,
};
validated.sort = options.sort;
}
if (options.select && options.select.length > 0) {
@@ -184,11 +177,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
if (options.limit) validated.limit = options.limit;
if (options.offset) validated.offset = options.offset;
// if counts disabled, add +1 to limit
if (this.options?.includeCounts === false) {
validated.limit = (validated.limit ?? 10) + 1;
}
return validated;
}
@@ -220,7 +208,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
const payload = {
@@ -455,16 +442,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
await this.triggerFindBefore(this.entity, options);
const res = await this.performQuery(qb);
if (this.options?.includeCounts === false) {
const hasMore = res.data.length === (options.limit ?? 10);
res.meta.has_more = hasMore;
if (hasMore) {
res.data = res.data.slice(0, -1);
res.result = res.result.slice(0, -1);
res.meta.items = res.data.length;
}
}
await this.triggerFindAfter(this.entity, options, res.data);
return res as any;
@@ -528,7 +505,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
};
}
async exists(where: Required<RepoQuery>["where"]): Promise<RepositoryExistsResponse> {
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
const entity = this.entity;
const options = this.getValidOptions({ where });
@@ -536,7 +513,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
let qb = this.conn.selectFrom(entity.name).select(selector);
// add mandatory where
qb = WhereBuilder.addClause(qb, options.where!).limit(1);
qb = WhereBuilder.addClause(qb, options.where).limit(1);
const { result, ...compiled } = await this.executeQb(qb);
@@ -90,7 +90,6 @@ const expressions = [
export type WhereQuery = FilterQuery<typeof expressions>;
const validator = makeValidator(expressions);
export const expressionKeys = validator.expressionKeys;
export class WhereBuilder {
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { $console, type PrimaryFieldType } from "core";
import { Event, InvalidEventReturn } from "core/events";
import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "data/server/query";
import type { RepoQuery } from "../server/data-query-impl";
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
static override slug = "mutator-insert-before";
+6 -31
View File
@@ -1,17 +1,13 @@
import { config } from "core";
import { StringEnum, uuidv7, type Static } from "core/utils";
import type { Static } from "core/utils";
import { Field, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const primaryFieldTypes = ["integer", "uuid"] as const;
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
export const primaryFieldConfigSchema = Type.Composite([
Type.Omit(baseFieldConfigSchema, ["required"]),
Type.Object({
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
required: Type.Optional(Type.Literal(false)),
}),
]);
@@ -25,8 +21,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
> {
override readonly type = "primary";
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
super(name, { fillable: false, required: false, ...cfg });
constructor(name: string = config.data.default_primary_field) {
super(name, { fillable: false, required: false });
}
override isRequired(): boolean {
@@ -34,53 +30,32 @@ export class PrimaryField<Required extends true | false = false> extends Field<
}
protected getSchema() {
return primaryFieldConfigSchema;
}
get format() {
return this.config.format ?? "integer";
}
get fieldType() {
return this.format === "integer" ? "integer" : "text";
return baseFieldConfigSchema;
}
override schema() {
return Object.freeze({
type: this.fieldType,
type: "integer",
name: this.name,
primary: true,
nullable: false,
});
}
getNewValue(): any {
if (this.format === "uuid") {
return uuidv7();
}
return undefined;
}
override async transformPersist(value: any): Promise<number> {
throw new Error("PrimaryField: This function should not be called");
}
override toJsonSchema() {
if (this.format === "uuid") {
return this.toSchemaWrapIfRequired(Type.String({ writeOnly: undefined }));
}
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
}
override toType(): TFieldTSType {
const type = this.format === "integer" ? "number" : "string";
return {
...super.toType(),
required: true,
import: [{ package: "kysely", name: "Generated" }],
type: `Generated<${type}>`,
type: "Generated<number>",
};
}
}
+5 -6
View File
@@ -10,15 +10,14 @@ export * from "./connection";
export {
type RepoQuery,
type RepoQueryIn,
getRepoQueryTemplate,
repoQuery,
} from "./server/query";
export type { WhereQuery } from "./entities/query/WhereBuilder";
defaultQuerySchema,
querySchema,
whereSchema,
} from "./server/data-query-impl";
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
export { constructEntity, constructRelation } from "./schema/constructor";
export { constructEntity, constructRelation, constructIndex } from "./schema/constructor";
export const DatabaseEvents = {
...MutatorEvents,
+2 -3
View File
@@ -6,10 +6,9 @@ import {
type MutationInstructionResponse,
RelationHelper,
} from "../relations";
import type { RepoQuery } from "../server/query";
import type { RepoQuery } from "../server/data-query-impl";
import type { RelationType } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
import type { PrimaryFieldType } from "core";
const { Type } = tbbox;
const directions = ["source", "target"] as const;
@@ -73,7 +72,7 @@ export abstract class EntityRelation<
reference: string,
): KyselyQueryBuilder;
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
return {};
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import { Entity, type EntityManager } from "../entities";
import { type Field, PrimaryField } from "../fields";
import type { RepoQuery } from "../server/query";
import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField } from "./RelationField";
+1 -1
View File
@@ -3,7 +3,7 @@ import { snakeToPascalWithSpaces } from "core/utils";
import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import type { RepoQuery } from "../server/query";
import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import { NumberField, TextField } from "../fields";
import type { RepoQuery } from "../server/query";
import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { type RelationType, RelationTypes } from "./relation-types";
+2 -4
View File
@@ -1,6 +1,6 @@
import { type Static, StringEnum } from "core/utils";
import type { EntityManager } from "../entities";
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
import { Field, baseFieldConfigSchema } from "../fields";
import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import * as tbbox from "@sinclair/typebox";
@@ -15,7 +15,6 @@ export const relationFieldConfigSchema = Type.Composite([
reference: Type.String(),
target: Type.String(), // @todo: potentially has to be an instance!
target_field: Type.Optional(Type.String({ default: "id" })),
target_field_type: Type.Optional(StringEnum(["integer", "text"], { default: "integer" })),
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
}),
]);
@@ -46,7 +45,6 @@ export class RelationField extends Field<RelationFieldConfig> {
reference: target.reference,
target: target.entity.name,
target_field: target.entity.getPrimaryField().name,
target_field_type: target.entity.getPrimaryField().fieldType,
});
}
@@ -65,7 +63,7 @@ export class RelationField extends Field<RelationFieldConfig> {
override schema() {
return Object.freeze({
...super.schema()!,
type: this.config.target_field_type ?? "integer",
type: "integer",
references: `${this.config.target}.${this.config.target_field}`,
onDelete: this.config.on_delete ?? "set null",
});
-2
View File
@@ -2,7 +2,6 @@ import type { CompiledQuery, TableMetadata } from "kysely";
import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
import type { Entity, EntityManager } from "../entities";
import { PrimaryField } from "../fields";
import { $console } from "core";
type IntrospectedTable = TableMetadata & {
indices: IndexMetadata[];
@@ -333,7 +332,6 @@ export class SchemaManager {
if (config.force) {
try {
$console.log("[SchemaManager]", sql);
await qb.execute();
} catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
+23 -2
View File
@@ -1,6 +1,13 @@
import { transformObject } from "core/utils";
import { Entity, type Field } from "data";
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
import { Entity, EntityIndex, type Field } from "data";
import {
FIELDS,
RELATIONS,
type TAppDataEntity,
type TAppDataField,
type TAppDataIndex,
type TAppDataRelation,
} from "data/data-schema";
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
@@ -32,3 +39,17 @@ export function constructRelation(
relationConfig.config,
);
}
export function constructIndex(
indexConfig: TAppDataIndex,
resolver: (name: Entity | string) => Entity,
name: string,
) {
const entity = resolver(indexConfig.entity);
return new EntityIndex(
entity,
entity.fields.filter((f) => indexConfig.fields.includes(f.name)),
indexConfig.unique,
name,
);
}
+148
View File
@@ -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;
-184
View File
@@ -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);
}
});
});
-153
View File
@@ -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>;
+61 -131
View File
@@ -6,7 +6,12 @@ import { DataPermissions } from "data";
import { Controller } from "modules/Controller";
import type { AppMedia } from "../AppMedia";
import { MediaField } from "../MediaField";
import { jsc, s, describeRoute } from "core/object/schema";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class MediaController extends Controller {
constructor(private readonly media: AppMedia) {
@@ -26,165 +31,90 @@ export class MediaController extends Controller {
// @todo: implement range requests
const { auth, permission } = this.middlewares;
const hono = this.create().use(auth());
const entitiesEnum = this.getEntitiesEnum(this.media.em);
// get files list (temporary)
hono.get(
"/files",
describeRoute({
summary: "Get the list of files",
tags: ["media"],
}),
permission(MediaPermissions.listFiles),
async (c) => {
const files = await this.getStorageAdapter().listObjects();
return c.json(files);
},
);
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
const files = await this.getStorageAdapter().listObjects();
return c.json(files);
});
// get file by name
// @todo: implement more aggressive cache? (configurable)
hono.get(
"/file/:filename",
describeRoute({
summary: "Get a file by name",
tags: ["media"],
}),
permission(MediaPermissions.readFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().emgr.emit(
new StorageEvents.FileAccessEvent({ name: filename }),
);
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
const headers = new Headers(res.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
const headers = new Headers(res.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
},
);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
});
// delete a file by name
hono.delete(
"/file/:filename",
describeRoute({
summary: "Delete a file by name",
tags: ["media"],
}),
permission(MediaPermissions.deleteFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().deleteFile(filename);
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().deleteFile(filename);
return c.json({ message: "File deleted" });
},
);
return c.json({ message: "File deleted" });
});
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
if (isDebug()) {
hono.post(
"/inspect",
describeRoute({
summary: "Inspect a file",
tags: ["media"],
}),
async (c) => {
const file = await getFileFromContext(c);
return c.json({
type: file?.type,
name: file?.name,
size: file?.size,
});
},
);
hono.post("/inspect", async (c) => {
const file = await getFileFromContext(c);
return c.json({
type: file?.type,
name: file?.name,
size: file?.size,
});
});
}
const requestBody = {
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
},
},
required: ["file"],
},
},
"application/octet-stream": {
schema: {
type: "string",
format: "binary",
},
},
},
} as any;
// upload file
// @todo: add required type for "upload endpoints"
hono.post(
"/upload/:filename?",
describeRoute({
summary: "Upload a file",
tags: ["media"],
requestBody,
}),
jsc("param", s.object({ filename: s.string().optional() })),
permission(MediaPermissions.uploadFile),
async (c) => {
const reqname = c.req.param("filename");
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => {
const reqname = c.req.param("filename");
const body = await getFileFromContext(c);
if (!body) {
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
}
if (body.size > maxSize) {
return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE,
);
}
const body = await getFileFromContext(c);
if (!body) {
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
}
if (body.size > maxSize) {
return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE,
);
}
const filename = reqname ?? getRandomizedFilename(body as File);
const res = await this.getStorage().uploadFile(body, filename);
const filename = reqname ?? getRandomizedFilename(body as File);
const res = await this.getStorage().uploadFile(body, filename);
return c.json(res, HttpStatus.CREATED);
},
);
return c.json(res, HttpStatus.CREATED);
});
// add upload file to entity
// @todo: add required type for "upload endpoints"
hono.post(
"/entity/:entity/:id/:field",
describeRoute({
summary: "Add a file to an entity",
tags: ["media"],
requestBody,
}),
jsc(
"param",
s.object({
entity: entitiesEnum,
id: s.number(),
field: s.string(),
tb(
"query",
Type.Object({
overwrite: Type.Optional(booleanLike),
}),
),
jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
async (c) => {
const entity_name = c.req.param("entity");
+2 -9
View File
@@ -1,11 +1,9 @@
import type { App } from "App";
import { type Context, type Env, Hono } from "hono";
import { type Context, Hono } from "hono";
import * as middlewares from "modules/middlewares";
import type { SafeUser } from "auth";
import type { EntityManager } from "data";
import { s } from "core/object/schema";
export type ServerEnv = Env & {
export type ServerEnv = {
Variables: {
app: App;
// to prevent resolving auth multiple times
@@ -48,9 +46,4 @@ export class Controller {
return c.notFound();
}
protected getEntitiesEnum(em: EntityManager<any>) {
const entities = em.entities.map((e) => e.name);
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
}
}
+1 -1
View File
@@ -183,7 +183,7 @@ export class ModuleManager {
const context = this.ctx(true);
for (const key in MODULES) {
const moduleConfig = initial && key in initial ? initial[key] : {};
const moduleConfig = key in initial ? initial[key] : {};
const module = new MODULES[key](moduleConfig, context) as Module;
module.setListener(async (c) => {
await this.onModuleConfigUpdated(key, c);
+41 -50
View File
@@ -54,7 +54,7 @@ export class AdminController extends Controller {
}
private withBasePath(route: string = "") {
return (this.options.basepath + route).replace(/(?<!:)\/+/g, "/");
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
}
private withAdminBasePath(route: string = "") {
@@ -80,51 +80,25 @@ export class AdminController extends Controller {
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
login: this.withAdminBasePath("/auth/login"),
register: this.withAdminBasePath("/auth/register"),
logout: "/api/auth/logout",
logout: this.withAdminBasePath("/auth/logout"),
};
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
if (isDebug()) {
paths.push("/test/*");
}
hono.use("*", async (c, next) => {
const obj = {
user: c.get("auth")?.user,
logout_route: this.withAdminBasePath(authRoutes.logout),
admin_basepath: this.options.adminBasepath,
};
const html = await this.getHtml(obj);
if (!html) {
console.warn("Couldn't generate HTML for admin UI");
// re-casting to void as a return is not required
return c.notFound() as unknown as void;
}
c.set("html", html);
for (const path of paths) {
hono.get(
path,
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
if (!path.startsWith("/auth")) {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
$console.log("redirecting", authRoutes.login);
return c.redirect(authRoutes.login);
}
return;
},
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
},
}),
async (c) => {
const obj = {
user: c.get("auth")?.user,
logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath,
};
const html = await this.getHtml(obj);
if (!html) {
console.warn("Couldn't generate HTML for admin UI");
// re-casting to void as a return is not required
return c.notFound() as unknown as void;
}
await auth.authenticator?.requestCookieRefresh(c);
return c.html(html);
},
);
}
await next();
});
if (auth_enabled) {
const redirectRouteParams = [
@@ -152,6 +126,27 @@ export class AdminController extends Controller {
});
}
// @todo: only load known paths
hono.get(
"/*",
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
$console.log("redirecting");
return c.redirect(authRoutes.login);
},
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
},
}),
async (c) => {
return c.html(c.get("html")!);
},
);
return hono;
}
@@ -199,13 +194,9 @@ export class AdminController extends Controller {
}).then((res) => res.default);
}
try {
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
} catch (e) {
$console.warn("Couldn't find assets in manifest", e);
}
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
}
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
+54 -72
View File
@@ -13,8 +13,9 @@ import {
import { getRuntimeKey } from "core/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
import { openAPISpecs } from "jsonv-ts/hono";
import { swaggerUI } from "@hono/swagger-ui";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import {
MODULE_NAMES,
type ModuleConfigs,
@@ -23,8 +24,12 @@ import {
getDefaultConfig,
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { jsc, s, describeRoute } from "core/object/schema";
import { getVersion } from "core/env";
import { generateOpenAPI } from "modules/server/openapi";
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
module: Key;
@@ -56,27 +61,20 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead));
hono.get(
"/raw",
describeRoute({
summary: "Get the raw config",
tags: ["system"],
}),
permission([SystemPermissions.configReadSecrets]),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
},
);
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
});
hono.get(
"/:module?",
describeRoute({
summary: "Get the config for a module",
tags: ["system"],
}),
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
jsc("query", s.object({ secrets: s.boolean().optional() })),
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
tb(
"query",
Type.Object({
secrets: Type.Optional(booleanLike),
}),
),
async (c) => {
// @todo: allow secrets if authenticated user is admin
const { secrets } = c.req.valid("query");
@@ -121,7 +119,12 @@ export class SystemController extends Controller {
hono.post(
"/set/:module",
permission(SystemPermissions.configWrite),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
tb(
"query",
Type.Object({
force: Type.Optional(booleanLike),
}),
),
async (c) => {
const module = c.req.param("module") as any;
const { force } = c.req.valid("query");
@@ -227,17 +230,13 @@ export class SystemController extends Controller {
hono.get(
"/schema/:module?",
describeRoute({
summary: "Get the schema for a module",
tags: ["system"],
}),
permission(SystemPermissions.schemaRead),
jsc(
tb(
"query",
s.partialObject({
config: s.boolean(),
secrets: s.boolean(),
fresh: s.boolean(),
Type.Object({
config: Type.Optional(booleanLike),
secrets: Type.Optional(booleanLike),
fresh: Type.Optional(booleanLike),
}),
),
async (c) => {
@@ -275,11 +274,13 @@ export class SystemController extends Controller {
hono.post(
"/build",
describeRoute({
summary: "Build the app",
tags: ["system"],
}),
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
tb(
"query",
Type.Object({
sync: Type.Optional(booleanLike),
fetch: Type.Optional(booleanLike),
}),
),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
@@ -292,45 +293,26 @@ export class SystemController extends Controller {
},
);
hono.get(
"/ping",
describeRoute({
summary: "Ping the server",
tags: ["system"],
}),
(c) => c.json({ pong: true }),
);
hono.get("/ping", (c) => c.json({ pong: true }));
hono.get(
"/info",
describeRoute({
summary: "Get the server info",
tags: ["system"],
}),
(c) =>
c.json({
version: c.get("app")?.version(),
runtime: getRuntimeKey(),
timezone: {
name: getTimezone(),
offset: getTimezoneOffset(),
local: datetimeStringLocal(),
utc: datetimeStringUTC(),
},
}),
);
hono.get(
"/openapi.json",
openAPISpecs(this.ctx.server, {
info: {
title: "bknd API",
version: getVersion(),
hono.get("/info", (c) =>
c.json({
version: c.get("app")?.version(),
runtime: getRuntimeKey(),
timezone: {
name: getTimezone(),
offset: getTimezoneOffset(),
local: datetimeStringLocal(),
utc: datetimeStringUTC(),
},
}),
);
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
return hono;
hono.get("/openapi.json", async (c) => {
const config = getDefaultConfig();
return c.json(generateOpenAPI(config));
});
return hono.all("*", (c) => c.notFound());
}
}
+38 -54
View File
@@ -1,64 +1,52 @@
import { Api, type ApiOptions, type AuthState } from "Api";
import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core";
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
import { createContext, type ReactNode, useContext } from "react";
import type { AdminBkndWindowContext } from "modules/server/AdminController";
export type BkndClientContext = {
baseUrl: string;
api: Api;
authState?: Partial<AuthState>;
};
const ClientContext = createContext<BkndClientContext>(undefined!);
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
baseUrl: undefined,
} as any);
export type ClientProviderProps = {
children?: ReactNode;
baseUrl?: string;
} & ApiOptions;
} & (
| { baseUrl?: string; user?: TApiUser | null | undefined }
| {
api: Api;
}
);
export const ClientProvider = ({
children,
host,
baseUrl: _baseUrl = host,
...props
}: ClientProviderProps) => {
const winCtx = useBkndWindowContext();
const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any = undefined;
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
let api: Api;
if (winCtx) {
user = winCtx.user;
}
if (props && "api" in props) {
api = props.api;
} else {
const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl();
const { baseUrl, user } = props;
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
if (!actualBaseUrl) {
try {
actualBaseUrl = window.location.origin;
} catch (e) {}
if (!baseUrl) {
if (_ctx_baseUrl) {
actualBaseUrl = _ctx_baseUrl;
console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin;
//console.log("setting from window", actualBaseUrl);
}
}
} catch (e) {
console.error("Error in ClientProvider", e);
}
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
}
const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo(
() =>
new Api({
...apiProps,
verbose: isDebug(),
onAuthStateChange: (state) => {
props.onAuthStateChange?.(state);
if (!authState?.token || state.token !== authState?.token) {
setAuthState(state);
}
},
}),
[JSON.stringify(apiProps)],
);
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
apiProps.user ? api.getAuthState() : undefined,
);
return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
{children}
</ClientContext.Provider>
);
@@ -73,16 +61,12 @@ export const useApi = (host?: ApiOptions["host"]): Api => {
return context.api;
};
export const useClientContext = () => {
return useContext(ClientContext);
};
/**
* @deprecated use useApi().baseUrl instead
*/
export const useBaseUrl = () => {
const context = useClientContext();
return context?.baseUrl;
const context = useContext(ClientContext);
return context.baseUrl;
};
export function useBkndWindowContext(): AdminBkndWindowContext {
+19 -9
View File
@@ -1,7 +1,7 @@
import type { AuthState } from "Api";
import type { AuthResponse } from "auth";
import { useState } from "react";
import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
type LoginData = {
email: string;
@@ -10,7 +10,7 @@ type LoginData = {
};
type UseAuth = {
data: Partial<AuthState> | undefined;
data: AuthState | undefined;
user: AuthState["user"] | undefined;
token: AuthState["token"] | undefined;
verified: boolean;
@@ -24,36 +24,46 @@ type UseAuth = {
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
const api = useApi(options?.baseUrl);
const invalidate = useInvalidate();
const { authState } = useClientContext();
const authState = api.getAuthState();
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
const verified = authState?.verified ?? false;
function updateAuthState() {
setAuthData(api.getAuthState());
}
async function login(input: LoginData) {
const res = await api.auth.login("password", input);
const res = await api.auth.loginWithPassword(input);
updateAuthState();
return res.data;
}
async function register(input: LoginData) {
const res = await api.auth.register("password", input);
const res = await api.auth.registerWithPassword(input);
updateAuthState();
return res.data;
}
function setToken(token: string) {
api.updateToken(token);
updateAuthState();
}
async function logout() {
api.updateToken(undefined);
await api.updateToken(undefined);
setAuthData(undefined);
invalidate();
}
async function verify() {
await api.verifyAuth();
updateAuthState();
}
return {
data: authState,
user: authState?.user,
token: authState?.token,
data: authData,
user: authData?.user,
token: authData?.token,
verified,
login,
register,
@@ -70,6 +70,7 @@ export function useBkndData() {
};
const $data = {
entity: (name: string) => entities[name],
indicesOf: (name: string) => app.indices.filter((i) => i.entity.name === name),
modals,
system: (name: string) => ({
any: entities[name]?.type === "system",
@@ -82,6 +83,7 @@ export function useBkndData() {
$data,
entities,
relations: app.relations,
indices: app.indices,
config: config.data,
schema: schema.data,
actions,
+17 -1
View File
@@ -1,5 +1,12 @@
import type { App } from "App";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import {
type Entity,
type EntityIndex,
type EntityRelation,
constructEntity,
constructRelation,
constructIndex,
} from "data";
import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows";
import type { BkndAdminOptions } from "ui/client/BkndProvider";
@@ -14,6 +21,7 @@ export class AppReduced {
// @todo: change to record
private _entities: Entity[] = [];
private _relations: EntityRelation[] = [];
private _indices: EntityIndex[] = [];
private _flows: Flow[] = [];
constructor(
@@ -30,6 +38,10 @@ export class AppReduced {
return constructRelation(relation, this.entity.bind(this));
});
this._indices = Object.entries(this.appJson.data.indices ?? {}).map(([name, index]) => {
return constructIndex(index, this.entity.bind(this), name);
});
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
// @ts-ignore
// @todo: fix constructing flow
@@ -58,6 +70,10 @@ export class AppReduced {
return new RelationAccessor(this._relations);
}
get indices(): EntityIndex[] {
return this._indices;
}
get flows(): Flow[] {
return this._flows;
}
+23 -5
View File
@@ -13,13 +13,15 @@ import {
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useTheme } from "ui/client/use-theme";
type CanvasProps = ReactFlowProps & {
export type CanvasProps = Omit<ReactFlowProps, "onNodesChange" | "onEdgesChange"> & {
externalProvider?: boolean;
backgroundStyle?: "lines" | "dots";
minimap?: boolean | MiniMapProps;
children?: Element | ReactNode;
onDropNewNode?: (base: any) => any;
onDropNewEdge?: (base: any) => any;
onNodesChange?: (changes: any) => void;
onEdgesChange?: (changes: any) => void;
};
export function Canvas({
@@ -33,8 +35,8 @@ export function Canvas({
onDropNewEdge,
...props
}: CanvasProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
const [nodes, setNodes, _onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, _onEdgesChange] = useEdgesState(_edges ?? []);
const { screenToFlowPosition } = useReactFlow();
const { theme } = useTheme();
@@ -42,6 +44,22 @@ export function Canvas({
const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isPointerPressed, setIsPointerPressed] = useState(false);
const onNodesChange = useCallback(
(changes) => {
_onNodesChange(changes);
props.onNodesChange?.(changes);
},
[_onNodesChange, props.onNodesChange],
);
const onEdgesChange = useCallback(
(changes) => {
_onEdgesChange(changes);
props.onEdgesChange?.(changes);
},
[_onEdgesChange, props.onEdgesChange],
);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey) {
setIsCommandPressed(true);
@@ -173,8 +191,6 @@ export function Canvas({
snapToGrid
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodesConnectable={false}
/*panOnDrag={isSpacePressed}*/
panOnDrag={true}
@@ -183,6 +199,8 @@ export function Canvas({
zoomOnDoubleClick={false}
selectionOnDrag={!isSpacePressed}
{...props}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
>
{backgroundStyle === "lines" && (
<Background
@@ -13,7 +13,7 @@ export function DefaultNode({ selected, children, className, ...props }: TDefaul
{...props}
className={twMerge(
"relative w-80 shadow-lg rounded-lg bg-background",
selected && "outline outline-blue-500/25",
selected && "ring-4 ring-blue-400/50",
className,
)}
>
+2 -6
View File
@@ -1,8 +1,4 @@
import {
default as CodeMirror,
type ReactCodeMirrorProps,
EditorView,
} from "@uiw/react-codemirror";
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { useTheme } from "ui/client/use-theme";
@@ -47,7 +43,7 @@ export default function CodeEditor({
theme={theme === "dark" ? "dark" : "light"}
editable={editable}
basicSetup={_basicSetup}
extensions={[...extensions, EditorView.lineWrapping]}
extensions={extensions}
{...props}
/>
);
@@ -246,7 +246,6 @@ export const Switch = forwardRef<
props.disabled && "opacity-50 !cursor-not-allowed",
)}
onCheckedChange={(bool) => {
console.log("setting", bool);
props.onChange?.({ target: { value: bool } });
}}
{...(props as any)}
@@ -272,7 +271,7 @@ export const Switch = forwardRef<
export const Select = forwardRef<
HTMLSelectElement,
React.ComponentProps<"select"> & {
options?: { value: string; label: string }[] | (string | number)[];
options?: { value: string; label: string; disabled?: boolean }[] | (string | number)[];
}
>(({ children, options, ...props }, ref) => (
<div className="flex w-full relative">
@@ -297,7 +296,7 @@ export const Select = forwardRef<
return o;
})
.map((opt) => (
<option key={opt.value} value={opt.value}>
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
@@ -78,6 +78,7 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
return (
<div key={itemPath} className="flex flex-row gap-2">
<FieldComponent
required={schema.minItems > 0}
name={itemPath}
schema={subschema!}
value={value}
@@ -19,7 +19,6 @@ export type DropdownItem =
onClick?: () => void;
destructive?: boolean;
disabled?: boolean;
title?: string;
[key: string]: any;
};
@@ -143,7 +142,6 @@ export function Dropdown({
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
)}
onClick={onClick}
title={item.title}
>
{space_for_icon && (
<div className="size-[16px] text-left mr-1.5 opacity-80">
+34 -61
View File
@@ -34,7 +34,6 @@ export type DataTableProps<Data> = {
checkable?: boolean;
onClickRow?: (row: Data) => void;
onClickPage?: (page: number) => void;
hasMore?: boolean;
total?: number;
page?: number;
perPage?: number;
@@ -60,7 +59,6 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
onClickRow,
onClickPage,
onClickSort,
hasMore,
total,
sort,
page = 1,
@@ -77,7 +75,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
page = page || 1;
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
const pages = hasMore === undefined ? Math.max(Math.ceil(total / perPage), 1) : undefined;
const pages = Math.max(Math.ceil(total / perPage), 1);
const CellRender = renderValue || CellValue;
return (
@@ -198,14 +196,12 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div>
<div className="flex flex-row items-center justify-between">
<div className="hidden md:flex text-primary/40">
{hasMore === undefined ? (
<TableDisplay
perPage={perPage}
page={page}
items={data?.length || 0}
total={total}
/>
) : null}
<TableDisplay
perPage={perPage}
page={page}
items={data?.length || 0}
total={total}
/>
</div>
<div className="flex flex-row gap-2 md:gap-10 items-center">
{perPageOptions && (
@@ -224,16 +220,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div>
)}
<div className="text-primary/40">
Page {page} {pages ? `of ${pages}` : ""}
Page {page} of {pages}
</div>
{onClickPage && (
<div className="flex flex-row gap-1.5">
<TableNav
current={page}
total={pages}
hasMore={hasMore}
onClick={onClickPage}
/>
<TableNav current={page} total={pages} onClick={onClickPage} />
</div>
)}
</div>
@@ -283,59 +274,41 @@ const TableDisplay = ({ perPage, page, items, total }) => {
return <>Showing 1 row</>;
}
const text = `Showing ${perPage * (page - 1) + 1}-${perPage * (page - 1) + items}`;
if (total) {
return `${text} of ${total} rows`;
}
return text;
return (
<>
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
</>
);
};
type TableNavProps = {
current: number;
total?: number;
hasMore?: boolean;
total: number;
onClick?: (page: number) => void;
};
const TableNav: React.FC<TableNavProps> = ({
current,
total: _total,
hasMore: _hasMore,
onClick,
}: TableNavProps) => {
const total = _total !== undefined ? _total : _hasMore !== undefined ? current + 1 : current;
const hasMore = _hasMore !== undefined ? _hasMore : current < (total ?? 0);
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
const navMap = [
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
{ value: current + 1, Icon: TbChevronRight, disabled: !hasMore },
{
value: _hasMore === undefined ? total : undefined,
Icon: TbChevronsRight,
disabled: !hasMore,
},
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
{ value: total, Icon: TbChevronsRight, disabled: current === total },
] as const;
return navMap.map((nav, key) => {
const page = nav.value;
if (page === undefined) return null;
return (
<button
role="button"
type="button"
key={key}
disabled={nav.disabled}
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
const safePage = page < 1 ? 1 : page > total ? total : page;
onClick?.(safePage);
}}
>
<nav.Icon />
</button>
);
});
return navMap.map((nav, key) => (
<button
role="button"
type="button"
key={key}
disabled={nav.disabled}
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
const page = nav.value;
const safePage = page < 1 ? 1 : page > total ? total : page;
onClick?.(safePage);
}}
>
<nav.Icon />
</button>
));
};
@@ -1,10 +1,9 @@
import type { Api } from "bknd/client";
import type { PrimaryFieldType } from "core";
import type { RepoQueryIn } from "data";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId, useEffect, useRef, useState } from "react";
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper";
@@ -15,7 +14,7 @@ export type DropzoneContainerProps = {
infinite?: boolean;
entity?: {
name: string;
id: PrimaryFieldType;
id: number;
field: string;
};
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
+6 -4
View File
@@ -28,10 +28,10 @@ export function useRoutePathState(_path?: string, identifier?: string) {
const [, navigate] = useLocation();
function toggle(_open?: boolean) {
const open = _open ?? !localActive;
const open = _open ?? !active;
if (ctx) {
ctx.setActiveIdentifier(identifier!);
ctx.setActiveIdentifier(open ? identifier! : "");
}
if (path) {
@@ -58,7 +58,7 @@ export function useRoutePathState(_path?: string, identifier?: string) {
}
type RoutePathStateContextType = {
defaultIdentifier: string;
defaultIdentifier?: string;
path: string;
activeIdentifier: string;
setActiveIdentifier: (identifier: string) => void;
@@ -72,7 +72,9 @@ export function RoutePathStateProvider({
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
const segment = extractPathSegment(path);
const routeIdentifier = useParams()[segment];
const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier);
const [activeIdentifier, setActiveIdentifier] = useState(
routeIdentifier ?? defaultIdentifier ?? "",
);
return (
<RoutePathStateContext.Provider
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
+31 -32
View File
@@ -1,47 +1,46 @@
import { decodeSearch, encodeSearch, mergeObject, parseDecode } from "core/utils";
import {
type Static,
type StaticDecode,
type TSchema,
decodeSearch,
encodeSearch,
parseDecode,
} from "core/utils";
import { isEqual, transform } from "lodash-es";
import { useLocation, useSearch as useWouterSearch } from "wouter";
import { type s, parse, cloneSchema } from "core/object/schema";
// @todo: migrate to Typebox
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
_schema: Schema,
defaultValue?: Partial<s.StaticCoerced<Schema>>,
export function useSearch<Schema extends TSchema = TSchema>(
schema: Schema,
defaultValue?: Partial<StaticDecode<Schema>>,
) {
const schema = cloneSchema(_schema as any) as s.TSchema;
const searchString = useWouterSearch();
const [location, navigate] = useLocation();
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
const value = parse(schema, initial, {
withDefaults: true,
clone: true,
}) as s.StaticCoerced<Schema>;
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
// @ts-ignore
const _defaults = mergeObject(schema.template({ withOptional: true }), defaultValue ?? {});
if (searchString.length > 0) {
value = parseDecode(schema, decodeSearch(searchString));
//console.log("search:decode", value);
}
function set<Update extends Partial<s.StaticCoerced<Schema>>>(update: Update): void {
// @ts-ignore
if (schema.validate(update).valid) {
const search = getWithoutDefaults(mergeObject(value, update), _defaults);
const encoded = encodeSearch(search, { encode: false });
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
}
// @todo: add option to set multiple keys at once
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
//console.log("set", key, value);
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value });
const search = transform(
update as any,
(result, value, key) => {
if (defaultValue && isEqual(value, defaultValue[key])) return;
result[key] = value;
},
{} as Static<Schema>,
);
const encoded = encodeSearch(search, { encode: false });
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
}
return {
value: value as Required<s.StaticCoerced<Schema>>,
value: value as Required<StaticDecode<Schema>>,
set,
};
}
function getWithoutDefaults(value: object, defaultValue: object) {
return transform(
value as any,
(result, value, key) => {
if (defaultValue && isEqual(value, defaultValue[key])) return;
result[key] = value;
},
{} as object,
);
}
+2 -19
View File
@@ -1,5 +1,5 @@
import { SegmentedControl, Tooltip } from "@mantine/core";
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import {
TbDatabase,
TbFingerprint,
@@ -24,7 +24,6 @@ import { useLocation } from "wouter";
import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store";
import { getVersion } from "core/env";
export function HeaderNavigation() {
const [location, navigate] = useLocation();
@@ -160,16 +159,6 @@ function UserMenu() {
const items: DropdownItem[] = [
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
{
label: "OpenAPI",
onClick: () => window.open("/api/system/swagger", "_blank"),
icon: IconApi,
},
{
label: "Docs",
onClick: () => window.open("https://docs.bknd.io", "_blank"),
icon: IconBook,
},
];
if (config.auth.enabled) {
@@ -177,8 +166,7 @@ function UserMenu() {
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
} else {
items.push({
label: "Logout",
title: `Logout ${auth.user.email}`,
label: `Logout ${auth.user.email}`,
onClick: handleLogout,
icon: IconKeyOff,
});
@@ -188,11 +176,6 @@ function UserMenu() {
if (!options.theme) {
items.push(() => <UserMenuThemeToggler />);
}
items.push(() => (
<div className="font-mono leading-none text-xs text-primary/50 text-center pb-1 pt-2 mt-1 border-t border-primary/5">
{getVersion()}
</div>
));
return (
<>
+1 -1
View File
@@ -10,7 +10,7 @@ import { useClipboard } from "@mantine/hooks";
import { ButtonLink } from "ui/components/buttons/Button";
import { routes } from "ui/lib/routes";
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
import { JsonViewer } from "ui";
import { JsonViewer } from "ui/components/code/JsonViewer";
export type MediaInfoModalProps = {
file: FileState;
@@ -22,7 +22,6 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { Alert } from "ui/components/display/Alert";
import { bkndModals } from "ui/modals";
import type { PrimaryFieldType } from "core";
// simplify react form types 🤦
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
@@ -31,7 +30,7 @@ export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, an
type EntityFormProps = {
entity: Entity;
entityId?: PrimaryFieldType;
entityId?: number;
data?: EntityData;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
fieldsDisabled: boolean;
@@ -226,7 +225,7 @@ function EntityMediaFormField({
formApi: FormApi;
field: MediaField;
entity: Entity;
entityId?: PrimaryFieldType;
entityId?: number;
disabled?: boolean;
}) {
if (!entityId) return;
@@ -1,17 +1,43 @@
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
import { MarkerType, type Node, Position, ReactFlowProvider, useReactFlow } from "@xyflow/react";
import type { AppDataConfig, TAppDataEntity, TAppDataField } from "data/data-schema";
import { useBknd } from "ui/client/BkndProvider";
import { Canvas } from "ui/components/canvas/Canvas";
import { Canvas, type CanvasProps } from "ui/components/canvas/Canvas";
import { layoutWithDagre } from "ui/components/canvas/layouts";
import { Panels } from "ui/components/canvas/panels";
import { EntityTableNode } from "./EntityTableNode";
import { useTheme } from "ui/client/use-theme";
import { useCallback } from "react";
import { mergeObject, transformObject } from "core/utils";
export interface TCanvasEntityField extends TAppDataField {
name: string;
indexed?: boolean;
}
export interface TCanvasEntity extends TAppDataEntity {
label: string;
fields: Record<string, TCanvasEntityField>;
[key: string]: any;
}
function entitiesToNodes(
entities: AppDataConfig["entities"],
indices?: AppDataConfig["indices"],
): Node<TCanvasEntity>[] {
const indexed_fields = Object.entries(indices ?? {}).flatMap(([, index]) => index.fields);
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
return Object.entries(entities ?? {}).map(([name, entity]) => {
return {
id: name,
data: { label: name, ...entity },
data: {
...entity,
label: name,
fields: transformObject(entity.fields ?? {}, (f, name) => ({
...f,
name,
indexed: indexed_fields.includes(name),
})),
},
type: "entity",
dragHandle: ".drag-handle",
position: { x: 0, y: 0 },
@@ -65,24 +91,29 @@ const nodeTypes = {
entity: EntityTableNode.Component,
} as const;
const getEdgeStyle = (theme: string) => ({
stroke: theme === "light" ? "#ccc" : "#666",
});
const getMarkerEndStyle = (theme: string) => ({
type: MarkerType.Arrow,
width: 20,
height: 20,
color: theme === "light" ? "#aaa" : "#777",
});
export function DataSchemaCanvas() {
const {
config: { data },
} = useBknd();
const { theme } = useTheme();
const nodes = entitiesToNodes(data.entities);
const nodes = entitiesToNodes(data.entities, data.indices);
console.log(nodes);
const edges = relationsToEdges(data.relations).map((e) => ({
...e,
style: {
stroke: theme === "light" ? "#ccc" : "#666",
},
style: getEdgeStyle(theme),
type: "smoothstep",
markerEnd: {
type: MarkerType.Arrow,
width: 20,
height: 20,
color: theme === "light" ? "#aaa" : "#777",
},
markerEnd: getMarkerEndStyle(theme),
}));
const nodeLayout = layoutWithDagre({
@@ -107,7 +138,7 @@ export function DataSchemaCanvas() {
return (
<ReactFlowProvider>
<Canvas
<ActualCanvas
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
@@ -119,7 +150,49 @@ export function DataSchemaCanvas() {
}}
>
<Panels zoom minimap />
</Canvas>
</ActualCanvas>
</ReactFlowProvider>
);
}
function toRecord(nodes: Node<TAppDataEntity>[]): Record<string, Node<TAppDataEntity>> {
return Object.fromEntries(nodes.map((n) => [n.id, n]));
}
function ActualCanvas(props: CanvasProps) {
const flow = useReactFlow();
const { theme } = useTheme();
const onNodesChange = useCallback((changes: any) => {
const nodes = mergeObject<Record<string, Node<TAppDataEntity>>>(
toRecord(flow.getNodes()),
toRecord(changes),
);
const selected = Object.values(nodes).filter((n) => n.selected);
const selected_names = selected.map((n) => n.id);
flow.setEdges((edges) =>
edges.map((edge) => {
if (selected_names.includes(edge.source) || selected_names.includes(edge.target)) {
return {
...edge,
animated: true,
style: { stroke: "#6495c6" },
markerEnd: {
...getMarkerEndStyle(theme),
color: "#6495c6",
},
};
}
return {
...edge,
style: getEdgeStyle(theme),
animated: false,
markerEnd: getMarkerEndStyle(theme),
};
}),
);
}, []);
return <Canvas {...props} onNodesChange={onNodesChange as any} />;
}
@@ -1,26 +1,21 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { Handle, type Node, type NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { TAppDataEntity } from "data/data-schema";
import { useState } from "react";
import { TbDiamonds, TbKey } from "react-icons/tb";
import { useEffect, useState } from "react";
import { TbBolt, TbDiamonds, TbKey } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { DefaultNode } from "ui/components/canvas/components/nodes/DefaultNode";
import { useTheme } from "ui/client/use-theme";
import { useNavigate } from "ui/lib/routes";
import type { TCanvasEntity, TCanvasEntityField } from "./DataSchemaCanvas";
export type TableProps = {
name: string;
type?: string;
fields: TableField[];
};
export type TableField = {
name: string;
type: string;
primary?: boolean;
foreign?: boolean;
indexed?: boolean;
fields: TCanvasEntityField[];
};
function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>>) {
const [hovered, setHovered] = useState(false);
function NodeComponent(props: NodeProps<Node<TCanvasEntity>>) {
const { data } = props;
const fields = props.data.fields ?? {};
const field_count = Object.keys(fields).length;
@@ -32,10 +27,11 @@ function NodeComponent(props: NodeProps<Node<TAppDataEntity & { label: string }>
{Object.entries(fields).map(([name, field], index) => (
<TableRow
key={index}
field={{ name, ...field }}
field={field}
table={data.label}
index={index}
last={field_count === index + 1}
selected={props.selected && ["relation", "primary"].includes(field.type)}
/>
))}
</div>
@@ -53,13 +49,16 @@ const TableRow = ({
index,
onHover,
last,
selected,
}: {
field: TableField;
field: TCanvasEntityField;
table: string;
index: number;
last?: boolean;
selected?: boolean;
onHover?: (hovered: boolean) => void;
}) => {
const [navigate] = useNavigate();
const handleTop = HEIGHTS.header + HEIGHTS.row * index + HEIGHTS.row / 2;
const handles = true;
const handleId = `${table}:${field.name}`;
@@ -67,10 +66,12 @@ const TableRow = ({
return (
<div
className={twMerge(
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-auto",
"flex flex-row w-full justify-between font-mono py-1.5 px-2.5 border-b border-primary/15 border-l border-r cursor-pointer",
last && "rounded-bl-lg rounded-br-lg",
selected && "bg-primary/5",
"hover:bg-primary/5",
)}
onClick={() => navigate(`/entity/${table}/fields/${field.name}`)}
>
{handles && (
<Handle
@@ -86,7 +87,12 @@ const TableRow = ({
{field.type === "primary" && <TbKey className="text-yellow-700" />}
{field.type === "relation" && <TbDiamonds className="text-sky-700" />}
</div>
<div className="flex flex-grow">{field.name}</div>
<div className="flex flex-grow items-center gap-1">
<span className={field.config?.required ? "font-bold" : "opacity-90"}>
{field.name}
</span>{" "}
{field.indexed && <TbBolt className="text-warning-foreground/50" />}
</div>
<div className="flex opacity-60">{field.type}</div>
{handles && (
@@ -11,14 +11,12 @@ import {
type EntityFieldsFormRef,
} from "ui/routes/data/forms/entity.fields.form";
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
const schema = entitiesSchema;
type Schema = Static<typeof schema>;
export function StepEntityFields() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { config } = useBkndData();
const entity = state.entities?.create?.[0]!;
const defaultFields = { id: { type: "primary", name: "id" } } as const;
const ref = useRef<EntityFieldsFormRef>(null);
@@ -84,8 +82,6 @@ export function StepEntityFields() {
ref={ref}
fields={initial.fields as any}
onChange={updateListener}
defaultPrimaryFormat={config?.default_primary_format}
isNew={true}
/>
</div>
</div>
@@ -10,13 +10,12 @@ import {
entitySchema,
useStepContext,
} from "./CreateModal";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
export function StepEntity() {
const focusTrapRef = useFocusTrap();
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const { register, handleSubmit, formState, watch, control } = useForm({
const { register, handleSubmit, formState, watch } = useForm({
mode: "onTouched",
resolver: typeboxResolver(entitySchema),
defaultValues: state.entities?.create?.[0] ?? {},
@@ -57,6 +56,7 @@ export function StepEntity() {
label="What's the name of the entity?"
description="Use plural form, and all lowercase. It will be used as the database table."
/>
{/*<input type="submit" value="submit" />*/}
<TextInput
{...register("config.name")}
error={formState.errors.config?.name?.message}
+1 -2
View File
@@ -1,6 +1,6 @@
import { isDebug } from "core";
import { autoFormatString } from "core/utils";
import { type ChangeEvent, useState } from "react";
import type { ChangeEvent } from "react";
import {
TbAt,
TbBrandAppleFilled,
@@ -13,7 +13,6 @@ import {
TbBrandX,
TbSettings,
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
-4
View File
@@ -83,10 +83,6 @@ export function DataRoot({ children }) {
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow py-3 gap-3">
{/*<div className="pt-3 px-3">
<SearchInput placeholder="Search entities" />
</div>*/}
<EntityLinkList entities={entityList.regular} context={context} suggestCreate />
<EntityLinkList entities={entityList.system} context={context} title="System" />
<EntityLinkList
+3 -4
View File
@@ -1,4 +1,3 @@
import type { PrimaryFieldType } from "core";
import { ucFirst } from "core/utils";
import type { Entity, EntityData, EntityRelation } from "data";
import { Fragment, useState } from "react";
@@ -25,7 +24,7 @@ export function DataEntityUpdate({ params }) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const entityId = params.id as PrimaryFieldType;
const entityId = Number.parseInt(params.id as string);
const [error, setError] = useState<string | null>(null);
const [navigate] = useNavigate();
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
@@ -203,7 +202,7 @@ function EntityDetailRelations({
entity,
relations,
}: {
id: PrimaryFieldType;
id: number;
entity: Entity;
relations: EntityRelation[];
}) {
@@ -251,7 +250,7 @@ function EntityDetailInner({
entity,
relation,
}: {
id: PrimaryFieldType;
id: number;
entity: Entity;
relation: EntityRelation;
}) {
@@ -11,7 +11,8 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes } from "ui/lib/routes";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { s } from "core/object/schema";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export function DataEntityCreate({ params }) {
const { $data } = useBkndData();
@@ -28,7 +29,7 @@ export function DataEntityCreate({ params }) {
const $q = useEntityMutate(entity.name);
// @todo: use entity schema for prefilling
const search = useSearch(s.object({}), {});
const search = useSearch(Type.Object({}), {});
function goBack() {
window.history.go(-1);
+21 -13
View File
@@ -1,4 +1,4 @@
import { type Entity, repoQuery } from "data";
import { type Entity, querySchema } from "data";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client";
@@ -14,16 +14,22 @@ import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { s } from "core/object/schema";
import { pick } from "core/utils/objects";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const searchSchema = s.partialObject({
...pick(repoQuery.properties, ["select", "where", "sort"]),
page: s.number({ default: 1 }).optional(),
perPage: s.number({ default: 10 }).optional(),
});
// @todo: migrate to Typebox
const searchSchema = Type.Composite(
[
Type.Pick(querySchema, ["select", "where", "sort"]),
Type.Object({
page: Type.Optional(Type.Number({ default: 1 })),
perPage: Type.Optional(Type.Number({ default: 10 })),
}),
],
{ additionalProperties: false },
);
const PER_PAGE_OPTIONS = [5, 10, 25, 50, 100];
const PER_PAGE_OPTIONS = [5, 10, 25];
export function DataEntityList({ params }) {
const { $data } = useBkndData();
@@ -61,18 +67,21 @@ export function DataEntityList({ params }) {
}
function handleClickPage(page: number) {
search.set({ page });
search.set("page", page);
}
function handleSortClick(name: string) {
const sort = search.value.sort!;
const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" };
search.set({ sort: newSort as any });
// // @ts-expect-error - somehow all search keys are optional
console.log("new sort", newSort);
search.set("sort", newSort as any);
}
function handleClickPerPage(perPage: number) {
search.set({ perPage, page: 1 });
// @todo: also reset page to 1
search.set("perPage", perPage);
}
const isUpdating = $q.isLoading || $q.isValidating;
@@ -131,7 +140,6 @@ export function DataEntityList({ params }) {
perPage={search.value.perPage}
perPageOptions={PER_PAGE_OPTIONS}
total={meta?.count}
hasMore={meta?.has_more}
onClickPage={handleClickPage}
onClickPerPage={handleClickPerPage}
/>
+128 -100
View File
@@ -31,6 +31,8 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
import { EntityIndicesForm } from "./forms/entity.indices.form";
import type { TAppDataIndex } from "data/data-schema";
export function DataSchemaEntity({ params }) {
const { $data } = useBkndData();
@@ -42,113 +44,107 @@ export function DataSchemaEntity({ params }) {
}
return (
<RoutePathStateProvider path={`/entity/${entity.name}/:setting?`} defaultIdentifier="fields">
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Data",
onClick: () =>
navigate(routes.data.root() + routes.data.entity.list(entity.name), {
absolute: true,
}),
},
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true,
}),
},
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () => $data.modals.createRelation(entity.name),
},
{
icon: TbPhoto,
label: "Add media",
onClick: () => $data.modals.createMedia(entity.name),
},
() => <div className="h-px my-1 w-full bg-primary/5" />,
{
icon: TbDatabasePlus,
label: "Create Entity",
onClick: () => $data.modals.createEntity(),
},
]}
position="bottom-end"
>
<Button IconRight={TbPlus}>Add</Button>
</Dropdown>
</>
}
className="pl-3"
<>
<RoutePathStateProvider
path={`/entity/${entity.name}/:setting?`}
defaultIdentifier="fields"
>
<div className="flex flex-row gap-4">
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
backTo="/"
/>
<Link to="/" className="hidden md:inline">
<Button IconLeft={TbSitemap}>Overview</Button>
</Link>
</div>
</AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}>
<Fields entity={entity} />
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Data",
onClick: () =>
navigate(
routes.data.root() + routes.data.entity.list(entity.name),
{
absolute: true,
},
),
},
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true,
}),
},
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () => $data.modals.createRelation(entity.name),
},
{
icon: TbPhoto,
label: "Add media",
onClick: () => $data.modals.createMedia(entity.name),
},
() => <div className="h-px my-1 w-full bg-primary/5" />,
{
icon: TbDatabasePlus,
label: "Create Entity",
onClick: () => $data.modals.createEntity(),
},
]}
position="bottom-end"
>
<Button IconRight={TbPlus}>Add</Button>
</Dropdown>
</>
}
className="pl-3"
>
<div className="flex flex-row gap-4">
<Breadcrumbs2
path={[{ label: "Schema", href: "/" }, { label: entity.label }]}
backTo="/"
/>
<Link to="/" className="hidden md:inline">
<Button IconLeft={TbSitemap}>Overview</Button>
</Link>
</div>
</AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}>
<Fields entity={entity} />
<BasicSettings entity={entity} />
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="relations"
title="Relations"
ActiveIcon={IconCirclesRelation}
>
<Empty
<BasicSettings entity={entity} />
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="relations"
title="Relations"
description="This will soon be available here. Meanwhile, check advanced settings."
primary={{
children: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
}}
/>
</AppShell.RouteAwareSectionHeaderAccordionItem>
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="indices"
title="Indices"
ActiveIcon={IconBolt}
>
<Empty
title="Indices"
description="This will soon be available here. Meanwhile, check advanced settings."
primary={{
children: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "indices"]), {
absolute: true,
}),
}}
/>
</AppShell.RouteAwareSectionHeaderAccordionItem>
</div>
</RoutePathStateProvider>
ActiveIcon={IconCirclesRelation}
>
<Empty
title="Relations"
description="This will soon be available here. Meanwhile, check advanced settings."
primary={{
children: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["data", "relations"]), {
absolute: true,
}),
}}
/>
</AppShell.RouteAwareSectionHeaderAccordionItem>
<Indices entity={entity} />
</div>
</RoutePathStateProvider>
</>
);
}
const Fields = ({ entity }: { entity: Entity }) => {
const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0);
const { actions, $data, config } = useBkndData();
const { actions, $data } = useBkndData();
const [res, setRes] = useState<any>();
const ref = useRef<EntityFieldsFormRef>(null);
async function handleUpdate() {
@@ -201,8 +197,6 @@ const Fields = ({ entity }: { entity: Entity }) => {
}
},
}))}
defaultPrimaryFormat={config?.default_primary_format}
isNew={false}
/>
{isDebug() && (
@@ -283,3 +277,37 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
</AppShell.RouteAwareSectionHeaderAccordionItem>
);
};
const Indices = ({ entity }: { entity: Entity }) => {
const [navigate] = useNavigate();
const [data, setData] = useState<Record<string, TAppDataIndex>>({});
const d = useBkndData();
const [submitting, setSubmitting] = useState(false);
async function handleUpdate() {
console.log("update", data);
return;
/*if (submitting) return;
setSubmitting(true);
await d.actions.entity.patch(entity.name).indices.set(data);
setSubmitting(false);*/
}
return (
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="indices"
title="Indices"
ActiveIcon={IconBolt}
renderHeaderRight={({ open }) =>
open ? (
<Button variant="primary" disabled={!open || submitting} onClick={handleUpdate}>
Update
</Button>
) : null
}
>
<div className="flex flex-col flex-grow py-3 px-4 max-w-4xl gap-3 relative">
<EntityIndicesForm entity={entity} onChange={setData} />
</div>
</AppShell.RouteAwareSectionHeaderAccordionItem>
);
};
@@ -28,8 +28,6 @@ import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-s
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
import * as tbbox from "@sinclair/typebox";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
const { Type } = tbbox;
const fieldsSchemaObject = originalFieldsSchemaObject;
@@ -67,8 +65,6 @@ export type EntityFieldsFormProps = {
sortable?: boolean;
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
routePattern?: string;
defaultPrimaryFormat?: TPrimaryFieldFormat;
isNew?: boolean;
};
export type EntityFieldsFormRef = {
@@ -81,7 +77,7 @@ export type EntityFieldsFormRef = {
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
function EntityFieldsForm(
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props },
ref,
) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({
@@ -176,10 +172,6 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
remove={remove}
dnd={dnd}
routePattern={routePattern}
primary={{
defaultFormat: props.defaultPrimaryFormat,
editable: isNew,
}}
/>
)}
/>
@@ -194,10 +186,6 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
errors={errors}
remove={remove}
routePattern={routePattern}
primary={{
defaultFormat: props.defaultPrimaryFormat,
editable: isNew,
}}
/>
))}
</div>
@@ -293,7 +281,6 @@ function EntityField({
errors,
dnd,
routePattern,
primary,
}: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
index: number;
@@ -305,10 +292,6 @@ function EntityField({
errors: any;
dnd?: SortableItemProps;
routePattern?: string;
primary?: {
defaultFormat?: TPrimaryFieldFormat;
editable?: boolean;
};
}) {
const prefix = `fields.${index}.field` as const;
const type = field.field.type;
@@ -380,29 +363,15 @@ function EntityField({
</div>
)}
<div className="flex-col gap-1 hidden md:flex">
<span className="text-xs text-primary/50 leading-none">Required</span>
{is_primary ? (
<>
<MantineSelect
data={["integer", "uuid"]}
defaultValue={primary?.defaultFormat}
disabled={!primary?.editable}
placeholder="Select format"
name={`${prefix}.config.format`}
allowDeselect={false}
control={control}
size="xs"
className="w-22"
/>
</>
<Switch size="sm" defaultChecked disabled />
) : (
<>
<span className="text-xs text-primary/50 leading-none">Required</span>
<MantineSwitch
size="sm"
name={`${prefix}.config.required`}
control={control}
/>
</>
<MantineSwitch
size="sm"
name={`${prefix}.config.required`}
control={control}
/>
)}
</div>
</div>
@@ -0,0 +1,118 @@
import { TbBolt, TbTrash } from "react-icons/tb";
import type { Entity } from "data/entities";
import { IconButton } from "ui/components/buttons/IconButton";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { CollapsibleList } from "ui/components/list/CollapsibleList";
import { EntityIndex } from "data";
import { Button } from "ui/components/buttons/Button";
import { useEffect, useState } from "react";
import { JsonViewer } from "ui/components/code/JsonViewer";
import type { TAppDataIndex } from "data/data-schema";
import * as Formy from "ui/components/form/Formy";
export interface EntityIndicesFormProps {
entity: Entity;
onChange?: (indices: Record<string, TAppDataIndex>) => void;
}
export function EntityIndicesForm({ entity, onChange }: EntityIndicesFormProps) {
const { $data } = useBkndData();
const [indices, setIndices] = useState<EntityIndex[]>($data.indicesOf(entity.name));
const [create, setCreate] = useState<TAppDataIndex>({
entity: entity.name,
fields: [],
unique: false,
});
const indexed_fields = indices.flatMap((i) => i.fields).map((f) => f.name);
const required_fields = indices
.flatMap((i) => i.fields)
.filter((f) => f.isRequired())
.map((f) => f.name);
const fields = entity.fields.filter(
(f) => !["primary", "relation", "media"].includes(f.type) && !indexed_fields.includes(f.name),
);
useEffect(() => {
onChange?.(Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])));
}, [indices]);
function handleAdd() {
setIndices((prev) => [
...prev,
new EntityIndex(
entity,
create.fields.map((f) => entity.fields.find((f2) => f2.name === f)!),
create.unique,
),
]);
}
//console.log("indices", { indices, schema, config });
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{indices.map((index) => (
<CollapsibleList.Item key={index.name} title={index.name}>
<CollapsibleList.Preview
left={<TbBolt />}
right={<IconButton size="lg" Icon={TbTrash} />}
>
<span>{index.fields.map((f) => f.name).join(", ")}</span>
<span className="opacity-50">{index.name}</span>
</CollapsibleList.Preview>
</CollapsibleList.Item>
))}
</div>
<div className="flex flex-row gap-7 items-center">
<span className="font-bold">Add Index</span>
<div className="flex flex-row gap-2">
<Formy.Label className="opacity-70">Unique</Formy.Label>
<Formy.Switch
checked={create.unique}
size="sm"
onCheckedChange={(checked) => {
setCreate((prev) => ({
...prev,
unique: checked,
fields: prev.fields.some((f) => required_fields.includes(f))
? prev.fields
: [],
}));
}}
/>
</div>
<div className="flex flex-row flex-wrap gap-2 items-center">
<Formy.Label className="opacity-70">Field</Formy.Label>
<div className="min-w-0">
<Formy.Select
className="h-9 py-1.5 pl-3 pr-8"
options={fields.map((f) => ({
label: f.getLabel()!,
value: f.name,
disabled: create.unique && !required_fields.includes(f.name),
}))}
value={create.fields[0]}
onChange={(e) => {
setCreate((prev) => ({ ...prev, fields: [e.target.value] }));
}}
/>
</div>
</div>
<div className="flex flex-grow" />
<Button variant="primary" disabled={create.fields.length === 0} onClick={handleAdd}>
Add
</Button>
</div>
<div>
<JsonViewer
json={{
create,
data: Object.fromEntries(indices.map((i) => [i.name, i.toJSON()])),
}}
expand={9}
/>
</div>
</div>
);
}
-1
View File
@@ -49,7 +49,6 @@ input[type="date"]::-webkit-calendar-picker-indicator {
.cm-editor {
display: flex;
flex: 1;
max-width: 100%;
}
.animate-fade-in {

Some files were not shown because too many files have changed in this diff Show More