Merge pull request #169 from bknd-io/release/0.13

Release 0.13
This commit is contained in:
dswbx
2025-05-27 16:29:43 +02:00
committed by GitHub
57 changed files with 1514 additions and 836 deletions

View File

@@ -15,7 +15,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.2.5" bun-version: "1.2.14"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app

4
.gitignore vendored
View File

@@ -29,4 +29,6 @@ packages/media/.env
.idea .idea
.vscode .vscode
.git_old .git_old
docker/tmp docker/tmp
.debug
.history

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; 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", () => { describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" }; const q: ObjectQuery = { name: "Michael" };

View File

@@ -1,25 +1,18 @@
import { describe, expect, test } from "bun:test"; import { describe, test, expect } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils"; import { getDummyConnection } from "../helper";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; import { type WhereQuery, WhereBuilder } from "data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
const decode = (input: RepoQueryIn, expected: RepoQuery) => { function qb() {
const result = Value.Decode(querySchema, input); const c = getDummyConnection();
expect(result).toEqual(expected); const kysely = c.dummyConnection.kysely;
}; return kysely.selectFrom("t").selectAll();
}
describe("data-query-impl", () => { function compile(q: WhereQuery) {
function qb() { const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
const c = getDummyConnection(); return { sql, parameters };
const kysely = c.dummyConnection.kysely; }
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => { test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [ const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]], [{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
expect(keys).toEqual(expectedKeys); expect(keys).toEqual(expectedKeys);
} }
}); });
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
}); });

View File

@@ -43,8 +43,9 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test.only("accepts direct", async () => { test("accepts direct", async () => {
const app = await makeApp(); const app = await makeApp();
console.log("app", app);
const file = Bun.file(path); const file = Bun.file(path);
const name = makeName("png"); const name = makeName("png");

View File

@@ -1,5 +1,6 @@
import { $ } from "bun"; import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -9,7 +10,7 @@ const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean"); const clean = args.includes("--clean");
if (clean) { 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 {} +`; await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
} }
@@ -21,11 +22,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.log("Types built"); console.info("Types built");
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.log("Types aliased"); console.info("Types aliased");
types_running = false; types_running = false;
}, },
}); });
@@ -47,10 +48,10 @@ if (types && !watch) {
} }
function banner(title: string) { function banner(title: string) {
console.log(""); console.info("");
console.log("=".repeat(40)); console.info("=".repeat(40));
console.log(title.toUpperCase()); console.info(title.toUpperCase());
console.log("-".repeat(40)); console.info("-".repeat(40));
} }
// collection of always-external packages // collection of always-external packages
@@ -65,6 +66,9 @@ async function buildApi() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
define: {
__version: JSON.stringify(pkg.version),
},
entry: [ entry: [
"src/index.ts", "src/index.ts",
"src/core/index.ts", "src/core/index.ts",

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.12.0", "version": "0.13.0-rc.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -49,9 +49,11 @@
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2", "@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10", "@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.4.4",
@@ -64,12 +66,11 @@
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3", "radix-ui": "^1.1.3",
"swr": "^2.3.3", "swr": "^2.3.3"
"lodash-es": "^4.17.21",
"@sinclair/typebox": "0.34.30"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
@@ -98,14 +99,15 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2", "posthog-js-lite": "^3.4.2",
"picocolors": "^1.1.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",

View File

@@ -1,10 +1,11 @@
import type { SafeUser } from "auth"; import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi"; import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi"; import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt"; 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 { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils"; import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser; export type TApiUser = SafeUser;
@@ -21,14 +22,24 @@ declare global {
} }
} }
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = { export type ApiOptions = {
host?: string; host?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
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; fetcher?: ApiFetcher;
verbose?: boolean; verbose?: boolean;
verified?: boolean; verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & ( } & (
| { | {
token?: string; token?: string;
@@ -61,18 +72,18 @@ export class Api {
this.verified = options.verified === true; this.verified = options.verified === true;
// prefer request if given // prefer request if given
if ("request" in options) { if ("request" in options && options.request) {
this.options.host = options.host ?? new URL(options.request.url).origin; this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers; this.options.headers = options.headers ?? options.request.headers;
this.extractToken(); this.extractToken();
// then check for a token // then check for a token
} else if ("token" in options) { } else if ("token" in options && options.token) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(options.token); this.updateToken(options.token, { trigger: false });
// then check for an user object // then check for an user object
} else if ("user" in options) { } else if ("user" in options && options.user) {
this.token_transport = "none"; this.token_transport = "none";
this.user = options.user; this.user = options.user;
this.verified = options.verified !== false; this.verified = options.verified !== false;
@@ -115,16 +126,30 @@ export class Api {
this.updateToken(headerToken); this.updateToken(headerToken);
return; return;
} }
} else if (this.options.localStorage) { } else if (this.storage) {
const token = localStorage.getItem(this.tokenKey); this.storage.getItem(this.tokenKey).then((token) => {
if (token) {
this.token_transport = "header"; 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.token = token;
this.verified = false; this.verified = false;
@@ -134,17 +159,25 @@ export class Api {
this.user = undefined; this.user = undefined;
} }
if (this.options.localStorage) { if (this.storage) {
const key = this.tokenKey; const key = this.tokenKey;
if (token) { if (token) {
localStorage.setItem(key, token); this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} else { } 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) { private markAuthVerified(verfied: boolean) {
@@ -214,15 +247,32 @@ export class Api {
const fetcher = this.options.fetcher; const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher); this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher); this.data = new DataApi(
this.auth = new AuthApi(
{ {
...baseParams, ...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, fetcher,
); );
this.media = new MediaApi(baseParams, fetcher);
} }
} }

View File

@@ -151,7 +151,7 @@ export class App {
} }
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
return this.server.fetch; return this.server.fetch as any;
} }
get module() { get module() {

View File

@@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & { export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token: string) => void | Promise<void>; onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
}; };
export class AuthApi extends ModuleApi<AuthApiOptions> { export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> { protected override getDefaultOptions(): Partial<AuthApiOptions> {
return { return {
basepath: "/api/auth", basepath: "/api/auth",
credentials: "include",
}; };
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, { const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include", credentials: this.options.credentials,
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, { const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include", credentials: this.options.credentials,
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]); return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
} }
async logout() {} async logout() {
await this.options.onTokenUpdate?.(undefined);
}
} }

View File

@@ -1,11 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { TypeInvalidError, parse, transformObject } from "core/utils"; import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data"; import { DataPermissions } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller"; import { Controller, type ServerEnv } from "modules/Controller";
import * as tbbox from "@sinclair/typebox"; import { describeRoute, jsc, s } from "core/object/schema";
const { Type } = tbbox;
export type AuthActionResponse = { export type AuthActionResponse = {
success: boolean; success: boolean;
@@ -14,10 +12,6 @@ export type AuthActionResponse = {
errors?: any; errors?: any;
}; };
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller { export class AuthController extends Controller {
constructor(private auth: AppAuth) { constructor(private auth: AppAuth) {
super(); super();
@@ -56,6 +50,10 @@ export class AuthController extends Controller {
hono.post( hono.post(
"/create", "/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]), permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => { async (c) => {
try { try {
const body = await this.auth.authenticator.getBody(c); const body = await this.auth.authenticator.getBody(c);
@@ -93,9 +91,16 @@ export class AuthController extends Controller {
} }
}, },
); );
hono.get("create/schema.json", async (c) => { hono.get(
return c.json(create.schema); "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); mainHono.route(`/${name}/actions`, hono);
@@ -104,42 +109,54 @@ export class AuthController extends Controller {
override getController() { override getController() {
const { auth } = this.middlewares; const { auth } = this.middlewares;
const hono = this.create(); const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) { hono.get(
if (!this.auth.isStrategyEnabled(strategy)) continue; "/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)); return c.json({ user: null }, 403);
this.registerStrategyActions(strategy, hono); },
} );
hono.get("/me", auth(), async (c) => { hono.get(
const claims = c.get("auth")?.user; "/logout",
if (claims) { describeRoute({
const { data: user } = await this.userRepo.findId(claims.id); summary: "Logout the current user",
return c.json({ 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) => { return c.redirect("/");
await this.auth.authenticator.logout(c); },
if (this.auth.authenticator.isJsonRequest(c)) { );
return c.json({ ok: true });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.redirect("/");
});
hono.get( hono.get(
"/strategies", "/strategies",
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) => { async (c) => {
const { include_disabled } = c.req.valid("query"); const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false); const { strategies, basepath } = this.auth.toJSON(false);
@@ -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()); return hono.all("*", (c) => c.notFound());
} }
} }

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 = { const envs = {
// used in $console to determine the log level // used in $console to determine the log level
cli_log_level: { cli_log_level: {

View File

@@ -26,6 +26,7 @@ export {
} from "./object/query/query"; } from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry"; export { Registry, type Constructor } from "./registry/Registry";
export { getFlashMessage } from "./server/flash"; export { getFlashMessage } from "./server/flash";
export { s, jsc, describeRoute } from "./object/schema";
export * from "./console"; export * from "./console";
export * from "./events"; export * from "./events";

View File

@@ -34,6 +34,8 @@ type ExpressionMap<Exps extends Expressions> = {
? E ? E
: never; : never;
}; };
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
type ExpressionCondition<Exps extends Expressions> = { type ExpressionCondition<Exps extends Expressions> = {
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] }; [K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
}[keyof ExpressionMap<Exps>]; }[keyof ExpressionMap<Exps>];
@@ -195,5 +197,7 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
const fns = _build(query, expressions, options); const fns = _build(query, expressions, options);
return _validate(fns); return _validate(fns);
}, },
expressions,
expressionKeys: expressions.map((e) => e.key) as ExpressionKeys<Exps>,
}; };
} }

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;
}

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;

View File

@@ -0,0 +1 @@
export { tbValidator } from "./tbValidator";

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;
});
};

View File

@@ -406,3 +406,16 @@ export function objectToJsLiteral(value: object, indent: number = 0, _level: num
throw new TypeError(`Unsupported data type: ${t}`); 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>,
);
}

View File

@@ -1,6 +1,4 @@
import { $console, isDebug, tbValidator as tb } from "core"; import { $console, isDebug } from "core";
import { StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import { import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
@@ -8,14 +6,15 @@ import {
type MutatorResponse, type MutatorResponse,
type RepoQuery, type RepoQuery,
type RepositoryResponse, type RepositoryResponse,
querySchema, repoQuery,
} from "data"; } from "data";
import type { Handler } from "hono/types"; import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules"; import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema"; import type { AppDataConfig } from "../data-schema";
const { Type } = tbbox; import { omitKeys } from "core/utils";
export class DataController extends Controller { export class DataController extends Controller {
constructor( constructor(
@@ -71,6 +70,7 @@ export class DataController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: sample implementation how to augment handler with additional info // @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any { function handler<HH extends Handler>(name: string, h: HH): any {
@@ -83,6 +83,10 @@ export class DataController extends Controller {
// info // info
hono.get( hono.get(
"/", "/",
describeRoute({
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => { handler("data info", (c) => {
// sample implementation // sample implementation
return c.json(this.em.toJSON()); return c.json(this.em.toJSON());
@@ -90,49 +94,75 @@ export class DataController extends Controller {
); );
// sync endpoint // sync endpoint
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => { hono.get(
const force = c.req.query("force") === "1"; "/sync",
const drop = c.req.query("drop") === "1"; permission(DataPermissions.databaseSync),
//console.log("force", force); describeRoute({
const tables = await this.em.schema().introspect(); summary: "Sync database schema",
//console.log("tables", tables); tags: ["data"],
const changes = await this.em.schema().sync({ }),
force, jsc(
drop, "query",
}); s.partialObject({
return c.json({ tables: tables.map((t) => t.name), changes }); 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 * Schema endpoints
*/ */
// read entity schema // read entity schema
hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => { hono.get(
const $id = `${this.config.basepath}/schema.json`; "/schema.json",
const schemas = Object.fromEntries( permission(DataPermissions.entityRead),
this.em.entities.map((e) => [ describeRoute({
e.name, summary: "Retrieve data schema",
{ tags: ["data"],
$ref: `${this.config.basepath}/schemas/${e.name}`, }),
}, async (c) => {
]), const $id = `${this.config.basepath}/schema.json`;
); const schemas = Object.fromEntries(
return c.json({ this.em.entities.map((e) => [
$schema: "https://json-schema.org/draft/2020-12/schema", e.name,
$id, {
properties: schemas, $ref: `${this.config.basepath}/schemas/${e.name}`,
}); },
}); ]),
);
return c.json({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id,
properties: schemas,
});
},
);
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
tb( describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
}),
jsc(
"param", "param",
Type.Object({ s.object({
entity: Type.String(), entity: entitiesEnum,
context: Type.Optional(StringEnum(["create", "update"])), context: s.string({ enum: ["create", "update"], default: "create" }).optional(),
}), }),
), ),
async (c) => { async (c) => {
@@ -161,30 +191,39 @@ export class DataController extends Controller {
/** /**
* Info endpoints * Info endpoints
*/ */
hono.get("/info/:entity", async (c) => { hono.get(
const { entity } = c.req.param(); "/info/:entity",
if (!this.entityExists(entity)) { permission(DataPermissions.entityRead),
return this.notFound(c); describeRoute({
} summary: "Retrieve entity info",
const _entity = this.em.entity(entity); tags: ["data"],
const fields = _entity.fields.map((f) => f.name); }),
const $rels = (r: any) => jsc("param", s.object({ entity: entitiesEnum })),
r.map((r: any) => ({ async (c) => {
entity: r.other(_entity).entity.name, const { entity } = c.req.param();
ref: r.other(_entity).reference, 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({ return c.json({
name: _entity.name, name: _entity.name,
fields, fields,
relations: { relations: {
all: $rels(this.em.relations.relationsOf(_entity)), all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)), listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)), source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity)), target: $rels(this.em.relations.targetRelationsOf(_entity)),
}, },
}); });
}); },
);
return hono.all("*", (c) => c.notFound()); return hono.all("*", (c) => c.notFound());
} }
@@ -193,10 +232,7 @@ export class DataController extends Controller {
const { permission } = this.middlewares; const { permission } = this.middlewares;
const hono = this.create(); const hono = this.create();
const definedEntities = this.em.entities.map((e) => e.name); const entitiesEnum = this.getEntitiesEnum(this.em);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
.Encode(String);
/** /**
* Function endpoints * Function endpoints
@@ -205,14 +241,19 @@ export class DataController extends Controller {
hono.post( hono.post(
"/:entity/fn/count", "/:entity/fn/count",
permission(DataPermissions.entityRead), 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) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const where = (await c.req.json()) as any; const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).count(where); const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count }); return c.json({ entity, count: result.count });
}, },
@@ -222,14 +263,19 @@ export class DataController extends Controller {
hono.post( hono.post(
"/:entity/fn/exists", "/:entity/fn/exists",
permission(DataPermissions.entityRead), 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) => { async (c) => {
const { entity } = c.req.valid("param"); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const where = c.req.json() as any; const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).exists(where); const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists }); return c.json({ entity, exists: result.exists });
}, },
@@ -239,13 +285,31 @@ export class DataController extends Controller {
* Read endpoints * Read endpoints
*/ */
// read many // read many
const saveRepoQuery = s.partialObject({
...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }),
select: s.array(s.string()),
join: s.array(s.string()),
});
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
...(schemaToSpec(saveRepoQuery, "query").parameters?.filter(
// @ts-ignore
(p) => pick.includes(p.name),
) as any),
];
hono.get( hono.get(
"/:entity", "/:entity",
describeRoute({
summary: "Read many",
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })), jsc("param", s.object({ entity: entitiesEnum })),
tb("query", querySchema), jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -259,17 +323,22 @@ export class DataController extends Controller {
// read one // read one
hono.get( hono.get(
"/:entity/:id", "/:entity/:id",
describeRoute({
summary: "Read one",
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
tb( jsc(
"param", "param",
Type.Object({ s.object({
entity: Type.String(), entity: entitiesEnum,
id: tbNumber, id: s.string(),
}), }),
), ),
tb("query", querySchema), jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
const { entity, id } = c.req.param(); const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -283,18 +352,23 @@ export class DataController extends Controller {
// read many by reference // read many by reference
hono.get( hono.get(
"/:entity/:id/:reference", "/:entity/:id/:reference",
describeRoute({
summary: "Read many by reference",
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
tb( jsc(
"param", "param",
Type.Object({ s.object({
entity: Type.String(), entity: entitiesEnum,
id: tbNumber, id: s.string(),
reference: Type.String(), reference: s.string(),
}), }),
), ),
tb("query", querySchema), jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
const { entity, id, reference } = c.req.param(); const { entity, id, reference } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -309,17 +383,33 @@ export class DataController extends Controller {
); );
// func query // func query
const fnQuery = s.partialObject({
...saveRepoQuery.properties,
with: s.object({}),
});
hono.post( hono.post(
"/:entity/query", "/:entity/query",
describeRoute({
summary: "Query entities",
requestBody: {
content: {
"application/json": {
schema: fnQuery.toJSON(),
example: fnQuery.template({ withOptional: true }),
},
},
},
tags: ["data"],
}),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })), jsc("param", s.object({ entity: entitiesEnum })),
tb("json", querySchema), jsc("json", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); 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); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
@@ -332,11 +422,15 @@ export class DataController extends Controller {
// insert one // insert one
hono.post( hono.post(
"/:entity", "/:entity",
describeRoute({
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate), permission(DataPermissions.entityCreate),
tb("param", Type.Object({ entity: Type.String() })), jsc("param", s.object({ entity: entitiesEnum })),
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
async (c) => { async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -355,13 +449,17 @@ export class DataController extends Controller {
// update many // update many
hono.patch( hono.patch(
"/:entity", "/:entity",
describeRoute({
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String() })), jsc("param", s.object({ entity: entitiesEnum })),
tb( jsc(
"json", "json",
Type.Object({ s.object({
update: Type.Object({}), update: s.object({}),
where: querySchema.properties.where, where: repoQuery.properties.where,
}), }),
), ),
async (c) => { async (c) => {
@@ -382,10 +480,15 @@ export class DataController extends Controller {
// update one // update one
hono.patch( hono.patch(
"/:entity/:id", "/:entity/:id",
describeRoute({
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
jsc("json", s.object({})),
async (c) => { async (c) => {
const { entity, id } = c.req.param(); const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -399,10 +502,14 @@ export class DataController extends Controller {
// delete one // delete one
hono.delete( hono.delete(
"/:entity/:id", "/:entity/:id",
describeRoute({
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
async (c) => { async (c) => {
const { entity, id } = c.req.param(); const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
@@ -415,15 +522,19 @@ export class DataController extends Controller {
// delete many // delete many
hono.delete( hono.delete(
"/:entity", "/:entity",
describeRoute({
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String() })), jsc("param", s.object({ entity: entitiesEnum })),
tb("json", querySchema.properties.where), jsc("json", repoQuery.properties.where),
async (c) => { async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return this.notFound(c); return this.notFound(c);
} }
const where = c.req.valid("json") as RepoQuery["where"]; const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where); const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));

View File

@@ -6,7 +6,7 @@ import type { Entity, EntityData, EntityManager } from "../entities";
import { InvalidSearchParamsException } from "../errors"; import { InvalidSearchParamsException } from "../errors";
import { MutatorEvents } from "../events"; import { MutatorEvents } from "../events";
import { RelationMutator } from "../relations"; import { RelationMutator } from "../relations";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/query";
type MutatorQB = type MutatorQB =
| InsertQueryBuilder<any, any, any> | InsertQueryBuilder<any, any, any>

View File

@@ -20,6 +20,7 @@ export class JoinBuilder {
// @todo: returns multiple on manytomany (edit: so?) // @todo: returns multiple on manytomany (edit: so?)
static getJoinedEntityNames(em: EntityManager<any>, entity: Entity, joins: string[]): string[] { static getJoinedEntityNames(em: EntityManager<any>, entity: Entity, joins: string[]): string[] {
console.log("join", joins);
return joins.flatMap((join) => { return joins.flatMap((join) => {
const relation = em.relationOf(entity.name, join); const relation = em.relationOf(entity.name, join);
if (!relation) { if (!relation) {

View File

@@ -2,10 +2,9 @@ import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { $console } from "core"; import { $console } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es";
import { InvalidSearchParamsException } from "../../errors"; import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents, RepositoryEvents } from "../../events"; import { MutatorEvents, RepositoryEvents } from "../../events";
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl"; import { type RepoQuery, getRepoQueryTemplate } from "data/server/query";
import { import {
type Entity, type Entity,
type EntityData, 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; const entity = this.entity;
// @todo: if not cloned deep, it will keep references and error if multiple requests come in // @todo: if not cloned deep, it will keep references and error if multiple requests come in
const validated = { const validated = {
...cloneDeep(defaultQuerySchema), ...structuredClone(getRepoQueryTemplate()),
sort: entity.getDefaultSort(), sort: entity.getDefaultSort(),
select: entity.getSelect(), select: entity.getSelect(),
}; } satisfies Required<RepoQuery>;
if (!options) return validated; 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)) { if (!validated.select.includes(options.sort.by)) {
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`); throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
} }
if (!["asc", "desc"].includes(options.sort.dir)) { if (!["asc", "desc"].includes(options.sort.dir!)) {
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`); throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
} }
this.checkIndex(entity.name, options.sort.by, "sort"); this.checkIndex(entity.name, options.sort.by, "sort");
validated.sort = options.sort; validated.sort = {
dir: "asc",
...options.sort,
};
} }
if (options.select && options.select.length > 0) { 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 entity = this.entity;
const options = this.getValidOptions({ where }); 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); let qb = this.conn.selectFrom(entity.name).select(selector);
// add mandatory where // add mandatory where
qb = WhereBuilder.addClause(qb, options.where).limit(1); qb = WhereBuilder.addClause(qb, options.where!).limit(1);
const { result, ...compiled } = await this.executeQb(qb); const { result, ...compiled } = await this.executeQb(qb);

View File

@@ -90,6 +90,7 @@ const expressions = [
export type WhereQuery = FilterQuery<typeof expressions>; export type WhereQuery = FilterQuery<typeof expressions>;
const validator = makeValidator(expressions); const validator = makeValidator(expressions);
export const expressionKeys = validator.expressionKeys;
export class WhereBuilder { export class WhereBuilder {
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) { static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {

View File

@@ -1,7 +1,7 @@
import { $console, type PrimaryFieldType } from "core"; import { $console, type PrimaryFieldType } from "core";
import { Event, InvalidEventReturn } from "core/events"; import { Event, InvalidEventReturn } from "core/events";
import type { Entity, EntityData } from "../entities"; import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "data/server/query";
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> { export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
static override slug = "mutator-insert-before"; static override slug = "mutator-insert-before";

View File

@@ -10,10 +10,11 @@ export * from "./connection";
export { export {
type RepoQuery, type RepoQuery,
type RepoQueryIn, type RepoQueryIn,
defaultQuerySchema, getRepoQueryTemplate,
querySchema, repoQuery,
whereSchema, } from "./server/query";
} from "./server/data-query-impl";
export type { WhereQuery } from "./entities/query/WhereBuilder";
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner"; export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";

View File

@@ -6,7 +6,7 @@ import {
type MutationInstructionResponse, type MutationInstructionResponse,
RelationHelper, RelationHelper,
} from "../relations"; } from "../relations";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types"; import type { RelationType } from "./relation-types";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox; const { Type } = tbbox;

View File

@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely"; import type { ExpressionBuilder } from "kysely";
import { Entity, type EntityManager } from "../entities"; import { Entity, type EntityManager } from "../entities";
import { type Field, PrimaryField } from "../fields"; import { type Field, PrimaryField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/query";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField } from "./RelationField"; import { RelationField } from "./RelationField";

View File

@@ -3,7 +3,7 @@ import { snakeToPascalWithSpaces } from "core/utils";
import type { Static } from "core/utils"; import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely"; import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/query";
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField"; import { RelationField, type RelationFieldBaseConfig } from "./RelationField";

View File

@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
import type { ExpressionBuilder } from "kysely"; import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import { NumberField, TextField } from "../fields"; import { NumberField, TextField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/query";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { type RelationType, RelationTypes } from "./relation-types"; import { type RelationType, RelationTypes } from "./relation-types";

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;

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);
}
});
});

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>;

View File

@@ -6,12 +6,7 @@ import { DataPermissions } from "data";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import type { AppMedia } from "../AppMedia"; import type { AppMedia } from "../AppMedia";
import { MediaField } from "../MediaField"; import { MediaField } from "../MediaField";
import * as tbbox from "@sinclair/typebox"; import { jsc, s, describeRoute } from "core/object/schema";
const { Type } = tbbox;
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class MediaController extends Controller { export class MediaController extends Controller {
constructor(private readonly media: AppMedia) { constructor(private readonly media: AppMedia) {
@@ -31,90 +26,165 @@ export class MediaController extends Controller {
// @todo: implement range requests // @todo: implement range requests
const { auth, permission } = this.middlewares; const { auth, permission } = this.middlewares;
const hono = this.create().use(auth()); const hono = this.create().use(auth());
const entitiesEnum = this.getEntitiesEnum(this.media.em);
// get files list (temporary) // get files list (temporary)
hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { hono.get(
const files = await this.getStorageAdapter().listObjects(); "/files",
return c.json(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 // get file by name
// @todo: implement more aggressive cache? (configurable) // @todo: implement more aggressive cache? (configurable)
hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { hono.get(
const { filename } = c.req.param(); "/file/:filename",
if (!filename) { describeRoute({
throw new Error("No file name provided"); 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 })); await this.getStorage().emgr.emit(
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers); new StorageEvents.FileAccessEvent({ name: filename }),
);
const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable"); headers.set("Cache-Control", "public, max-age=31536000, immutable");
return new Response(res.body, { return new Response(res.body, {
status: res.status, status: res.status,
statusText: res.statusText, statusText: res.statusText,
headers, headers,
}); });
}); },
);
// delete a file by name // delete a file by name
hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { hono.delete(
const { filename } = c.req.param(); "/file/:filename",
if (!filename) { describeRoute({
throw new Error("No file name provided"); summary: "Delete a file by name",
} tags: ["media"],
await this.getStorage().deleteFile(filename); }),
permission(MediaPermissions.deleteFile),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.getStorage().deleteFile(filename);
return c.json({ message: "File deleted" }); return c.json({ message: "File deleted" });
}); },
);
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
if (isDebug()) { if (isDebug()) {
hono.post("/inspect", async (c) => { hono.post(
const file = await getFileFromContext(c); "/inspect",
return c.json({ describeRoute({
type: file?.type, summary: "Inspect a file",
name: file?.name, tags: ["media"],
size: file?.size, }),
}); async (c) => {
}); const file = await getFileFromContext(c);
return c.json({
type: file?.type,
name: file?.name,
size: file?.size,
});
},
);
} }
const requestBody = {
content: {
"multipart/form-data": {
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
},
},
required: ["file"],
},
},
"application/octet-stream": {
schema: {
type: "string",
format: "binary",
},
},
},
} as any;
// upload file // upload file
// @todo: add required type for "upload endpoints" // @todo: add required type for "upload endpoints"
hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { hono.post(
const reqname = c.req.param("filename"); "/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); const body = await getFileFromContext(c);
if (!body) { if (!body) {
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
} }
if (body.size > maxSize) { if (body.size > maxSize) {
return c.json( return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` }, { error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE, HttpStatus.PAYLOAD_TOO_LARGE,
); );
} }
const filename = reqname ?? getRandomizedFilename(body as File); const filename = reqname ?? getRandomizedFilename(body as File);
const res = await this.getStorage().uploadFile(body, filename); const res = await this.getStorage().uploadFile(body, filename);
return c.json(res, HttpStatus.CREATED); return c.json(res, HttpStatus.CREATED);
}); },
);
// add upload file to entity // add upload file to entity
// @todo: add required type for "upload endpoints" // @todo: add required type for "upload endpoints"
hono.post( hono.post(
"/entity/:entity/:id/:field", "/entity/:entity/:id/:field",
tb( describeRoute({
"query", summary: "Add a file to an entity",
Type.Object({ tags: ["media"],
overwrite: Type.Optional(booleanLike), 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]), permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
async (c) => { async (c) => {
const entity_name = c.req.param("entity"); const entity_name = c.req.param("entity");

View File

@@ -1,9 +1,11 @@
import type { App } from "App"; 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 * as middlewares from "modules/middlewares";
import type { SafeUser } from "auth"; import type { SafeUser } from "auth";
import type { EntityManager } from "data";
import { s } from "core/object/schema";
export type ServerEnv = { export type ServerEnv = Env & {
Variables: { Variables: {
app: App; app: App;
// to prevent resolving auth multiple times // to prevent resolving auth multiple times
@@ -46,4 +48,9 @@ export class Controller {
return c.notFound(); return c.notFound();
} }
protected getEntitiesEnum(em: EntityManager<any>) {
const entities = em.entities.map((e) => e.name);
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
}
} }

View File

@@ -86,7 +86,7 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { const obj = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: this.withAdminBasePath(authRoutes.logout), logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath, admin_basepath: this.options.adminBasepath,
}; };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);

View File

@@ -13,9 +13,8 @@ import {
import { getRuntimeKey } from "core/utils"; import { getRuntimeKey } from "core/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as tbbox from "@sinclair/typebox"; import { openAPISpecs } from "jsonv-ts/hono";
const { Type } = tbbox; import { swaggerUI } from "@hono/swagger-ui";
import { import {
MODULE_NAMES, MODULE_NAMES,
type ModuleConfigs, type ModuleConfigs,
@@ -24,12 +23,8 @@ import {
getDefaultConfig, getDefaultConfig,
} from "modules/ModuleManager"; } from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import { generateOpenAPI } from "modules/server/openapi"; import { jsc, s, describeRoute } from "core/object/schema";
import { getVersion } from "core/env";
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = { export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true; success: true;
module: Key; module: Key;
@@ -61,20 +56,27 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead)); hono.use(permission(SystemPermissions.configRead));
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => { hono.get(
// @ts-expect-error "fetch" is private "/raw",
return c.json(await this.app.modules.fetch()); 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( hono.get(
"/:module?", "/:module?",
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })), describeRoute({
tb( summary: "Get the config for a module",
"query", tags: ["system"],
Type.Object({ }),
secrets: Type.Optional(booleanLike), jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
}), jsc("query", s.object({ secrets: s.boolean().optional() })),
),
async (c) => { async (c) => {
// @todo: allow secrets if authenticated user is admin // @todo: allow secrets if authenticated user is admin
const { secrets } = c.req.valid("query"); const { secrets } = c.req.valid("query");
@@ -119,12 +121,7 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/set/:module", "/set/:module",
permission(SystemPermissions.configWrite), permission(SystemPermissions.configWrite),
tb( jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
"query",
Type.Object({
force: Type.Optional(booleanLike),
}),
),
async (c) => { async (c) => {
const module = c.req.param("module") as any; const module = c.req.param("module") as any;
const { force } = c.req.valid("query"); const { force } = c.req.valid("query");
@@ -230,13 +227,17 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/schema/:module?", "/schema/:module?",
describeRoute({
summary: "Get the schema for a module",
tags: ["system"],
}),
permission(SystemPermissions.schemaRead), permission(SystemPermissions.schemaRead),
tb( jsc(
"query", "query",
Type.Object({ s.partialObject({
config: Type.Optional(booleanLike), config: s.boolean(),
secrets: Type.Optional(booleanLike), secrets: s.boolean(),
fresh: Type.Optional(booleanLike), fresh: s.boolean(),
}), }),
), ),
async (c) => { async (c) => {
@@ -274,13 +275,11 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/build", "/build",
tb( describeRoute({
"query", summary: "Build the app",
Type.Object({ tags: ["system"],
sync: Type.Optional(booleanLike), }),
fetch: Type.Optional(booleanLike), jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
}),
),
async (c) => { async (c) => {
const options = c.req.valid("query") as Record<string, boolean>; const options = c.req.valid("query") as Record<string, boolean>;
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
@@ -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) => hono.get(
c.json({ "/info",
version: c.get("app")?.version(), describeRoute({
runtime: getRuntimeKey(), summary: "Get the server info",
timezone: { tags: ["system"],
name: getTimezone(), }),
offset: getTimezoneOffset(), (c) =>
local: datetimeStringLocal(), c.json({
utc: datetimeStringUTC(), version: c.get("app")?.version(),
runtime: getRuntimeKey(),
timezone: {
name: getTimezone(),
offset: getTimezoneOffset(),
local: datetimeStringLocal(),
utc: datetimeStringUTC(),
},
}),
);
hono.get(
"/openapi.json",
openAPISpecs(this.ctx.server, {
info: {
title: "bknd API",
version: getVersion(),
}, },
}), }),
); );
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
hono.get("/openapi.json", async (c) => {
const config = getDefaultConfig();
return c.json(generateOpenAPI(config));
});
return hono.all("*", (c) => c.notFound()); return hono.all("*", (c) => c.notFound());
} }

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 { 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"; import type { AdminBkndWindowContext } from "modules/server/AdminController";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ export type BkndClientContext = {
baseUrl: undefined, baseUrl: string;
} as any); api: Api;
authState?: Partial<AuthState>;
};
const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = { export type ClientProviderProps = {
children?: ReactNode; children?: ReactNode;
} & ( baseUrl?: string;
| { baseUrl?: string; user?: TApiUser | null | undefined } } & ApiOptions;
| {
api: Api;
}
);
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => { export const ClientProvider = ({
let api: Api; 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) { if (winCtx) {
api = props.api; user = winCtx.user;
} 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 (!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 ( return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
{children} {children}
</ClientContext.Provider> </ClientContext.Provider>
); );
@@ -61,12 +73,16 @@ export const useApi = (host?: ApiOptions["host"]): Api => {
return context.api; return context.api;
}; };
export const useClientContext = () => {
return useContext(ClientContext);
};
/** /**
* @deprecated use useApi().baseUrl instead * @deprecated use useApi().baseUrl instead
*/ */
export const useBaseUrl = () => { export const useBaseUrl = () => {
const context = useContext(ClientContext); const context = useClientContext();
return context.baseUrl; return context?.baseUrl;
}; };
export function useBkndWindowContext(): AdminBkndWindowContext { export function useBkndWindowContext(): AdminBkndWindowContext {

View File

@@ -1,7 +1,7 @@
import type { AuthState } from "Api"; import type { AuthState } from "Api";
import type { AuthResponse } from "auth"; import type { AuthResponse } from "auth";
import { useState } from "react";
import { useApi, useInvalidate } from "ui/client"; import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
type LoginData = { type LoginData = {
email: string; email: string;
@@ -10,7 +10,7 @@ type LoginData = {
}; };
type UseAuth = { type UseAuth = {
data: AuthState | undefined; data: Partial<AuthState> | undefined;
user: AuthState["user"] | undefined; user: AuthState["user"] | undefined;
token: AuthState["token"] | undefined; token: AuthState["token"] | undefined;
verified: boolean; verified: boolean;
@@ -24,46 +24,36 @@ type UseAuth = {
export const useAuth = (options?: { baseUrl?: string }): UseAuth => { export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
const api = useApi(options?.baseUrl); const api = useApi(options?.baseUrl);
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const authState = api.getAuthState(); const { authState } = useClientContext();
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
const verified = authState?.verified ?? false; const verified = authState?.verified ?? false;
function updateAuthState() {
setAuthData(api.getAuthState());
}
async function login(input: LoginData) { async function login(input: LoginData) {
const res = await api.auth.loginWithPassword(input); const res = await api.auth.login("password", input);
updateAuthState();
return res.data; return res.data;
} }
async function register(input: LoginData) { async function register(input: LoginData) {
const res = await api.auth.registerWithPassword(input); const res = await api.auth.register("password", input);
updateAuthState();
return res.data; return res.data;
} }
function setToken(token: string) { function setToken(token: string) {
api.updateToken(token); api.updateToken(token);
updateAuthState();
} }
async function logout() { async function logout() {
await api.updateToken(undefined); api.updateToken(undefined);
setAuthData(undefined);
invalidate(); invalidate();
} }
async function verify() { async function verify() {
await api.verifyAuth(); await api.verifyAuth();
updateAuthState();
} }
return { return {
data: authData, data: authState,
user: authData?.user, user: authState?.user,
token: authData?.token, token: authState?.token,
verified, verified,
login, login,
register, register,

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 { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html"; import { html } from "@codemirror/lang-html";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
@@ -43,7 +47,7 @@ export default function CodeEditor({
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
editable={editable} editable={editable}
basicSetup={_basicSetup} basicSetup={_basicSetup}
extensions={extensions} extensions={[...extensions, EditorView.lineWrapping]}
{...props} {...props}
/> />
); );

View File

@@ -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,
};

View File

@@ -19,6 +19,7 @@ export type DropdownItem =
onClick?: () => void; onClick?: () => void;
destructive?: boolean; destructive?: boolean;
disabled?: boolean; disabled?: boolean;
title?: string;
[key: string]: any; [key: string]: any;
}; };
@@ -142,6 +143,7 @@ export function Dropdown({
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white", item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
)} )}
onClick={onClick} onClick={onClick}
title={item.title}
> >
{space_for_icon && ( {space_for_icon && (
<div className="size-[16px] text-left mr-1.5 opacity-80"> <div className="size-[16px] text-left mr-1.5 opacity-80">

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;
}

View File

@@ -1,46 +1,42 @@
import { import { decodeSearch, encodeSearch, parseDecode } from "core/utils";
type Static,
type StaticDecode,
type TSchema,
decodeSearch,
encodeSearch,
parseDecode,
} from "core/utils";
import { isEqual, transform } from "lodash-es"; import { isEqual, transform } from "lodash-es";
import { useLocation, useSearch as useWouterSearch } from "wouter"; import { useLocation, useSearch as useWouterSearch } from "wouter";
import { type s, parse } from "core/object/schema";
// @todo: migrate to Typebox // @todo: migrate to Typebox
export function useSearch<Schema extends TSchema = TSchema>( export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
schema: Schema, schema: Schema,
defaultValue?: Partial<StaticDecode<Schema>>, defaultValue?: Partial<s.StaticCoerced<Schema>>,
) { ) {
const searchString = useWouterSearch(); const searchString = useWouterSearch();
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {}; const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {});
const value = parse(schema, initial, {
if (searchString.length > 0) { withDefaults: true,
value = parseDecode(schema, decodeSearch(searchString)); clone: true,
//console.log("search:decode", value); }) as s.StaticCoerced<Schema>;
}
// @todo: add option to set multiple keys at once // @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); //console.log("set", key, value);
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value }); const update = parse(schema, { ...decodeSearch(searchString), [key]: value });
const search = transform( const search = transform(
update as any, update as any,
(result, value, key) => { (result, value, key) => {
if (defaultValue && isEqual(value, defaultValue[key])) return; if (defaultValue && isEqual(value, defaultValue[key])) return;
result[key] = value; result[key] = value;
}, },
{} as Static<Schema>, {} as s.StaticCoerced<Schema>,
); );
const encoded = encodeSearch(search, { encode: false }); const encoded = encodeSearch(search, { encode: false });
navigate(location + (encoded.length > 0 ? "?" + encoded : "")); navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
} }
return { return {
value: value as Required<StaticDecode<Schema>>, value: value as Required<s.StaticCoerced<Schema>>,
set, set,
}; };
} }

View File

@@ -13,6 +13,7 @@ import {
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { appShellStore } from "ui/store"; import { appShellStore } from "ui/store";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
@@ -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 = ({ export const SectionHeaderAccordionItem = ({
title, title,
open, open,
@@ -383,14 +393,7 @@ export const SectionHeaderAccordionItem = ({
ActiveIcon = IconChevronUp, ActiveIcon = IconChevronUp,
children, children,
renderHeaderRight, renderHeaderRight,
}: { }: SectionHeaderAccordionItemProps) => (
title: string;
open: boolean;
toggle: () => void;
ActiveIcon?: any;
children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
}) => (
<div <div
style={{ minHeight: 49 }} style={{ minHeight: 49 }}
className={twMerge( className={twMerge(
@@ -422,6 +425,19 @@ export const SectionHeaderAccordionItem = ({
</div> </div>
); );
export const RouteAwareSectionHeaderAccordionItem = ({
routePattern,
identifier,
...props
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
// it's optional because it could be provided using the context
routePattern?: string;
identifier: string;
}) => {
const { active, toggle } = useRoutePathState(routePattern, identifier);
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
};
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => ( export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
<hr {...props} className={twMerge("border-muted my-3", className)} /> <hr {...props} className={twMerge("border-muted my-3", className)} />
); );

View File

@@ -1,5 +1,5 @@
import { SegmentedControl, Tooltip } from "@mantine/core"; import { SegmentedControl, Tooltip } from "@mantine/core";
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react"; import { IconApi, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import { import {
TbDatabase, TbDatabase,
TbFingerprint, TbFingerprint,
@@ -159,6 +159,11 @@ function UserMenu() {
const items: DropdownItem[] = [ const items: DropdownItem[] = [
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }, { label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings },
{
label: "OpenAPI",
onClick: () => window.open("/api/system/swagger", "_blank"),
icon: IconApi,
},
]; ];
if (config.auth.enabled) { if (config.auth.enabled) {
@@ -166,7 +171,8 @@ function UserMenu() {
items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
} else { } else {
items.push({ items.push({
label: `Logout ${auth.user.email}`, label: "Logout",
title: `Logout ${auth.user.email}`,
onClick: handleLogout, onClick: handleLogout,
icon: IconKeyOff, icon: IconKeyOff,
}); });

View File

@@ -33,6 +33,8 @@ import {
} from "ui/components/form/json-schema-form"; } from "ui/components/form/json-schema-form";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell"; 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) { export function AuthStrategiesList(props) {
useBrowserTitle(["Auth", "Strategies"]); useBrowserTitle(["Auth", "Strategies"]);
@@ -104,7 +106,7 @@ function AuthStrategiesListInternal() {
<p className="opacity-70"> <p className="opacity-70">
Allow users to sign in or sign up using different strategies. Allow users to sign in or sign up using different strategies.
</p> </p>
<div className="flex flex-col gap-2 max-w-4xl"> <CollapsibleList.Root>
<Strategy type="password" name="password" /> <Strategy type="password" name="password" />
<Strategy type="oauth" name="google" /> <Strategy type="oauth" name="google" />
<Strategy type="oauth" name="github" /> <Strategy type="oauth" name="github" />
@@ -113,7 +115,7 @@ function AuthStrategiesListInternal() {
<Strategy type="oauth" name="instagram" unavailable /> <Strategy type="oauth" name="instagram" unavailable />
<Strategy type="oauth" name="apple" unavailable /> <Strategy type="oauth" name="apple" unavailable />
<Strategy type="oauth" name="discord" unavailable /> <Strategy type="oauth" name="discord" unavailable />
</div> </CollapsibleList.Root>
</div> </div>
<FormDebug /> <FormDebug />
</AppShell.Scrollable> </AppShell.Scrollable>
@@ -138,47 +140,40 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
]), ]),
); );
const schema = schemas[type]; const schema = schemas[type];
const [open, setOpen] = useState(false);
const { active, toggle } = useRoutePathState("/strategies/:strategy?", name);
if (!schema) return null; if (!schema) return null;
return ( return (
<FormContextOverride schema={schema} prefix={name}> <FormContextOverride schema={schema} prefix={name}>
<div <CollapsibleList.Item
className={twMerge( hasError={errors.length > 0}
"flex flex-col border border-muted rounded bg-background", className={
unavailable && "opacity-20 pointer-events-none cursor-not-allowed", unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined
errors.length > 0 && "border-red-500", }
)}
> >
<div className="flex flex-row justify-between p-3 gap-3 items-center"> <CollapsibleList.Preview
<div className="flex flex-row items-center p-2 bg-primary/5 rounded"> left={<StrategyIcon type={type} provider={name} />}
<StrategyIcon type={type} provider={name} /> right={
</div> <>
<div className="font-mono flex-grow flex flex-row gap-3"> <StrategyToggle type={type} />
<span className="leading-none">{autoFormatString(name)}</span> <IconButton
</div> Icon={TbSettings}
<div className="flex flex-row gap-4 items-center"> size="lg"
<StrategyToggle type={type} /> iconProps={{ strokeWidth: 1.5 }}
<IconButton variant={active ? "primary" : "ghost"}
Icon={TbSettings} onClick={() => toggle(!active)}
size="lg" />
iconProps={{ strokeWidth: 1.5 }} </>
variant={open ? "primary" : "ghost"} }
onClick={() => setOpen((o) => !o)} >
/> <span className="leading-none">{autoFormatString(name)}</span>
</div> </CollapsibleList.Preview>
</div> <CollapsibleList.Detail open={active}>
{open && ( <StrategyForm type={type} name={name} />
<div </CollapsibleList.Detail>
className={twMerge( </CollapsibleList.Item>
"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>
</FormContextOverride> </FormContextOverride>
); );
}; };

View File

@@ -14,7 +14,7 @@ export default function AuthRoutes() {
<Route path="/users" component={AuthUsersList} /> <Route path="/users" component={AuthUsersList} />
<Route path="/roles" component={AuthRolesList} /> <Route path="/roles" component={AuthRolesList} />
<Route path="/roles/edit/:role" component={AuthRolesEdit} /> <Route path="/roles/edit/:role" component={AuthRolesEdit} />
<Route path="/strategies" component={AuthStrategiesList} /> <Route path="/strategies/:strategy?" component={AuthStrategiesList} />
<Route path="/settings" component={AuthSettings} /> <Route path="/settings" component={AuthSettings} />
</AuthRoot> </AuthRoot>
); );

View File

@@ -11,8 +11,7 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import { EntityForm } from "ui/modules/data/components/EntityForm"; import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import * as tbbox from "@sinclair/typebox"; import { s } from "core/object/schema";
const { Type } = tbbox;
export function DataEntityCreate({ params }) { export function DataEntityCreate({ params }) {
const { $data } = useBkndData(); const { $data } = useBkndData();
@@ -29,7 +28,7 @@ export function DataEntityCreate({ params }) {
const $q = useEntityMutate(entity.name); const $q = useEntityMutate(entity.name);
// @todo: use entity schema for prefilling // @todo: use entity schema for prefilling
const search = useSearch(Type.Object({}), {}); const search = useSearch(s.object({}), {});
function goBack() { function goBack() {
window.history.go(-1); window.history.go(-1);

View File

@@ -1,4 +1,4 @@
import { type Entity, querySchema } from "data"; import { type Entity, repoQuery } from "data";
import { Fragment } from "react"; import { Fragment } from "react";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client"; 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 { routes, useNavigate } from "ui/lib/routes";
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal"; import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import * as tbbox from "@sinclair/typebox"; import { s } from "core/object/schema";
const { Type } = tbbox; import { pick } from "core/utils/objects";
// @todo: migrate to Typebox const searchSchema = s.partialObject({
const searchSchema = Type.Composite( ...pick(repoQuery.properties, ["select", "where", "sort"]),
[ page: s.number({ default: 1 }).optional(),
Type.Pick(querySchema, ["select", "where", "sort"]), perPage: s.number({ default: 10 }).optional(),
Type.Object({ });
page: Type.Optional(Type.Number({ default: 1 })),
perPage: Type.Optional(Type.Number({ default: 10 })),
}),
],
{ additionalProperties: false },
);
const PER_PAGE_OPTIONS = [5, 10, 25]; const PER_PAGE_OPTIONS = [5, 10, 25];
@@ -74,8 +68,6 @@ export function DataEntityList({ params }) {
const sort = search.value.sort!; const sort = search.value.sort!;
const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" }; const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" };
// // @ts-expect-error - somehow all search keys are optional
console.log("new sort", newSort);
search.set("sort", newSort as any); search.set("sort", newSort as any);
} }

View File

@@ -30,14 +30,10 @@ import { routes, useNavigate } from "ui/lib/routes";
import { fieldSpecs } from "ui/modules/data/components/fields-specs"; import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { extractSchema } from "../settings/utils/schema"; import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form"; import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
export function DataSchemaEntity({ params }) { export function DataSchemaEntity({ params }) {
const { $data } = useBkndData(); const { $data } = useBkndData();
const [value, setValue] = useState("fields");
function toggle(value) {
return () => setValue(value);
}
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const entity = $data.entity(params.entity as string)!; const entity = $data.entity(params.entity as string)!;
@@ -46,7 +42,7 @@ export function DataSchemaEntity({ params }) {
} }
return ( return (
<> <RoutePathStateProvider path={`/entity/${entity.name}/:setting?`} defaultIdentifier="fields">
<AppShell.SectionHeader <AppShell.SectionHeader
right={ right={
<> <>
@@ -109,13 +105,12 @@ export function DataSchemaEntity({ params }) {
</div> </div>
</AppShell.SectionHeader> </AppShell.SectionHeader>
<div className="flex flex-col h-full" key={entity.name}> <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")} /> <BasicSettings entity={entity} />
<AppShell.SectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
identifier="relations"
title="Relations" title="Relations"
open={value === "3"}
toggle={toggle("3")}
ActiveIcon={IconCirclesRelation} ActiveIcon={IconCirclesRelation}
> >
<Empty <Empty
@@ -127,11 +122,10 @@ export function DataSchemaEntity({ params }) {
navigate(routes.settings.path(["data", "relations"]), { absolute: true }), navigate(routes.settings.path(["data", "relations"]), { absolute: true }),
}} }}
/> />
</AppShell.SectionHeaderAccordionItem> </AppShell.RouteAwareSectionHeaderAccordionItem>
<AppShell.SectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
identifier="indices"
title="Indices" title="Indices"
open={value === "4"}
toggle={toggle("4")}
ActiveIcon={IconBolt} ActiveIcon={IconBolt}
> >
<Empty <Empty
@@ -145,17 +139,13 @@ export function DataSchemaEntity({ params }) {
}), }),
}} }}
/> />
</AppShell.SectionHeaderAccordionItem> </AppShell.RouteAwareSectionHeaderAccordionItem>
</div> </div>
</> </RoutePathStateProvider>
); );
} }
const Fields = ({ const Fields = ({ entity }: { entity: Entity }) => {
entity,
open,
toggle,
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0); const [updates, setUpdates] = useState(0);
const { actions, $data } = useBkndData(); const { actions, $data } = useBkndData();
@@ -174,10 +164,9 @@ const Fields = ({
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any; const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
return ( return (
<AppShell.SectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
identifier="fields"
title="Fields" title="Fields"
open={open}
toggle={toggle}
ActiveIcon={IconAlignJustified} ActiveIcon={IconAlignJustified}
renderHeaderRight={({ open }) => renderHeaderRight={({ open }) =>
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" /> <div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
)} )}
<EntityFieldsForm <EntityFieldsForm
routePattern={`/entity/${entity.name}/fields/:sub?`}
fields={initialFields} fields={initialFields}
ref={ref} ref={ref}
key={String(updates)} key={String(updates)}
@@ -237,15 +227,11 @@ const Fields = ({
</div> </div>
)} )}
</div> </div>
</AppShell.SectionHeaderAccordionItem> </AppShell.RouteAwareSectionHeaderAccordionItem>
); );
}; };
const BasicSettings = ({ const BasicSettings = ({ entity }: { entity: Entity }) => {
entity,
open,
toggle,
}: { entity: Entity; open: boolean; toggle: () => void }) => {
const d = useBkndData(); const d = useBkndData();
const config = d.entities?.[entity.name]?.config; const config = d.entities?.[entity.name]?.config;
const formRef = useRef<JsonSchemaFormRef>(null); const formRef = useRef<JsonSchemaFormRef>(null);
@@ -271,10 +257,9 @@ const BasicSettings = ({
} }
return ( return (
<AppShell.SectionHeaderAccordionItem <AppShell.RouteAwareSectionHeaderAccordionItem
identifier="settings"
title="Settings" title="Settings"
open={open}
toggle={toggle}
ActiveIcon={IconSettings} ActiveIcon={IconSettings}
renderHeaderRight={({ open }) => renderHeaderRight={({ open }) =>
open ? ( open ? (
@@ -293,6 +278,6 @@ const BasicSettings = ({
className="legacy hide-required-mark fieldset-alternative mute-root" className="legacy hide-required-mark fieldset-alternative mute-root"
/> />
</div> </div>
</AppShell.SectionHeaderAccordionItem> </AppShell.RouteAwareSectionHeaderAccordionItem>
); );
}; };

View File

@@ -27,6 +27,7 @@ import { Popover } from "ui/components/overlay/Popover";
import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs"; import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs";
import { dataFieldsUiSchema } from "../../settings/routes/data.settings"; import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
import * as tbbox from "@sinclair/typebox"; import * as tbbox from "@sinclair/typebox";
import { useRoutePathState } from "ui/hooks/use-route-path-state";
const { Type } = tbbox; const { Type } = tbbox;
const fieldsSchemaObject = originalFieldsSchemaObject; const fieldsSchemaObject = originalFieldsSchemaObject;
@@ -63,6 +64,7 @@ export type EntityFieldsFormProps = {
onChange?: (formData: TAppDataEntityFields) => void; onChange?: (formData: TAppDataEntityFields) => void;
sortable?: boolean; sortable?: boolean;
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[]; additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
routePattern?: string;
}; };
export type EntityFieldsFormRef = { export type EntityFieldsFormRef = {
@@ -74,7 +76,10 @@ export type EntityFieldsFormRef = {
}; };
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>( 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]) => ({ const entityFields = Object.entries(_fields).map(([name, field]) => ({
name, name,
field, field,
@@ -166,6 +171,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
errors={errors} errors={errors}
remove={remove} remove={remove}
dnd={dnd} dnd={dnd}
routePattern={routePattern}
/> />
)} )}
/> />
@@ -179,6 +185,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
form={formProps} form={formProps}
errors={errors} errors={errors}
remove={remove} remove={remove}
routePattern={routePattern}
/> />
))} ))}
</div> </div>
@@ -273,6 +280,7 @@ function EntityField({
remove, remove,
errors, errors,
dnd, dnd,
routePattern,
}: { }: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">; field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
index: number; index: number;
@@ -283,11 +291,12 @@ function EntityField({
remove: (index: number) => void; remove: (index: number) => void;
errors: any; errors: any;
dnd?: SortableItemProps; dnd?: SortableItemProps;
routePattern?: string;
}) { }) {
const [opened, handlers] = useDisclosure(false);
const prefix = `fields.${index}.field` as const; const prefix = `fields.${index}.field` as const;
const type = field.field.type; const type = field.field.type;
const name = watch(`fields.${index}.name`); const name = watch(`fields.${index}.name`);
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
const fieldSpec = fieldSpecs.find((s) => s.type === type)!; const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
const specificData = omit(field.field.config, commonProps); const specificData = omit(field.field.config, commonProps);
const disabled = fieldSpec.disabled || []; const disabled = fieldSpec.disabled || [];
@@ -300,9 +309,11 @@ function EntityField({
return () => { return () => {
if (name.length === 0) { if (name.length === 0) {
remove(index); 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`)); //console.log("register", register(`${prefix}.config.required`));
@@ -313,7 +324,7 @@ function EntityField({
key={field.id} key={field.id}
className={twMerge( className={twMerge(
"flex flex-col border border-muted rounded bg-background mb-2", "flex flex-col border border-muted rounded bg-background mb-2",
opened && "mb-6", active && "mb-6",
hasErrors && "border-red-500 ", hasErrors && "border-red-500 ",
)} )}
{...dndProps} {...dndProps}
@@ -371,13 +382,13 @@ function EntityField({
Icon={TbSettings} Icon={TbSettings}
disabled={is_primary} disabled={is_primary}
iconProps={{ strokeWidth: 1.5 }} iconProps={{ strokeWidth: 1.5 }}
onClick={handlers.toggle} onClick={() => toggle()}
variant={opened ? "primary" : "ghost"} variant={active ? "primary" : "ghost"}
/> />
</div> </div>
</div> </div>
</div> </div>
{opened && ( {active && (
<div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50"> <div className="flex flex-col border-t border-t-muted px-3 py-2 bg-lightest/50">
{/*<pre>{JSON.stringify(field, null, 2)}</pre>*/} {/*<pre>{JSON.stringify(field, null, 2)}</pre>*/}
<Tabs defaultValue="general"> <Tabs defaultValue="general">

View File

@@ -17,7 +17,7 @@ export default function DataRoutes() {
<Route path="/schema" nest> <Route path="/schema" nest>
<Route path="/" component={DataSchemaIndex} /> <Route path="/" component={DataSchemaIndex} />
<Route path="/entity/:entity" component={DataSchemaEntity} /> <Route path="/entity/:entity/:setting?/:sub?" component={DataSchemaEntity} />
</Route> </Route>
</Switch> </Switch>
</DataRoot> </DataRoot>

View File

@@ -49,6 +49,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
.cm-editor { .cm-editor {
display: flex; display: flex;
flex: 1; flex: 1;
max-width: 100%;
} }
.animate-fade-in { .animate-fade-in {

View File

@@ -4,11 +4,13 @@ import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import { devServerConfig } from "./src/adapter/vite/dev-server-config"; import { devServerConfig } from "./src/adapter/vite/dev-server-config";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json" with { type: "json" };
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
define: { define: {
__isDev: process.env.NODE_ENV === "production" ? "0" : "1", __isDev: process.env.NODE_ENV === "production" ? "0" : "1",
__version: JSON.stringify(pkg.version),
}, },
clearScreen: false, clearScreen: false,
publicDir: "./src/ui/assets", publicDir: "./src/ui/assets",

105
bun.lock
View File

@@ -27,18 +27,18 @@
}, },
"app": { "app": {
"name": "bknd", "name": "bknd",
"version": "0.11.0", "version": "0.12.0",
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2", "@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30", "@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10", "@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.4.4",
@@ -48,17 +48,14 @@
"fast-xml-parser": "^5.0.8", "fast-xml-parser": "^5.0.8",
"hono": "^4.7.4", "hono": "^4.7.4",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3", "radix-ui": "^1.1.3",
"swr": "^2.3.3", "swr": "^2.3.3",
"wrangler": "^4.4.1",
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
@@ -87,9 +84,11 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
@@ -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/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-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=="], "@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-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/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=="], "@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/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/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=="], "@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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "@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=="], "@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/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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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/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=="], "@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-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=="], "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=="], "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-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/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=="], "eslint/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],