diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6fbec3c..8bf85f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.2.5" + bun-version: "1.2.14" - name: Install dependencies working-directory: ./app diff --git a/.gitignore b/.gitignore index 3712ddf..f151cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ packages/media/.env .idea .vscode .git_old -docker/tmp \ No newline at end of file +docker/tmp +.debug +.history \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8fdd954 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/app/__test__/core/object/object-query.spec.ts b/app/__test__/core/object/object-query.spec.ts index 70ea70c..dc03fb6 100644 --- a/app/__test__/core/object/object-query.spec.ts +++ b/app/__test__/core/object/object-query.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query"; +import { type ObjectQuery, convert, validate } from "core/object/query/object-query"; describe("object-query", () => { const q: ObjectQuery = { name: "Michael" }; diff --git a/app/__test__/data/data-query-impl.spec.ts b/app/__test__/data/specs/WhereBuilder.spec.ts similarity index 52% rename from app/__test__/data/data-query-impl.spec.ts rename to app/__test__/data/specs/WhereBuilder.spec.ts index d96247e..a477cbe 100644 --- a/app/__test__/data/data-query-impl.spec.ts +++ b/app/__test__/data/specs/WhereBuilder.spec.ts @@ -1,25 +1,18 @@ -import { describe, expect, test } from "bun:test"; -import { Value, _jsonp } from "../../src/core/utils"; -import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; -import type { RepoQueryIn } from "../../src/data/server/data-query-impl"; -import { getDummyConnection } from "./helper"; +import { describe, test, expect } from "bun:test"; +import { getDummyConnection } from "../helper"; +import { type WhereQuery, WhereBuilder } from "data"; -const decode = (input: RepoQueryIn, expected: RepoQuery) => { - const result = Value.Decode(querySchema, input); - expect(result).toEqual(expected); -}; - -describe("data-query-impl", () => { - function qb() { - const c = getDummyConnection(); - const kysely = c.dummyConnection.kysely; - return kysely.selectFrom("t").selectAll(); - } - function compile(q: WhereQuery) { - const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile(); - return { sql, parameters }; - } +function qb() { + const c = getDummyConnection(); + const kysely = c.dummyConnection.kysely; + return kysely.selectFrom("t").selectAll(); +} +function compile(q: WhereQuery) { + const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile(); + return { sql, parameters }; +} +describe("WhereBuilder", () => { test("single validation", () => { const tests: [WhereQuery, string, any[]][] = [ [{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]], @@ -94,64 +87,4 @@ describe("data-query-impl", () => { expect(keys).toEqual(expectedKeys); } }); - - test("with", () => { - decode({ with: ["posts"] }, { with: { posts: {} } }); - decode({ with: { posts: {} } }, { with: { posts: {} } }); - decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } }); - decode( - { - with: { - posts: { - with: { - images: { - select: ["id"], - }, - }, - }, - }, - }, - { - with: { - posts: { - with: { - images: { - select: ["id"], - }, - }, - }, - }, - }, - ); - - // over http - { - const output = { with: { images: {} } }; - decode({ with: "images" }, output); - decode({ with: '["images"]' }, output); - decode({ with: ["images"] }, output); - decode({ with: { images: {} } }, output); - } - - { - const output = { with: { images: {}, comments: {} } }; - decode({ with: "images,comments" }, output); - decode({ with: ["images", "comments"] }, output); - decode({ with: '["images", "comments"]' }, output); - decode({ with: { images: {}, comments: {} } }, output); - } - }); -}); - -describe("data-query-impl: Typebox", () => { - test("sort", async () => { - const _dflt = { sort: { by: "id", dir: "asc" } }; - - decode({ sort: "" }, _dflt); - decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } }); - decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } }); - decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } }); - decode({ sort: "-1name" }, _dflt); - decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } }); - }); }); diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index f55591b..d0e56c9 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -43,8 +43,9 @@ beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("MediaController", () => { - test.only("accepts direct", async () => { + test("accepts direct", async () => { const app = await makeApp(); + console.log("app", app); const file = Bun.file(path); const name = makeName("png"); diff --git a/app/build.ts b/app/build.ts index 56dc1dc..58827d2 100644 --- a/app/build.ts +++ b/app/build.ts @@ -1,5 +1,6 @@ import { $ } from "bun"; import * as tsup from "tsup"; +import pkg from "./package.json" with { type: "json" }; const args = process.argv.slice(2); const watch = args.includes("--watch"); @@ -9,7 +10,7 @@ const sourcemap = args.includes("--sourcemap"); const clean = args.includes("--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 {} +`; } @@ -21,11 +22,11 @@ function buildTypes() { Bun.spawn(["bun", "build:types"], { stdout: "inherit", onExit: () => { - console.log("Types built"); + console.info("Types built"); Bun.spawn(["bun", "tsc-alias"], { stdout: "inherit", onExit: () => { - console.log("Types aliased"); + console.info("Types aliased"); types_running = false; }, }); @@ -47,10 +48,10 @@ if (types && !watch) { } function banner(title: string) { - console.log(""); - console.log("=".repeat(40)); - console.log(title.toUpperCase()); - console.log("-".repeat(40)); + console.info(""); + console.info("=".repeat(40)); + console.info(title.toUpperCase()); + console.info("-".repeat(40)); } // collection of always-external packages @@ -65,6 +66,9 @@ async function buildApi() { minify, sourcemap, watch, + define: { + __version: JSON.stringify(pkg.version), + }, entry: [ "src/index.ts", "src/core/index.ts", diff --git a/app/package.json b/app/package.json index 05de587..9da605e 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "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.", "homepage": "https://bknd.io", "repository": { @@ -49,9 +49,11 @@ "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", "@hello-pangea/dnd": "^18.0.1", + "@hono/swagger-ui": "^0.5.1", "@libsql/client": "^0.15.2", "@mantine/core": "^7.17.1", "@mantine/hooks": "^7.17.1", + "@sinclair/typebox": "0.34.30", "@tanstack/react-form": "^1.0.5", "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", @@ -64,12 +66,11 @@ "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", "kysely": "^0.27.6", + "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "radix-ui": "^1.1.3", - "swr": "^2.3.3", - "lodash-es": "^4.17.21", - "@sinclair/typebox": "0.34.30" + "swr": "^2.3.3" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -98,14 +99,15 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", + "jsonv-ts": "^0.0.14-alpha.6", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", + "picocolors": "^1.1.1", "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "posthog-js-lite": "^3.4.2", - "picocolors": "^1.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", diff --git a/app/src/Api.ts b/app/src/Api.ts index 3b356f6..8c93d97 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -1,10 +1,11 @@ import type { SafeUser } from "auth"; -import { AuthApi } from "auth/api/AuthApi"; -import { DataApi } from "data/api/DataApi"; +import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi"; +import { DataApi, type DataApiOptions } from "data/api/DataApi"; import { decode } from "hono/jwt"; -import { MediaApi } from "media/api/MediaApi"; +import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; import { omitKeys } from "core/utils"; +import type { BaseModuleApiOptions } from "modules"; export type TApiUser = SafeUser; @@ -21,14 +22,24 @@ declare global { } } +type SubApiOptions = Omit; + export type ApiOptions = { host?: string; headers?: Headers; key?: string; - localStorage?: boolean; + storage?: { + getItem: (key: string) => string | undefined | null | Promise; + setItem: (key: string, value: string) => void | Promise; + removeItem: (key: string) => void | Promise; + }; + onAuthStateChange?: (state: AuthState) => void; fetcher?: ApiFetcher; verbose?: boolean; verified?: boolean; + data?: SubApiOptions; + auth?: SubApiOptions; + media?: SubApiOptions; } & ( | { token?: string; @@ -61,18 +72,18 @@ export class Api { this.verified = options.verified === true; // prefer request if given - if ("request" in options) { + if ("request" in options && options.request) { this.options.host = options.host ?? new URL(options.request.url).origin; this.options.headers = options.headers ?? options.request.headers; this.extractToken(); // then check for a token - } else if ("token" in options) { + } else if ("token" in options && options.token) { this.token_transport = "header"; - this.updateToken(options.token); + this.updateToken(options.token, { trigger: false }); // then check for an user object - } else if ("user" in options) { + } else if ("user" in options && options.user) { this.token_transport = "none"; this.user = options.user; this.verified = options.verified !== false; @@ -115,16 +126,30 @@ export class Api { this.updateToken(headerToken); return; } - } else if (this.options.localStorage) { - const token = localStorage.getItem(this.tokenKey); - if (token) { + } else if (this.storage) { + this.storage.getItem(this.tokenKey).then((token) => { this.token_transport = "header"; - this.updateToken(token); - } + this.updateToken(token ? String(token) : undefined); + }); } } - updateToken(token?: string, rebuild?: boolean) { + private get storage() { + if (!this.options.storage) return null; + return { + getItem: async (key: string) => { + return await this.options.storage!.getItem(key); + }, + setItem: async (key: string, value: string) => { + return await this.options.storage!.setItem(key, value); + }, + removeItem: async (key: string) => { + return await this.options.storage!.removeItem(key); + }, + }; + } + + updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { this.token = token; this.verified = false; @@ -134,17 +159,25 @@ export class Api { this.user = undefined; } - if (this.options.localStorage) { + if (this.storage) { const key = this.tokenKey; if (token) { - localStorage.setItem(key, token); + this.storage.setItem(key, token).then(() => { + this.options.onAuthStateChange?.(this.getAuthState()); + }); } else { - localStorage.removeItem(key); + this.storage.removeItem(key).then(() => { + this.options.onAuthStateChange?.(this.getAuthState()); + }); + } + } else { + if (opts?.trigger !== false) { + this.options.onAuthStateChange?.(this.getAuthState()); } } - if (rebuild) this.buildApis(); + if (opts?.rebuild) this.buildApis(); } private markAuthVerified(verfied: boolean) { @@ -214,15 +247,32 @@ export class Api { const fetcher = this.options.fetcher; this.system = new SystemApi(baseParams, fetcher); - this.data = new DataApi(baseParams, fetcher); - this.auth = new AuthApi( + this.data = new DataApi( { ...baseParams, - onTokenUpdate: (token) => this.updateToken(token, true), + ...this.options.data, + }, + fetcher, + ); + this.auth = new AuthApi( + { + ...baseParams, + credentials: this.options.storage ? "omit" : "include", + ...this.options.auth, + onTokenUpdate: (token) => { + this.updateToken(token, { rebuild: true }); + this.options.auth?.onTokenUpdate?.(token); + }, + }, + fetcher, + ); + this.media = new MediaApi( + { + ...baseParams, + ...this.options.media, }, fetcher, ); - this.media = new MediaApi(baseParams, fetcher); } } diff --git a/app/src/App.ts b/app/src/App.ts index 9683616..639d891 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -151,7 +151,7 @@ export class App { } get fetch(): Hono["fetch"] { - return this.server.fetch; + return this.server.fetch as any; } get module() { diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index d4cc3d2..91b3c17 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; export type AuthApiOptions = BaseModuleApiOptions & { - onTokenUpdate?: (token: string) => void | Promise; + onTokenUpdate?: (token?: string) => void | Promise; + credentials?: "include" | "same-origin" | "omit"; }; export class AuthApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/auth", + credentials: "include", }; } async login(strategy: string, input: any) { const res = await this.post([strategy, "login"], input, { - credentials: "include", + credentials: this.options.credentials, }); if (res.ok && res.body.token) { @@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi { async register(strategy: string, input: any) { const res = await this.post([strategy, "register"], input, { - credentials: "include", + credentials: this.options.credentials, }); if (res.ok && res.body.token) { @@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi { return this.get>(["strategies"]); } - async logout() {} + async logout() { + await this.options.onTokenUpdate?.(undefined); + } } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 1597888..57cfd4c 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,11 +1,9 @@ import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; -import { tbValidator as tb } from "core"; import { TypeInvalidError, parse, transformObject } from "core/utils"; import { DataPermissions } from "data"; import type { Hono } from "hono"; import { Controller, type ServerEnv } from "modules/Controller"; -import * as tbbox from "@sinclair/typebox"; -const { Type } = tbbox; +import { describeRoute, jsc, s } from "core/object/schema"; export type AuthActionResponse = { success: boolean; @@ -14,10 +12,6 @@ export type AuthActionResponse = { errors?: any; }; -const booleanLike = Type.Transform(Type.String()) - .Decode((v) => v === "1") - .Encode((v) => (v ? "1" : "0")); - export class AuthController extends Controller { constructor(private auth: AppAuth) { super(); @@ -56,6 +50,10 @@ export class AuthController extends Controller { hono.post( "/create", permission([AuthPermissions.createUser, DataPermissions.entityCreate]), + describeRoute({ + summary: "Create a new user", + tags: ["auth"], + }), async (c) => { try { const body = await this.auth.authenticator.getBody(c); @@ -93,9 +91,16 @@ export class AuthController extends Controller { } }, ); - hono.get("create/schema.json", async (c) => { - return c.json(create.schema); - }); + hono.get( + "create/schema.json", + describeRoute({ + summary: "Get the schema for creating a user", + tags: ["auth"], + }), + async (c) => { + return c.json(create.schema); + }, + ); } mainHono.route(`/${name}/actions`, hono); @@ -104,42 +109,54 @@ export class AuthController extends Controller { override getController() { const { auth } = this.middlewares; const hono = this.create(); - const strategies = this.auth.authenticator.getStrategies(); - for (const [name, strategy] of Object.entries(strategies)) { - if (!this.auth.isStrategyEnabled(strategy)) continue; + hono.get( + "/me", + describeRoute({ + summary: "Get the current user", + tags: ["auth"], + }), + auth(), + async (c) => { + const claims = c.get("auth")?.user; + if (claims) { + const { data: user } = await this.userRepo.findId(claims.id); + return c.json({ user }); + } - hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); - this.registerStrategyActions(strategy, hono); - } + return c.json({ user: null }, 403); + }, + ); - hono.get("/me", auth(), async (c) => { - const claims = c.get("auth")?.user; - if (claims) { - const { data: user } = await this.userRepo.findId(claims.id); - return c.json({ user }); - } + hono.get( + "/logout", + describeRoute({ + summary: "Logout the current user", + tags: ["auth"], + }), + auth(), + async (c) => { + await this.auth.authenticator.logout(c); + if (this.auth.authenticator.isJsonRequest(c)) { + return c.json({ ok: true }); + } - return c.json({ user: null }, 403); - }); + const referer = c.req.header("referer"); + if (referer) { + return c.redirect(referer); + } - hono.get("/logout", auth(), async (c) => { - await this.auth.authenticator.logout(c); - if (this.auth.authenticator.isJsonRequest(c)) { - return c.json({ ok: true }); - } - - const referer = c.req.header("referer"); - if (referer) { - return c.redirect(referer); - } - - return c.redirect("/"); - }); + return c.redirect("/"); + }, + ); hono.get( "/strategies", - tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })), + describeRoute({ + summary: "Get the available authentication strategies", + tags: ["auth"], + }), + jsc("query", s.object({ include_disabled: s.boolean().optional() })), async (c) => { const { include_disabled } = c.req.valid("query"); const { strategies, basepath } = this.auth.toJSON(false); @@ -157,6 +174,15 @@ export class AuthController extends Controller { }, ); + const strategies = this.auth.authenticator.getStrategies(); + + for (const [name, strategy] of Object.entries(strategies)) { + if (!this.auth.isStrategyEnabled(strategy)) continue; + + hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); + this.registerStrategyActions(strategy, hono); + } + return hono.all("*", (c) => c.notFound()); } } diff --git a/app/src/core/env.ts b/app/src/core/env.ts index 6d4f0ba..7771073 100644 --- a/app/src/core/env.ts +++ b/app/src/core/env.ts @@ -13,6 +13,15 @@ export function isDebug(): boolean { } } +export function getVersion(): string { + try { + // @ts-expect-error - this is a global variable in dev + return __version; + } catch (e) { + return "0.0.0"; + } +} + const envs = { // used in $console to determine the log level cli_log_level: { diff --git a/app/src/core/index.ts b/app/src/core/index.ts index a0f572d..9ff5370 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -26,6 +26,7 @@ export { } from "./object/query/query"; export { Registry, type Constructor } from "./registry/Registry"; export { getFlashMessage } from "./server/flash"; +export { s, jsc, describeRoute } from "./object/schema"; export * from "./console"; export * from "./events"; diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index c432daf..27180ee 100644 --- a/app/src/core/object/query/query.ts +++ b/app/src/core/object/query/query.ts @@ -34,6 +34,8 @@ type ExpressionMap = { ? E : never; }; +type ExpressionKeys = Exps[number]["key"]; + type ExpressionCondition = { [K in keyof ExpressionMap]: { [P in K]: ExpressionMap[K] }; }[keyof ExpressionMap]; @@ -195,5 +197,7 @@ export function makeValidator(expressions: Exps) { const fns = _build(query, expressions, options); return _validate(fns); }, + expressions, + expressionKeys: expressions.map((e) => e.key) as ExpressionKeys, }; } diff --git a/app/src/core/object/schema/index.ts b/app/src/core/object/schema/index.ts new file mode 100644 index 0000000..97f8066 --- /dev/null +++ b/app/src/core/object/schema/index.ts @@ -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 = (schema: S): S => { + const json = schema.toJSON(); + return s.fromSchema(json) as S; +}; + +export function parse( + _schema: S, + v: unknown, + opts: ParseOptions = {}, +): s.StaticCoerced { + 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; +} diff --git a/app/src/core/object/schema/validator.ts b/app/src/core/object/schema/validator.ts new file mode 100644 index 0000000..7e8c61c --- /dev/null +++ b/app/src/core/object/schema/validator.ts @@ -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 = ( + result: { result: ValidationResult; data: T }, + c: Context, +) => Response | Promise | 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 : StaticCoerced, + I extends Input = { + in: { [K in Target]: Static }; + out: { [K in Target]: Out }; + }, +>( + target: Target, + schema: Schema, + options?: Opts, + hook?: Hook, +): MiddlewareHandler => { + // @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; diff --git a/app/src/core/server/lib/index.ts b/app/src/core/server/lib/index.ts new file mode 100644 index 0000000..d6eea75 --- /dev/null +++ b/app/src/core/server/lib/index.ts @@ -0,0 +1 @@ +export { tbValidator } from "./tbValidator"; diff --git a/app/src/core/server/lib/jscValidator.ts b/app/src/core/server/lib/jscValidator.ts new file mode 100644 index 0000000..c61f362 --- /dev/null +++ b/app/src/core/server/lib/jscValidator.ts @@ -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, + I extends Input = { + in: { [K in Target]: Static }; + out: { [K in Target]: Static }; + }, +>( + target: Target, + schema: Schema, +): MiddlewareHandler => { + // @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; + }); +}; diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index a8469c7..a14f1c3 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -406,3 +406,16 @@ export function objectToJsLiteral(value: object, indent: number = 0, _level: num throw new TypeError(`Unsupported data type: ${t}`); } + +// lodash-es compatible `pick` with perfect type inference +export function pick(obj: T, keys: K[]): Pick { + return keys.reduce( + (acc, key) => { + if (key in obj) { + acc[key] = obj[key]; + } + return acc; + }, + {} as Pick, + ); +} diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 333b9a3..8c3ab45 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,6 +1,4 @@ -import { $console, isDebug, tbValidator as tb } from "core"; -import { StringEnum } from "core/utils"; -import * as tbbox from "@sinclair/typebox"; +import { $console, isDebug } from "core"; import { DataPermissions, type EntityData, @@ -8,14 +6,15 @@ import { type MutatorResponse, type RepoQuery, type RepositoryResponse, - querySchema, + repoQuery, } from "data"; import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; +import { jsc, s, describeRoute, schemaToSpec } from "core/object/schema"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; -const { Type } = tbbox; +import { omitKeys } from "core/utils"; export class DataController extends Controller { constructor( @@ -71,6 +70,7 @@ export class DataController extends Controller { override getController() { const { permission, auth } = this.middlewares; const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); + const entitiesEnum = this.getEntitiesEnum(this.em); // @todo: sample implementation how to augment handler with additional info function handler(name: string, h: HH): any { @@ -83,6 +83,10 @@ export class DataController extends Controller { // info hono.get( "/", + describeRoute({ + summary: "Retrieve data configuration", + tags: ["data"], + }), handler("data info", (c) => { // sample implementation return c.json(this.em.toJSON()); @@ -90,49 +94,75 @@ export class DataController extends Controller { ); // sync endpoint - hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => { - const force = c.req.query("force") === "1"; - const drop = c.req.query("drop") === "1"; - //console.log("force", force); - const tables = await this.em.schema().introspect(); - //console.log("tables", tables); - const changes = await this.em.schema().sync({ - force, - drop, - }); - return c.json({ tables: tables.map((t) => t.name), changes }); - }); + hono.get( + "/sync", + permission(DataPermissions.databaseSync), + describeRoute({ + summary: "Sync database schema", + tags: ["data"], + }), + jsc( + "query", + s.partialObject({ + force: s.boolean(), + drop: s.boolean(), + }), + ), + async (c) => { + const { force, drop } = c.req.valid("query"); + //console.log("force", force); + const tables = await this.em.schema().introspect(); + //console.log("tables", tables); + const changes = await this.em.schema().sync({ + force, + drop, + }); + return c.json({ tables: tables.map((t) => t.name), changes }); + }, + ); /** * Schema endpoints */ // read entity schema - hono.get("/schema.json", permission(DataPermissions.entityRead), async (c) => { - const $id = `${this.config.basepath}/schema.json`; - const schemas = Object.fromEntries( - this.em.entities.map((e) => [ - e.name, - { - $ref: `${this.config.basepath}/schemas/${e.name}`, - }, - ]), - ); - return c.json({ - $schema: "https://json-schema.org/draft/2020-12/schema", - $id, - properties: schemas, - }); - }); + hono.get( + "/schema.json", + permission(DataPermissions.entityRead), + describeRoute({ + summary: "Retrieve data schema", + tags: ["data"], + }), + async (c) => { + const $id = `${this.config.basepath}/schema.json`; + const schemas = Object.fromEntries( + this.em.entities.map((e) => [ + e.name, + { + $ref: `${this.config.basepath}/schemas/${e.name}`, + }, + ]), + ); + return c.json({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id, + properties: schemas, + }); + }, + ); // read schema hono.get( "/schemas/:entity/:context?", permission(DataPermissions.entityRead), - tb( + describeRoute({ + summary: "Retrieve entity schema", + tags: ["data"], + }), + jsc( "param", - Type.Object({ - entity: Type.String(), - context: Type.Optional(StringEnum(["create", "update"])), + s.object({ + entity: entitiesEnum, + context: s.string({ enum: ["create", "update"], default: "create" }).optional(), }), ), async (c) => { @@ -161,30 +191,39 @@ export class DataController extends Controller { /** * Info endpoints */ - hono.get("/info/:entity", async (c) => { - const { entity } = c.req.param(); - if (!this.entityExists(entity)) { - return this.notFound(c); - } - const _entity = this.em.entity(entity); - const fields = _entity.fields.map((f) => f.name); - const $rels = (r: any) => - r.map((r: any) => ({ - entity: r.other(_entity).entity.name, - ref: r.other(_entity).reference, - })); + hono.get( + "/info/:entity", + permission(DataPermissions.entityRead), + describeRoute({ + summary: "Retrieve entity info", + tags: ["data"], + }), + jsc("param", s.object({ entity: entitiesEnum })), + async (c) => { + const { entity } = c.req.param(); + if (!this.entityExists(entity)) { + return this.notFound(c); + } + const _entity = this.em.entity(entity); + const fields = _entity.fields.map((f) => f.name); + const $rels = (r: any) => + r.map((r: any) => ({ + entity: r.other(_entity).entity.name, + ref: r.other(_entity).reference, + })); - return c.json({ - name: _entity.name, - fields, - relations: { - all: $rels(this.em.relations.relationsOf(_entity)), - listable: $rels(this.em.relations.listableRelationsOf(_entity)), - source: $rels(this.em.relations.sourceRelationsOf(_entity)), - target: $rels(this.em.relations.targetRelationsOf(_entity)), - }, - }); - }); + return c.json({ + name: _entity.name, + fields, + relations: { + all: $rels(this.em.relations.relationsOf(_entity)), + listable: $rels(this.em.relations.listableRelationsOf(_entity)), + source: $rels(this.em.relations.sourceRelationsOf(_entity)), + target: $rels(this.em.relations.targetRelationsOf(_entity)), + }, + }); + }, + ); return hono.all("*", (c) => c.notFound()); } @@ -193,10 +232,7 @@ export class DataController extends Controller { const { permission } = this.middlewares; const hono = this.create(); - const definedEntities = this.em.entities.map((e) => e.name); - const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) - .Decode(Number.parseInt) - .Encode(String); + const entitiesEnum = this.getEntitiesEnum(this.em); /** * Function endpoints @@ -205,14 +241,19 @@ export class DataController extends Controller { hono.post( "/:entity/fn/count", permission(DataPermissions.entityRead), - tb("param", Type.Object({ entity: Type.String() })), + describeRoute({ + summary: "Count entities", + tags: ["data"], + }), + jsc("param", s.object({ entity: entitiesEnum })), + jsc("json", repoQuery.properties.where), async (c) => { const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } - const where = (await c.req.json()) as any; + const where = c.req.valid("json") as any; const result = await this.em.repository(entity).count(where); return c.json({ entity, count: result.count }); }, @@ -222,14 +263,19 @@ export class DataController extends Controller { hono.post( "/:entity/fn/exists", permission(DataPermissions.entityRead), - tb("param", Type.Object({ entity: Type.String() })), + describeRoute({ + summary: "Check if entity exists", + tags: ["data"], + }), + jsc("param", s.object({ entity: entitiesEnum })), + jsc("json", repoQuery.properties.where), async (c) => { const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } - const where = c.req.json() as any; + const where = c.req.valid("json") as any; const result = await this.em.repository(entity).exists(where); return c.json({ entity, exists: result.exists }); }, @@ -239,13 +285,31 @@ export class DataController extends Controller { * Read endpoints */ // read many + const saveRepoQuery = s.partialObject({ + ...omitKeys(repoQuery.properties, ["with"]), + sort: s.string({ default: "id" }), + select: s.array(s.string()), + join: s.array(s.string()), + }); + const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [ + ...(schemaToSpec(saveRepoQuery, "query").parameters?.filter( + // @ts-ignore + (p) => pick.includes(p.name), + ) as any), + ]; + hono.get( "/:entity", + describeRoute({ + summary: "Read many", + parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]), + tags: ["data"], + }), permission(DataPermissions.entityRead), - tb("param", Type.Object({ entity: Type.String() })), - tb("query", querySchema), + jsc("param", s.object({ entity: entitiesEnum })), + jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { - const { entity } = c.req.param(); + const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } @@ -259,17 +323,22 @@ export class DataController extends Controller { // read one hono.get( "/:entity/:id", + describeRoute({ + summary: "Read one", + parameters: saveRepoQueryParams(["offset", "sort", "select"]), + tags: ["data"], + }), permission(DataPermissions.entityRead), - tb( + jsc( "param", - Type.Object({ - entity: Type.String(), - id: tbNumber, + s.object({ + entity: entitiesEnum, + id: s.string(), }), ), - tb("query", querySchema), + jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { - const { entity, id } = c.req.param(); + const { entity, id } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } @@ -283,18 +352,23 @@ export class DataController extends Controller { // read many by reference hono.get( "/:entity/:id/:reference", + describeRoute({ + summary: "Read many by reference", + parameters: saveRepoQueryParams(), + tags: ["data"], + }), permission(DataPermissions.entityRead), - tb( + jsc( "param", - Type.Object({ - entity: Type.String(), - id: tbNumber, - reference: Type.String(), + s.object({ + entity: entitiesEnum, + id: s.string(), + reference: s.string(), }), ), - tb("query", querySchema), + jsc("query", repoQuery, { skipOpenAPI: true }), async (c) => { - const { entity, id, reference } = c.req.param(); + const { entity, id, reference } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } @@ -309,17 +383,33 @@ export class DataController extends Controller { ); // func query + const fnQuery = s.partialObject({ + ...saveRepoQuery.properties, + with: s.object({}), + }); hono.post( "/:entity/query", + describeRoute({ + summary: "Query entities", + requestBody: { + content: { + "application/json": { + schema: fnQuery.toJSON(), + example: fnQuery.template({ withOptional: true }), + }, + }, + }, + tags: ["data"], + }), permission(DataPermissions.entityRead), - tb("param", Type.Object({ entity: Type.String() })), - tb("json", querySchema), + jsc("param", s.object({ entity: entitiesEnum })), + jsc("json", repoQuery, { skipOpenAPI: true }), async (c) => { - const { entity } = c.req.param(); + const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } - const options = (await c.req.valid("json")) as RepoQuery; + const options = (await c.req.json()) as RepoQuery; const result = await this.em.repository(entity).findMany(options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); @@ -332,11 +422,15 @@ export class DataController extends Controller { // insert one hono.post( "/:entity", + describeRoute({ + summary: "Insert one or many", + tags: ["data"], + }), permission(DataPermissions.entityCreate), - tb("param", Type.Object({ entity: Type.String() })), - tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])), + jsc("param", s.object({ entity: entitiesEnum })), + jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), async (c) => { - const { entity } = c.req.param(); + const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } @@ -355,13 +449,17 @@ export class DataController extends Controller { // update many hono.patch( "/:entity", + describeRoute({ + summary: "Update many", + tags: ["data"], + }), permission(DataPermissions.entityUpdate), - tb("param", Type.Object({ entity: Type.String() })), - tb( + jsc("param", s.object({ entity: entitiesEnum })), + jsc( "json", - Type.Object({ - update: Type.Object({}), - where: querySchema.properties.where, + s.object({ + update: s.object({}), + where: repoQuery.properties.where, }), ), async (c) => { @@ -382,10 +480,15 @@ export class DataController extends Controller { // update one hono.patch( "/:entity/:id", + describeRoute({ + summary: "Update one", + tags: ["data"], + }), permission(DataPermissions.entityUpdate), - tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), + jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), + jsc("json", s.object({})), async (c) => { - const { entity, id } = c.req.param(); + const { entity, id } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } @@ -399,10 +502,14 @@ export class DataController extends Controller { // delete one hono.delete( "/:entity/:id", + describeRoute({ + summary: "Delete one", + tags: ["data"], + }), permission(DataPermissions.entityDelete), - tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), + jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), async (c) => { - const { entity, id } = c.req.param(); + const { entity, id } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } @@ -415,15 +522,19 @@ export class DataController extends Controller { // delete many hono.delete( "/:entity", + describeRoute({ + summary: "Delete many", + tags: ["data"], + }), permission(DataPermissions.entityDelete), - tb("param", Type.Object({ entity: Type.String() })), - tb("json", querySchema.properties.where), + jsc("param", s.object({ entity: entitiesEnum })), + jsc("json", repoQuery.properties.where), async (c) => { - const { entity } = c.req.param(); + const { entity } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } - const where = c.req.valid("json") as RepoQuery["where"]; + const where = (await c.req.json()) as RepoQuery["where"]; const result = await this.em.mutator(entity).deleteWhere(where); return c.json(this.mutatorResult(result)); diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index 0e4dae4..104ca33 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -6,7 +6,7 @@ import type { Entity, EntityData, EntityManager } from "../entities"; import { InvalidSearchParamsException } from "../errors"; import { MutatorEvents } from "../events"; import { RelationMutator } from "../relations"; -import type { RepoQuery } from "../server/data-query-impl"; +import type { RepoQuery } from "../server/query"; type MutatorQB = | InsertQueryBuilder diff --git a/app/src/data/entities/query/JoinBuilder.ts b/app/src/data/entities/query/JoinBuilder.ts index 0aa1ca7..6bd6ec6 100644 --- a/app/src/data/entities/query/JoinBuilder.ts +++ b/app/src/data/entities/query/JoinBuilder.ts @@ -20,6 +20,7 @@ export class JoinBuilder { // @todo: returns multiple on manytomany (edit: so?) static getJoinedEntityNames(em: EntityManager, entity: Entity, joins: string[]): string[] { + console.log("join", joins); return joins.flatMap((join) => { const relation = em.relationOf(entity.name, join); if (!relation) { diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 1d2897a..625e701 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -2,10 +2,9 @@ import type { DB as DefaultDB, PrimaryFieldType } from "core"; import { $console } from "core"; import { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; -import { cloneDeep } from "lodash-es"; import { InvalidSearchParamsException } from "../../errors"; import { MutatorEvents, RepositoryEvents } from "../../events"; -import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl"; +import { type RepoQuery, getRepoQueryTemplate } from "data/server/query"; import { type Entity, type EntityData, @@ -84,14 +83,14 @@ export class Repository): RepoQuery { + getValidOptions(options?: RepoQuery): RepoQuery { const entity = this.entity; // @todo: if not cloned deep, it will keep references and error if multiple requests come in const validated = { - ...cloneDeep(defaultQuerySchema), + ...structuredClone(getRepoQueryTemplate()), sort: entity.getDefaultSort(), select: entity.getSelect(), - }; + } satisfies Required; if (!options) return validated; @@ -99,12 +98,15 @@ export class Repository 0) { @@ -505,7 +507,7 @@ export class Repository): Promise { + async exists(where: Required["where"]): Promise { const entity = this.entity; const options = this.getValidOptions({ where }); @@ -513,7 +515,7 @@ export class Repository; const validator = makeValidator(expressions); +export const expressionKeys = validator.expressionKeys; export class WhereBuilder { static addClause(qb: QB, query: WhereQuery) { diff --git a/app/src/data/events/index.ts b/app/src/data/events/index.ts index 8549750..b10f3b6 100644 --- a/app/src/data/events/index.ts +++ b/app/src/data/events/index.ts @@ -1,7 +1,7 @@ import { $console, type PrimaryFieldType } from "core"; import { Event, InvalidEventReturn } from "core/events"; import type { Entity, EntityData } from "../entities"; -import type { RepoQuery } from "../server/data-query-impl"; +import type { RepoQuery } from "data/server/query"; export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> { static override slug = "mutator-insert-before"; diff --git a/app/src/data/index.ts b/app/src/data/index.ts index eb3e893..5069fce 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -10,10 +10,11 @@ export * from "./connection"; export { type RepoQuery, type RepoQueryIn, - defaultQuerySchema, - querySchema, - whereSchema, -} from "./server/data-query-impl"; + getRepoQueryTemplate, + repoQuery, +} from "./server/query"; + +export type { WhereQuery } from "./entities/query/WhereBuilder"; export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner"; diff --git a/app/src/data/relations/EntityRelation.ts b/app/src/data/relations/EntityRelation.ts index 232476a..bf0d8e6 100644 --- a/app/src/data/relations/EntityRelation.ts +++ b/app/src/data/relations/EntityRelation.ts @@ -6,7 +6,7 @@ import { type MutationInstructionResponse, RelationHelper, } from "../relations"; -import type { RepoQuery } from "../server/data-query-impl"; +import type { RepoQuery } from "../server/query"; import type { RelationType } from "./relation-types"; import * as tbbox from "@sinclair/typebox"; const { Type } = tbbox; diff --git a/app/src/data/relations/ManyToManyRelation.ts b/app/src/data/relations/ManyToManyRelation.ts index 4767432..bd05108 100644 --- a/app/src/data/relations/ManyToManyRelation.ts +++ b/app/src/data/relations/ManyToManyRelation.ts @@ -2,7 +2,7 @@ import type { Static } from "core/utils"; import type { ExpressionBuilder } from "kysely"; import { Entity, type EntityManager } from "../entities"; import { type Field, PrimaryField } from "../fields"; -import type { RepoQuery } from "../server/data-query-impl"; +import type { RepoQuery } from "../server/query"; import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { RelationField } from "./RelationField"; diff --git a/app/src/data/relations/ManyToOneRelation.ts b/app/src/data/relations/ManyToOneRelation.ts index 192b965..7fde72a 100644 --- a/app/src/data/relations/ManyToOneRelation.ts +++ b/app/src/data/relations/ManyToOneRelation.ts @@ -3,7 +3,7 @@ import { snakeToPascalWithSpaces } from "core/utils"; import type { Static } from "core/utils"; import type { ExpressionBuilder } from "kysely"; import type { Entity, EntityManager } from "../entities"; -import type { RepoQuery } from "../server/data-query-impl"; +import type { RepoQuery } from "../server/query"; import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { RelationField, type RelationFieldBaseConfig } from "./RelationField"; diff --git a/app/src/data/relations/PolymorphicRelation.ts b/app/src/data/relations/PolymorphicRelation.ts index 16d8b4e..d0e7cdd 100644 --- a/app/src/data/relations/PolymorphicRelation.ts +++ b/app/src/data/relations/PolymorphicRelation.ts @@ -2,7 +2,7 @@ import type { Static } from "core/utils"; import type { ExpressionBuilder } from "kysely"; import type { Entity, EntityManager } from "../entities"; import { NumberField, TextField } from "../fields"; -import type { RepoQuery } from "../server/data-query-impl"; +import type { RepoQuery } from "../server/query"; import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { type RelationType, RelationTypes } from "./relation-types"; diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts deleted file mode 100644 index 72ef50f..0000000 --- a/app/src/data/server/data-query-impl.ts +++ /dev/null @@ -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 & { - with?: unknown; - } ->; - -export const withSchema = (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; - join?: string[]; - where?: WhereQuery; -}; -export type RepoQuery = Required>; -export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery; diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts new file mode 100644 index 0000000..4c1552d --- /dev/null +++ b/app/src/data/server/query.spec.ts @@ -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); + } + }); +}); diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts new file mode 100644 index 0000000..f5c73dd --- /dev/null +++ b/app/src/data/server/query.ts @@ -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; +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; +//type WhereSchema = s.StaticCoerced; + +// ------ +// with +// @todo: waiting for recursion support +export type RepoWithSchema = Record< + string, + Omit & { + with?: unknown; + } +>; + +const withSchema = (self: s.TSchema): s.TSchemaInOut => + s.anyOf([stringIdentifier, s.array(stringIdentifier), self], { + coerce: function (this: TAnyOf, _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(self), + }), +); +export const getRepoQueryTemplate = () => + repoQuery.template({ + withOptional: true, + }) as Required; + +export type RepoQueryIn = { + limit?: number; + offset?: number; + sort?: string | { by: string; dir: "asc" | "desc" }; + select?: string[]; + with?: string | string[] | Record; + join?: string[]; + where?: WhereQuery; +}; +export type RepoQuery = s.StaticCoerced; diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 4783be9..cdcbe5a 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -6,12 +6,7 @@ import { DataPermissions } from "data"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; -import * as tbbox from "@sinclair/typebox"; -const { Type } = tbbox; - -const booleanLike = Type.Transform(Type.String()) - .Decode((v) => v === "1") - .Encode((v) => (v ? "1" : "0")); +import { jsc, s, describeRoute } from "core/object/schema"; export class MediaController extends Controller { constructor(private readonly media: AppMedia) { @@ -31,90 +26,165 @@ export class MediaController extends Controller { // @todo: implement range requests const { auth, permission } = this.middlewares; const hono = this.create().use(auth()); + const entitiesEnum = this.getEntitiesEnum(this.media.em); // get files list (temporary) - hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { - const files = await this.getStorageAdapter().listObjects(); - return c.json(files); - }); + hono.get( + "/files", + describeRoute({ + summary: "Get the list of files", + tags: ["media"], + }), + permission(MediaPermissions.listFiles), + async (c) => { + const files = await this.getStorageAdapter().listObjects(); + return c.json(files); + }, + ); // get file by name // @todo: implement more aggressive cache? (configurable) - hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } + hono.get( + "/file/:filename", + describeRoute({ + summary: "Get a file by name", + tags: ["media"], + }), + permission(MediaPermissions.readFile), + async (c) => { + const { filename } = c.req.param(); + if (!filename) { + throw new Error("No file name provided"); + } - await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename })); - const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers); + await this.getStorage().emgr.emit( + new StorageEvents.FileAccessEvent({ name: filename }), + ); + const res = await this.getStorageAdapter().getObject(filename, c.req.raw.headers); - const headers = new Headers(res.headers); - headers.set("Cache-Control", "public, max-age=31536000, immutable"); + const headers = new Headers(res.headers); + headers.set("Cache-Control", "public, max-age=31536000, immutable"); - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); - }); + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers, + }); + }, + ); // delete a file by name - hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { - const { filename } = c.req.param(); - if (!filename) { - throw new Error("No file name provided"); - } - await this.getStorage().deleteFile(filename); + hono.delete( + "/file/:filename", + describeRoute({ + summary: "Delete a file by name", + tags: ["media"], + }), + permission(MediaPermissions.deleteFile), + async (c) => { + const { filename } = c.req.param(); + if (!filename) { + throw new Error("No file name provided"); + } + await this.getStorage().deleteFile(filename); - return c.json({ message: "File deleted" }); - }); + return c.json({ message: "File deleted" }); + }, + ); const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; if (isDebug()) { - hono.post("/inspect", async (c) => { - const file = await getFileFromContext(c); - return c.json({ - type: file?.type, - name: file?.name, - size: file?.size, - }); - }); + hono.post( + "/inspect", + describeRoute({ + summary: "Inspect a file", + tags: ["media"], + }), + async (c) => { + const file = await getFileFromContext(c); + return c.json({ + type: file?.type, + name: file?.name, + size: file?.size, + }); + }, + ); } + const requestBody = { + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + file: { + type: "string", + format: "binary", + }, + }, + required: ["file"], + }, + }, + "application/octet-stream": { + schema: { + type: "string", + format: "binary", + }, + }, + }, + } as any; + // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { - const reqname = c.req.param("filename"); + hono.post( + "/upload/:filename?", + describeRoute({ + summary: "Upload a file", + tags: ["media"], + requestBody, + }), + jsc("param", s.object({ filename: s.string().optional() })), + permission(MediaPermissions.uploadFile), + async (c) => { + const reqname = c.req.param("filename"); - const body = await getFileFromContext(c); - if (!body) { - return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); - } - if (body.size > maxSize) { - return c.json( - { error: `Max size (${maxSize} bytes) exceeded` }, - HttpStatus.PAYLOAD_TOO_LARGE, - ); - } + const body = await getFileFromContext(c); + if (!body) { + return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST); + } + if (body.size > maxSize) { + return c.json( + { error: `Max size (${maxSize} bytes) exceeded` }, + HttpStatus.PAYLOAD_TOO_LARGE, + ); + } - const filename = reqname ?? getRandomizedFilename(body as File); - const res = await this.getStorage().uploadFile(body, filename); + const filename = reqname ?? getRandomizedFilename(body as File); + const res = await this.getStorage().uploadFile(body, filename); - return c.json(res, HttpStatus.CREATED); - }); + return c.json(res, HttpStatus.CREATED); + }, + ); // add upload file to entity // @todo: add required type for "upload endpoints" hono.post( "/entity/:entity/:id/:field", - tb( - "query", - Type.Object({ - overwrite: Type.Optional(booleanLike), + describeRoute({ + summary: "Add a file to an entity", + tags: ["media"], + requestBody, + }), + jsc( + "param", + s.object({ + entity: entitiesEnum, + id: s.number(), + field: s.string(), }), ), + jsc("query", s.object({ overwrite: s.boolean().optional() })), permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), async (c) => { const entity_name = c.req.param("entity"); diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 95023fc..610337d 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -1,9 +1,11 @@ import type { App } from "App"; -import { type Context, Hono } from "hono"; +import { type Context, type Env, Hono } from "hono"; import * as middlewares from "modules/middlewares"; import type { SafeUser } from "auth"; +import type { EntityManager } from "data"; +import { s } from "core/object/schema"; -export type ServerEnv = { +export type ServerEnv = Env & { Variables: { app: App; // to prevent resolving auth multiple times @@ -46,4 +48,9 @@ export class Controller { return c.notFound(); } + + protected getEntitiesEnum(em: EntityManager) { + const entities = em.entities.map((e) => e.name); + return entities.length > 0 ? s.string({ enum: entities }) : s.string(); + } } diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 6105a4b..baa36d6 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -86,7 +86,7 @@ export class AdminController extends Controller { hono.use("*", async (c, next) => { const obj = { user: c.get("auth")?.user, - logout_route: this.withAdminBasePath(authRoutes.logout), + logout_route: authRoutes.logout, admin_basepath: this.options.adminBasepath, }; const html = await this.getHtml(obj); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 7ade0e8..511876a 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -13,9 +13,8 @@ import { import { getRuntimeKey } from "core/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; -import * as tbbox from "@sinclair/typebox"; -const { Type } = tbbox; - +import { openAPISpecs } from "jsonv-ts/hono"; +import { swaggerUI } from "@hono/swagger-ui"; import { MODULE_NAMES, type ModuleConfigs, @@ -24,12 +23,8 @@ import { getDefaultConfig, } from "modules/ModuleManager"; import * as SystemPermissions from "modules/permissions"; -import { generateOpenAPI } from "modules/server/openapi"; - -const booleanLike = Type.Transform(Type.String()) - .Decode((v) => v === "1") - .Encode((v) => (v ? "1" : "0")); - +import { jsc, s, describeRoute } from "core/object/schema"; +import { getVersion } from "core/env"; export type ConfigUpdate = { success: true; module: Key; @@ -61,20 +56,27 @@ export class SystemController extends Controller { hono.use(permission(SystemPermissions.configRead)); - hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => { - // @ts-expect-error "fetch" is private - return c.json(await this.app.modules.fetch()); - }); + hono.get( + "/raw", + describeRoute({ + summary: "Get the raw config", + tags: ["system"], + }), + permission([SystemPermissions.configReadSecrets]), + async (c) => { + // @ts-expect-error "fetch" is private + return c.json(await this.app.modules.fetch()); + }, + ); hono.get( "/:module?", - tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })), - tb( - "query", - Type.Object({ - secrets: Type.Optional(booleanLike), - }), - ), + describeRoute({ + summary: "Get the config for a module", + tags: ["system"], + }), + jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })), + jsc("query", s.object({ secrets: s.boolean().optional() })), async (c) => { // @todo: allow secrets if authenticated user is admin const { secrets } = c.req.valid("query"); @@ -119,12 +121,7 @@ export class SystemController extends Controller { hono.post( "/set/:module", permission(SystemPermissions.configWrite), - tb( - "query", - Type.Object({ - force: Type.Optional(booleanLike), - }), - ), + jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }), async (c) => { const module = c.req.param("module") as any; const { force } = c.req.valid("query"); @@ -230,13 +227,17 @@ export class SystemController extends Controller { hono.get( "/schema/:module?", + describeRoute({ + summary: "Get the schema for a module", + tags: ["system"], + }), permission(SystemPermissions.schemaRead), - tb( + jsc( "query", - Type.Object({ - config: Type.Optional(booleanLike), - secrets: Type.Optional(booleanLike), - fresh: Type.Optional(booleanLike), + s.partialObject({ + config: s.boolean(), + secrets: s.boolean(), + fresh: s.boolean(), }), ), async (c) => { @@ -274,13 +275,11 @@ export class SystemController extends Controller { hono.post( "/build", - tb( - "query", - Type.Object({ - sync: Type.Optional(booleanLike), - fetch: Type.Optional(booleanLike), - }), - ), + describeRoute({ + summary: "Build the app", + tags: ["system"], + }), + jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })), async (c) => { const options = c.req.valid("query") as Record; this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c); @@ -293,25 +292,44 @@ export class SystemController extends Controller { }, ); - hono.get("/ping", (c) => c.json({ pong: true })); + hono.get( + "/ping", + describeRoute({ + summary: "Ping the server", + tags: ["system"], + }), + (c) => c.json({ pong: true }), + ); - hono.get("/info", (c) => - c.json({ - version: c.get("app")?.version(), - runtime: getRuntimeKey(), - timezone: { - name: getTimezone(), - offset: getTimezoneOffset(), - local: datetimeStringLocal(), - utc: datetimeStringUTC(), + hono.get( + "/info", + describeRoute({ + summary: "Get the server info", + tags: ["system"], + }), + (c) => + c.json({ + version: c.get("app")?.version(), + runtime: getRuntimeKey(), + timezone: { + name: getTimezone(), + offset: getTimezoneOffset(), + local: datetimeStringLocal(), + utc: datetimeStringUTC(), + }, + }), + ); + + hono.get( + "/openapi.json", + openAPISpecs(this.ctx.server, { + info: { + title: "bknd API", + version: getVersion(), }, }), ); - - hono.get("/openapi.json", async (c) => { - const config = getDefaultConfig(); - return c.json(generateOpenAPI(config)); - }); + hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); return hono.all("*", (c) => c.notFound()); } diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 18926eb..37fe534 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,52 +1,64 @@ -import { Api, type ApiOptions, type TApiUser } from "Api"; +import { Api, type ApiOptions, type AuthState } from "Api"; import { isDebug } from "core"; -import { createContext, type ReactNode, useContext } from "react"; +import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; import type { AdminBkndWindowContext } from "modules/server/AdminController"; -const ClientContext = createContext<{ baseUrl: string; api: Api }>({ - baseUrl: undefined, -} as any); +export type BkndClientContext = { + baseUrl: string; + api: Api; + authState?: Partial; +}; + +const ClientContext = createContext(undefined!); export type ClientProviderProps = { children?: ReactNode; -} & ( - | { baseUrl?: string; user?: TApiUser | null | undefined } - | { - api: Api; - } -); + baseUrl?: string; +} & ApiOptions; -export const ClientProvider = ({ children, ...props }: ClientProviderProps) => { - let api: Api; +export const ClientProvider = ({ + children, + host, + baseUrl: _baseUrl = host, + ...props +}: ClientProviderProps) => { + const winCtx = useBkndWindowContext(); + const _ctx = useClientContext(); + let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; + let user: any = undefined; - if (props && "api" in props) { - api = props.api; - } else { - const winCtx = useBkndWindowContext(); - const _ctx_baseUrl = useBaseUrl(); - const { baseUrl, user } = props; - let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? ""; - - try { - if (!baseUrl) { - if (_ctx_baseUrl) { - actualBaseUrl = _ctx_baseUrl; - console.warn("wrapped many times, take from context", actualBaseUrl); - } else if (typeof window !== "undefined") { - actualBaseUrl = window.location.origin; - //console.log("setting from window", actualBaseUrl); - } - } - } catch (e) { - console.error("Error in ClientProvider", e); - } - - //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); - api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() }); + if (winCtx) { + user = winCtx.user; } + if (!actualBaseUrl) { + try { + actualBaseUrl = window.location.origin; + } catch (e) {} + } + + const apiProps = { user, ...props, host: actualBaseUrl }; + const api = useMemo( + () => + new Api({ + ...apiProps, + verbose: isDebug(), + onAuthStateChange: (state) => { + props.onAuthStateChange?.(state); + if (!authState?.token || state.token !== authState?.token) { + setAuthState(state); + } + }, + }), + [JSON.stringify(apiProps)], + ); + + const [authState, setAuthState] = useState | undefined>( + apiProps.user ? api.getAuthState() : undefined, + ); + return ( - + {children} ); @@ -61,12 +73,16 @@ export const useApi = (host?: ApiOptions["host"]): Api => { return context.api; }; +export const useClientContext = () => { + return useContext(ClientContext); +}; + /** * @deprecated use useApi().baseUrl instead */ export const useBaseUrl = () => { - const context = useContext(ClientContext); - return context.baseUrl; + const context = useClientContext(); + return context?.baseUrl; }; export function useBkndWindowContext(): AdminBkndWindowContext { diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index 85681b3..e932d78 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,7 +1,7 @@ import type { AuthState } from "Api"; import type { AuthResponse } from "auth"; -import { useState } from "react"; import { useApi, useInvalidate } from "ui/client"; +import { useClientContext } from "ui/client/ClientProvider"; type LoginData = { email: string; @@ -10,7 +10,7 @@ type LoginData = { }; type UseAuth = { - data: AuthState | undefined; + data: Partial | undefined; user: AuthState["user"] | undefined; token: AuthState["token"] | undefined; verified: boolean; @@ -24,46 +24,36 @@ type UseAuth = { export const useAuth = (options?: { baseUrl?: string }): UseAuth => { const api = useApi(options?.baseUrl); const invalidate = useInvalidate(); - const authState = api.getAuthState(); - const [authData, setAuthData] = useState(authState); + const { authState } = useClientContext(); const verified = authState?.verified ?? false; - function updateAuthState() { - setAuthData(api.getAuthState()); - } - async function login(input: LoginData) { - const res = await api.auth.loginWithPassword(input); - updateAuthState(); + const res = await api.auth.login("password", input); return res.data; } async function register(input: LoginData) { - const res = await api.auth.registerWithPassword(input); - updateAuthState(); + const res = await api.auth.register("password", input); return res.data; } function setToken(token: string) { api.updateToken(token); - updateAuthState(); } async function logout() { - await api.updateToken(undefined); - setAuthData(undefined); + api.updateToken(undefined); invalidate(); } async function verify() { await api.verifyAuth(); - updateAuthState(); } return { - data: authData, - user: authData?.user, - token: authData?.token, + data: authState, + user: authState?.user, + token: authState?.token, verified, login, register, diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx index 6c81434..4c38fda 100644 --- a/app/src/ui/components/code/CodeEditor.tsx +++ b/app/src/ui/components/code/CodeEditor.tsx @@ -1,4 +1,8 @@ -import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; +import { + default as CodeMirror, + type ReactCodeMirrorProps, + EditorView, +} from "@uiw/react-codemirror"; import { json } from "@codemirror/lang-json"; import { html } from "@codemirror/lang-html"; import { useTheme } from "ui/client/use-theme"; @@ -43,7 +47,7 @@ export default function CodeEditor({ theme={theme === "dark" ? "dark" : "light"} editable={editable} basicSetup={_basicSetup} - extensions={extensions} + extensions={[...extensions, EditorView.lineWrapping]} {...props} /> ); diff --git a/app/src/ui/components/list/CollapsibleList.tsx b/app/src/ui/components/list/CollapsibleList.tsx new file mode 100644 index 0000000..805ad54 --- /dev/null +++ b/app/src/ui/components/list/CollapsibleList.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +export interface CollapsibleListRootProps extends React.HTMLAttributes {} + +const Root = ({ className, ...props }: CollapsibleListRootProps) => ( +
+); + +export interface CollapsibleListItemProps extends React.HTMLAttributes { + hasError?: boolean; + disabled?: boolean; +} + +const Item = ({ className, hasError, disabled, ...props }: CollapsibleListItemProps) => ( +
+); + +export interface CollapsibleListPreviewProps extends React.HTMLAttributes { + left?: ReactNode; + right?: ReactNode; +} + +const Preview = ({ className, left, right, children, ...props }: CollapsibleListPreviewProps) => ( +
+ {left &&
{left}
} +
{children}
+ {right &&
{right}
} +
+); + +export interface CollapsibleListDetailProps extends React.HTMLAttributes { + open?: boolean; +} + +const Detail = ({ className, open, ...props }: CollapsibleListDetailProps) => + open && ( +
+ ); + +export const CollapsibleList = { + Root, + Item, + Preview, + Detail, +}; diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index 78ed2ff..2c65989 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -19,6 +19,7 @@ export type DropdownItem = onClick?: () => void; destructive?: boolean; disabled?: boolean; + title?: string; [key: string]: any; }; @@ -142,6 +143,7 @@ export function Dropdown({ item.destructive && "text-red-500 hover:bg-red-600 hover:text-white", )} onClick={onClick} + title={item.title} > {space_for_icon && (
diff --git a/app/src/ui/hooks/use-route-path-state.tsx b/app/src/ui/hooks/use-route-path-state.tsx new file mode 100644 index 0000000..620d1a8 --- /dev/null +++ b/app/src/ui/hooks/use-route-path-state.tsx @@ -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(undefined!); + +export function RoutePathStateProvider({ + children, + defaultIdentifier, + path, +}: Pick & { children: React.ReactNode }) { + const segment = extractPathSegment(path); + const routeIdentifier = useParams()[segment]; + const [activeIdentifier, setActiveIdentifier] = useState(routeIdentifier ?? defaultIdentifier); + return ( + + {children} + + ); +} + +function useRoutePathContext(path?: string) { + const ctx = use(RoutePathStateContext); + if (ctx && (!path || ctx.path === path)) { + return ctx; + } + return undefined; +} diff --git a/app/src/ui/hooks/use-search.ts b/app/src/ui/hooks/use-search.ts index b550c2c..08e118d 100644 --- a/app/src/ui/hooks/use-search.ts +++ b/app/src/ui/hooks/use-search.ts @@ -1,46 +1,42 @@ -import { - type Static, - type StaticDecode, - type TSchema, - decodeSearch, - encodeSearch, - parseDecode, -} from "core/utils"; +import { decodeSearch, encodeSearch, parseDecode } from "core/utils"; import { isEqual, transform } from "lodash-es"; import { useLocation, useSearch as useWouterSearch } from "wouter"; +import { type s, parse } from "core/object/schema"; // @todo: migrate to Typebox -export function useSearch( +export function useSearch( schema: Schema, - defaultValue?: Partial>, + defaultValue?: Partial>, ) { const searchString = useWouterSearch(); const [location, navigate] = useLocation(); - let value: StaticDecode = defaultValue ? parseDecode(schema, defaultValue as any) : {}; - - if (searchString.length > 0) { - value = parseDecode(schema, decodeSearch(searchString)); - //console.log("search:decode", value); - } + const initial = searchString.length > 0 ? decodeSearch(searchString) : (defaultValue ?? {}); + const value = parse(schema, initial, { + withDefaults: true, + clone: true, + }) as s.StaticCoerced; // @todo: add option to set multiple keys at once - function set>(key: Key, value: Static[Key]): void { + function set>( + key: Key, + value: s.StaticCoerced[Key], + ): void { //console.log("set", key, value); - const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value }); + const update = parse(schema, { ...decodeSearch(searchString), [key]: value }); const search = transform( update as any, (result, value, key) => { if (defaultValue && isEqual(value, defaultValue[key])) return; result[key] = value; }, - {} as Static, + {} as s.StaticCoerced, ); const encoded = encodeSearch(search, { encode: false }); navigate(location + (encoded.length > 0 ? "?" + encoded : "")); } return { - value: value as Required>, + value: value as Required>, set, }; } diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 5d61bbf..9018e29 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -13,6 +13,7 @@ import { import type { IconType } from "react-icons"; import { twMerge } from "tailwind-merge"; import { IconButton } from "ui/components/buttons/IconButton"; +import { useRoutePathState } from "ui/hooks/use-route-path-state"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { appShellStore } from "ui/store"; import { useLocation } from "wouter"; @@ -376,6 +377,15 @@ export function Scrollable({ ); } +type SectionHeaderAccordionItemProps = { + title: string; + open: boolean; + toggle: () => void; + ActiveIcon?: any; + children?: React.ReactNode; + renderHeaderRight?: (props: { open: boolean }) => React.ReactNode; +}; + export const SectionHeaderAccordionItem = ({ title, open, @@ -383,14 +393,7 @@ export const SectionHeaderAccordionItem = ({ ActiveIcon = IconChevronUp, children, renderHeaderRight, -}: { - title: string; - open: boolean; - toggle: () => void; - ActiveIcon?: any; - children?: React.ReactNode; - renderHeaderRight?: (props: { open: boolean }) => React.ReactNode; -}) => ( +}: SectionHeaderAccordionItemProps) => (
); +export const RouteAwareSectionHeaderAccordionItem = ({ + routePattern, + identifier, + ...props +}: Omit & { + // it's optional because it could be provided using the context + routePattern?: string; + identifier: string; +}) => { + const { active, toggle } = useRoutePathState(routePattern, identifier); + return ; +}; + export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
); diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 866535e..55d93d5 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -1,5 +1,5 @@ import { SegmentedControl, Tooltip } from "@mantine/core"; -import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react"; +import { IconApi, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react"; import { TbDatabase, TbFingerprint, @@ -159,6 +159,11 @@ function UserMenu() { const items: DropdownItem[] = [ { label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }, + { + label: "OpenAPI", + onClick: () => window.open("/api/system/swagger", "_blank"), + icon: IconApi, + }, ]; if (config.auth.enabled) { @@ -166,7 +171,8 @@ function UserMenu() { items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); } else { items.push({ - label: `Logout ${auth.user.email}`, + label: "Logout", + title: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff, }); diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 7e68a96..8fbc3b7 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -33,6 +33,8 @@ import { } from "ui/components/form/json-schema-form"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "../../layouts/AppShell/AppShell"; +import { CollapsibleList } from "ui/components/list/CollapsibleList"; +import { useRoutePathState } from "ui/hooks/use-route-path-state"; export function AuthStrategiesList(props) { useBrowserTitle(["Auth", "Strategies"]); @@ -104,7 +106,7 @@ function AuthStrategiesListInternal() {

Allow users to sign in or sign up using different strategies.

-
+ @@ -113,7 +115,7 @@ function AuthStrategiesListInternal() { -
+
@@ -138,47 +140,40 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { ]), ); const schema = schemas[type]; - const [open, setOpen] = useState(false); + + const { active, toggle } = useRoutePathState("/strategies/:strategy?", name); if (!schema) return null; return ( -
0 && "border-red-500", - )} + 0} + className={ + unavailable ? "opacity-20 pointer-events-none cursor-not-allowed" : undefined + } > -
-
- -
-
- {autoFormatString(name)} -
-
- - setOpen((o) => !o)} - /> -
-
- {open && ( -
- -
- )} -
+ } + right={ + <> + + toggle(!active)} + /> + + } + > + {autoFormatString(name)} + + + + +
); }; diff --git a/app/src/ui/routes/auth/index.tsx b/app/src/ui/routes/auth/index.tsx index 47bdfdc..c672d1b 100644 --- a/app/src/ui/routes/auth/index.tsx +++ b/app/src/ui/routes/auth/index.tsx @@ -14,7 +14,7 @@ export default function AuthRoutes() { - + ); diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 6f7adea..bb83eaf 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -11,8 +11,7 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { routes } from "ui/lib/routes"; import { EntityForm } from "ui/modules/data/components/EntityForm"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; -import * as tbbox from "@sinclair/typebox"; -const { Type } = tbbox; +import { s } from "core/object/schema"; export function DataEntityCreate({ params }) { const { $data } = useBkndData(); @@ -29,7 +28,7 @@ export function DataEntityCreate({ params }) { const $q = useEntityMutate(entity.name); // @todo: use entity schema for prefilling - const search = useSearch(Type.Object({}), {}); + const search = useSearch(s.object({}), {}); function goBack() { window.history.go(-1); diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 757e10a..e71452d 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -1,4 +1,4 @@ -import { type Entity, querySchema } from "data"; +import { type Entity, repoQuery } from "data"; import { Fragment } from "react"; import { TbDots } from "react-icons/tb"; import { useApiQuery } from "ui/client"; @@ -14,20 +14,14 @@ import * as AppShell from "ui/layouts/AppShell/AppShell"; import { routes, useNavigate } from "ui/lib/routes"; import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; -import * as tbbox from "@sinclair/typebox"; -const { Type } = tbbox; +import { s } from "core/object/schema"; +import { pick } from "core/utils/objects"; -// @todo: migrate to Typebox -const searchSchema = Type.Composite( - [ - Type.Pick(querySchema, ["select", "where", "sort"]), - Type.Object({ - page: Type.Optional(Type.Number({ default: 1 })), - perPage: Type.Optional(Type.Number({ default: 10 })), - }), - ], - { additionalProperties: false }, -); +const searchSchema = s.partialObject({ + ...pick(repoQuery.properties, ["select", "where", "sort"]), + page: s.number({ default: 1 }).optional(), + perPage: s.number({ default: 10 }).optional(), +}); const PER_PAGE_OPTIONS = [5, 10, 25]; @@ -74,8 +68,6 @@ export function DataEntityList({ params }) { const sort = search.value.sort!; const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" }; - // // @ts-expect-error - somehow all search keys are optional - console.log("new sort", newSort); search.set("sort", newSort as any); } diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index e93d7f2..0831284 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -30,14 +30,10 @@ import { routes, useNavigate } from "ui/lib/routes"; import { fieldSpecs } from "ui/modules/data/components/fields-specs"; import { extractSchema } from "../settings/utils/schema"; import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form"; +import { RoutePathStateProvider } from "ui/hooks/use-route-path-state"; export function DataSchemaEntity({ params }) { const { $data } = useBkndData(); - const [value, setValue] = useState("fields"); - - function toggle(value) { - return () => setValue(value); - } const [navigate] = useNavigate(); const entity = $data.entity(params.entity as string)!; @@ -46,7 +42,7 @@ export function DataSchemaEntity({ params }) { } return ( - <> + @@ -109,13 +105,12 @@ export function DataSchemaEntity({ params }) {
- + - - + - - + - +
- + ); } -const Fields = ({ - entity, - open, - toggle, -}: { entity: Entity; open: boolean; toggle: () => void }) => { +const Fields = ({ entity }: { entity: Entity }) => { const [submitting, setSubmitting] = useState(false); const [updates, setUpdates] = useState(0); const { actions, $data } = useBkndData(); @@ -174,10 +164,9 @@ const Fields = ({ const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any; return ( - open ? ( @@ -192,6 +181,7 @@ const Fields = ({
)} )}
-
+ ); }; -const BasicSettings = ({ - entity, - open, - toggle, -}: { entity: Entity; open: boolean; toggle: () => void }) => { +const BasicSettings = ({ entity }: { entity: Entity }) => { const d = useBkndData(); const config = d.entities?.[entity.name]?.config; const formRef = useRef(null); @@ -271,10 +257,9 @@ const BasicSettings = ({ } return ( - open ? ( @@ -293,6 +278,6 @@ const BasicSettings = ({ className="legacy hide-required-mark fieldset-alternative mute-root" />
- + ); }; diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index 2b7eb26..a0ca16c 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -27,6 +27,7 @@ import { Popover } from "ui/components/overlay/Popover"; import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-specs"; import { dataFieldsUiSchema } from "../../settings/routes/data.settings"; import * as tbbox from "@sinclair/typebox"; +import { useRoutePathState } from "ui/hooks/use-route-path-state"; const { Type } = tbbox; const fieldsSchemaObject = originalFieldsSchemaObject; @@ -63,6 +64,7 @@ export type EntityFieldsFormProps = { onChange?: (formData: TAppDataEntityFields) => void; sortable?: boolean; additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[]; + routePattern?: string; }; export type EntityFieldsFormRef = { @@ -74,7 +76,10 @@ export type EntityFieldsFormRef = { }; export const EntityFieldsForm = forwardRef( - function EntityFieldsForm({ fields: _fields, sortable, additionalFieldTypes, ...props }, ref) { + function EntityFieldsForm( + { fields: _fields, sortable, additionalFieldTypes, routePattern, ...props }, + ref, + ) { const entityFields = Object.entries(_fields).map(([name, field]) => ({ name, field, @@ -166,6 +171,7 @@ export const EntityFieldsForm = forwardRef )} /> @@ -179,6 +185,7 @@ export const EntityFieldsForm = forwardRef ))}
@@ -273,6 +280,7 @@ function EntityField({ remove, errors, dnd, + routePattern, }: { field: FieldArrayWithId; index: number; @@ -283,11 +291,12 @@ function EntityField({ remove: (index: number) => void; errors: any; dnd?: SortableItemProps; + routePattern?: string; }) { - const [opened, handlers] = useDisclosure(false); const prefix = `fields.${index}.field` as const; const type = field.field.type; const name = watch(`fields.${index}.name`); + const { active, toggle } = useRoutePathState(routePattern ?? "", name); const fieldSpec = fieldSpecs.find((s) => s.type === type)!; const specificData = omit(field.field.config, commonProps); const disabled = fieldSpec.disabled || []; @@ -300,9 +309,11 @@ function EntityField({ return () => { if (name.length === 0) { remove(index); - return; + toggle(); + } else if (window.confirm(`Sure to delete "${name}"?`)) { + remove(index); + toggle(); } - window.confirm(`Sure to delete "${name}"?`) && remove(index); }; } //console.log("register", register(`${prefix}.config.required`)); @@ -313,7 +324,7 @@ function EntityField({ key={field.id} className={twMerge( "flex flex-col border border-muted rounded bg-background mb-2", - opened && "mb-6", + active && "mb-6", hasErrors && "border-red-500 ", )} {...dndProps} @@ -371,13 +382,13 @@ function EntityField({ Icon={TbSettings} disabled={is_primary} iconProps={{ strokeWidth: 1.5 }} - onClick={handlers.toggle} - variant={opened ? "primary" : "ghost"} + onClick={() => toggle()} + variant={active ? "primary" : "ghost"} />
- {opened && ( + {active && (
{/*
{JSON.stringify(field, null, 2)}
*/} diff --git a/app/src/ui/routes/data/index.tsx b/app/src/ui/routes/data/index.tsx index bade982..f7735fd 100644 --- a/app/src/ui/routes/data/index.tsx +++ b/app/src/ui/routes/data/index.tsx @@ -17,7 +17,7 @@ export default function DataRoutes() { - + diff --git a/app/src/ui/styles.css b/app/src/ui/styles.css index 77a0645..723e2dc 100644 --- a/app/src/ui/styles.css +++ b/app/src/ui/styles.css @@ -49,6 +49,7 @@ input[type="date"]::-webkit-calendar-picker-indicator { .cm-editor { display: flex; flex: 1; + max-width: 100%; } .animate-fade-in { diff --git a/app/vite.config.ts b/app/vite.config.ts index 1983c95..1e28989 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -4,11 +4,13 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { devServerConfig } from "./src/adapter/vite/dev-server-config"; import tailwindcss from "@tailwindcss/vite"; +import pkg from "./package.json" with { type: "json" }; // https://vitejs.dev/config/ export default defineConfig({ define: { __isDev: process.env.NODE_ENV === "production" ? "0" : "1", + __version: JSON.stringify(pkg.version), }, clearScreen: false, publicDir: "./src/ui/assets", diff --git a/bun.lock b/bun.lock index a35322e..9744812 100644 --- a/bun.lock +++ b/bun.lock @@ -27,18 +27,18 @@ }, "app": { "name": "bknd", - "version": "0.11.0", + "version": "0.12.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.2", "@hello-pangea/dnd": "^18.0.1", + "@hono/swagger-ui": "^0.5.1", "@libsql/client": "^0.15.2", "@mantine/core": "^7.17.1", "@mantine/hooks": "^7.17.1", - "@sinclair/typebox": "^0.34.30", + "@sinclair/typebox": "0.34.30", "@tanstack/react-form": "^1.0.5", "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", @@ -48,17 +48,14 @@ "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", "json-schema-form-react": "^0.0.2", - "json-schema-library": "^10.0.0-rc7", + "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", "kysely": "^0.27.6", - "liquidjs": "^10.21.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", - "picocolors": "^1.1.1", "radix-ui": "^1.1.3", "swr": "^2.3.3", - "wrangler": "^4.4.1", }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -87,9 +84,11 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", + "jsonv-ts": "^0.0.14-alpha.6", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", + "picocolors": "^1.1.1", "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", @@ -514,8 +513,6 @@ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250311.0" }, "optionalPeers": ["workerd"] }, "sha512-AaKYnbFpHaVDZGh3Hjy3oLYd12+LZw9aupAOudYJ+tjekahxcIqlSAr0zK9kPOdtgn10tzaqH7QJFUWcLE+k7g=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250224.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-sBbaAF2vgQ9+T50ik1ihekdepStBp0w4fvNghBfXIw1iWqfNWnypcjDMmi/7JhXJt2uBxBrSlXCvE5H7Gz+kbw=="], "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250224.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-naetGefgjAaDbEacpwaVruJXNwxmRRL7v3ppStgEiqAlPmTpQ/Edjn2SQ284QwOw3MvaVPHrWcaTBupUpkqCyg=="], @@ -542,8 +539,6 @@ "@codemirror/lang-json": ["@codemirror/lang-json@6.0.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ=="], - "@codemirror/lang-liquid": ["@codemirror/lang-liquid@6.2.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.1" } }, "sha512-7Dm841fk37+JQW6j2rI1/uGkJyESrjzyhiIkaLjbbR0U6aFFQvMrJn35WxQreRMADMhzkyVkZM4467OR7GR8nQ=="], - "@codemirror/language": ["@codemirror/language@6.10.8", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw=="], "@codemirror/lint": ["@codemirror/lint@6.8.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A=="], @@ -646,6 +641,8 @@ "@hono/node-server": ["@hono/node-server@1.13.8", "", { "peerDependencies": { "hono": "^4" } }, "sha512-fsn8ucecsAXUoVxrUil0m13kOEq4mkX4/4QozCqmY+HpGfKl74OYSn8JcMA8GnG0ClfdRI4/ZSeG7zhFaVg+wg=="], + "@hono/swagger-ui": ["@hono/swagger-ui@0.5.1", "", { "peerDependencies": { "hono": "*" } }, "sha512-XpUCfszLJ9b1rtFdzqOSHfdg9pfBiC2J5piEjuSanYpDDTIwpMz0ciiv5N3WWUaQpz9fEgH8lttQqL41vIFuDA=="], + "@hono/typebox-validator": ["@hono/typebox-validator@0.3.2", "", { "peerDependencies": { "@sinclair/typebox": ">=0.31.15 <1", "hono": ">=3.9.0" } }, "sha512-MIxYk80vtuFnkvbNreMubZ/vLoNCCQivLH8n3vNDY5dFNsZ12BFaZV3FmsLJHGibNMMpmkO6y4w5gNWY4KzSdg=="], "@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="], @@ -2038,8 +2035,6 @@ "express-rate-limit": ["express-rate-limit@5.5.1", "", {}, "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg=="], - "exsolve": ["exsolve@1.0.4", "", {}, "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], @@ -2526,6 +2521,8 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="], + "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], "jsprim": ["jsprim@2.0.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ=="], @@ -2586,8 +2583,6 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "liquidjs": ["liquidjs@10.21.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-DouqxNU2jfoZzb1LinVjOc/f6ssitGIxiDJT+kEKyYqPSSSd+WmGOAhtWbVm1/n75svu4aQ+FyQ3ctd3wh1bbw=="], - "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], "locate-app": ["locate-app@2.5.0", "", { "dependencies": { "@promptbook/utils": "0.69.5", "type-fest": "4.26.0", "userhome": "1.0.1" } }, "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q=="], @@ -3858,8 +3853,6 @@ "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], - "@cloudflare/unenv-preset/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="], - "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -4164,8 +4157,6 @@ "bknd/vitest": ["vitest@3.0.9", "", { "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", "@vitest/pretty-format": "^3.0.9", "@vitest/runner": "3.0.9", "@vitest/snapshot": "3.0.9", "@vitest/spy": "3.0.9", "@vitest/utils": "3.0.9", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.0.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.0.9", "@vitest/ui": "3.0.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ=="], - "bknd/wrangler": ["wrangler@4.4.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.0", "blake3-wasm": "2.1.5", "esbuild": "0.24.2", "miniflare": "4.20250321.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.15", "workerd": "1.20250321.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250321.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EFwr7hiVeAmPOuOGQ7HFfeaLKLxEXQMJ86kyn6RFB8pGjMEUtvZMsVa9cPubKkKgNi3WcDEFeFLalclGyq+tGA=="], - "bknd-cli/@libsql/client": ["@libsql/client@0.14.0", "", { "dependencies": { "@libsql/core": "^0.14.0", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.4.4", "promise-limit": "^2.7.0" } }, "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -4428,8 +4419,6 @@ "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], - "liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], - "locate-app/type-fest": ["type-fest@4.26.0", "", {}, "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw=="], "log-update/ansi-escapes": ["ansi-escapes@3.2.0", "", {}, "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ=="], @@ -4708,8 +4697,6 @@ "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], - "@cloudflare/unenv-preset/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], @@ -4790,16 +4777,6 @@ "bknd-cli/@libsql/client/libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="], - "bknd/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], - - "bknd/wrangler/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], - - "bknd/wrangler/miniflare": ["miniflare@4.20250321.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250321.0", "ws": "8.18.0", "youch": "3.2.3", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-os+NJA7Eqi00BJHdVhzIa+3PMotnCtZg3hiUIRYcsZF5W7He8SK2EkV8csAb+npZq3jZ4SNpDebO01swM5dcWw=="], - - "bknd/wrangler/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="], - - "bknd/wrangler/workerd": ["workerd@1.20250321.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250321.0", "@cloudflare/workerd-darwin-arm64": "1.20250321.0", "@cloudflare/workerd-linux-64": "1.20250321.0", "@cloudflare/workerd-linux-arm64": "1.20250321.0", "@cloudflare/workerd-windows-64": "1.20250321.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vyuz9pdJ+7o1lC79vQ2UVRLXPARa2Lq94PbTfqEcYQeSxeR9X+YqhNq2yysv8Zs5vpokmexLCtMniPp9u+2LVQ=="], - "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "class-utils/define-property/is-descriptor": ["is-descriptor@0.1.7", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg=="], @@ -5304,68 +5281,6 @@ "bknd-cli/@libsql/client/libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], - "bknd/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], - - "bknd/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], - - "bknd/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], - - "bknd/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], - - "bknd/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], - - "bknd/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], - - "bknd/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], - - "bknd/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], - - "bknd/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], - - "bknd/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], - - "bknd/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], - - "bknd/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], - - "bknd/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], - - "bknd/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], - - "bknd/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], - - "bknd/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], - - "bknd/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], - - "bknd/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], - - "bknd/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], - - "bknd/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], - - "bknd/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], - - "bknd/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], - - "bknd/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], - - "bknd/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], - - "bknd/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], - - "bknd/wrangler/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - - "bknd/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250321.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-y273GfLaNCxkL8hTfo0c8FZKkOPdq+CPZAKJXPWB+YpS1JCOULu6lNTptpD7ZtF14dTYPkn5Weug31TTlviJmw=="], - - "bknd/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250321.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qvf7/gkkQq7fAsoMlntJSimN/WfwQqxi2oL0aWZMGodTvs/yRHO2I4oE0eOihVdK1BXyBHJXNxEvNDBjF0+Yuw=="], - - "bknd/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250321.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AEp3xjWFrNPO/h0StCOgOb0bWCcNThnkESpy91Wf4mfUF2p7tOCdp37Nk/1QIRqSxnfv4Hgxyi7gcWud9cJuMw=="], - - "bknd/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250321.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wRWyMIoPIS1UBXCisW0FYTgGsfZD4AVS0hXA5nuLc0c21CvzZpmmTjqEWMcwPFenwy/MNL61NautVOC4qJqQ3Q=="], - - "bknd/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250321.0", "", { "os": "win32", "cpu": "x64" }, "sha512-8vYP3QYO0zo2faUDfWl88jjfUvz7Si9GS3mUYaTh/TR9LcAUtsO7muLxPamqEyoxNFtbQgy08R4rTid94KRi3w=="], - "eslint/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "eslint/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],