Compare commits

..

13 Commits

Author SHA1 Message Date
dswbx af6cb0c8f0 bump 0.13.0 + separated cli build into separate file 2025-05-27 16:53:49 +02:00
dswbx 5a693c0370 Merge pull request #169 from bknd-io/release/0.13
Release 0.13
2025-05-27 16:29:43 +02:00
dswbx 262588decc update github action to use bun 1.2.14 + added .nvmrc (24 breaks for node tests) 2025-05-27 13:30:02 +02:00
dswbx 17ab35e245 api: added custom storage option (#174) 2025-05-27 13:09:24 +02:00
dswbx db795ec050 Controllers: New validation + auto OpenAPI (#173)
* updated controllers to use custom json schema and added auto openapi specs

* fix data routes parsing body

* added schema exports to core

* added swagger link to Admin, switched use-search
2025-05-27 09:06:36 +02:00
dswbx 773df544dd feat/custom-json-schema (#172)
* init

* update

* finished new repo query, removed old implementation

* remove debug folder
2025-05-22 08:52:25 +02:00
dswbx 0ac7d1fd6e remove batching workaround for Turso AWS endpoints (#171)
the underlying issue with batching on Turso AWS endpoints appears resolved, making the workaround unnecessary.
2025-05-21 07:47:24 +02:00
dswbx 6694c63990 admin: data/auth route-driven settings and collapsible components (#168)
introduced `useRoutePathState` for managing active states via routes, added `CollapsibleList` for reusable collapsible UI, and updated various components to leverage route awareness for improved navigation state handling. Also adjusted routing for entities, strategies, and schema to support optional sub-paths.
2025-05-03 11:05:38 +02:00
dswbx b3f95f9552 v0.12.0 2025-05-01 10:13:40 +02:00
dswbx 372f94d22a Release 0.12 (#143)
* changed tb imports

* cleanup: replace console.log/warn with $console, remove commented-out code

Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage.

* ts: enable incremental

* fix imports in test files

reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes.

* added media permissions (#142)

* added permissions support for media module

introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic.

* fix: handle token absence in getUploadHeaders and add tests for transport modes

ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options.

* remove console.log on DropzoneContainer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add bcrypt and refactored auth resolve (#147)

* reworked auth architecture with improved password handling and claims

Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly.

* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* refactored auth handling to support bcrypt, extracted user pool

* update email regex to allow '+' and '_' characters

* update test stub password for AppAuth spec

* update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool

* rework strategies to extend a base class instead of interface

* added simple bcrypt test

* add validation logs and improve data validation handling (#157)

Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation.

* modify MediaApi to support custom fetch implementation, defaults to native fetch (#158)

* modify MediaApi to support custom fetch implementation, defaults to native fetch

added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided.

* fix tests and improve api fetcher types

* update admin basepath handling and window context integration (#155)

Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context.

* trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160)

* refactor error handling in authenticator and password strategy (#161)

made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency.

* add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162)

Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage.

* update dependencies in package.json (#156)

moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries.

* update imports to adjust nodeTestRunner path and remove unused export (#163)

updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now.

* fix sync events not awaited (#164)

* refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)

Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.

* replace LiquidJs rendering with simplified renderer (#167)

* replace LiquidJs rendering with simplified renderer

Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags.

* remove liquid js from package json

* feat/cli-generate-types (#166)

* init types generation

* update type generation for entities and fields

Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files.

* update type generation code and CLI option description

removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description.

* fix json schema field type generation

* reworked system entities to prevent recursive types

* reworked system entities to prevent recursive types

* remove unused object function

* types: use number instead of Generated

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-01 10:12:18 +02:00
dswbx d6f94a2ce1 fix docs mdx snippets imports 2025-04-26 07:34:56 +02:00
dswbx 89a39a7dc6 fix docs: try locking version 2025-04-25 08:52:52 +02:00
dswbx 88cf65f792 fix docs by tmp renaming intro page 2025-04-24 21:20:12 +02:00
92 changed files with 12997 additions and 2317 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.5"
bun-version: "1.2.14"
- name: Install dependencies
working-directory: ./app
+3 -1
View File
@@ -29,4 +29,6 @@ packages/media/.env
.idea
.vscode
.git_old
docker/tmp
docker/tmp
.debug
.history
+1
View File
@@ -0,0 +1 @@
22
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
+5 -11
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "../../../src/data";
import { Entity, NumberField, TextField } from "data";
import * as p from "data/prototype";
describe("[data] Entity", async () => {
const entity = new Entity("test", [
@@ -47,14 +48,7 @@ describe("[data] Entity", async () => {
expect(entity.getField("new_field")).toBe(field);
});
// @todo: move this to ClientApp
/*test("serialize and deserialize", async () => {
const json = entity.toJSON();
//sconsole.log("json", json.fields);
const newEntity = Entity.deserialize(json);
//console.log("newEntity", newEntity.toJSON().fields);
expect(newEntity).toBeInstanceOf(Entity);
expect(json).toEqual(newEntity.toJSON());
expect(json.fields).toEqual(newEntity.toJSON().fields);
});*/
test.only("types", async () => {
console.log(entity.toTypes());
});
});
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
@@ -1,25 +1,18 @@
import { describe, expect, test } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
import { describe, test, expect } from "bun:test";
import { getDummyConnection } from "../helper";
import { type WhereQuery, WhereBuilder } from "data";
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
expect(keys).toEqual(expectedKeys);
}
});
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
});
+2 -1
View File
@@ -43,8 +43,9 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test.only("accepts direct", async () => {
test("accepts direct", async () => {
const app = await makeApp();
console.log("app", app);
const file = Bun.file(path);
const name = makeName("png");
+23
View File
@@ -0,0 +1,23 @@
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
import { formatNumber } from "core/utils";
const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"],
target: "node",
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
for (const output of result.outputs) {
const size_ = await output.text();
console.info(
c.cyan(formatNumber.fileSize(size_.length)),
c.dim(output.path.replace(import.meta.dir + "/", "")),
);
}
+17 -8
View File
@@ -1,5 +1,6 @@
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -8,8 +9,13 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) {
console.log("Cleaning dist (w/o static)");
console.info("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
@@ -21,11 +27,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.log("Types built");
console.info("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.log("Types aliased");
console.info("Types aliased");
types_running = false;
},
});
@@ -47,10 +53,10 @@ if (types && !watch) {
}
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
console.info("");
console.info("=".repeat(40));
console.info(title.toUpperCase());
console.info("-".repeat(40));
}
// collection of always-external packages
@@ -65,6 +71,7 @@ async function buildApi() {
minify,
sourcemap,
watch,
define,
entry: [
"src/index.ts",
"src/core/index.ts",
@@ -101,6 +108,7 @@ async function buildUi() {
minify,
sourcemap,
watch,
define,
external: [
...external,
"react",
@@ -160,6 +168,7 @@ async function buildUiElements() {
minify,
sourcemap,
watch,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
@@ -211,7 +220,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
},
...overrides,
define: {
__isDev: "0",
...define,
...overrides.define,
},
external: [
+8 -6
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.11.2",
"version": "0.13.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -18,7 +18,7 @@
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:cli": "bun run build.cli.ts",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -49,9 +49,11 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
@@ -64,12 +66,11 @@
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"lodash-es": "^4.17.21",
"@sinclair/typebox": "0.34.30"
"swr": "^2.3.3"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
@@ -98,14 +99,15 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2",
"picocolors": "^1.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
+72 -22
View File
@@ -1,10 +1,11 @@
import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi } from "media/api/MediaApi";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser;
@@ -21,14 +22,24 @@ declare global {
}
}
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = {
host?: string;
headers?: Headers;
key?: string;
localStorage?: boolean;
storage?: {
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
fetcher?: ApiFetcher;
verbose?: boolean;
verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & (
| {
token?: string;
@@ -61,18 +72,18 @@ export class Api {
this.verified = options.verified === true;
// prefer request if given
if ("request" in options) {
if ("request" in options && options.request) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options) {
} else if ("token" in options && options.token) {
this.token_transport = "header";
this.updateToken(options.token);
this.updateToken(options.token, { trigger: false });
// then check for an user object
} else if ("user" in options) {
} else if ("user" in options && options.user) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
@@ -115,16 +126,30 @@ export class Api {
this.updateToken(headerToken);
return;
}
} else if (this.options.localStorage) {
const token = localStorage.getItem(this.tokenKey);
if (token) {
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
this.token_transport = "header";
this.updateToken(token);
}
this.updateToken(token ? String(token) : undefined);
});
}
}
updateToken(token?: string, rebuild?: boolean) {
private get storage() {
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
}
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
this.token = token;
this.verified = false;
@@ -134,17 +159,25 @@ export class Api {
this.user = undefined;
}
if (this.options.localStorage) {
if (this.storage) {
const key = this.tokenKey;
if (token) {
localStorage.setItem(key, token);
this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} else {
localStorage.removeItem(key);
this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
}
}
if (rebuild) this.buildApis();
if (opts?.rebuild) this.buildApis();
}
private markAuthVerified(verfied: boolean) {
@@ -214,15 +247,32 @@ export class Api {
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
this.data = new DataApi(
{
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true),
...this.options.data,
},
fetcher,
);
this.auth = new AuthApi(
{
...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
},
fetcher,
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ export class App {
}
get fetch(): Hono["fetch"] {
return this.server.fetch;
return this.server.fetch as any;
}
get module() {
+4 -2
View File
@@ -1,6 +1,6 @@
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { $console, type DB, type PrimaryFieldType } from "core";
import { $console, type DB } from "core";
import { secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data";
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
@@ -8,11 +8,13 @@ import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "core" {
interface Users extends AppEntity, UserFieldSchema {}
interface DB {
users: { id: PrimaryFieldType } & UserFieldSchema;
users: Users;
}
}
+8 -4
View File
@@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token: string) => void | Promise<void>;
onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
};
export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> {
return {
basepath: "/api/auth",
credentials: "include",
};
}
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include",
credentials: this.options.credentials,
});
if (res.ok && res.body.token) {
@@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include",
credentials: this.options.credentials,
});
if (res.ok && res.body.token) {
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
}
async logout() {}
async logout() {
await this.options.onTokenUpdate?.(undefined);
}
}
+64 -38
View File
@@ -1,11 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { describeRoute, jsc, s } from "core/object/schema";
export type AuthActionResponse = {
success: boolean;
@@ -14,10 +12,6 @@ export type AuthActionResponse = {
errors?: any;
};
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
@@ -56,6 +50,10 @@ export class AuthController extends Controller {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
@@ -93,9 +91,16 @@ export class AuthController extends Controller {
}
},
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
hono.get(
"create/schema.json",
describeRoute({
summary: "Get the schema for creating a user",
tags: ["auth"],
}),
async (c) => {
return c.json(create.schema);
},
);
}
mainHono.route(`/${name}/actions`, hono);
@@ -104,42 +109,54 @@ export class AuthController extends Controller {
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.get(
"/me",
describeRoute({
summary: "Get the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
}
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return c.json({ user: null }, 403);
},
);
hono.get("/me", auth(), async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
}
hono.get(
"/logout",
describeRoute({
summary: "Logout the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
return c.json({ user: null }, 403);
});
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.redirect("/");
});
return c.redirect("/");
},
);
hono.get(
"/strategies",
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
describeRoute({
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => {
const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false);
@@ -157,6 +174,15 @@ export class AuthController extends Controller {
},
);
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return hono.all("*", (c) => c.notFound());
}
}
+1
View File
@@ -5,3 +5,4 @@ export { debug } from "./debug";
export { user } from "./user";
export { create } from "./create";
export { copyAssets } from "./copy-assets";
export { types } from "./types";
+1
View File
@@ -0,0 +1 @@
export * from "./types";
+37
View File
@@ -0,0 +1,37 @@
import type { CliCommand } from "cli/types";
import { Option } from "commander";
import { makeAppFromEnv } from "cli/commands/run";
import { EntityTypescript } from "data/entities/EntityTypescript";
import { writeFile } from "cli/utils/sys";
import c from "picocolors";
export const types: CliCommand = (program) => {
program
.command("types")
.description("generate types")
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
.addOption(new Option("--no-write", "do not write to file").default(true))
.action(action);
};
async function action({
outfile,
write,
}: {
outfile: string;
write: boolean;
}) {
const app = await makeAppFromEnv({
server: "node",
});
await app.build();
const et = new EntityTypescript(app.em);
if (write) {
await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`);
} else {
console.info(et.toString());
}
}
+12 -1
View File
@@ -1,5 +1,6 @@
import { $console } from "core";
import { execSync, exec as nodeExec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
import path from "node:path";
import url from "node:url";
@@ -48,6 +49,16 @@ export async function fileExists(filePath: string) {
}
}
export async function writeFile(filePath: string, content: string) {
try {
await nodeWriteFile(path.resolve(process.cwd(), filePath), content);
return true;
} catch (e) {
$console.error("Failed to write file", e);
return false;
}
}
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
const stdio = opts?.silent ? "pipe" : "inherit";
const output = execSync(command, {
+5 -1
View File
@@ -3,7 +3,11 @@
*/
import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>;
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
export interface AppEntity<IdType extends number = number> {
id: PrimaryFieldType<IdType>;
}
export interface DB {
// make sure to make unknown as "any"
+9
View File
@@ -13,6 +13,15 @@ export function isDebug(): boolean {
}
}
export function getVersion(): string {
try {
// @ts-expect-error - this is a global variable in dev
return __version;
} catch (e) {
return "0.0.0";
}
}
const envs = {
// used in $console to determine the log level
cli_log_level: {
+2 -1
View File
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors";
export { isDebug, env } from "./env";
export { type PrimaryFieldType, config, type DB } from "./config";
export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config";
export { AwsClient } from "./clients/aws/AwsClient";
export {
SimpleRenderer,
@@ -26,6 +26,7 @@ export {
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash";
export { s, jsc, describeRoute } from "./object/schema";
export * from "./console";
export * from "./events";
+4
View File
@@ -34,6 +34,8 @@ type ExpressionMap<Exps extends Expressions> = {
? E
: never;
};
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
type ExpressionCondition<Exps extends Expressions> = {
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
}[keyof ExpressionMap<Exps>];
@@ -195,5 +197,7 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
const fns = _build(query, expressions, options);
return _validate(fns);
},
expressions,
expressionKeys: expressions.map((e) => e.key) as ExpressionKeys<Exps>,
};
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeObject } from "core/utils";
//export { jsc, type Options, type Hook } from "./validator";
import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono";
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
export { s };
export class InvalidSchemaError extends Error {
constructor(
public schema: s.TAnySchema,
public value: unknown,
public errors: s.ErrorDetail[] = [],
) {
super(
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
`Error: ${JSON.stringify(errors[0], null, 2)}`,
);
}
}
export type ParseOptions = {
withDefaults?: boolean;
coerse?: boolean;
clone?: boolean;
};
const cloneSchema = <S extends s.TSchema>(schema: S): S => {
const json = schema.toJSON();
return s.fromSchema(json) as S;
};
export function parse<S extends s.TAnySchema>(
_schema: S,
v: unknown,
opts: ParseOptions = {},
): s.StaticCoerced<S> {
const schema = (opts.clone ? cloneSchema(_schema as any) : _schema) as s.TSchema;
const value = opts.coerse !== false ? schema.coerce(v) : v;
const result = schema.validate(value, {
shortCircuit: true,
ignoreUnsupported: true,
});
if (!result.valid) throw new InvalidSchemaError(schema, v, result.errors);
if (opts.withDefaults) {
return mergeObject(schema.template({ withOptional: true }), value) as any;
}
return value as any;
}
+63
View File
@@ -0,0 +1,63 @@
import type { Context, Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
import { validator as honoValidator } from "hono/validator";
import type { Static, StaticCoerced, TAnySchema } from "jsonv-ts";
export type Options = {
coerce?: boolean;
includeSchema?: boolean;
};
type ValidationResult = {
valid: boolean;
errors: {
keywordLocation: string;
instanceLocation: string;
error: string;
data?: unknown;
}[];
};
export type Hook<T, E extends Env, P extends string> = (
result: { result: ValidationResult; data: T },
c: Context<E, P>,
) => Response | Promise<Response> | void;
export const validator = <
// @todo: somehow hono prevents the usage of TSchema
Schema extends TAnySchema,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
Opts extends Options = Options,
Out = Opts extends { coerce: false } ? Static<Schema> : StaticCoerced<Schema>,
I extends Input = {
in: { [K in Target]: Static<Schema> };
out: { [K in Target]: Out };
},
>(
target: Target,
schema: Schema,
options?: Opts,
hook?: Hook<Out, E, P>,
): MiddlewareHandler<E, P, I> => {
// @ts-expect-error not typed well
return honoValidator(target, async (_value, c) => {
const value = options?.coerce !== false ? schema.coerce(_value) : _value;
// @ts-ignore
const result = schema.validate(value);
if (!result.valid) {
return c.json({ ...result, schema }, 400);
}
if (hook) {
const hookResult = hook({ result, data: value as Out }, c);
if (hookResult) {
return hookResult;
}
}
return value as Out;
});
};
export const jsc = validator;
+1
View File
@@ -0,0 +1 @@
export { tbValidator } from "./tbValidator";
+29
View File
@@ -0,0 +1,29 @@
import type { Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
import { validator } from "hono/validator";
import type { Static, TSchema } from "simple-jsonschema-ts";
export const honoValidator = <
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
const Schema extends TSchema = TSchema,
Out = Static<Schema>,
I extends Input = {
in: { [K in Target]: Static<Schema> };
out: { [K in Target]: Static<Schema> };
},
>(
target: Target,
schema: Schema,
): MiddlewareHandler<E, P, I> => {
// @ts-expect-error not typed well
return validator(target, async (value, c) => {
const coersed = schema.coerce(value);
const result = schema.validate(coersed);
if (!result.valid) {
return c.json({ ...result, schema }, 400);
}
return coersed as Out;
});
};
+60
View File
@@ -359,3 +359,63 @@ export function getPath(
throw new Error(`Invalid path: ${path.join(".")}`);
}
}
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
const openPad = pad(_level + 1);
const closePad = pad(_level);
// primitives
if (value === null) return "null";
if (value === undefined) return "undefined";
const t = typeof value;
if (t === "string") return JSON.stringify(value); // handles escapes
if (t === "number" || t === "boolean") return String(value);
// arrays
if (Array.isArray(value)) {
const out = value
.map((v) => objectToJsLiteral(v, indent, _level + 1))
.join(", " + (indent ? nl + openPad : ""));
return (
"[" +
(indent && value.length ? nl + openPad : "") +
out +
(indent && value.length ? nl + closePad : "") +
"]"
);
}
// objects
if (t === "object") {
const entries = Object.entries(value).map(([k, v]) => {
const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier?
const key = idOk ? k : JSON.stringify(k); // quote if needed
return key + ": " + objectToJsLiteral(v, indent, _level + 1);
});
const out = entries.join(", " + (indent ? nl + openPad : ""));
return (
"{" +
(indent && entries.length ? nl + openPad : "") +
out +
(indent && entries.length ? nl + closePad : "") +
"}"
);
}
throw new TypeError(`Unsupported data type: ${t}`);
}
// lodash-es compatible `pick` with perfect type inference
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce(
(acc, key) => {
if (key in obj) {
acc[key] = obj[key];
}
return acc;
},
{} as Pick<T, K>,
);
}
+30 -27
View File
@@ -1,5 +1,6 @@
import type { DB } from "core";
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
import type { Insertable, Selectable, Updateable } from "kysely";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
@@ -25,21 +26,23 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}
}
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
readOne<E extends keyof DB | string>(
entity: E,
id: PrimaryFieldType,
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
["entity", entity as any, id],
query,
);
}
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
readOneBy<E extends keyof DB | string>(
entity: E,
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
return this.readMany(entity, {
...query,
@@ -48,10 +51,8 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
}
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: RepoQueryIn = {},
) {
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
const input = query ?? this.options.defaultQuery;
@@ -64,68 +65,70 @@ export class DataApi extends ModuleApi<DataApiOptions> {
return this.post<T>(["entity", entity as any, "query"], input);
}
readManyByReference<
E extends keyof DB | string,
R extends keyof DB | string,
Data = R extends keyof DB ? DB[R] : EntityData,
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
readManyByReference<E extends keyof DB | string, R extends keyof DB | string>(
entity: E,
id: PrimaryFieldType,
reference: R,
query: RepoQueryIn = {},
) {
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
["entity", entity as any, id, reference],
query ?? this.options.defaultQuery,
);
}
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
createOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">,
input: Insertable<Input>,
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
}
createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">[],
input: Insertable<Input>[],
) {
if (!input || !Array.isArray(input) || input.length === 0) {
throw new Error("input is required");
}
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
}
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>,
input: Updateable<Input>,
) {
if (!id) throw new Error("ID is required");
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
}
updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
update: Partial<Omit<Data, "id">>,
update: Updateable<Input>,
) {
this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
update,
where,
});
}
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
) {
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
if (!id) throw new Error("ID is required");
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
}
deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
) {
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
}
+215 -104
View File
@@ -1,6 +1,4 @@
import { $console, isDebug, tbValidator as tb } from "core";
import { StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import { $console, isDebug } from "core";
import {
DataPermissions,
type EntityData,
@@ -8,14 +6,15 @@ import {
type MutatorResponse,
type RepoQuery,
type RepositoryResponse,
querySchema,
repoQuery,
} from "data";
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
const { Type } = tbbox;
import { omitKeys } from "core/utils";
export class DataController extends Controller {
constructor(
@@ -71,6 +70,7 @@ export class DataController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
@@ -83,6 +83,10 @@ export class DataController extends Controller {
// info
hono.get(
"/",
describeRoute({
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
@@ -90,49 +94,75 @@ export class DataController extends Controller {
);
// sync endpoint
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
const force = c.req.query("force") === "1";
const drop = c.req.query("drop") === "1";
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
});
return c.json({ tables: tables.map((t) => t.name), changes });
});
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
describeRoute({
summary: "Sync database schema",
tags: ["data"],
}),
jsc(
"query",
s.partialObject({
force: s.boolean(),
drop: s.boolean(),
}),
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
});
return c.json({ tables: tables.map((t) => t.name), changes });
},
);
/**
* Schema endpoints
*/
// read entity schema
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `${this.config.basepath}/schemas/${e.name}`,
},
]),
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas,
});
});
hono.get(
"/schema.json",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
}),
async (c) => {
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `${this.config.basepath}/schemas/${e.name}`,
},
]),
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas,
});
},
);
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
tb(
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
}),
jsc(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"])),
s.object({
entity: entitiesEnum,
context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
}),
),
async (c) => {
@@ -161,30 +191,39 @@ export class DataController extends Controller {
/**
* Info endpoints
*/
hono.get("/info/:entity", async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name);
const $rels = (r: any) =>
r.map((r: any) => ({
entity: r.other(_entity).entity.name,
ref: r.other(_entity).reference,
}));
hono.get(
"/info/:entity",
permission(DataPermissions.entityRead),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name);
const $rels = (r: any) =>
r.map((r: any) => ({
entity: r.other(_entity).entity.name,
ref: r.other(_entity).reference,
}));
return c.json({
name: _entity.name,
fields,
relations: {
all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity)),
},
});
});
return c.json({
name: _entity.name,
fields,
relations: {
all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity)),
},
});
},
);
return hono.all("*", (c) => c.notFound());
}
@@ -193,10 +232,7 @@ export class DataController extends Controller {
const { permission } = this.middlewares;
const hono = this.create();
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
.Encode(String);
const entitiesEnum = this.getEntitiesEnum(this.em);
/**
* Function endpoints
@@ -205,14 +241,19 @@ export class DataController extends Controller {
hono.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
describeRoute({
summary: "Count entities",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = (await c.req.json()) as any;
const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
},
@@ -222,14 +263,19 @@ export class DataController extends Controller {
hono.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
describeRoute({
summary: "Check if entity exists",
tags: ["data"],
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.json() as any;
const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists });
},
@@ -239,13 +285,31 @@ export class DataController extends Controller {
* Read endpoints
*/
// read many
const saveRepoQuery = s.partialObject({
...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }),
select: s.array(s.string()),
join: s.array(s.string()),
});
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
// @ts-ignore
(p) => pick.includes(p.name),
) as any),
];
hono.get(
"/:entity",
describeRoute({
summary: "Read many",
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
tb("query", querySchema),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity } = c.req.param();
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -259,17 +323,22 @@ export class DataController extends Controller {
// read one
hono.get(
"/:entity/:id",
describeRoute({
summary: "Read one",
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
tb(
jsc(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber,
s.object({
entity: entitiesEnum,
id: s.string(),
}),
),
tb("query", querySchema),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id } = c.req.param();
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -283,18 +352,23 @@ export class DataController extends Controller {
// read many by reference
hono.get(
"/:entity/:id/:reference",
describeRoute({
summary: "Read many by reference",
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
tb(
jsc(
"param",
Type.Object({
entity: Type.String(),
id: tbNumber,
reference: Type.String(),
s.object({
entity: entitiesEnum,
id: s.string(),
reference: s.string(),
}),
),
tb("query", querySchema),
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id, reference } = c.req.param();
const { entity, id, reference } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -309,17 +383,33 @@ export class DataController extends Controller {
);
// func query
const fnQuery = s.partialObject({
...saveRepoQuery.properties,
with: s.object({}),
});
hono.post(
"/:entity/query",
describeRoute({
summary: "Query entities",
requestBody: {
content: {
"application/json": {
schema: fnQuery.toJSON(),
example: fnQuery.template({ withOptional: true }),
},
},
},
tags: ["data"],
}),
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity } = c.req.param();
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const options = (await c.req.valid("json")) as RepoQuery;
const options = (await c.req.json()) as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
@@ -332,11 +422,15 @@ export class DataController extends Controller {
// insert one
hono.post(
"/:entity",
describeRoute({
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
async (c) => {
const { entity } = c.req.param();
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -355,13 +449,17 @@ export class DataController extends Controller {
// update many
hono.patch(
"/:entity",
describeRoute({
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String() })),
tb(
jsc("param", s.object({ entity: entitiesEnum })),
jsc(
"json",
Type.Object({
update: Type.Object({}),
where: querySchema.properties.where,
s.object({
update: s.object({}),
where: repoQuery.properties.where,
}),
),
async (c) => {
@@ -382,10 +480,15 @@ export class DataController extends Controller {
// update one
hono.patch(
"/:entity/:id",
describeRoute({
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
jsc("json", s.object({})),
async (c) => {
const { entity, id } = c.req.param();
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -399,10 +502,14 @@ export class DataController extends Controller {
// delete one
hono.delete(
"/:entity/:id",
describeRoute({
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
async (c) => {
const { entity, id } = c.req.param();
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
@@ -415,15 +522,19 @@ export class DataController extends Controller {
// delete many
hono.delete(
"/:entity",
describeRoute({
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
const { entity } = c.req.param();
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const where = c.req.valid("json") as RepoQuery["where"];
const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));
@@ -44,13 +44,6 @@ export class LibsqlConnection extends SqliteConnection {
}
client = createClient({ url, authToken });
// currently there is an issue in limbo implementation
// that prevents batching from working correctly
if (/\.aws.*turso\.io$/.test(url)) {
$console.warn("Using an Turso AWS endpoint currently disables batching support");
batching_enabled = false;
}
} else {
client = clientOrCredentials;
}
+9
View File
@@ -280,6 +280,15 @@ export class Entity<
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
}
toTypes() {
return {
name: this.name,
type: this.type,
comment: this.config.description,
fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])),
};
}
toJSON() {
return {
type: this.type,
+228
View File
@@ -0,0 +1,228 @@
import type { Entity, EntityManager, EntityRelation, TEntityType } from "data";
import { autoFormatString } from "core/utils";
import { AppAuth, AppMedia } from "modules";
export type TEntityTSType = {
name: string;
type: TEntityType;
comment?: string;
fields: Record<string, TFieldTSType>;
};
// [select, insert, update]
type TFieldContextType = boolean | [boolean, boolean, boolean];
export type TFieldTSType = {
required?: TFieldContextType;
fillable?: TFieldContextType;
type: "PrimaryFieldType" | string;
comment?: string;
import?: {
package: string;
name: string;
}[];
};
export type EntityTypescriptOptions = {
indentWidth?: number;
indentChar?: string;
entityCommentMultiline?: boolean;
fieldCommentMultiline?: boolean;
};
// keep a local copy here until properties have a type
const systemEntities = {
users: AppAuth.usersFields,
media: AppMedia.mediaFields,
};
export class EntityTypescript {
constructor(
protected em: EntityManager,
protected _options: EntityTypescriptOptions = {},
) {}
get options() {
return {
...this._options,
indentWidth: 2,
indentChar: " ",
entityCommentMultiline: true,
fieldCommentMultiline: false,
};
}
toTypes() {
return this.em.entities.map((e) => e.toTypes());
}
protected getTab(count = 1) {
return this.options.indentChar.repeat(this.options.indentWidth).repeat(count);
}
collectImports(
type: TEntityTSType,
imports: Record<string, string[]> = {},
): Record<string, string[]> {
for (const [, entity_type] of Object.entries(type.fields)) {
for (const imp of entity_type.import ?? []) {
const name = imp.name;
const pkg = imp.package;
if (!imports[pkg]) {
imports[pkg] = [];
}
if (!imports[pkg].includes(name)) {
imports[pkg].push(name);
}
}
}
return imports;
}
typeName(name: string) {
return autoFormatString(name);
}
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {
let string = "";
const coment_multiline = this.options.fieldCommentMultiline;
const indent = opts?.indent ?? 1;
for (const [field_name, field_type] of Object.entries(type.fields)) {
if (opts?.ignore_fields?.includes(field_name)) continue;
let f = "";
f += this.commentString(field_type.comment, indent, coment_multiline);
f += `${this.getTab(indent)}${field_name}${field_type.required ? "" : "?"}: `;
f += field_type.type + ";";
f += "\n";
string += f;
}
return string;
}
relationToFieldType(relation: EntityRelation, entity: Entity) {
const other = relation.other(entity);
const listable = relation.isListableFor(entity);
const name = this.typeName(other.entity.name);
let type = name;
if (other.entity.type === "system") {
type = `DB["${other.entity.name}"]`;
}
return {
fields: {
[other.reference]: {
required: false,
type: `${type}${listable ? "[]" : ""}`,
},
},
};
}
importsToString(imports: Record<string, string[]>) {
const strings: string[] = [];
for (const [pkg, names] of Object.entries(imports)) {
strings.push(`import type { ${names.join(", ")} } from "${pkg}";`);
}
return strings;
}
commentString(comment?: string, indents = 0, multiline = true) {
if (!comment) return "";
const indent = this.getTab(indents);
if (!multiline) return `${indent}// ${comment}\n`;
return `${indent}/**\n${indent} * ${comment}\n${indent} */\n`;
}
entityToTypeString(
entity: Entity,
opts?: { ignore_fields?: string[]; indent?: number; export?: boolean },
) {
const type = entity.toTypes();
const name = this.typeName(type.name);
const indent = opts?.indent ?? 1;
const min_indent = Math.max(0, indent - 1);
let s = this.commentString(type.comment, min_indent, this.options.entityCommentMultiline);
s += `${opts?.export ? "export " : ""}interface ${name} {\n`;
s += this.fieldTypesToString(type, opts);
// add listable relations
const relations = this.em.relations.relationsOf(entity);
const rel_types = relations.map((r) =>
this.relationToFieldType(r, entity),
) as TEntityTSType[];
for (const rel_type of rel_types) {
s += this.fieldTypesToString(rel_type, {
indent,
});
}
s += `${this.getTab(min_indent)}}`;
return s;
}
toString() {
const strings: string[] = [];
const tables: Record<string, string> = {};
const imports: Record<string, string[]> = {
"bknd/core": ["DB"],
kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
};
// add global types
let g = "declare global {\n";
g += `${this.getTab(1)}type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;\n`;
g += `${this.getTab(1)}type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;\n`;
g += `${this.getTab(1)}type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;\n`;
g += "}";
strings.push(g);
const system_entities = this.em.entities.filter((e) => e.type === "system");
for (const entity of this.em.entities) {
// skip system entities, declare addtional props in the DB interface
if (system_entities.includes(entity)) continue;
const type = entity.toTypes();
if (!type) continue;
this.collectImports(type, imports);
tables[type.name] = this.typeName(type.name);
const s = this.entityToTypeString(entity, {
export: true,
});
strings.push(s);
}
// write tables
let tables_string = "interface Database {\n";
for (const [name, type] of Object.entries(tables)) {
tables_string += `${this.getTab(1)}${name}: ${type};\n`;
}
tables_string += "}";
strings.push(tables_string);
// merge
let merge = `declare module "bknd/core" {\n`;
for (const systemEntity of system_entities) {
const system_fields = Object.keys(systemEntities[systemEntity.name]);
const additional_fields = systemEntity.fields
.filter((f) => !system_fields.includes(f.name) && f.type !== "primary")
.map((f) => f.name);
if (additional_fields.length === 0) continue;
merge += `${this.getTab(1)}${this.entityToTypeString(systemEntity, {
ignore_fields: ["id", ...system_fields],
indent: 2,
})}\n\n`;
}
merge += `${this.getTab(1)}interface DB extends Database {}\n}`;
strings.push(merge);
const final = [this.importsToString(imports).join("\n"), strings.join("\n\n")];
return final.join("\n\n");
}
}
+1 -1
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/data-query-impl";
import type { RepoQuery } from "../server/query";
type MutatorQB =
| InsertQueryBuilder<any, any, any>
+11 -9
View File
@@ -2,10 +2,9 @@ import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { $console } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es";
import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents, RepositoryEvents } from "../../events";
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
import { type RepoQuery, getRepoQueryTemplate } from "data/server/query";
import {
type Entity,
type EntityData,
@@ -84,14 +83,14 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}
}
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
getValidOptions(options?: RepoQuery): RepoQuery {
const entity = this.entity;
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
const validated = {
...cloneDeep(defaultQuerySchema),
...structuredClone(getRepoQueryTemplate()),
sort: entity.getDefaultSort(),
select: entity.getSelect(),
};
} satisfies Required<RepoQuery>;
if (!options) return validated;
@@ -99,12 +98,15 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
if (!validated.select.includes(options.sort.by)) {
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
}
if (!["asc", "desc"].includes(options.sort.dir)) {
if (!["asc", "desc"].includes(options.sort.dir!)) {
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
}
this.checkIndex(entity.name, options.sort.by, "sort");
validated.sort = options.sort;
validated.sort = {
dir: "asc",
...options.sort,
};
}
if (options.select && options.select.length > 0) {
@@ -505,7 +507,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
};
}
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
async exists(where: Required<RepoQuery>["where"]): Promise<RepositoryExistsResponse> {
const entity = this.entity;
const options = this.getValidOptions({ where });
@@ -513,7 +515,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
let qb = this.conn.selectFrom(entity.name).select(selector);
// add mandatory where
qb = WhereBuilder.addClause(qb, options.where).limit(1);
qb = WhereBuilder.addClause(qb, options.where!).limit(1);
const { result, ...compiled } = await this.executeQb(qb);
@@ -90,6 +90,7 @@ const expressions = [
export type WhereQuery = FilterQuery<typeof expressions>;
const validator = makeValidator(expressions);
export const expressionKeys = validator.expressionKeys;
export class WhereBuilder {
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
+1 -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 "../server/data-query-impl";
import type { RepoQuery } from "data/server/query";
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
static override slug = "mutator-insert-before";
+7
View File
@@ -88,4 +88,11 @@ export class BooleanField<Required extends true | false = false> extends Field<
override toJsonSchema() {
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
}
override toType() {
return {
...super.toType(),
type: "boolean",
};
}
}
+8
View File
@@ -3,6 +3,7 @@ import type { EntityManager } from "../entities";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import { $console } from "core";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const dateFieldConfigSchema = Type.Composite(
@@ -144,4 +145,11 @@ export class DateField<Required extends true | false = false> extends Field<
override toJsonSchema() {
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "Date | string",
};
}
}
+11
View File
@@ -3,6 +3,7 @@ import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const enumFieldConfigSchema = Type.Composite(
@@ -140,4 +141,14 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
}),
);
}
override toType(): TFieldTSType {
const union = this.getOptions().map(({ value }) =>
typeof value === "string" ? `"${value}"` : value,
);
return {
...super.toType(),
type: union.length > 0 ? union.join(" | ") : "string",
};
}
}
+9
View File
@@ -11,6 +11,7 @@ import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
import type { FieldSpec } from "data/connection/Connection";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
// @todo: contexts need to be reworked
@@ -235,6 +236,14 @@ export abstract class Field<
return this.toSchemaWrapIfRequired(Type.Any());
}
toType(): TFieldTSType {
return {
required: this.isRequired(),
comment: this.getDescription(),
type: "any",
};
}
toJSON() {
return {
// @todo: current workaround because of fixed string type
+8
View File
@@ -3,6 +3,7 @@ import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
@@ -98,4 +99,11 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
return JSON.stringify(value);
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "any",
};
}
}
+10 -1
View File
@@ -1,9 +1,10 @@
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
import { Default, FromSchema, type Static } from "core/utils";
import { Default, FromSchema, objectToJsLiteral, type Static } from "core/utils";
import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const jsonSchemaFieldConfigSchema = Type.Composite(
@@ -121,4 +122,12 @@ export class JsonSchemaField<
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
import: [{ package: "json-schema-to-ts", name: "FromSchema" }],
type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`,
};
}
}
+8
View File
@@ -3,6 +3,7 @@ import type { EntityManager } from "data";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const numberFieldConfigSchema = Type.Composite(
@@ -102,4 +103,11 @@ export class NumberField<Required extends true | false = false> extends Field<
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "number",
};
}
}
+10
View File
@@ -2,6 +2,7 @@ import { config } from "core";
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 primaryFieldConfigSchema = Type.Composite([
@@ -48,4 +49,13 @@ export class PrimaryField<Required extends true | false = false> extends Field<
override toJsonSchema() {
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
}
override toType(): TFieldTSType {
return {
...super.toType(),
required: true,
import: [{ package: "kysely", name: "Generated" }],
type: "Generated<number>",
};
}
}
+7
View File
@@ -121,4 +121,11 @@ export class TextField<Required extends true | false = false> extends Field<
}),
);
}
override toType() {
return {
...super.toType(),
type: "string",
};
}
}
+5 -4
View File
@@ -10,10 +10,11 @@ export * from "./connection";
export {
type RepoQuery,
type RepoQueryIn,
defaultQuerySchema,
querySchema,
whereSchema,
} from "./server/data-query-impl";
getRepoQueryTemplate,
repoQuery,
} from "./server/query";
export type { WhereQuery } from "./entities/query/WhereBuilder";
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
+9 -2
View File
@@ -6,11 +6,14 @@ import {
type MutationInstructionResponse,
RelationHelper,
} from "../relations";
import type { RepoQuery } from "../server/data-query-impl";
import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number];
export type KyselyJsonFrom = any;
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
@@ -27,7 +30,7 @@ export abstract class EntityRelation<
// @todo: add unit tests
// allowed directions, used in RelationAccessor for visibility
directions: ("source" | "target")[] = ["source", "target"];
directions: TDirection[] = ["source", "target"];
static schema = Type.Object({
mappedBy: Type.Optional(Type.String()),
@@ -102,6 +105,10 @@ export abstract class EntityRelation<
);
}
self(entity: Entity | string): EntityRelationAnchor {
return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source;
}
ref(reference: string): EntityRelationAnchor {
return this.source.reference === reference ? this.source : this.target;
}
+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/data-query-impl";
import type { RepoQuery } from "../server/query";
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/data-query-impl";
import type { RepoQuery } from "../server/query";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities";
import { NumberField, TextField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl";
import type { RepoQuery } from "../server/query";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { type RelationType, RelationTypes } from "./relation-types";
+8
View File
@@ -4,6 +4,7 @@ import { Field, baseFieldConfigSchema } from "../fields";
import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
@@ -83,4 +84,11 @@ export class RelationField extends Field<RelationFieldConfig> {
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
type: "number",
};
}
}
-148
View File
@@ -1,148 +0,0 @@
import type { TThis } from "@sinclair/typebox";
import { type SchemaOptions, type StaticDecode, StringEnum, Value, isObject } from "core/utils";
import { WhereBuilder, type WhereQuery } from "../entities";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const NumberOrString = (options: SchemaOptions = {}) =>
Type.Transform(Type.Union([Type.Number(), Type.String()], options))
.Decode((value) => Number.parseInt(String(value)))
.Encode(String);
const limit = NumberOrString({ default: 10 });
const offset = NumberOrString({ default: 0 });
const sort_default = { by: "id", dir: "asc" };
const sort = Type.Transform(
Type.Union(
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
{
default: sort_default,
},
),
)
.Decode((value): { by: string; dir: "asc" | "desc" } => {
if (typeof value === "string") {
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
const dir = value[0] === "-" ? "desc" : "asc";
return { by: dir === "desc" ? value.slice(1) : value, dir } as any;
} else if (/^{.*}$/.test(value)) {
return JSON.parse(value) as any;
}
return sort_default as any;
}
return value as any;
})
.Encode((value) => value);
const stringArray = Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String())], { default: [] }),
)
.Decode((value) => {
if (Array.isArray(value)) {
return value;
} else if (value.includes(",")) {
return value.split(",");
}
return [value];
})
.Encode((value) => (Array.isArray(value) ? value : [value]));
export const whereSchema = Type.Transform(
Type.Union([Type.String(), Type.Object({})], { default: {} }),
)
.Decode((value) => {
const q = typeof value === "string" ? JSON.parse(value) : value;
return WhereBuilder.convert(q);
})
.Encode(JSON.stringify);
export type RepoWithSchema = Record<
string,
Omit<RepoQueryIn, "with"> & {
with?: unknown;
}
>;
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)]),
)
.Decode((value) => {
// images
// images,comments
// ["images","comments"]
// { "images": {} }
if (!Array.isArray(value) && isObject(value)) {
return value as RepoWithSchema;
}
let _value: any = null;
if (typeof value === "string") {
// if stringified object
if (value.match(/^\{/)) {
return JSON.parse(value) as RepoWithSchema;
}
// if stringified array
if (value.match(/^\[/)) {
_value = JSON.parse(value) as string[];
// if comma-separated string
} else if (value.includes(",")) {
_value = value.split(",");
// if single string
} else {
_value = [value];
}
} else if (Array.isArray(value)) {
_value = value;
}
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
return _value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
})
.Encode((value) => value);
export const querySchema = Type.Recursive(
(Self) =>
Type.Partial(
Type.Object(
{
limit: limit,
offset: offset,
sort: sort,
select: stringArray,
with: withSchema(Self),
join: stringArray,
where: whereSchema,
},
{
// @todo: determine if unknown is allowed, it's ignore anyway
additionalProperties: false,
},
),
),
{ $id: "query-schema" },
);
export type RepoQueryIn = {
limit?: number;
offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[];
with?: string | string[] | Record<string, RepoQueryIn>;
join?: string[];
where?: WhereQuery;
};
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
+184
View File
@@ -0,0 +1,184 @@
import { test, describe, expect } from "bun:test";
import * as q from "./query";
import { s as schema, parse as $parse, type ParseOptions } from "core/object/schema";
const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, o);
// compatibility
const decode = (input: any, output: any) => {
expect(parse(input)).toEqual(output);
};
describe("server/query", () => {
test("limit & offset", () => {
expect(() => parse({ limit: false })).toThrow();
expect(parse({ limit: "11" })).toEqual({ limit: 11 });
expect(parse({ limit: 20 })).toEqual({ limit: 20 });
expect(parse({ offset: "1" })).toEqual({ offset: 1 });
});
test("select", () => {
expect(parse({ select: "id" })).toEqual({ select: ["id"] });
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
expect(() => parse({ select: "not allowed" })).toThrow();
expect(() => parse({ select: "id," })).toThrow();
});
test("join", () => {
expect(parse({ join: "id" })).toEqual({ join: ["id"] });
expect(parse({ join: "id,title" })).toEqual({ join: ["id", "title"] });
expect(parse({ join: ["id", "title"] })).toEqual({ join: ["id", "title"] });
});
test("sort", () => {
expect(parse({ sort: "id" }).sort).toEqual({
by: "id",
dir: "asc",
});
expect(parse({ sort: "-id" }).sort).toEqual({
by: "id",
dir: "desc",
});
expect(parse({ sort: { by: "title" } }).sort).toEqual({
by: "title",
});
expect(
parse(
{ sort: { by: "id" } },
{
withDefaults: true,
},
).sort,
).toEqual({
by: "id",
dir: "asc",
});
expect(parse({ sort: { by: "count", dir: "desc" } }).sort).toEqual({
by: "count",
dir: "desc",
});
// invalid gives default
expect(parse({ sort: "not allowed" }).sort).toEqual({
by: "id",
dir: "asc",
});
// json
expect(parse({ sort: JSON.stringify({ by: "count", dir: "desc" }) }).sort).toEqual({
by: "count",
dir: "desc",
});
});
test("sort2", () => {
const _dflt = { sort: { by: "id", dir: "asc" } } as const;
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
test("where", () => {
expect(parse({ where: { id: 1 } }).where).toEqual({
id: { $eq: 1 },
});
expect(parse({ where: JSON.stringify({ id: 1 }) }).where).toEqual({
id: { $eq: 1 },
});
expect(parse({ where: { count: { $gt: 1 } } }).where).toEqual({
count: { $gt: 1 },
});
expect(parse({ where: JSON.stringify({ count: { $gt: 1 } }) }).where).toEqual({
count: { $gt: 1 },
});
});
test("template", () => {
expect(
q.repoQuery.template({
withOptional: true,
}),
).toEqual({
limit: 10,
offset: 0,
sort: { by: "id", dir: "asc" },
where: {},
select: [],
join: [],
});
});
test("with", () => {
let example = {
limit: 10,
with: {
posts: { limit: "10", with: ["comments"] },
},
};
expect(parse(example)).toEqual({
limit: 10,
with: {
posts: {
limit: 10,
with: {
comments: {},
},
},
},
});
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
limit: "10",
select: "id",
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
limit: 10,
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
+153
View File
@@ -0,0 +1,153 @@
import { s } from "core/object/schema";
import { WhereBuilder, type WhereQuery } from "data";
import { $console } from "core";
import { isObject } from "core/utils";
import type { CoercionOptions, TAnyOf } from "jsonv-ts";
// -------
// helpers
const stringIdentifier = s.string({
// allow "id", "id,title" but not "id," or "not allowed"
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
});
const stringArray = s.anyOf(
[
stringIdentifier,
s.array(stringIdentifier, {
uniqueItems: true,
}),
],
{
default: [],
coerce: (v): string[] => {
if (Array.isArray(v)) {
return v;
} else if (typeof v === "string") {
if (v.includes(",")) {
return v.split(",");
}
return [v];
}
return [];
},
},
);
// -------
// sorting
const sortDefault = { by: "id", dir: "asc" };
const sortSchema = s.object({
by: s.string(),
dir: s.string({ enum: ["asc", "desc"] }).optional(),
});
type SortSchema = s.Static<typeof sortSchema>;
const sort = s.anyOf([s.string(), sortSchema], {
default: sortDefault,
coerce: (v): SortSchema => {
if (typeof v === "string") {
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(v)) {
const dir = v[0] === "-" ? "desc" : "asc";
return { by: dir === "desc" ? v.slice(1) : v, dir } as any;
} else if (/^{.*}$/.test(v)) {
return JSON.parse(v) as any;
}
$console.warn(`Invalid sort given: '${JSON.stringify(v)}'`);
return sortDefault as any;
}
return v as any;
},
});
// ------
// filter
const where = s.anyOf([s.string(), s.object({})], {
default: {},
examples: [
{
attribute: {
$eq: 1,
},
},
],
coerce: (value: unknown) => {
const q = typeof value === "string" ? JSON.parse(value) : value;
return WhereBuilder.convert(q);
},
});
//type WhereSchemaIn = s.Static<typeof where>;
//type WhereSchema = s.StaticCoerced<typeof where>;
// ------
// with
// @todo: waiting for recursion support
export type RepoWithSchema = Record<
string,
Omit<RepoQueryIn, "with"> & {
with?: unknown;
}
>;
const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
coerce: function (this: TAnyOf<any>, _value: unknown, opts: CoercionOptions = {}) {
let value: any = _value;
if (typeof value === "string") {
// if stringified object
if (value.match(/^\{/) || value.match(/^\[/)) {
value = JSON.parse(value);
} else if (value.includes(",")) {
value = value.split(",");
} else {
value = [value];
}
}
// Convert arrays to objects
if (Array.isArray(value)) {
value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as any);
}
// Handle object case
if (isObject(value)) {
for (const k in value) {
value[k] = self.coerce(value[k], opts);
}
}
return value as unknown as any;
},
}) as any;
// ==========
// REPO QUERY
export const repoQuery = s.recursive((self) =>
s.partialObject({
limit: s.number({ default: 10 }),
offset: s.number({ default: 0 }),
sort,
where,
select: stringArray,
join: stringArray,
with: withSchema<RepoWithSchema>(self),
}),
);
export const getRepoQueryTemplate = () =>
repoQuery.template({
withOptional: true,
}) as Required<RepoQuery>;
export type RepoQueryIn = {
limit?: number;
offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[];
with?: string | string[] | Record<string, RepoQueryIn>;
join?: string[];
where?: WhereQuery;
};
export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
+3 -2
View File
@@ -1,4 +1,4 @@
import { $console, type PrimaryFieldType } from "core";
import { $console, type AppEntity } from "core";
import type { Entity, EntityManager } from "data";
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
import { Module } from "modules/Module";
@@ -17,8 +17,9 @@ import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-sche
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
declare module "core" {
interface Media extends AppEntity, MediaFieldSchema {}
interface DB {
media: { id: PrimaryFieldType } & MediaFieldSchema;
media: Media;
}
}
+131 -61
View File
@@ -6,12 +6,7 @@ import { DataPermissions } from "data";
import { Controller } from "modules/Controller";
import type { AppMedia } from "../AppMedia";
import { MediaField } from "../MediaField";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
import { jsc, s, describeRoute } from "core/object/schema";
export class MediaController extends Controller {
constructor(private readonly media: AppMedia) {
@@ -31,90 +26,165 @@ export class MediaController extends Controller {
// @todo: implement range requests
const { auth, permission } = this.middlewares;
const hono = this.create().use(auth());
const entitiesEnum = this.getEntitiesEnum(this.media.em);
// get files list (temporary)
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => {
const files = await this.getStorageAdapter().listObjects();
return c.json(files);
});
hono.get(
"/files",
describeRoute({
summary: "Get the list of files",
tags: ["media"],
}),
permission(MediaPermissions.listFiles),
async (c) => {
const files = await this.getStorageAdapter().listObjects();
return c.json(files);
},
);
// get file by name
// @todo: implement more aggressive cache? (configurable)
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
hono.get(
"/file/:filename",
describeRoute({
summary: "Get a file by name",
tags: ["media"],
}),
permission(MediaPermissions.readFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
await this.getStorage().emgr.emit(
new StorageEvents.FileAccessEvent({ name: filename }),
);
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
const headers = new Headers(res.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
const headers = new Headers(res.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
});
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
},
);
// delete a file by name
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().deleteFile(filename);
hono.delete(
"/file/:filename",
describeRoute({
summary: "Delete a file by name",
tags: ["media"],
}),
permission(MediaPermissions.deleteFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().deleteFile(filename);
return c.json({ message: "File deleted" });
});
return c.json({ message: "File deleted" });
},
);
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
if (isDebug()) {
hono.post("/inspect", async (c) => {
const file = await getFileFromContext(c);
return c.json({
type: file?.type,
name: file?.name,
size: file?.size,
});
});
hono.post(
"/inspect",
describeRoute({
summary: "Inspect a file",
tags: ["media"],
}),
async (c) => {
const file = await getFileFromContext(c);
return c.json({
type: file?.type,
name: file?.name,
size: file?.size,
});
},
);
}
const requestBody = {
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
},
},
required: ["file"],
},
},
"application/octet-stream": {
schema: {
type: "string",
format: "binary",
},
},
},
} as any;
// upload file
// @todo: add required type for "upload endpoints"
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => {
const reqname = c.req.param("filename");
hono.post(
"/upload/:filename?",
describeRoute({
summary: "Upload a file",
tags: ["media"],
requestBody,
}),
jsc("param", s.object({ filename: s.string().optional() })),
permission(MediaPermissions.uploadFile),
async (c) => {
const reqname = c.req.param("filename");
const body = await getFileFromContext(c);
if (!body) {
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
}
if (body.size > maxSize) {
return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE,
);
}
const body = await getFileFromContext(c);
if (!body) {
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
}
if (body.size > maxSize) {
return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE,
);
}
const filename = reqname ?? getRandomizedFilename(body as File);
const res = await this.getStorage().uploadFile(body, filename);
const filename = reqname ?? getRandomizedFilename(body as File);
const res = await this.getStorage().uploadFile(body, filename);
return c.json(res, HttpStatus.CREATED);
});
return c.json(res, HttpStatus.CREATED);
},
);
// add upload file to entity
// @todo: add required type for "upload endpoints"
hono.post(
"/entity/:entity/:id/:field",
tb(
"query",
Type.Object({
overwrite: Type.Optional(booleanLike),
describeRoute({
summary: "Add a file to an entity",
tags: ["media"],
requestBody,
}),
jsc(
"param",
s.object({
entity: entitiesEnum,
id: s.number(),
field: s.string(),
}),
),
jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
async (c) => {
const entity_name = c.req.param("entity");
+9 -2
View File
@@ -1,9 +1,11 @@
import type { App } from "App";
import { type Context, Hono } from "hono";
import { type Context, type Env, Hono } from "hono";
import * as middlewares from "modules/middlewares";
import type { SafeUser } from "auth";
import type { EntityManager } from "data";
import { s } from "core/object/schema";
export type ServerEnv = {
export type ServerEnv = Env & {
Variables: {
app: App;
// to prevent resolving auth multiple times
@@ -46,4 +48,9 @@ export class Controller {
return c.notFound();
}
protected getEntitiesEnum(em: EntityManager<any>) {
const entities = em.entities.map((e) => e.name);
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => {
const obj = {
user: c.get("auth")?.user,
logout_route: this.withAdminBasePath(authRoutes.logout),
logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath,
};
const html = await this.getHtml(obj);
+71 -53
View File
@@ -13,9 +13,8 @@ import {
import { getRuntimeKey } from "core/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { openAPISpecs } from "jsonv-ts/hono";
import { swaggerUI } from "@hono/swagger-ui";
import {
MODULE_NAMES,
type ModuleConfigs,
@@ -24,12 +23,8 @@ import {
getDefaultConfig,
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { generateOpenAPI } from "modules/server/openapi";
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
import { jsc, s, describeRoute } from "core/object/schema";
import { getVersion } from "core/env";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
module: Key;
@@ -61,20 +56,27 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead));
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
});
hono.get(
"/raw",
describeRoute({
summary: "Get the raw config",
tags: ["system"],
}),
permission([SystemPermissions.configReadSecrets]),
async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
},
);
hono.get(
"/:module?",
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
tb(
"query",
Type.Object({
secrets: Type.Optional(booleanLike),
}),
),
describeRoute({
summary: "Get the config for a module",
tags: ["system"],
}),
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
jsc("query", s.object({ secrets: s.boolean().optional() })),
async (c) => {
// @todo: allow secrets if authenticated user is admin
const { secrets } = c.req.valid("query");
@@ -119,12 +121,7 @@ export class SystemController extends Controller {
hono.post(
"/set/:module",
permission(SystemPermissions.configWrite),
tb(
"query",
Type.Object({
force: Type.Optional(booleanLike),
}),
),
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
async (c) => {
const module = c.req.param("module") as any;
const { force } = c.req.valid("query");
@@ -230,13 +227,17 @@ export class SystemController extends Controller {
hono.get(
"/schema/:module?",
describeRoute({
summary: "Get the schema for a module",
tags: ["system"],
}),
permission(SystemPermissions.schemaRead),
tb(
jsc(
"query",
Type.Object({
config: Type.Optional(booleanLike),
secrets: Type.Optional(booleanLike),
fresh: Type.Optional(booleanLike),
s.partialObject({
config: s.boolean(),
secrets: s.boolean(),
fresh: s.boolean(),
}),
),
async (c) => {
@@ -274,13 +275,11 @@ export class SystemController extends Controller {
hono.post(
"/build",
tb(
"query",
Type.Object({
sync: Type.Optional(booleanLike),
fetch: Type.Optional(booleanLike),
}),
),
describeRoute({
summary: "Build the app",
tags: ["system"],
}),
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
@@ -293,25 +292,44 @@ export class SystemController extends Controller {
},
);
hono.get("/ping", (c) => c.json({ pong: true }));
hono.get(
"/ping",
describeRoute({
summary: "Ping the server",
tags: ["system"],
}),
(c) => c.json({ pong: true }),
);
hono.get("/info", (c) =>
c.json({
version: c.get("app")?.version(),
runtime: getRuntimeKey(),
timezone: {
name: getTimezone(),
offset: getTimezoneOffset(),
local: datetimeStringLocal(),
utc: datetimeStringUTC(),
hono.get(
"/info",
describeRoute({
summary: "Get the server info",
tags: ["system"],
}),
(c) =>
c.json({
version: c.get("app")?.version(),
runtime: getRuntimeKey(),
timezone: {
name: getTimezone(),
offset: getTimezoneOffset(),
local: datetimeStringLocal(),
utc: datetimeStringUTC(),
},
}),
);
hono.get(
"/openapi.json",
openAPISpecs(this.ctx.server, {
info: {
title: "bknd API",
version: getVersion(),
},
}),
);
hono.get("/openapi.json", async (c) => {
const config = getDefaultConfig();
return c.json(generateOpenAPI(config));
});
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
return hono.all("*", (c) => c.notFound());
}
+56 -40
View File
@@ -1,52 +1,64 @@
import { Api, type ApiOptions, type TApiUser } from "Api";
import { Api, type ApiOptions, type AuthState } from "Api";
import { isDebug } from "core";
import { createContext, type ReactNode, useContext } from "react";
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
import type { AdminBkndWindowContext } from "modules/server/AdminController";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
baseUrl: undefined,
} as any);
export type BkndClientContext = {
baseUrl: string;
api: Api;
authState?: Partial<AuthState>;
};
const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = {
children?: ReactNode;
} & (
| { baseUrl?: string; user?: TApiUser | null | undefined }
| {
api: Api;
}
);
baseUrl?: string;
} & ApiOptions;
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
let api: Api;
export const ClientProvider = ({
children,
host,
baseUrl: _baseUrl = host,
...props
}: ClientProviderProps) => {
const winCtx = useBkndWindowContext();
const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any = undefined;
if (props && "api" in props) {
api = props.api;
} else {
const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl();
const { baseUrl, user } = props;
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
try {
if (!baseUrl) {
if (_ctx_baseUrl) {
actualBaseUrl = _ctx_baseUrl;
console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin;
//console.log("setting from window", actualBaseUrl);
}
}
} catch (e) {
console.error("Error in ClientProvider", e);
}
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
if (winCtx) {
user = winCtx.user;
}
if (!actualBaseUrl) {
try {
actualBaseUrl = window.location.origin;
} catch (e) {}
}
const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo(
() =>
new Api({
...apiProps,
verbose: isDebug(),
onAuthStateChange: (state) => {
props.onAuthStateChange?.(state);
if (!authState?.token || state.token !== authState?.token) {
setAuthState(state);
}
},
}),
[JSON.stringify(apiProps)],
);
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
apiProps.user ? api.getAuthState() : undefined,
);
return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
{children}
</ClientContext.Provider>
);
@@ -61,12 +73,16 @@ export const useApi = (host?: ApiOptions["host"]): Api => {
return context.api;
};
export const useClientContext = () => {
return useContext(ClientContext);
};
/**
* @deprecated use useApi().baseUrl instead
*/
export const useBaseUrl = () => {
const context = useContext(ClientContext);
return context.baseUrl;
const context = useClientContext();
return context?.baseUrl;
};
export function useBkndWindowContext(): AdminBkndWindowContext {
+70 -27
View File
@@ -1,9 +1,10 @@
import type { DB, PrimaryFieldType } from "core";
import { objectTransform } from "core/utils/objects";
import { encodeSearch } from "core/utils/reqres";
import type { EntityData, RepoQueryIn } from "data";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, mutate } from "swr";
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
import type { Insertable, Selectable, Updateable } from "kysely";
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error {
@@ -23,6 +24,26 @@ export class UseEntityApiError<Payload = any> extends Error {
}
}
interface UseEntityReturn<
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
Response = ResponseObject<RepositoryResponse<Selectable<Data>>>,
> {
create: (input: Insertable<Data>) => Promise<Response>;
read: (
query?: RepoQueryIn,
) => Promise<
ResponseObject<
RepositoryResponse<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>
>
>;
update: Id extends undefined
? (input: Updateable<Data>, id: Id) => Promise<Response>
: (input: Updateable<Data>) => Promise<Response>;
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
}
export const useEntity = <
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
@@ -30,28 +51,26 @@ export const useEntity = <
>(
entity: Entity,
id?: Id,
) => {
): UseEntityReturn<Entity, Id, Data> => {
const api = useApi().data;
return {
create: async (input: Omit<Data, "id">) => {
const res = await api.createOne(entity, input);
create: async (input: Insertable<Data>) => {
const res = await api.createOne(entity, input as any);
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
}
return res;
return res as any;
},
read: async (query: RepoQueryIn = {}) => {
read: async (query?: RepoQueryIn) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.ok) {
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
}
// must be manually typed
return res as unknown as Id extends undefined
? ResponseObject<Data[]>
: ResponseObject<Data>;
return res as any;
},
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
// @ts-ignore
update: async (input: Updateable<Data>, _id: PrimaryFieldType | undefined = id) => {
if (!_id) {
throw new Error("id is required");
}
@@ -59,8 +78,9 @@ export const useEntity = <
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
}
return res;
return res as any;
},
// @ts-ignore
_delete: async (_id: PrimaryFieldType | undefined = id) => {
if (!_id) {
throw new Error("id is required");
@@ -70,7 +90,7 @@ export const useEntity = <
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
}
return res;
return res as any;
},
};
};
@@ -91,6 +111,19 @@ export function makeKey(
);
}
interface UseEntityQueryReturn<
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? Selectable<DB[Entity]> : EntityData,
Return = Id extends undefined ? ResponseObject<Data[]> : ResponseObject<Data>,
> extends Omit<SWRResponse<Return>, "mutate">,
Omit<ReturnType<typeof useEntity<Entity, Id>>, "read"> {
mutate: (id?: PrimaryFieldType) => Promise<any>;
mutateRaw: SWRResponse<Return>["mutate"];
api: Api["data"];
key: string;
}
export const useEntityQuery = <
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
@@ -99,11 +132,11 @@ export const useEntityQuery = <
id?: Id,
query?: RepoQueryIn,
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean },
) => {
): UseEntityQueryReturn<Entity, Id> => {
const api = useApi().data;
const key = makeKey(api, entity as string, id, query);
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query);
const fetcher = () => read(query ?? {});
type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
@@ -136,6 +169,7 @@ export const useEntityQuery = <
...swr,
...mapped,
mutate: mutateFn,
// @ts-ignore
mutateRaw: swr.mutate,
api,
key,
@@ -144,8 +178,8 @@ export const useEntityQuery = <
export async function mutateEntityCache<
Entity extends keyof DB | string,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Selectable<Data>>) {
function update(prev: any, partialNext: any) {
if (
typeof prev !== "undefined" &&
@@ -176,28 +210,37 @@ export async function mutateEntityCache<
);
}
interface UseEntityMutateReturn<
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
> extends Omit<ReturnType<typeof useEntityQuery<Entity, Id>>, "mutate"> {
mutate: Id extends undefined
? (id: PrimaryFieldType, data: Partial<Selectable<Data>>) => Promise<void>
: (data: Partial<Selectable<Data>>) => Promise<void>;
}
export const useEntityMutate = <
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData,
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
>(
entity: Entity,
id?: Id,
options?: SWRConfiguration,
) => {
): UseEntityMutateReturn<Entity, Id, Data> => {
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
...options,
enabled: false,
});
const _mutate = id
? (data) => mutateEntityCache($q.api, entity, id, data)
: (id, data) => mutateEntityCache($q.api, entity, id, data);
? (data: Partial<Selectable<Data>>) => mutateEntityCache($q.api, entity, id, data)
: (id: PrimaryFieldType, data: Partial<Selectable<Data>>) =>
mutateEntityCache($q.api, entity, id, data);
return {
...$q,
mutate: _mutate as unknown as Id extends undefined
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
: (data: Partial<Data>) => Promise<void>,
};
mutate: _mutate,
} as any;
};
+9 -19
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: AuthState | undefined;
data: Partial<AuthState> | undefined;
user: AuthState["user"] | undefined;
token: AuthState["token"] | undefined;
verified: boolean;
@@ -24,46 +24,36 @@ type UseAuth = {
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
const api = useApi(options?.baseUrl);
const invalidate = useInvalidate();
const authState = api.getAuthState();
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
const { authState } = useClientContext();
const verified = authState?.verified ?? false;
function updateAuthState() {
setAuthData(api.getAuthState());
}
async function login(input: LoginData) {
const res = await api.auth.loginWithPassword(input);
updateAuthState();
const res = await api.auth.login("password", input);
return res.data;
}
async function register(input: LoginData) {
const res = await api.auth.registerWithPassword(input);
updateAuthState();
const res = await api.auth.register("password", input);
return res.data;
}
function setToken(token: string) {
api.updateToken(token);
updateAuthState();
}
async function logout() {
await api.updateToken(undefined);
setAuthData(undefined);
api.updateToken(undefined);
invalidate();
}
async function verify() {
await api.verifyAuth();
updateAuthState();
}
return {
data: authData,
user: authData?.user,
token: authData?.token,
data: authState,
user: authState?.user,
token: authState?.token,
verified,
login,
register,
+6 -2
View File
@@ -1,4 +1,8 @@
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import {
default as CodeMirror,
type ReactCodeMirrorProps,
EditorView,
} from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { useTheme } from "ui/client/use-theme";
@@ -43,7 +47,7 @@ export default function CodeEditor({
theme={theme === "dark" ? "dark" : "light"}
editable={editable}
basicSetup={_basicSetup}
extensions={extensions}
extensions={[...extensions, EditorView.lineWrapping]}
{...props}
/>
);
@@ -0,0 +1,62 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export interface CollapsibleListRootProps extends React.HTMLAttributes<HTMLDivElement> {}
const Root = ({ className, ...props }: CollapsibleListRootProps) => (
<div className={twMerge("flex flex-col gap-2 max-w-4xl", className)} {...props} />
);
export interface CollapsibleListItemProps extends React.HTMLAttributes<HTMLDivElement> {
hasError?: boolean;
disabled?: boolean;
}
const Item = ({ className, hasError, disabled, ...props }: CollapsibleListItemProps) => (
<div
className={twMerge(
"flex flex-col border border-muted rounded bg-background",
hasError && "border-error",
className,
)}
{...props}
/>
);
export interface CollapsibleListPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
left?: ReactNode;
right?: ReactNode;
}
const Preview = ({ className, left, right, children, ...props }: CollapsibleListPreviewProps) => (
<div
{...props}
className={twMerge("flex flex-row justify-between p-3 gap-3 items-center", className)}
>
{left && <div className="flex flex-row items-center p-2 bg-primary/5 rounded">{left}</div>}
<div className="font-mono flex-grow flex flex-row gap-3">{children}</div>
{right && <div className="flex flex-row gap-4 items-center">{right}</div>}
</div>
);
export interface CollapsibleListDetailProps extends React.HTMLAttributes<HTMLDivElement> {
open?: boolean;
}
const Detail = ({ className, open, ...props }: CollapsibleListDetailProps) =>
open && (
<div
{...props}
className={twMerge(
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
className,
)}
/>
);
export const CollapsibleList = {
Root,
Item,
Preview,
Detail,
};
@@ -19,6 +19,7 @@ export type DropdownItem =
onClick?: () => void;
destructive?: boolean;
disabled?: boolean;
title?: string;
[key: string]: any;
};
@@ -142,6 +143,7 @@ export function Dropdown({
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
)}
onClick={onClick}
title={item.title}
>
{space_for_icon && (
<div className="size-[16px] text-left mr-1.5 opacity-80">
+91
View File
@@ -0,0 +1,91 @@
import { use, createContext, useEffect } from "react";
import { useState } from "react";
import { useLocation, useParams } from "wouter";
// extract path segment from path, e.g. /auth/strategies/:strategy? -> "strategy"
function extractPathSegment(path: string): string {
const match = path.match(/:(\w+)\??/);
return match?.[1] ?? "";
}
// get url by replacing path segment with identifier
// e.g. /auth/strategies/:strategy? -> /auth/strategies/x
function getPath(path: string, identifier?: string) {
if (!identifier) {
return path.replace(/\/:\w+\??/, "");
}
return path.replace(/:\w+\??/, identifier);
}
export function useRoutePathState(_path?: string, identifier?: string) {
const ctx = useRoutePathContext(_path ?? "");
const path = _path ?? ctx?.path ?? "";
const segment = extractPathSegment(path);
const routeIdentifier = useParams()[segment];
const [localActive, setLocalActive] = useState(routeIdentifier === identifier);
const active = ctx ? identifier === ctx.activeIdentifier : localActive;
const [, navigate] = useLocation();
function toggle(_open?: boolean) {
const open = _open ?? !localActive;
if (ctx) {
ctx.setActiveIdentifier(identifier!);
}
if (path) {
if (open) {
navigate(getPath(path, identifier));
} else {
navigate(getPath(path));
}
} else {
setLocalActive(open);
}
}
useEffect(() => {
if (!ctx && _path && identifier) {
setLocalActive(routeIdentifier === identifier);
}
}, [routeIdentifier, identifier, _path]);
return {
active,
toggle,
};
}
type RoutePathStateContextType = {
defaultIdentifier: string;
path: string;
activeIdentifier: string;
setActiveIdentifier: (identifier: string) => void;
};
const RoutePathStateContext = createContext<RoutePathStateContextType>(undefined!);
export function RoutePathStateProvider({
children,
defaultIdentifier,
path,
}: Pick<RoutePathStateContextType, "path" | "defaultIdentifier"> & { children: React.ReactNode }) {
const segment = extractPathSegment(path);
const routeIdentifier = useParams()[segment];
const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier);
return (
<RoutePathStateContext.Provider
value={{ defaultIdentifier, path, activeIdentifier, setActiveIdentifier }}
>
{children}
</RoutePathStateContext.Provider>
);
}
function useRoutePathContext(path?: string) {
const ctx = use(RoutePathStateContext);
if (ctx && (!path || ctx.path === path)) {
return ctx;
}
return undefined;
}
+16 -20
View File
@@ -1,46 +1,42 @@
import {
type Static,
type StaticDecode,
type TSchema,
decodeSearch,
encodeSearch,
parseDecode,
} from "core/utils";
import { decodeSearch, encodeSearch, parseDecode } from "core/utils";
import { isEqual, transform } from "lodash-es";
import { useLocation, useSearch as useWouterSearch } from "wouter";
import { type s, parse } from "core/object/schema";
// @todo: migrate to Typebox
export function useSearch<Schema extends TSchema = TSchema>(
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
schema: Schema,
defaultValue?: Partial<StaticDecode<Schema>>,
defaultValue?: Partial<s.StaticCoerced<Schema>>,
) {
const searchString = useWouterSearch();
const [location, navigate] = useLocation();
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
if (searchString.length > 0) {
value = parseDecode(schema, decodeSearch(searchString));
//console.log("search:decode", value);
}
const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
const value = parse(schema, initial, {
withDefaults: true,
clone: true,
}) as s.StaticCoerced<Schema>;
// @todo: add option to set multiple keys at once
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
function set<Key extends keyof s.StaticCoerced<Schema>>(
key: Key,
value: s.StaticCoerced<Schema>[Key],
): void {
//console.log("set", key, value);
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value });
const update = parse(schema, { ...decodeSearch(searchString), [key]: value });
const search = transform(
update as any,
(result, value, key) => {
if (defaultValue && isEqual(value, defaultValue[key])) return;
result[key] = value;
},
{} as Static<Schema>,
{} as s.StaticCoerced<Schema>,
);
const encoded = encodeSearch(search, { encode: false });
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
}
return {
value: value as Required<StaticDecode<Schema>>,
value: value as Required<s.StaticCoerced<Schema>>,
set,
};
}
+24 -8
View File
@@ -13,6 +13,7 @@ import {
import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { appShellStore } from "ui/store";
import { useLocation } from "wouter";
@@ -376,6 +377,15 @@ export function Scrollable({
);
}
type SectionHeaderAccordionItemProps = {
title: string;
open: boolean;
toggle: () => void;
ActiveIcon?: any;
children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
};
export const SectionHeaderAccordionItem = ({
title,
open,
@@ -383,14 +393,7 @@ export const SectionHeaderAccordionItem = ({
ActiveIcon = IconChevronUp,
children,
renderHeaderRight,
}: {
title: string;
open: boolean;
toggle: () => void;
ActiveIcon?: any;
children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
}) => (
}: SectionHeaderAccordionItemProps) => (
<div
style={{ minHeight: 49 }}
className={twMerge(
@@ -422,6 +425,19 @@ export const SectionHeaderAccordionItem = ({
</div>
);
export const RouteAwareSectionHeaderAccordionItem = ({
routePattern,
identifier,
...props
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
// it's optional because it could be provided using the context
routePattern?: string;
identifier: string;
}) => {
const { active, toggle } = useRoutePathState(routePattern, identifier);
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
};
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
<hr {...props} className={twMerge("border-muted my-3", className)} />
);
+8 -2
View File
@@ -1,5 +1,5 @@
import { SegmentedControl, Tooltip } from "@mantine/core";
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import { IconApi, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import {
TbDatabase,
TbFingerprint,
@@ -159,6 +159,11 @@ function UserMenu() {
const items: DropdownItem[] = [
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
{
label: "OpenAPI",
onClick: () => window.open("/api/system/swagger", "_blank"),
icon: IconApi,
},
];
if (config.auth.enabled) {
@@ -166,7 +171,8 @@ function UserMenu() {
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
} else {
items.push({
label: `Logout ${auth.user.email}`,
label: "Logout",
title: `Logout ${auth.user.email}`,
onClick: handleLogout,
icon: IconKeyOff,
});
+32 -37
View File
@@ -33,6 +33,8 @@ import {
} from "ui/components/form/json-schema-form";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { CollapsibleList } from "ui/components/list/CollapsibleList";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
export function AuthStrategiesList(props) {
useBrowserTitle(["Auth", "Strategies"]);
@@ -104,7 +106,7 @@ function AuthStrategiesListInternal() {
<p className="opacity-70">
Allow users to sign in or sign up using different strategies.
</p>
<div className="flex flex-col gap-2 max-w-4xl">
<CollapsibleList.Root>
<Strategy type="password" name="password" />
<Strategy type="oauth" name="google" />
<Strategy type="oauth" name="github" />
@@ -113,7 +115,7 @@ function AuthStrategiesListInternal() {
<Strategy type="oauth" name="instagram" unavailable />
<Strategy type="oauth" name="apple" unavailable />
<Strategy type="oauth" name="discord" unavailable />
</div>
</CollapsibleList.Root>
</div>
<FormDebug />
</AppShell.Scrollable>
@@ -138,47 +140,40 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
]),
);
const schema = schemas[type];
const [open, setOpen] = useState(false);
const { active, toggle } = useRoutePathState("/strategies/:strategy?", name);
if (!schema) return null;
return (
<FormContextOverride schema={schema} prefix={name}>
<div
className={twMerge(
"flex flex-col border border-muted rounded bg-background",
unavailable && "opacity-20 pointer-events-none cursor-not-allowed",
errors.length > 0 && "border-red-500",
)}
<CollapsibleList.Item
hasError={errors.length > 0}
className={
unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined
}
>
<div className="flex flex-row justify-between p-3 gap-3 items-center">
<div className="flex flex-row items-center p-2 bg-primary/5 rounded">
<StrategyIcon type={type} provider={name} />
</div>
<div className="font-mono flex-grow flex flex-row gap-3">
<span className="leading-none">{autoFormatString(name)}</span>
</div>
<div className="flex flex-row gap-4 items-center">
<StrategyToggle type={type} />
<IconButton
Icon={TbSettings}
size="lg"
iconProps={{ strokeWidth: 1.5 }}
variant={open ? "primary" : "ghost"}
onClick={() => setOpen((o) => !o)}
/>
</div>
</div>
{open && (
<div
className={twMerge(
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
)}
>
<StrategyForm type={type} name={name} />
</div>
)}
</div>
<CollapsibleList.Preview
left={<StrategyIcon type={type} provider={name} />}
right={
<>
<StrategyToggle type={type} />
<IconButton
Icon={TbSettings}
size="lg"
iconProps={{ strokeWidth: 1.5 }}
variant={active ? "primary" : "ghost"}
onClick={() => toggle(!active)}
/>
</>
}
>
<span className="leading-none">{autoFormatString(name)}</span>
</CollapsibleList.Preview>
<CollapsibleList.Detail open={active}>
<StrategyForm type={type} name={name} />
</CollapsibleList.Detail>
</CollapsibleList.Item>
</FormContextOverride>
);
};
+1 -1
View File
@@ -14,7 +14,7 @@ export default function AuthRoutes() {
<Route path="/users" component={AuthUsersList} />
<Route path="/roles" component={AuthRolesList} />
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
<Route path="/strategies" component={AuthStrategiesList} />
<Route path="/strategies/:strategy?" component={AuthStrategiesList} />
<Route path="/settings" component={AuthSettings} />
</AuthRoot>
);
@@ -52,7 +52,6 @@ export function DataEntityUpdate({ params }) {
}
async function onSubmitted(changeSet?: EntityData) {
console.log("update:changeSet", changeSet);
//return;
if (!changeSet) {
goBack();
@@ -11,8 +11,7 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes } from "ui/lib/routes";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
export function DataEntityCreate({ params }) {
const { $data } = useBkndData();
@@ -29,7 +28,7 @@ export function DataEntityCreate({ params }) {
const $q = useEntityMutate(entity.name);
// @todo: use entity schema for prefilling
const search = useSearch(Type.Object({}), {});
const search = useSearch(s.object({}), {});
function goBack() {
window.history.go(-1);
+8 -16
View File
@@ -1,4 +1,4 @@
import { type Entity, querySchema } from "data";
import { type Entity, repoQuery } from "data";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client";
@@ -14,20 +14,14 @@ import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
import { s } from "core/object/schema";
import { pick } from "core/utils/objects";
// @todo: migrate to Typebox
const searchSchema = Type.Composite(
[
Type.Pick(querySchema, ["select", "where", "sort"]),
Type.Object({
page: Type.Optional(Type.Number({ default: 1 })),
perPage: Type.Optional(Type.Number({ default: 10 })),
}),
],
{ additionalProperties: false },
);
const searchSchema = s.partialObject({
...pick(repoQuery.properties, ["select", "where", "sort"]),
page: s.number({ default: 1 }).optional(),
perPage: s.number({ default: 10 }).optional(),
});
const PER_PAGE_OPTIONS = [5, 10, 25];
@@ -74,8 +68,6 @@ export function DataEntityList({ params }) {
const sort = search.value.sort!;
const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" };
// // @ts-expect-error - somehow all search keys are optional
console.log("new sort", newSort);
search.set("sort", newSort as any);
}
+20 -35
View File
@@ -30,14 +30,10 @@ import { routes, useNavigate } from "ui/lib/routes";
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";
export function DataSchemaEntity({ params }) {
const { $data } = useBkndData();
const [value, setValue] = useState("fields");
function toggle(value) {
return () => setValue(value);
}
const [navigate] = useNavigate();
const entity = $data.entity(params.entity as string)!;
@@ -46,7 +42,7 @@ export function DataSchemaEntity({ params }) {
}
return (
<>
<RoutePathStateProvider path={`/entity/${entity.name}/:setting?`} defaultIdentifier="fields">
<AppShell.SectionHeader
right={
<>
@@ -109,13 +105,12 @@ export function DataSchemaEntity({ params }) {
</div>
</AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}>
<Fields entity={entity} open={value === "fields"} toggle={toggle("fields")} />
<Fields entity={entity} />
<BasicSettings entity={entity} open={value === "2"} toggle={toggle("2")} />
<AppShell.SectionHeaderAccordionItem
<BasicSettings entity={entity} />
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="relations"
title="Relations"
open={value === "3"}
toggle={toggle("3")}
ActiveIcon={IconCirclesRelation}
>
<Empty
@@ -127,11 +122,10 @@ export function DataSchemaEntity({ params }) {
navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
}}
/>
</AppShell.SectionHeaderAccordionItem>
<AppShell.SectionHeaderAccordionItem
</AppShell.RouteAwareSectionHeaderAccordionItem>
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="indices"
title="Indices"
open={value === "4"}
toggle={toggle("4")}
ActiveIcon={IconBolt}
>
<Empty
@@ -145,17 +139,13 @@ export function DataSchemaEntity({ params }) {
}),
}}
/>
</AppShell.SectionHeaderAccordionItem>
</AppShell.RouteAwareSectionHeaderAccordionItem>
</div>
</>
</RoutePathStateProvider>
);
}
const Fields = ({
entity,
open,
toggle,
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const Fields = ({ entity }: { entity: Entity }) => {
const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0);
const { actions, $data } = useBkndData();
@@ -174,10 +164,9 @@ const Fields = ({
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
return (
<AppShell.SectionHeaderAccordionItem
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="fields"
title="Fields"
open={open}
toggle={toggle}
ActiveIcon={IconAlignJustified}
renderHeaderRight={({ open }) =>
open ? (
@@ -192,6 +181,7 @@ const Fields = ({
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
)}
<EntityFieldsForm
routePattern={`/entity/${entity.name}/fields/:sub?`}
fields={initialFields}
ref={ref}
key={String(updates)}
@@ -237,15 +227,11 @@ const Fields = ({
</div>
)}
</div>
</AppShell.SectionHeaderAccordionItem>
</AppShell.RouteAwareSectionHeaderAccordionItem>
);
};
const BasicSettings = ({
entity,
open,
toggle,
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const BasicSettings = ({ entity }: { entity: Entity }) => {
const d = useBkndData();
const config = d.entities?.[entity.name]?.config;
const formRef = useRef<JsonSchemaFormRef>(null);
@@ -271,10 +257,9 @@ const BasicSettings = ({
}
return (
<AppShell.SectionHeaderAccordionItem
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="settings"
title="Settings"
open={open}
toggle={toggle}
ActiveIcon={IconSettings}
renderHeaderRight={({ open }) =>
open ? (
@@ -293,6 +278,6 @@ const BasicSettings = ({
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</AppShell.SectionHeaderAccordionItem>
</AppShell.RouteAwareSectionHeaderAccordionItem>
);
};
@@ -27,6 +27,7 @@ import { Popover } from "ui/components/overlay/Popover";
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
import * as tbbox from "@sinclair/typebox";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
const { Type } = tbbox;
const fieldsSchemaObject = originalFieldsSchemaObject;
@@ -63,6 +64,7 @@ export type EntityFieldsFormProps = {
onChange?: (formData: TAppDataEntityFields) => void;
sortable?: boolean;
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
routePattern?: string;
};
export type EntityFieldsFormRef = {
@@ -74,7 +76,10 @@ export type EntityFieldsFormRef = {
};
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) {
function EntityFieldsForm(
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props },
ref,
) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({
name,
field,
@@ -166,6 +171,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
errors={errors}
remove={remove}
dnd={dnd}
routePattern={routePattern}
/>
)}
/>
@@ -179,6 +185,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
form={formProps}
errors={errors}
remove={remove}
routePattern={routePattern}
/>
))}
</div>
@@ -273,6 +280,7 @@ function EntityField({
remove,
errors,
dnd,
routePattern,
}: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
index: number;
@@ -283,11 +291,12 @@ function EntityField({
remove: (index: number) => void;
errors: any;
dnd?: SortableItemProps;
routePattern?: string;
}) {
const [opened, handlers] = useDisclosure(false);
const prefix = `fields.${index}.field` as const;
const type = field.field.type;
const name = watch(`fields.${index}.name`);
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
const specificData = omit(field.field.config, commonProps);
const disabled = fieldSpec.disabled || [];
@@ -300,9 +309,11 @@ function EntityField({
return () => {
if (name.length === 0) {
remove(index);
return;
toggle();
} else if (window.confirm(`Sure to delete "${name}"?`)) {
remove(index);
toggle();
}
window.confirm(`Sure to delete "${name}"?`) && remove(index);
};
}
//console.log("register", register(`${prefix}.config.required`));
@@ -313,7 +324,7 @@ function EntityField({
key={field.id}
className={twMerge(
"flex flex-col border border-muted rounded bg-background mb-2",
opened && "mb-6",
active && "mb-6",
hasErrors && "border-red-500 ",
)}
{...dndProps}
@@ -371,13 +382,13 @@ function EntityField({
Icon={TbSettings}
disabled={is_primary}
iconProps={{ strokeWidth: 1.5 }}
onClick={handlers.toggle}
variant={opened ? "primary" : "ghost"}
onClick={() => toggle()}
variant={active ? "primary" : "ghost"}
/>
</div>
</div>
</div>
{opened && (
{active && (
<div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50">
{/*<pre>{JSON.stringify(field, null, 2)}</pre>*/}
<Tabs defaultValue="general">
+1 -1
View File
@@ -17,7 +17,7 @@ export default function DataRoutes() {
<Route path="/schema" nest>
<Route path="/" component={DataSchemaIndex} />
<Route path="/entity/:entity" component={DataSchemaEntity} />
<Route path="/entity/:entity/:setting?/:sub?" component={DataSchemaEntity} />
</Route>
</Switch>
</DataRoot>
@@ -45,8 +45,40 @@ function QueryMutateDataApi() {
);
}
function QueryMutateDataApi2() {
const { mutate } = useEntityMutate("users");
const { data, ...r } = useEntityQuery("users", undefined, {
limit: 2,
});
return (
<div>
bla
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
{data && (
<div>
{data.map((user) => (
<input
key={String(user.id)}
type="text"
value={user.email}
onChange={async (e) => {
await mutate(user.id, { email: e.target.value });
}}
className="border border-black"
/>
))}
</div>
)}
</div>
);
}
function QueryDataApi() {
const { data, update, ...r } = useEntityQuery("comments", undefined, {
const { data, update, ...r } = useEntityQuery("users", undefined, {
sort: { by: "id", dir: "asc" },
limit: 3,
});
+1
View File
@@ -49,6 +49,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
.cm-editor {
display: flex;
flex: 1;
max-width: 100%;
}
.animate-fade-in {
+1
View File
@@ -39,6 +39,7 @@
"./src/**/*.tsx",
"vite.dev.ts",
"build.ts",
"build.cli.ts",
"__test__",
"e2e/**/*.ts"
],
+2
View File
@@ -4,11 +4,13 @@ import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json" with { type: "json" };
// https://vitejs.dev/config/
export default defineConfig({
define: {
__isDev: process.env.NODE_ENV === "production" ? "0" : "1",
__version: JSON.stringify(pkg.version),
},
clearScreen: false,
publicDir: "./src/ui/assets",
+10 -95
View File
@@ -27,18 +27,18 @@
},
"app": {
"name": "bknd",
"version": "0.11.0",
"version": "0.12.0",
"bin": "./dist/cli/index.js",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30",
"@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
@@ -48,17 +48,14 @@
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.4",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"wrangler": "^4.4.1",
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
@@ -87,9 +84,11 @@
"dotenv": "^16.4.7",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
@@ -514,8 +513,6 @@
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="],
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250311.0" }, "optionalPeers": ["workerd"] }, "sha512-AaKYnbFpHaVDZGh3Hjy3oLYd12+LZw9aupAOudYJ+tjekahxcIqlSAr0zK9kPOdtgn10tzaqH7QJFUWcLE+k7g=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250224.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-sBbaAF2vgQ9+T50ik1ihekdepStBp0w4fvNghBfXIw1iWqfNWnypcjDMmi/7JhXJt2uBxBrSlXCvE5H7Gz+kbw=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250224.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-naetGefgjAaDbEacpwaVruJXNwxmRRL7v3ppStgEiqAlPmTpQ/Edjn2SQ284QwOw3MvaVPHrWcaTBupUpkqCyg=="],
@@ -542,8 +539,6 @@
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ=="],
"@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.2.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-7Dm841fk37+JQW6j2rI1/uGkJyESrjzyhiIkaLjbbR0U6aFFQvMrJn35WxQreRMADMhzkyVkZM4467OR7GR8nQ=="],
"@codemirror/language": ["@codemirror/language@6.10.8", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw=="],
"@codemirror/lint": ["@codemirror/lint@6.8.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A=="],
@@ -646,6 +641,8 @@
"@hono/node-server": ["@hono/node-server@1.13.8", "", { "peerDependencies": { "hono": "^4" } }, "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg=="],
"@hono/swagger-ui": ["@hono/swagger-ui@0.5.1", "", { "peerDependencies": { "hono": "*" } }, "sha512-XpUCfszLJ9b1rtFdzqOSHfdg9pfBiC2J5piEjuSanYpDDTIwpMz0ciiv5N3WWUaQpz9fEgH8lttQqL41vIFuDA=="],
"@hono/typebox-validator": ["@hono/typebox-validator@0.3.2", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-MIxYk80vtuFnkvbNreMubZ/vLoNCCQivLH8n3vNDY5dFNsZ12BFaZV3FmsLJHGibNMMpmkO6y4w5gNWY4KzSdg=="],
"@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="],
@@ -2038,8 +2035,6 @@
"express-rate-limit": ["express-rate-limit@5.5.1", "", {}, "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg=="],
"exsolve": ["exsolve@1.0.4", "", {}, "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="],
@@ -2526,6 +2521,8 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
"jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="],
@@ -2586,8 +2583,6 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"liquidjs": ["liquidjs@10.21.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-DouqxNU2jfoZzb1LinVjOc/f6ssitGIxiDJT+kEKyYqPSSSd+WmGOAhtWbVm1/n75svu4aQ+FyQ3ctd3wh1bbw=="],
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
"locate-app": ["locate-app@2.5.0", "", { "dependencies": { "@promptbook/utils": "0.69.5", "type-fest": "4.26.0", "userhome": "1.0.1" } }, "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q=="],
@@ -3858,8 +3853,6 @@
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
"@cloudflare/unenv-preset/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="],
"@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
@@ -4164,8 +4157,6 @@
"bknd/vitest": ["vitest@3.0.9", "", { "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", "@vitest/pretty-format": "^3.0.9", "@vitest/runner": "3.0.9", "@vitest/snapshot": "3.0.9", "@vitest/spy": "3.0.9", "@vitest/utils": "3.0.9", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.9", "@vitest/ui": "3.0.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ=="],
"bknd/wrangler": ["wrangler@4.4.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.0", "blake3-wasm": "2.1.5", "esbuild": "0.24.2", "miniflare": "4.20250321.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.15", "workerd": "1.20250321.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250321.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EFwr7hiVeAmPOuOGQ7HFfeaLKLxEXQMJ86kyn6RFB8pGjMEUtvZMsVa9cPubKkKgNi3WcDEFeFLalclGyq+tGA=="],
"bknd-cli/@libsql/client": ["@libsql/client@0.14.0", "", { "dependencies": { "@libsql/core": "^0.14.0", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.4.4", "promise-limit": "^2.7.0" } }, "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -4428,8 +4419,6 @@
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"locate-app/type-fest": ["type-fest@4.26.0", "", {}, "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw=="],
"log-update/ansi-escapes": ["ansi-escapes@3.2.0", "", {}, "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ=="],
@@ -4708,8 +4697,6 @@
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
"@cloudflare/unenv-preset/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
@@ -4790,16 +4777,6 @@
"bknd-cli/@libsql/client/libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="],
"bknd/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="],
"bknd/wrangler/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="],
"bknd/wrangler/miniflare": ["miniflare@4.20250321.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250321.0", "ws": "8.18.0", "youch": "3.2.3", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-os+NJA7Eqi00BJHdVhzIa+3PMotnCtZg3hiUIRYcsZF5W7He8SK2EkV8csAb+npZq3jZ4SNpDebO01swM5dcWw=="],
"bknd/wrangler/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="],
"bknd/wrangler/workerd": ["workerd@1.20250321.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250321.0", "@cloudflare/workerd-darwin-arm64": "1.20250321.0", "@cloudflare/workerd-linux-64": "1.20250321.0", "@cloudflare/workerd-linux-arm64": "1.20250321.0", "@cloudflare/workerd-windows-64": "1.20250321.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vyuz9pdJ+7o1lC79vQ2UVRLXPARa2Lq94PbTfqEcYQeSxeR9X+YqhNq2yysv8Zs5vpokmexLCtMniPp9u+2LVQ=="],
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"class-utils/define-property/is-descriptor": ["is-descriptor@0.1.7", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg=="],
@@ -5304,68 +5281,6 @@
"bknd-cli/@libsql/client/libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"bknd/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
"bknd/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
"bknd/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
"bknd/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
"bknd/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
"bknd/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
"bknd/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
"bknd/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
"bknd/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
"bknd/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
"bknd/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
"bknd/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
"bknd/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
"bknd/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
"bknd/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
"bknd/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
"bknd/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
"bknd/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
"bknd/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
"bknd/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
"bknd/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
"bknd/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
"bknd/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
"bknd/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
"bknd/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
"bknd/wrangler/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"bknd/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250321.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-y273GfLaNCxkL8hTfo0c8FZKkOPdq+CPZAKJXPWB+YpS1JCOULu6lNTptpD7ZtF14dTYPkn5Weug31TTlviJmw=="],
"bknd/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250321.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qvf7/gkkQq7fAsoMlntJSimN/WfwQqxi2oL0aWZMGodTvs/yRHO2I4oE0eOihVdK1BXyBHJXNxEvNDBjF0+Yuw=="],
"bknd/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250321.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AEp3xjWFrNPO/h0StCOgOb0bWCcNThnkESpy91Wf4mfUF2p7tOCdp37Nk/1QIRqSxnfv4Hgxyi7gcWud9cJuMw=="],
"bknd/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250321.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wRWyMIoPIS1UBXCisW0FYTgGsfZD4AVS0hXA5nuLc0c21CvzZpmmTjqEWMcwPFenwy/MNL61NautVOC4qJqQ3Q=="],
"bknd/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250321.0", "", { "os": "win32", "cpu": "x64" }, "sha512-8vYP3QYO0zo2faUDfWl88jjfUvz7Si9GS3mUYaTh/TR9LcAUtsO7muLxPamqEyoxNFtbQgy08R4rTid94KRi3w=="],
"eslint/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"eslint/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
# define bknd version to be used as:
# `docker build --build-arg VERSION=<version> -t bknd .`
ARG VERSION=0.10.2
ARG VERSION=0.13.0
# Install & copy required cli
RUN npm install --omit=dev bknd@${VERSION}
-1378
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@ title: 'Introduction'
description: 'Integrate bknd into your runtime/framework of choice'
---
import { cloudflare, nextjs, reactRouter, astro, bun, node, docker, vite, aws } from "/snippets/integration-icons.mdx"
import { cloudflare, nextjs, rr, astro, bun, node, docker, vite, aws } from "/snippets/integration-icons.mdx"
## Start with a Framework
bknd seamlessly integrates with popular frameworks, allowing you to use what you're already familar with. The following guides will help you get started with your framework of choice.
@@ -16,7 +16,7 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
/>
<Card
title="React Router"
icon={<div className="text-primary-light">{reactRouter}</div>}
icon={<div className="text-primary-light">{rr}</div>}
href="/integration/react-router"
/>
<Card
+1 -1
View File
@@ -61,7 +61,7 @@
"navigation": [
{
"group": "Getting Started",
"pages": ["introduction", "motivation"]
"pages": ["start", "motivation"]
},
{
"group": "Usage",
+10855
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,10 +1,10 @@
{
"name": "bknd-docs",
"private": true,
"scripts": {
"dev": "mintlify dev"
},
"devDependencies": {
"mintlify": "^4.0.285"
}
}
"name": "bknd-docs",
"private": true,
"scripts": {
"dev": "mintlify dev"
},
"devDependencies": {
"mintlify": "4.0.510"
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ export const nextjs = <svg xmlns="http://www.w3.org/2000/svg" width="28px" heigh
stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 15V9l7.745 10.65A9 9 0 1 1 19 17.657M15 12V9"/></svg>
export const reactRouter = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 256 140"><path d="M78.066 92.588c12.818 0 23.209-10.391 23.209-23.21c0-12.817-10.391-23.208-23.21-23.208c-12.817 0-23.208 10.39-23.208 23.209s10.391 23.209 23.209 23.209m-54.857 46.417c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.391-23.208-23.21-23.208C10.392 92.588 0 102.978 0 115.796s10.39 23.21 23.209 23.21m209.582 0c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.39-23.208-23.209-23.208s-23.209 10.39-23.209 23.208s10.391 23.21 23.21 23.21"/><path fill="currentColor" d="M156.565 70.357c-.742-7.754-1.12-14.208-7.06-18.744c-7.522-5.744-16.044-2.017-26.54-5.806C112.65 43.312 105 34.155 105 23.24C105 10.405 115.578 0 128.626 0c9.665 0 17.974 5.707 21.634 13.883c5.601 10.64 1.96 21.467 8.998 26.921c8.333 6.458 19.568 1.729 32.104 7.848a23.6 23.6 0 0 1 9.84 8.425A22.86 22.86 0 0 1 205 69.718c0 10.915-7.65 20.073-17.964 22.568c-10.497 3.789-19.019.062-26.541 5.806c-8.46 6.46-3.931 17.
export const rr = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 256 140"><path d="M78.066 92.588c12.818 0 23.209-10.391 23.209-23.21c0-12.817-10.391-23.208-23.21-23.208c-12.817 0-23.208 10.39-23.208 23.209s10.391 23.209 23.209 23.209m-54.857 46.417c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.391-23.208-23.21-23.208C10.392 92.588 0 102.978 0 115.796s10.39 23.21 23.209 23.21m209.582 0c12.818 0 23.209-10.39 23.209-23.209c0-12.817-10.39-23.208-23.209-23.208s-23.209 10.39-23.209 23.208s10.391 23.21 23.21 23.21"/><path fill="currentColor" d="M156.565 70.357c-.742-7.754-1.12-14.208-7.06-18.744c-7.522-5.744-16.044-2.017-26.54-5.806C112.65 43.312 105 34.155 105 23.24C105 10.405 115.578 0 128.626 0c9.665 0 17.974 5.707 21.634 13.883c5.601 10.64 1.96 21.467 8.998 26.921c8.333 6.458 19.568 1.729 32.104 7.848a23.6 23.6 0 0 1 9.84 8.425A22.86 22.86 0 0 1 205 69.718c0 10.915-7.65 20.073-17.964 22.568c-10.497 3.789-19.019.062-26.541 5.806c-8.46 6.46-3.931 17.
267-10.826 28.682c-3.913 7.518-11.867 12.663-21.043 12.663c-13.048 0-23.626-10.405-23.626-23.24c0-9.323 5.582-17.364 13.638-21.066c12.536-6.12 23.77-1.39 32.104-7.848c4.807-3.726 5.823-9.473 5.823-16.926"/></svg>;
export const astro = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+2 -2
View File
@@ -2,7 +2,7 @@
title: Introduction
---
import { cloudflare, nextjs, reactRouter, astro, bun, node, docker, vite, aws, d1, libsql, sqlite, postgres, turso } from "/snippets/integration-icons.mdx"
import { cloudflare, nextjs, rr, astro, bun, node, docker, vite, aws, d1, libsql, sqlite, postgres, turso } from "/snippets/integration-icons.mdx"
import { Stackblitz, examples } from "/snippets/stackblitz.mdx"
Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature-rich backend that runs in any JavaScript environment.
@@ -44,7 +44,7 @@ in the future, so stay tuned!
/>
<Card
title="React Router"
icon={<div className="text-primary-light">{reactRouter}</div>}
icon={<div className="text-primary-light">{rr}</div>}
href="/integration/react-router"
/>
<Card