diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 224f18f3..d9f51ee5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,21 @@ jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: bknd + ports: + - 5430:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 @@ -20,11 +35,11 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.3.2" + bun-version: "1.3.3" - name: Install dependencies working-directory: ./app - run: bun install + run: bun install #--linker=hoisted - name: Build working-directory: ./app diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index 1b0bfa76..02a35a6b 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -67,7 +67,7 @@ describe("MediaApi", () => { const res = await mockedBackend.request("/api/media/file/" + name); await Bun.write(path, res); - const file = await Bun.file(path); + const file = Bun.file(path); expect(file.size).toBeGreaterThan(0); expect(file.type).toBe("image/png"); await file.delete(); @@ -154,15 +154,12 @@ describe("MediaApi", () => { } // upload via readable from bun - await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); + await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); // upload via readable from response { const response = (await mockedBackend.request(url)) as Response; - await matches( - await api.upload(response.body!, { filename: "readable.png" }), - "readable.png", - ); + await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png"); } }); }); diff --git a/app/__test__/app/code-only.test.ts b/app/__test__/app/code-only.test.ts index 26fb8e9f..003c1e40 100644 --- a/app/__test__/app/code-only.test.ts +++ b/app/__test__/app/code-only.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, mock, test } from "bun:test"; +import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test"; import { createApp as internalCreateApp, type CreateAppConfig } from "bknd"; import { getDummyConnection } from "../../__test__/helper"; import { ModuleManager } from "modules/ModuleManager"; import { em, entity, text } from "data/prototype"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); async function createApp(config: CreateAppConfig = {}) { const app = internalCreateApp({ diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts index de52198f..63f6880a 100644 --- a/app/__test__/app/mcp/mcp.system.test.ts +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -1,7 +1,11 @@ import { AppEvents } from "App"; -import { describe, test, expect, beforeAll, mock } from "bun:test"; +import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import type { McpServer } from "bknd/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); /** * - [x] system_config diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index caa55663..2f920ff6 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -1,8 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Permission } from "auth/authorize/Permission"; import { Role, type RoleSchema } from "auth/authorize/Role"; import { objectTransform, s } from "bknd/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); function createGuard( permissionNames: string[], diff --git a/app/__test__/auth/authorize/data.permissions.test.ts b/app/__test__/auth/authorize/data.permissions.test.ts index 6ff0c3ee..5b796c23 100644 --- a/app/__test__/auth/authorize/data.permissions.test.ts +++ b/app/__test__/auth/authorize/data.permissions.test.ts @@ -7,8 +7,8 @@ import type { App, DB } from "bknd"; import type { CreateUserPayload } from "auth/AppAuth"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; -beforeAll(() => disableConsoleLog()); -afterAll(() => enableConsoleLog()); +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); async function makeApp(config: Partial = {}) { const app = createApp({ diff --git a/app/__test__/auth/authorize/http/DataController.test.ts b/app/__test__/auth/authorize/http/DataController.test.ts new file mode 100644 index 00000000..0a4fc528 --- /dev/null +++ b/app/__test__/auth/authorize/http/DataController.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { em, entity, text } from "data/prototype"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +const schema = em( + { + posts: entity("posts", { + title: text(), + content: text(), + }), + comments: entity("comments", { + content: text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(posts).manyToOne(comments); + }, +); + +describe("DataController (auth)", () => { + test("reading schema.json", async () => { + const { request } = await createAuthTestApp( + { + permission: ["system.access.api", "data.entity.read", "system.schema.read"], + request: new Request("http://localhost/api/data/schema.json"), + }, + { + config: { data: schema.toJSON() }, + }, + ); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts deleted file mode 100644 index 40e6493e..00000000 --- a/app/__test__/auth/authorize/http/SystemController.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { SystemController } from "modules/server/SystemController"; -import { createApp } from "core/test/utils"; -import type { CreateAppConfig } from "App"; -import { getPermissionRoutes } from "auth/middlewares/permission.middleware"; - -async function makeApp(config: Partial = {}) { - const app = createApp(config); - await app.build(); - return app; -} - -describe.skip("SystemController", () => { - it("...", async () => { - const app = await makeApp(); - const controller = new SystemController(app); - const hono = controller.getController(); - console.log(getPermissionRoutes(hono)); - }); -}); diff --git a/app/__test__/auth/authorize/http/SystemController.test.ts b/app/__test__/auth/authorize/http/SystemController.test.ts new file mode 100644 index 00000000..7974a0d9 --- /dev/null +++ b/app/__test__/auth/authorize/http/SystemController.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("SystemController (auth)", () => { + test("reading info", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.info"], + request: new Request("http://localhost/api/system/info"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("reading permissions", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.schema.read"], + request: new Request("http://localhost/api/system/permissions"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("access openapi", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.openapi"], + request: new Request("http://localhost/api/system/openapi.json"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/shared.ts b/app/__test__/auth/authorize/http/shared.ts new file mode 100644 index 00000000..7ec79f97 --- /dev/null +++ b/app/__test__/auth/authorize/http/shared.ts @@ -0,0 +1,171 @@ +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import type { RoleSchema } from "auth/authorize/Role"; +import { isPlainObject } from "core/utils"; + +export type AuthTestConfig = { + guest?: RoleSchema; + member?: RoleSchema; + authorized?: RoleSchema; +}; + +export async function createAuthTestApp( + testConfig: { + permission: AuthTestConfig | string | string[]; + request: Request; + }, + config: Partial = {}, +) { + let member: RoleSchema | undefined; + let authorized: RoleSchema | undefined; + let guest: RoleSchema | undefined; + if (isPlainObject(testConfig.permission)) { + if (testConfig.permission.guest) + guest = { + ...testConfig.permission.guest, + is_default: true, + }; + if (testConfig.permission.member) member = testConfig.permission.member; + if (testConfig.permission.authorized) authorized = testConfig.permission.authorized; + } else { + member = { + permissions: [], + }; + authorized = { + permissions: Array.isArray(testConfig.permission) + ? testConfig.permission + : [testConfig.permission], + }; + guest = { + permissions: [], + is_default: true, + }; + } + + console.log("authorized", authorized); + + const app = createApp({ + ...config, + config: { + ...config.config, + auth: { + ...config.config?.auth, + enabled: true, + guard: { + enabled: true, + ...config.config?.auth?.guard, + }, + jwt: { + ...config.config?.auth?.jwt, + secret: "secret", + }, + roles: { + ...config.config?.auth?.roles, + guest, + member, + authorized, + admin: { + implicit_allow: true, + }, + }, + }, + }, + }); + await app.build(); + + const users = { + guest: null, + member: await app.createUser({ + email: "member@test.com", + password: "12345678", + role: "member", + }), + authorized: await app.createUser({ + email: "authorized@test.com", + password: "12345678", + role: "authorized", + }), + admin: await app.createUser({ + email: "admin@test.com", + password: "12345678", + role: "admin", + }), + } as const; + + const tokens = {} as Record; + for (const [key, user] of Object.entries(users)) { + if (user) { + tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user); + } + } + + async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) { + const headers = new Headers(init.headers ?? {}); + if (user in tokens) { + headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`); + } + const res = await app.server.request(input, { + ...init, + headers, + }); + + let data: any; + if (res.headers.get("Content-Type")?.startsWith("application/json")) { + data = await res.json(); + } else if (res.headers.get("Content-Type")?.startsWith("text/")) { + data = await res.text(); + } + + return { + status: res.status, + ok: res.ok, + headers: Object.fromEntries(res.headers.entries()), + data, + }; + } + + const requestFn = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async (input: string, init: RequestInit = {}) => { + return makeRequest(prop, input, init); + }; + }, + }, + ) as { + [K in keyof typeof users]: ( + input: string, + init?: RequestInit, + ) => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + const request = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async () => { + return makeRequest(prop, testConfig.request.url, { + headers: testConfig.request.headers, + method: testConfig.request.method, + body: testConfig.request.body, + }); + }; + }, + }, + ) as { + [K in keyof typeof users]: () => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + return { app, users, request, requestFn }; +} diff --git a/app/__test__/data/postgres.test.ts b/app/__test__/data/postgres.test.ts new file mode 100644 index 00000000..f9016cd5 --- /dev/null +++ b/app/__test__/data/postgres.test.ts @@ -0,0 +1,85 @@ +import { describe, beforeAll, afterAll, test } from "bun:test"; +import type { PostgresConnection } from "data/connection/postgres"; +import { pg, postgresJs } from "bknd"; +import { Pool } from "pg"; +import postgres from 'postgres' +import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils"; +import { $ } from "bun"; +import { connectionTestSuite } from "data/connection/connection-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +const credentials = { + host: "localhost", + port: 5430, + user: "postgres", + password: "postgres", + database: "bknd", +}; + +async function cleanDatabase(connection: InstanceType) { + const kysely = connection.kysely; + + // drop all tables+indexes & create new schema + await kysely.schema.dropSchema("public").ifExists().cascade().execute(); + await kysely.schema.dropIndex("public").ifExists().cascade().execute(); + await kysely.schema.createSchema("public").execute(); +} + +async function isPostgresRunning() { + try { + // Try to actually connect to PostgreSQL + const conn = pg({ pool: new Pool(credentials) }); + await conn.ping(); + await conn.close(); + return true; + } catch (e) { + return false; + } +} + +describe("postgres", () => { + beforeAll(async () => { + if (!(await isPostgresRunning())) { + await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`; + await $waitUntil("Postgres is running", isPostgresRunning); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + disableConsoleLog(); + }); + afterAll(async () => { + if (await isPostgresRunning()) { + try { + await $`docker stop bknd-test-postgres`; + } catch (e) {} + } + + enableConsoleLog(); + }); + + describe.serial.each([ + ["pg", () => pg({ pool: new Pool(credentials) })], + ["postgresjs", () => postgresJs({ postgres: postgres(credentials) })], + ])("%s", (name, createConnection) => { + connectionTestSuite( + { + ...bunTestRunner, + test: test.serial, + }, + { + makeConnection: () => { + const connection = createConnection(); + return { + connection, + dispose: async () => { + await cleanDatabase(connection); + await connection.close(); + }, + }; + }, + rawDialectDetails: [], + disableConsoleLog: false, + }, + ); + }); +}); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index d9b2dc2f..efb987de 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -124,6 +124,81 @@ describe("[Repository]", async () => { .then((r) => [r.count, r.total]), ).resolves.toEqual([undefined, undefined]); }); + + test("auto join", async () => { + const schema = $em( + { + posts: $entity("posts", { + title: $text(), + content: $text(), + }), + comments: $entity("comments", { + content: $text(), + }), + another: $entity("another", { + title: $text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(comments).manyToOne(posts); + }, + ); + const em = schema.proto.withConnection(getDummyConnection().dummyConnection); + await em.schema().sync({ force: true }); + + await em.mutator("posts").insertOne({ title: "post1", content: "content1" }); + await em + .mutator("comments") + .insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any); + + const res = await em.repo("comments").findMany({ + where: { + "posts.title": "post1", + }, + }); + expect(res.data as any).toEqual([ + { + id: 1, + content: "comment1", + posts_id: 1, + }, + ]); + + { + // manual join should still work + const res = await em.repo("comments").findMany({ + join: ["posts"], + where: { + "posts.title": "post1", + }, + }); + expect(res.data as any).toEqual([ + { + id: 1, + content: "comment1", + posts_id: 1, + }, + ]); + } + + // inexistent should be detected and thrown + expect( + em.repo("comments").findMany({ + where: { + "random.title": "post1", + }, + }), + ).rejects.toThrow(/Invalid where field/); + + // existing alias, but not a relation should throw + expect( + em.repo("comments").findMany({ + where: { + "another.title": "post1", + }, + }), + ).rejects.toThrow(/Invalid where field/); + }); }); describe("[data] Repository (Events)", async () => { diff --git a/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts b/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts index ee46b7bc..a884674c 100644 --- a/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts +++ b/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts @@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => { dataType: "INTEGER", isNullable: false, isAutoIncrementing: true, - hasDefaultValue: false, + hasDefaultValue: true, comment: undefined, }, { @@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => { dataType: "INTEGER", isNullable: false, isAutoIncrementing: true, - hasDefaultValue: false, + hasDefaultValue: true, comment: undefined, }, { diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index bf625998..3eae83e9 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(() => { - //disableConsoleLog(); + disableConsoleLog(); registries.media.register("local", StorageLocalAdapter); }); afterAll(enableConsoleLog); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 89872def..14cc5c53 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -10,12 +10,6 @@ beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("AppAuth", () => { - test.skip("...", () => { - const auth = new AppAuth({}); - console.log(auth.toJSON()); - console.log(auth.config); - }); - moduleTestSuite(AppAuth); let ctx: ModuleBuildContext; @@ -39,11 +33,9 @@ describe("AppAuth", () => { await auth.build(); const oldConfig = auth.toJSON(true); - //console.log(oldConfig); await auth.schema().patch("enabled", true); await auth.build(); const newConfig = auth.toJSON(true); - //console.log(newConfig); expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); }); @@ -69,7 +61,6 @@ describe("AppAuth", () => { const app = new AuthController(auth).getController(); { - disableConsoleLog(); const res = await app.request("/password/register", { method: "POST", headers: { @@ -80,7 +71,6 @@ describe("AppAuth", () => { password: "12345678", }), }); - enableConsoleLog(); expect(res.status).toBe(200); const { data: users } = await ctx.em.repository("users").findMany(); @@ -119,7 +109,6 @@ describe("AppAuth", () => { const app = new AuthController(auth).getController(); { - disableConsoleLog(); const res = await app.request("/password/register", { method: "POST", headers: { @@ -130,7 +119,6 @@ describe("AppAuth", () => { password: "12345678", }), }); - enableConsoleLog(); expect(res.status).toBe(200); const { data: users } = await ctx.em.repository("users").findMany(); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index fb5464a1..5a1f6ce1 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { createApp } from "core/test/utils"; import { em, entity, text } from "data/prototype"; import { registries } from "modules/registries"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { AppMedia } from "../../src/media/AppMedia"; import { moduleTestSuite } from "./module-test-suite"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("AppMedia", () => { test.skip("...", () => { diff --git a/app/__test__/modules/DbModuleManager.spec.ts b/app/__test__/modules/DbModuleManager.spec.ts index b85ebbfd..657ab060 100644 --- a/app/__test__/modules/DbModuleManager.spec.ts +++ b/app/__test__/modules/DbModuleManager.spec.ts @@ -1,7 +1,11 @@ -import { it, expect, describe } from "bun:test"; +import { it, expect, describe, beforeAll, afterAll } from "bun:test"; import { DbModuleManager } from "modules/db/DbModuleManager"; import { getDummyConnection } from "../helper"; import { TABLE_NAME } from "modules/db/migrations"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("DbModuleManager", () => { it("should extract secrets", async () => { diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index de5c889a..5448c783 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema"; import { Connection } from "data/connection/Connection"; import { entity, text } from "data/prototype"; -beforeAll(disableConsoleLog); +beforeAll(() => disableConsoleLog()); afterAll(enableConsoleLog); describe("ModuleManager", async () => { @@ -82,7 +82,6 @@ describe("ModuleManager", async () => { }, }, } as any; - //const { version, ...json } = mm.toJSON() as any; const { dummyConnection } = getDummyConnection(); const db = dummyConnection.kysely; @@ -97,10 +96,6 @@ describe("ModuleManager", async () => { await mm2.build(); - /* console.log({ - json, - configs: mm2.configs(), - }); */ //expect(stripMark(json)).toEqual(stripMark(mm2.configs())); expect(mm2.configs().data.entities?.test).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); @@ -228,8 +223,6 @@ describe("ModuleManager", async () => { const c = getDummyConnection(); const mm = new ModuleManager(c.dummyConnection); await mm.build(); - console.log("==".repeat(30)); - console.log(""); const json = mm.configs(); const c2 = getDummyConnection(); @@ -275,7 +268,6 @@ describe("ModuleManager", async () => { } override async build() { - //console.log("building FailingModule", this.config); if (this.config.value && this.config.value < 0) { throw new Error("value must be positive, given: " + this.config.value); } @@ -296,9 +288,6 @@ describe("ModuleManager", async () => { } } - beforeEach(() => disableConsoleLog(["log", "warn", "error"])); - afterEach(enableConsoleLog); - test("it builds", async () => { const { dummyConnection } = getDummyConnection(); const mm = new TestModuleManager(dummyConnection); diff --git a/app/build.ts b/app/build.ts index 3de4e2aa..599ce821 100644 --- a/app/build.ts +++ b/app/build.ts @@ -186,6 +186,8 @@ async function buildUiElements() { outDir: "dist/ui/elements", external: [ "ui/client", + "bknd", + /^bknd\/.*/, "react", "react-dom", "react/jsx-runtime", diff --git a/app/package.json b/app/package.json index 1870b717..8e36a109 100644 --- a/app/package.json +++ b/app/package.json @@ -13,7 +13,7 @@ "bugs": { "url": "https://github.com/bknd-io/bknd/issues" }, - "packageManager": "bun@1.3.2", + "packageManager": "bun@1.3.3", "engines": { "node": ">=22.13" }, @@ -99,6 +99,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/node": "^24.10.0", + "@types/pg": "^8.15.6", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^5.1.0", @@ -110,14 +111,17 @@ "jotai": "^2.12.2", "jsdom": "^26.1.0", "kysely-generic-sqlite": "^1.2.1", + "kysely-postgres-js": "^2.0.0", "libsql": "^0.5.22", "libsql-stateless-easy": "^1.8.0", "miniflare": "^4.20251011.2", "open": "^10.2.0", "openapi-types": "^12.1.3", + "pg": "^8.16.3", "postcss": "^8.5.3", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "postgres": "^3.4.7", "posthog-js-lite": "^3.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts index 76a067ec..7e8bc750 100644 --- a/app/src/core/events/EventListener.ts +++ b/app/src/core/events/EventListener.ts @@ -1,3 +1,4 @@ +import type { MaybePromise } from "bknd"; import type { Event } from "./Event"; import type { EventClass } from "./EventManager"; @@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number]; export type ListenerHandler> = ( event: E, slug: string, -) => E extends Event ? R | Promise : never; +) => E extends Event ? MaybePromise : never; export class EventListener { mode: ListenerMode = "async"; diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 33c6a436..afdf1832 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record { export function omitKeys( obj: T, - keys_: readonly K[], + keys_: readonly K[] | K[] | string[], ): Omit> { - const keys = new Set(keys_); + const keys = new Set(keys_ as readonly K[]); const result = {} as Omit>; for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { if (!keys.has(key as K)) { diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 5b943ff4..e43a8ade 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -1,3 +1,4 @@ +import type { MaybePromise } from "core/types"; import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter"; /** @@ -77,3 +78,37 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) { return true; } } + +export async function threwAsync(fn: Promise, instance?: new (...args: any[]) => Error) { + try { + await fn; + return false; + } catch (e) { + if (instance) { + if (e instanceof instance) { + return true; + } + // if instance given but not what expected, throw + throw e; + } + return true; + } +} + +export async function $waitUntil( + message: string, + condition: () => MaybePromise, + delay = 100, + maxAttempts = 10, +) { + let attempts = 0; + while (attempts < maxAttempts) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + attempts++; + } + + throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`); +} diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 082ae0c9..531be0c7 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -96,6 +96,9 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -124,6 +127,9 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -161,7 +167,7 @@ export class DataController extends Controller { hono.get( "/types", permission(SystemPermissions.schemaRead, { - context: (c) => ({ module: "data" }), + context: (_c) => ({ module: "data" }), }), describeRoute({ summary: "Retrieve data typescript definitions", @@ -182,6 +188,9 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), diff --git a/app/src/data/connection/connection-test-suite.ts b/app/src/data/connection/connection-test-suite.ts index 270ccb08..bf45e0e5 100644 --- a/app/src/data/connection/connection-test-suite.ts +++ b/app/src/data/connection/connection-test-suite.ts @@ -14,27 +14,31 @@ export function connectionTestSuite( { makeConnection, rawDialectDetails, + disableConsoleLog: _disableConsoleLog = true, }: { makeConnection: () => MaybePromise<{ connection: Connection; dispose: () => MaybePromise; }>; rawDialectDetails: string[]; + disableConsoleLog?: boolean; }, ) { const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner; - beforeAll(() => disableConsoleLog()); - afterAll(() => enableConsoleLog()); + if (_disableConsoleLog) { + beforeAll(() => disableConsoleLog()); + afterAll(() => enableConsoleLog()); + } - describe("base", () => { - let ctx: Awaited>; - beforeEach(async () => { - ctx = await makeConnection(); - }); - afterEach(async () => { - await ctx.dispose(); - }); + let ctx: Awaited>; + beforeEach(async () => { + ctx = await makeConnection(); + }); + afterEach(async () => { + await ctx.dispose(); + }); + describe("base", async () => { test("pings", async () => { const res = await ctx.connection.ping(); expect(res).toBe(true); @@ -98,52 +102,54 @@ export function connectionTestSuite( }); describe("schema", async () => { - const { connection, dispose } = await makeConnection(); - afterAll(async () => { - await dispose(); - }); + const makeSchema = async () => { + const fields = [ + { + type: "integer", + name: "id", + primary: true, + }, + { + type: "text", + name: "text", + }, + { + type: "json", + name: "json", + }, + ] as const satisfies FieldSpec[]; - const fields = [ - { - type: "integer", - name: "id", - primary: true, - }, - { - type: "text", - name: "text", - }, - { - type: "json", - name: "json", - }, - ] as const satisfies FieldSpec[]; + let b = ctx.connection.kysely.schema.createTable("test"); + for (const field of fields) { + // @ts-expect-error + b = b.addColumn(...ctx.connection.getFieldSchema(field)); + } + await b.execute(); - let b = connection.kysely.schema.createTable("test"); - for (const field of fields) { - // @ts-expect-error - b = b.addColumn(...connection.getFieldSchema(field)); - } - await b.execute(); - - // add index - await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute(); + // add index + await ctx.connection.kysely.schema + .createIndex("test_index") + .on("test") + .columns(["id"]) + .execute(); + }; test("executes query", async () => { - await connection.kysely + await makeSchema(); + await ctx.connection.kysely .insertInto("test") .values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) }) .execute(); const expected = { id: 1, text: "test", json: { a: 1 } }; - const qb = connection.kysely.selectFrom("test").selectAll(); - const res = await connection.executeQuery(qb); + const qb = ctx.connection.kysely.selectFrom("test").selectAll(); + const res = await ctx.connection.executeQuery(qb); expect(res.rows).toEqual([expected]); expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true); { - const res = await connection.executeQueries(qb, qb); + const res = await ctx.connection.executeQueries(qb, qb); expect(res.length).toBe(2); res.map((r) => { expect(r.rows).toEqual([expected]); @@ -155,15 +161,21 @@ export function connectionTestSuite( }); test("introspects", async () => { - const tables = await connection.getIntrospector().getTables({ + await makeSchema(); + const tables = await ctx.connection.getIntrospector().getTables({ withInternalKyselyTables: false, }); const clean = tables.map((t) => ({ ...t, - columns: t.columns.map((c) => ({ - ...c, - dataType: undefined, - })), + columns: t.columns + .map((c) => ({ + ...c, + // ignore data type + dataType: undefined, + // ignore default value if "id" + hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined, + })) + .sort((a, b) => a.name.localeCompare(b.name)), })); expect(clean).toEqual([ @@ -176,14 +188,8 @@ export function connectionTestSuite( dataType: undefined, isNullable: false, isAutoIncrementing: true, - hasDefaultValue: false, - }, - { - name: "text", - dataType: undefined, - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, + hasDefaultValue: undefined, + comment: undefined, }, { name: "json", @@ -191,25 +197,34 @@ export function connectionTestSuite( isNullable: true, isAutoIncrementing: false, hasDefaultValue: false, + comment: undefined, + }, + { + name: "text", + dataType: undefined, + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + comment: undefined, + }, + ], + }, + ]); + + expect(await ctx.connection.getIntrospector().getIndices()).toEqual([ + { + name: "test_index", + table: "test", + isUnique: false, + columns: [ + { + name: "id", + order: 0, }, ], }, ]); }); - - expect(await connection.getIntrospector().getIndices()).toEqual([ - { - name: "test_index", - table: "test", - isUnique: false, - columns: [ - { - name: "id", - order: 0, - }, - ], - }, - ]); }); describe("integration", async () => { diff --git a/app/src/data/connection/postgres/PgPostgresConnection.ts b/app/src/data/connection/postgres/PgPostgresConnection.ts new file mode 100644 index 00000000..f9b54663 --- /dev/null +++ b/app/src/data/connection/postgres/PgPostgresConnection.ts @@ -0,0 +1,33 @@ +import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely"; +import { PostgresIntrospector } from "./PostgresIntrospector"; +import { PostgresConnection, plugins } from "./PostgresConnection"; +import { customIntrospector } from "../Connection"; +import type { Pool } from "pg"; + +export type PostgresDialectConfig = Omit & { + pool: Pool; +}; + +export class PgPostgresConnection extends PostgresConnection { + override name = "pg"; + + constructor(config: PostgresDialectConfig) { + const kysely = new Kysely({ + dialect: customIntrospector(PostgresDialect, PostgresIntrospector, { + excludeTables: [], + }).create(config), + plugins, + }); + + super(kysely); + this.client = config.pool; + } + + override async close(): Promise { + await this.client.end(); + } +} + +export function pg(config: PostgresDialectConfig): PgPostgresConnection { + return new PgPostgresConnection(config); +} diff --git a/packages/postgres/src/PostgresConnection.ts b/app/src/data/connection/postgres/PostgresConnection.ts similarity index 92% rename from packages/postgres/src/PostgresConnection.ts rename to app/src/data/connection/postgres/PostgresConnection.ts index ff67991c..b55df6d5 100644 --- a/packages/postgres/src/PostgresConnection.ts +++ b/app/src/data/connection/postgres/PostgresConnection.ts @@ -5,7 +5,7 @@ import { type SchemaResponse, type ConnQuery, type ConnQueryResults, -} from "bknd"; +} from "../Connection"; import { ParseJSONResultsPlugin, type ColumnDataType, @@ -20,7 +20,7 @@ export type QB = SelectQueryBuilder; export const plugins = [new ParseJSONResultsPlugin()]; -export abstract class PostgresConnection extends Connection { +export abstract class PostgresConnection extends Connection { protected override readonly supported = { batching: true, softscans: true, @@ -68,7 +68,7 @@ export abstract class PostgresConnection extends Connection { type, (col: ColumnDefinitionBuilder) => { if (spec.primary) { - return col.primaryKey(); + return col.primaryKey().notNull(); } if (spec.references) { return col @@ -76,7 +76,7 @@ export abstract class PostgresConnection extends Connection { .onDelete(spec.onDelete ?? "set null") .onUpdate(spec.onUpdate ?? "no action"); } - return spec.nullable ? col : col.notNull(); + return col; }, ]; } diff --git a/packages/postgres/src/PostgresIntrospector.ts b/app/src/data/connection/postgres/PostgresIntrospector.ts similarity index 81% rename from packages/postgres/src/PostgresIntrospector.ts rename to app/src/data/connection/postgres/PostgresIntrospector.ts index 4b1c928c..c4b52b3b 100644 --- a/packages/postgres/src/PostgresIntrospector.ts +++ b/app/src/data/connection/postgres/PostgresIntrospector.ts @@ -1,5 +1,5 @@ import { type SchemaMetadata, sql } from "kysely"; -import { BaseIntrospector } from "bknd"; +import { BaseIntrospector } from "../BaseIntrospector"; type PostgresSchemaSpec = { name: string; @@ -102,26 +102,27 @@ export class PostgresIntrospector extends BaseIntrospector { return tables.map((table) => ({ name: table.name, isView: table.type === "VIEW", - columns: table.columns.map((col) => { - return { - name: col.name, - dataType: col.type, - isNullable: !col.notnull, - // @todo: check default value on 'nextval' see https://www.postgresql.org/docs/17/datatype-numeric.html#DATATYPE-SERIAL - isAutoIncrementing: true, // just for now - hasDefaultValue: col.dflt != null, - comment: undefined, - }; - }), - indices: table.indices.map((index) => ({ - name: index.name, - table: table.name, - isUnique: index.sql?.match(/unique/i) != null, - columns: index.columns.map((col) => ({ - name: col.name, - order: col.seqno, - })), + columns: table.columns.map((col) => ({ + name: col.name, + dataType: col.type, + isNullable: !col.notnull, + isAutoIncrementing: col.dflt?.toLowerCase().includes("nextval") ?? false, + hasDefaultValue: col.dflt != null, + comment: undefined, })), + indices: table.indices + // filter out db-managed primary key index + .filter((index) => index.name !== `${table.name}_pkey`) + .map((index) => ({ + name: index.name, + table: table.name, + isUnique: index.sql?.match(/unique/i) != null, + columns: index.columns.map((col) => ({ + name: col.name, + // seqno starts at 1 + order: col.seqno - 1, + })), + })), })); } } diff --git a/app/src/data/connection/postgres/PostgresJsConnection.ts b/app/src/data/connection/postgres/PostgresJsConnection.ts new file mode 100644 index 00000000..710b033e --- /dev/null +++ b/app/src/data/connection/postgres/PostgresJsConnection.ts @@ -0,0 +1,31 @@ +import { Kysely } from "kysely"; +import { PostgresIntrospector } from "./PostgresIntrospector"; +import { PostgresConnection, plugins } from "./PostgresConnection"; +import { customIntrospector } from "../Connection"; +import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js"; + +export class PostgresJsConnection extends PostgresConnection { + override name = "postgres-js"; + + constructor(config: PostgresJSDialectConfig) { + const kysely = new Kysely({ + dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, { + excludeTables: [], + }).create(config), + plugins, + }); + + super(kysely); + this.client = config.postgres; + } + + override async close(): Promise { + await this.client.end(); + } +} + +export function postgresJs( + config: PostgresJSDialectConfig, +): PostgresJsConnection { + return new PostgresJsConnection(config); +} diff --git a/packages/postgres/src/custom.ts b/app/src/data/connection/postgres/custom.ts similarity index 91% rename from packages/postgres/src/custom.ts rename to app/src/data/connection/postgres/custom.ts index 9d626a0a..22e7809d 100644 --- a/packages/postgres/src/custom.ts +++ b/app/src/data/connection/postgres/custom.ts @@ -1,4 +1,4 @@ -import { customIntrospector, type DbFunctions } from "bknd"; +import { customIntrospector, type DbFunctions } from "../Connection"; import { Kysely, type Dialect, type KyselyPlugin } from "kysely"; import { plugins, PostgresConnection } from "./PostgresConnection"; import { PostgresIntrospector } from "./PostgresIntrospector"; @@ -6,7 +6,7 @@ import { PostgresIntrospector } from "./PostgresIntrospector"; export type Constructor = new (...args: any[]) => T; export type CustomPostgresConnection = { - supports?: PostgresConnection["supported"]; + supports?: Partial; fn?: Partial; plugins?: KyselyPlugin[]; excludeTables?: string[]; diff --git a/packages/postgres/src/index.ts b/app/src/data/connection/postgres/index.ts similarity index 100% rename from packages/postgres/src/index.ts rename to app/src/data/connection/postgres/index.ts diff --git a/app/src/data/connection/sqlite/SqliteConnection.ts b/app/src/data/connection/sqlite/SqliteConnection.ts index 33351338..21fe5a50 100644 --- a/app/src/data/connection/sqlite/SqliteConnection.ts +++ b/app/src/data/connection/sqlite/SqliteConnection.ts @@ -13,7 +13,6 @@ import { customIntrospector } from "../Connection"; import { SqliteIntrospector } from "./SqliteIntrospector"; import type { Field } from "data/fields/Field"; -// @todo: add pragmas export type SqliteConnectionConfig< CustomDialect extends Constructor = Constructor, > = { diff --git a/app/src/data/connection/sqlite/SqliteIntrospector.ts b/app/src/data/connection/sqlite/SqliteIntrospector.ts index 8ff26880..2ce43451 100644 --- a/app/src/data/connection/sqlite/SqliteIntrospector.ts +++ b/app/src/data/connection/sqlite/SqliteIntrospector.ts @@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector { dataType: col.type, isNullable: !col.notnull, isAutoIncrementing: col.name === autoIncrementCol, - hasDefaultValue: col.dflt_value != null, + hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null, comment: undefined, }; }) ?? [], diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 3d8f432c..218f25ef 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -103,6 +103,7 @@ export class Repository 0) { for (const entry of options.join) { const related = this.em.relationOf(entity.name, entry); @@ -127,12 +128,28 @@ export class Repository { if (field.includes(".")) { const [alias, prop] = field.split(".") as [string, string]; - if (!aliases.includes(alias)) { + // check aliases first (added joins) + if (aliases.includes(alias)) { + this.checkIndex(alias, prop, "where"); + return !this.em.entity(alias).getField(prop); + } + // check if alias (entity) exists + if (!this.em.hasEntity(alias)) { return true; } + // check related fields for auto join + const related = this.em.relationOf(entity.name, alias); + if (related) { + const other = related.other(entity); + if (other.entity.getField(prop)) { + // if related field is found, add join to validated options + validated.join?.push(alias); + this.checkIndex(alias, prop, "where"); + return false; + } + } - this.checkIndex(alias, prop, "where"); - return !this.em.entity(alias).getField(prop); + return true; } this.checkIndex(entity.name, field, "where"); diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index b2e4516a..0f7481f5 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -52,7 +52,7 @@ export class NumberField extends Field< switch (context) { case "submit": - return Number.parseInt(value); + return Number.parseInt(value, 10); } return value; diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 12c531bb..12120d83 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -28,7 +28,7 @@ export function getChangeSet( const value = _value === "" ? null : _value; // normalize to null if undefined - const newValue = field.getValue(value, "submit") || null; + const newValue = field.getValue(value, "submit") ?? null; // @todo: add typing for "action" if (action === "create" || newValue !== data[key]) { acc[key] = newValue; diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index 43df6a16..0741f956 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -289,7 +289,7 @@ class EntityManagerPrototype> extends En super(Object.values(__entities), new DummyConnection(), relations, indices); } - withConnection(connection: Connection): EntityManager> { + withConnection(connection: Connection): EntityManager> { return new EntityManager(this.entities, connection, this.relations.all, this.indices); } } diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index eb2eb2b1..8a336b52 100644 --- a/app/src/data/server/query.spec.ts +++ b/app/src/data/server/query.spec.ts @@ -1,8 +1,9 @@ -import { test, describe, expect } from "bun:test"; +import { test, describe, expect, beforeAll, afterAll } from "bun:test"; import * as q from "./query"; import { parse as $parse, type ParseOptions } from "bknd/utils"; import type { PrimaryFieldType } from "modules"; import type { Generated } from "kysely"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, { @@ -15,6 +16,9 @@ const decode = (input: any, output: any) => { expect(parse(input)).toEqual(output); }; +beforeAll(() => disableConsoleLog()); +afterAll(() => enableConsoleLog()); + describe("server/query", () => { test("limit & offset", () => { //expect(() => parse({ limit: false })).toThrow(); diff --git a/app/src/index.ts b/app/src/index.ts index e30af8a9..81b4c9a7 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -132,6 +132,8 @@ export type * from "data/entities/Entity"; export type { EntityManager } from "data/entities/EntityManager"; export type { SchemaManager } from "data/schema/SchemaManager"; export type * from "data/entities"; + +// data connection export { BaseIntrospector, Connection, @@ -144,9 +146,29 @@ export { type ConnQuery, type ConnQueryResults, } from "data/connection"; + +// data sqlite export { SqliteConnection } from "data/connection/sqlite/SqliteConnection"; export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector"; export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection"; + +// data postgres +export { + pg, + PgPostgresConnection, +} from "data/connection/postgres/PgPostgresConnection"; +export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector"; +export { PostgresConnection } from "data/connection/postgres/PostgresConnection"; +export { + postgresJs, + PostgresJsConnection, +} from "data/connection/postgres/PostgresJsConnection"; +export { + createCustomPostgresConnection, + type CustomPostgresConnection, +} from "data/connection/postgres/custom"; + +// data prototype export { text, number, diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts index 92973dc6..6b226c2d 100644 --- a/app/src/modes/shared.ts +++ b/app/src/modes/shared.ts @@ -87,7 +87,7 @@ export async function makeModeConfig< const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; const isProd = config.isProduction ?? _isProd(); - const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const plugins = config?.options?.plugins ?? ([] as AppPlugin[]); const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncSchemaOptions = typeof config.syncSchema === "object" diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 152072d4..0a581808 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -33,3 +33,5 @@ export const schemaRead = new Permission( ); export const build = new Permission("system.build"); export const mcp = new Permission("system.mcp"); +export const info = new Permission("system.info"); +export const openapi = new Permission("system.openapi"); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 3ae6cd27..45988add 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -1,5 +1,3 @@ -/// - import type { App } from "App"; import { datetimeStringLocal, @@ -125,7 +123,7 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController - const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */ + const hono = this.create(); if (!this.app.isReadOnly()) { const manager = this.app.modules as DbModuleManager; @@ -317,6 +315,11 @@ export class SystemController extends Controller { summary: "Get the config for a module", tags: ["system"], }), + permission(SystemPermissions.configRead, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), mcpTool("system_config", { annotations: { readOnlyHint: true, @@ -354,7 +357,7 @@ export class SystemController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth()); + const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {})); this.registerConfigController(hono); @@ -429,6 +432,9 @@ export class SystemController extends Controller { hono.get( "/permissions", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "auth" }), + }), describeRoute({ summary: "Get the permissions", tags: ["system"], @@ -441,6 +447,7 @@ export class SystemController extends Controller { hono.post( "/build", + permission(SystemPermissions.build, {}), describeRoute({ summary: "Build the app", tags: ["system"], @@ -471,6 +478,7 @@ export class SystemController extends Controller { hono.get( "/info", + permission(SystemPermissions.info, {}), mcpTool("system_info"), describeRoute({ summary: "Get the server info", @@ -504,6 +512,7 @@ export class SystemController extends Controller { hono.get( "/openapi.json", + permission(SystemPermissions.openapi, {}), openAPISpecs(this.ctx.server, { info: { title: "bknd API", @@ -511,7 +520,11 @@ export class SystemController extends Controller { }, }), ); - hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); + hono.get( + "/swagger", + permission(SystemPermissions.openapi, {}), + swaggerUI({ url: "/api/system/openapi.json" }), + ); return hono; } diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts new file mode 100644 index 00000000..cadc0a3e --- /dev/null +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -0,0 +1,683 @@ +import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test"; +import { emailOTP } from "./email-otp.plugin"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("otp plugin", () => { + test("should not work if auth is not enabled", async () => { + const app = createApp({ + options: { + plugins: [emailOTP({ showActualErrors: true })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + }); + + test("should require email driver if sendEmail is true", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP()], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + + { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + } + }); + + test("should prevent mutations of the OTP entity", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + drivers: { + email: { + send: async () => {}, + }, + }, + plugins: [emailOTP({ showActualErrors: true })], + }, + }); + await app.build(); + + const payload = { + email: "test@test.com", + code: "123456", + action: "login", + created_at: new Date(), + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24), + used_at: null, + }; + + expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow(); + expect( + await app + .getApi() + .data.createOne("users_otp", payload) + .then((r) => r.ok), + ).toBe(false); + }); + + test("should generate a token", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ showActualErrors: true })], + drivers: { + email: { + send: async (to) => { + expect(to).toBe("test@test.com"); + called(); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("login"); + expect(data.data.expires_at).toBeDefined(); + + { + const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" }); + expect(data?.code).toBeDefined(); + expect(data?.code?.length).toBe(6); + expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + true, + ); + expect(data?.email).toBe("test@test.com"); + } + expect(called).toHaveBeenCalled(); + }); + + test("should login with a code", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (to, _subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should register with a code", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (to, _subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("register"); + expect(data.data.expires_at).toBeDefined(); + + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should not send email if sendEmail is false", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + drivers: { + email: { + send: async () => { + called(); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + expect(called).not.toHaveBeenCalled(); + }); + + test("should reject invalid codes", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // First send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Try to use an invalid code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: "999999" }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + }); + + test("should reject code reuse", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (_to, _subject, body) => { + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Use the code successfully + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + } + + // Try to use the same code again + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should reject expired codes", async () => { + // Set a fixed system time + const baseTime = Date.now(); + setSystemTime(new Date(baseTime)); + + try { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + ttl: 1, // 1 second TTL + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + const sendRes = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(sendRes.status).toBe(201); + + // Get the code from the database + const { data: otpData } = await app.em + .fork() + .repo("users_otp") + .findOne({ email: "test@test.com" }); + expect(otpData?.code).toBeDefined(); + + // Advance system time by more than 1 second to expire the code + setSystemTime(new Date(baseTime + 1100)); + + // Try to use the expired code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: otpData?.code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } finally { + // Reset system time + setSystemTime(); + } + }); + + test("should reject codes with different actions", async () => { + let loginCode = ""; + let registerCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a login code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the login code + const { data: loginOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } }); + loginCode = loginOtp?.code || ""; + + // Send a register code + await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the register code + const { data: registerOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } }); + registerCode = registerOtp?.code || ""; + + // Try to use login code for register + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: loginCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // Try to use register code for login + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: registerCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should invalidate previous codes when sending new code", async () => { + let firstCode = ""; + let secondCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + const em = app.em.fork(); + + // Send first code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the first code + const { data: firstOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + firstCode = firstOtp?.code || ""; + expect(firstCode).toBeDefined(); + + // Send second code (should invalidate the first) + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the second code + const { data: secondOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + secondCode = secondOtp?.code || ""; + expect(secondCode).toBeDefined(); + expect(secondCode).not.toBe(firstCode); + + // Try to use the first code (should fail as it's been invalidated) + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: firstCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // The second code should work + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: secondCode }), + }); + expect(res.status).toBe(200); + } + }); +}); diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts new file mode 100644 index 00000000..13f9a932 --- /dev/null +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -0,0 +1,387 @@ +import { + datetime, + em, + entity, + enumm, + Exception, + text, + type App, + type AppPlugin, + type DB, + type FieldSchema, + type MaybePromise, + type EntityConfig, + DatabaseEvents, +} from "bknd"; +import { + invariant, + s, + jsc, + HttpStatus, + threwAsync, + randomString, + $console, + pickKeys, +} from "bknd/utils"; +import { Hono } from "hono"; + +export type EmailOTPPluginOptions = { + /** + * Customize code generation. If not provided, a random 6-digit code will be generated. + */ + generateCode?: (user: Pick) => string; + + /** + * The base path for the API endpoints. + * @default "/api/auth/otp" + */ + apiBasePath?: string; + + /** + * The TTL for the OTP tokens in seconds. + * @default 600 (10 minutes) + */ + ttl?: number; + + /** + * The name of the OTP entity. + * @default "users_otp" + */ + entity?: string; + + /** + * The config for the OTP entity. + */ + entityConfig?: EntityConfig; + + /** + * Customize email content. If not provided, a default email will be sent. + */ + generateEmail?: ( + otp: EmailOTPFieldSchema, + ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; + + /** + * Enable debug mode for error messages. + * @default false + */ + showActualErrors?: boolean; + + /** + * Allow direct mutations (create/update) of OTP codes outside of this plugin, + * e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows. + * @default false + */ + allowExternalMutations?: boolean; + + /** + * Whether to send the email with the OTP code. + * @default true + */ + sendEmail?: boolean; +}; + +const otpFields = { + action: enumm({ + enum: ["login", "register"], + }), + code: text().required(), + email: text().required(), + created_at: datetime(), + expires_at: datetime().required(), + used_at: datetime(), +}; + +export type EmailOTPFieldSchema = FieldSchema; + +class OTPError extends Exception { + override name = "OTPError"; + override code = HttpStatus.BAD_REQUEST; +} + +export function emailOTP({ + generateCode: _generateCode, + apiBasePath = "/api/auth/otp", + ttl = 600, + entity: entityName = "users_otp", + entityConfig, + generateEmail: _generateEmail, + showActualErrors = false, + allowExternalMutations = false, + sendEmail = true, +}: EmailOTPPluginOptions = {}): AppPlugin { + return (app: App) => { + return { + name: "email-otp", + schema: () => + em( + { + [entityName]: entity( + entityName, + otpFields, + { + name: "Users OTP", + sort_dir: "desc", + primary_format: app.module.data.config.default_primary_format, + ...entityConfig, + }, + "generated", + ), + }, + ({ index }, schema) => { + const otp = schema[entityName]!; + index(otp).on(["email", "expires_at", "code"]); + }, + ), + onBuilt: async () => { + const auth = app.module.auth; + invariant(auth && auth.enabled === true, "Auth is not enabled"); + invariant(!sendEmail || app.drivers?.email, "Email driver is not registered"); + + const generateCode = + _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); + const generateEmail = + _generateEmail ?? + ((otp: EmailOTPFieldSchema) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + })); + const em = app.em.fork(); + + const hono = new Hono() + .post( + "/login", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string({ minLength: 1 }).optional(), + }), + ), + jsc("query", s.object({ redirect: s.string().optional() })), + async (c) => { + const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); + const user = await findUser(app, email); + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "login", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); + } else { + const otpData = await invalidateAndGenerateCode( + app, + { generateCode, ttl, entity: entityName }, + user, + "login", + ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } + + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); + } + }, + ) + .post( + "/register", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string({ minLength: 1 }).optional(), + }), + ), + jsc("query", s.object({ redirect: s.string().optional() })), + async (c) => { + const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); + + // throw if user exists + if (!(await threwAsync(findUser(app, email)))) { + throw new Exception("User already exists", HttpStatus.BAD_REQUEST); + } + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "register", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const user = await app.createUser({ + email, + password: randomString(32, true), + }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); + } else { + const otpData = await invalidateAndGenerateCode( + app, + { generateCode, ttl, entity: entityName }, + { email }, + "register", + ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } + + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); + } + }, + ) + .onError((err) => { + if (showActualErrors || err instanceof OTPError) { + throw err; + } + + throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST); + }); + + app.server.route(apiBasePath, hono); + + if (allowExternalMutations !== true) { + registerListeners(app, entityName); + } + }, + }; + }; +} + +async function findUser(app: App, email: string) { + const user_entity = app.module.auth.config.entity_name as "users"; + const { data: user } = await app.em.repo(user_entity).findOne({ email }); + if (!user) { + throw new Exception("User not found", HttpStatus.BAD_REQUEST); + } + + return user; +} + +async function invalidateAndGenerateCode( + app: App, + opts: Required>, + user: Pick, + action: EmailOTPFieldSchema["action"], +) { + const { generateCode, ttl, entity: entityName } = opts; + const newCode = generateCode?.(user); + if (!newCode) { + throw new OTPError("Failed to generate code"); + } + + await invalidateAllUserCodes(app, entityName, user.email, ttl); + const { data: otpData } = await app.em + .fork() + .mutator(entityName) + .insertOne({ + code: newCode, + email: user.email, + action, + created_at: new Date(), + expires_at: new Date(Date.now() + ttl * 1000), + }); + + $console.log("[OTP Code]", newCode); + + return otpData; +} + +async function sendCode( + app: App, + otpData: EmailOTPFieldSchema, + opts: Required>, +) { + const { generateEmail } = opts; + const { subject, body } = await generateEmail(otpData); + await app.drivers?.email?.send(otpData.email, subject, body); +} + +async function getValidatedCode( + app: App, + entityName: string, + email: string, + code: string, + action: EmailOTPFieldSchema["action"], +) { + invariant(email, "[OTP Plugin]: Email is required"); + invariant(code, "[OTP Plugin]: Code is required"); + const em = app.em.fork(); + const { data: otpData } = await em.repo(entityName).findOne({ email, code, action }); + if (!otpData) { + throw new OTPError("Invalid code"); + } + + if (otpData.expires_at < new Date()) { + throw new OTPError("Code expired"); + } + + if (otpData.used_at) { + throw new OTPError("Code already used"); + } + + return otpData; +} + +async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) { + invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0"); + invariant(email, "[OTP Plugin]: Email is required"); + const em = app.em.fork(); + await em + .mutator(entityName) + .updateWhere( + { expires_at: new Date(Date.now() - 1000) }, + { email, used_at: { $isnull: true } }, + ); +} + +function registerListeners(app: App, entityName: string) { + [DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => { + app.emgr.onEvent( + event, + (e: { params: { entity: { name: string } } }) => { + if (e.params.entity.name === entityName) { + throw new OTPError("Mutations of the OTP entity are not allowed"); + } + }, + { + mode: "sync", + id: "bknd-email-otp", + }, + ); + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index b0090ffc..fcb23c15 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; +export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin"; diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 3ac5d5df..056e52d1 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -9,7 +9,7 @@ import { useState, type ReactNode, } from "react"; -import { useApi } from "ui/client"; +import { useApi } from "bknd/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; import { Message } from "ui/components/display/Message"; diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 88a54c1e..21866c3e 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -14,18 +14,20 @@ const ClientContext = createContext(undefined!); export type ClientProviderProps = { children?: ReactNode; baseUrl?: string; + api?: Api; } & ApiOptions; export const ClientProvider = ({ children, host, baseUrl: _baseUrl = host, + api: _api, ...props }: ClientProviderProps) => { const winCtx = useBkndWindowContext(); const _ctx = useClientContext(); let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; - let user: any = undefined; + let user: any; if (winCtx) { user = winCtx.user; @@ -40,6 +42,7 @@ export const ClientProvider = ({ const apiProps = { user, ...props, host: actualBaseUrl }; const api = useMemo( () => + _api ?? new Api({ ...apiProps, verbose: isDebug(), @@ -50,7 +53,7 @@ export const ClientProvider = ({ } }, }), - [JSON.stringify(apiProps)], + [_api, JSON.stringify(apiProps)], ); const [authState, setAuthState] = useState | undefined>(api.getAuthState()); @@ -64,6 +67,10 @@ export const ClientProvider = ({ export const useApi = (host?: ApiOptions["host"]): Api => { const context = useContext(ClientContext); + if (!context) { + throw new Error("useApi must be used within a ClientProvider"); + } + if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { return new Api({ host: host ?? "" }); } diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 573b9900..a93aaf2c 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -2,7 +2,7 @@ import type { Api } from "Api"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWRInfinite from "swr/infinite"; -import { useApi } from "ui/client"; +import { useApi } from "../ClientProvider"; import { useState } from "react"; export const useApiQuery = < diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 10423449..7edeaf14 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -10,7 +10,7 @@ import type { import { objectTransform, encodeSearch } from "bknd/utils"; import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; -import { type Api, useApi } from "ui/client"; +import { type Api, useApi } from "bknd/client"; export class UseEntityApiError extends Error { constructor( diff --git a/app/src/ui/client/index.ts b/app/src/ui/client/index.ts index 2fb520eb..f6117523 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -4,6 +4,7 @@ export { type ClientProviderProps, useApi, useBaseUrl, + useClientContext } from "./ClientProvider"; export * from "./api/use-api"; diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index 291c9636..3db502d3 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,7 +1,6 @@ import type { AuthState } from "Api"; import type { AuthResponse } from "bknd"; -import { useApi, useInvalidate } from "ui/client"; -import { useClientContext } from "ui/client/ClientProvider"; +import { useApi, useInvalidate, useClientContext } from "bknd/client"; type LoginData = { email: string; diff --git a/app/src/ui/elements/hooks/use-auth.ts b/app/src/ui/elements/hooks/use-auth.ts index 5907cf65..9bcdf573 100644 --- a/app/src/ui/elements/hooks/use-auth.ts +++ b/app/src/ui/elements/hooks/use-auth.ts @@ -1,9 +1,11 @@ import type { AppAuthSchema } from "auth/auth-schema"; import { useEffect, useState } from "react"; -import { useApi } from "ui/client"; +import { useApi } from "bknd/client"; type AuthStrategyData = Pick; -export const useAuthStrategies = (options?: { baseUrl?: string }): Partial & { +export const useAuthStrategies = (options?: { + baseUrl?: string; +}): Partial & { loading: boolean; } => { const [data, setData] = useState(); diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index b7cb384c..896807f9 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils"; import { type FileWithPath, useDropzone } from "./use-dropzone"; import { checkMaxReached } from "./helper"; import { DropzoneInner } from "./DropzoneInner"; -import { createDropzoneStore } from "ui/elements/media/dropzone-state"; +import { createDropzoneStore } from "./dropzone-state"; import { useStore } from "zustand"; export type FileState = { diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 2a99a5ff..bd412dc8 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -1,9 +1,8 @@ -import type { Api } from "bknd/client"; import type { PrimaryFieldType, RepoQueryIn } from "bknd"; import type { MediaFieldSchema } from "media/AppMedia"; import type { TAppMediaConfig } from "media/media-schema"; import { useId, useEffect, useRef, useState } from "react"; -import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; +import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; import { useEvent } from "ui/hooks/use-event"; import { Dropzone, type DropzoneProps } from "./Dropzone"; import { mediaItemsToFileStates } from "./helper"; @@ -132,26 +131,24 @@ export function DropzoneContainer({ } return ( - <> - $q.setSize($q.size + 1)} - /> - ) - } - {...props} - /> - + $q.setSize($q.size + 1)} + /> + ) + } + {...props} + /> ); } diff --git a/app/src/ui/elements/media/DropzoneInner.tsx b/app/src/ui/elements/media/DropzoneInner.tsx index 6c3cb87b..4e7927cc 100644 --- a/app/src/ui/elements/media/DropzoneInner.tsx +++ b/app/src/ui/elements/media/DropzoneInner.tsx @@ -19,8 +19,8 @@ import { } from "react-icons/tb"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { IconButton } from "ui/components/buttons/IconButton"; -import { formatNumber } from "core/utils"; -import type { DropzoneRenderProps, FileState } from "ui/elements"; +import { formatNumber } from "bknd/utils"; +import type { DropzoneRenderProps, FileState } from "./Dropzone"; import { useDropzoneFileState, useDropzoneState } from "./Dropzone"; function handleUploadError(e: unknown) { diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index f53beb37..5d978c85 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -10,7 +10,7 @@ import { TbUser, TbX, } from "react-icons/tb"; -import { useAuth, useBkndWindowContext } from "ui/client"; +import { useAuth, useBkndWindowContext } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { useTheme } from "ui/client/use-theme"; import { Button } from "ui/components/buttons/Button"; diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx index bfce4f14..4ed787c2 100644 --- a/app/src/ui/modals/media/MediaInfoModal.tsx +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -1,6 +1,6 @@ import type { ContextModalProps } from "@mantine/modals"; import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { useEntityQuery } from "ui/client"; +import { useEntityQuery } from "bknd/client"; import { type FileState, Media } from "ui/elements"; import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils"; import { twMerge } from "tailwind-merge"; diff --git a/app/src/ui/modules/auth/hooks/use-create-user-modal.ts b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts index f3d20eca..c0062b64 100644 --- a/app/src/ui/modules/auth/hooks/use-create-user-modal.ts +++ b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts @@ -1,4 +1,4 @@ -import { useApi, useInvalidate } from "ui/client"; +import { useApi, useInvalidate } from "bknd/client"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { routes, useNavigate } from "ui/lib/routes"; import { bkndModals } from "ui/modals"; diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index aa331bdf..1a4300ef 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -4,7 +4,7 @@ import type { EntityData } from "bknd"; import type { RelationField } from "data/relations"; import { useEffect, useRef, useState } from "react"; import { TbEye } from "react-icons/tb"; -import { useEntityQuery } from "ui/client"; +import { useEntityQuery } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; import * as Formy from "ui/components/form/Formy"; diff --git a/app/src/ui/routes/auth/auth.index.tsx b/app/src/ui/routes/auth/auth.index.tsx index 93796065..e7c5bfc7 100644 --- a/app/src/ui/routes/auth/auth.index.tsx +++ b/app/src/ui/routes/auth/auth.index.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb"; -import { useApiQuery } from "ui/client"; +import { useApiQuery } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button"; diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index d24ac9b6..477ca9e5 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core"; import { Popover } from "ui/components/overlay/Popover"; import { cn } from "ui/lib/utils"; import { JsonViewer } from "ui/components/code/JsonViewer"; -import { mountOnce, useApiQuery } from "ui/client"; +import { mountOnce, useApiQuery } from "bknd/client"; import { CodePreview } from "ui/components/code/CodePreview"; import type { JsonError } from "json-schema-library"; import { Alert } from "ui/components/display/Alert"; diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index 30669e9c..204c41bf 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils"; import type { Entity, EntityData, EntityRelation } from "bknd"; import { Fragment, useState } from "react"; import { TbDots } from "react-icons/tb"; -import { useApiQuery, useEntityQuery } from "ui/client"; +import { useApiQuery, useEntityQuery } from "bknd/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 20b67caa..a1c3bfd1 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -1,6 +1,6 @@ import type { EntityData } from "bknd"; import { useState } from "react"; -import { useEntityMutate } from "ui/client"; +import { useEntityMutate } from "bknd/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { Message } from "ui/components/display/Message"; diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 5945f378..91cfbd9a 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -2,7 +2,7 @@ import type { Entity } from "bknd"; import { repoQuery } from "data/server/query"; import { Fragment } from "react"; import { TbDots } from "react-icons/tb"; -import { useApiQuery } from "ui/client"; +import { useApiQuery } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; 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 4272f89b..deba43ce 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -5,12 +5,12 @@ import { ucFirstAllSnakeToPascalWithSpaces, s, stringIdentifier, + pickKeys, } from "bknd/utils"; import { type TAppDataEntityFields, fieldsSchemaObject as originalFieldsSchemaObject, } from "data/data-schema"; -import { omit } from "lodash-es"; import { forwardRef, memo, useEffect, useImperativeHandle } from "react"; import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb"; @@ -317,7 +317,6 @@ function EntityField({ 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 || []; const hidden = fieldSpec.hidden || []; const dragDisabled = index === 0; @@ -476,7 +475,7 @@ function EntityField({ field={field} onChange={(value) => { setValue(`${prefix}.config`, { - ...getValues([`fields.${index}.config`])[0], + ...pickKeys(getValues([`${prefix}.config`])[0], commonProps), ...value, }); }} @@ -520,7 +519,7 @@ const SpecificForm = ({ readonly?: boolean; }) => { const type = field.field.type; - const specificData = omit(field.field.config, commonProps); + const specificData = omitKeys(field.field.config ?? {}, commonProps); return (
- +
); diff --git a/app/src/ui/routes/root.tsx b/app/src/ui/routes/root.tsx index 184c903d..44383621 100644 --- a/app/src/ui/routes/root.tsx +++ b/app/src/ui/routes/root.tsx @@ -1,6 +1,6 @@ import { IconHome } from "@tabler/icons-react"; import { useEffect } from "react"; -import { useAuth } from "ui/client"; +import { useAuth } from "bknd/client"; import { useEffectOnce } from "ui/hooks/use-effect"; import { Empty } from "../components/display/Empty"; import { useBrowserTitle } from "../hooks/use-browser-title"; diff --git a/app/src/ui/routes/test/tests/swr-and-api.tsx b/app/src/ui/routes/test/tests/swr-and-api.tsx index 0f204280..92b0b104 100644 --- a/app/src/ui/routes/test/tests/swr-and-api.tsx +++ b/app/src/ui/routes/test/tests/swr-and-api.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useApi, useApiQuery } from "ui/client"; +import { useApi, useApiQuery } from "bknd/client"; import { Scrollable } from "ui/layouts/AppShell/AppShell"; function Bla() { diff --git a/app/tsconfig.json b/app/tsconfig.json index 10260b4b..d6060d98 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -35,7 +35,8 @@ "bknd/adapter": ["./src/adapter/index.ts"], "bknd/adapter/*": ["./src/adapter/*/index.ts"], "bknd/client": ["./src/ui/client/index.ts"], - "bknd/modes": ["./src/modes/index.ts"] + "bknd/modes": ["./src/modes/index.ts"], + "bknd/elements": ["./src/ui/elements/index.ts"] } }, "include": [ diff --git a/biome.json b/biome.json index 278fd1c9..f1d462bf 100644 --- a/biome.json +++ b/biome.json @@ -20,6 +20,9 @@ "css": { "formatter": { "indentWidth": 3 + }, + "parser": { + "tailwindDirectives": true } }, "json": { diff --git a/bun.lock b/bun.lock index 86906137..1a3d4264 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "bknd", @@ -69,6 +70,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/node": "^24.10.0", + "@types/pg": "^8.15.6", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^5.1.0", @@ -80,14 +82,17 @@ "jotai": "^2.12.2", "jsdom": "^26.1.0", "kysely-generic-sqlite": "^1.2.1", + "kysely-postgres-js": "^2.0.0", "libsql": "^0.5.22", "libsql-stateless-easy": "^1.8.0", "miniflare": "^4.20251011.2", "open": "^10.2.0", "openapi-types": "^12.1.3", + "pg": "^8.16.3", "postcss": "^8.5.3", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "postgres": "^3.4.7", "posthog-js-lite": "^3.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -146,26 +151,6 @@ "react-dom": ">=18", }, }, - "packages/postgres": { - "name": "@bknd/postgres", - "version": "0.2.0", - "devDependencies": { - "@types/bun": "^1.2.5", - "@types/node": "^22.13.10", - "@types/pg": "^8.11.11", - "@xata.io/client": "^0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", - "@xata.io/kysely": "^0.2.1", - "bknd": "workspace:*", - "kysely-neon": "^1.3.0", - "tsup": "^8.4.0", - }, - "optionalDependencies": { - "kysely": "^0.27.6", - "kysely-postgres-js": "^2.0.0", - "pg": "^8.14.0", - "postgres": "^3.4.7", - }, - }, "packages/sqlocal": { "name": "@bknd/sqlocal", "version": "0.0.1", @@ -500,8 +485,6 @@ "@bknd/plasmic": ["@bknd/plasmic@workspace:packages/plasmic"], - "@bknd/postgres": ["@bknd/postgres@workspace:packages/postgres"], - "@bknd/sqlocal": ["@bknd/sqlocal@workspace:packages/sqlocal"], "@bluwy/giget-core": ["@bluwy/giget-core@0.1.6", "", { "dependencies": { "modern-tar": "^0.3.5" } }, "sha512-5BwSIzqhpzXKUnSSheB0M+Qb4iGskepb35FiPA1/7AciPArTqt9H5yc53NmV21gNkDFrgbDBuzSWwrlo2aAKxg=="], @@ -804,8 +787,6 @@ "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], - "@neondatabase/serverless": ["@neondatabase/serverless@0.4.26", "", { "dependencies": { "@types/pg": "8.6.6" } }, "sha512-6DYEKos2GYn8NTgcJf33BLAx//LcgqzHVavQWe6ZkaDqmEq0I0Xtub6pzwFdq9iayNdCj7e2b0QKr5a8QKB8kQ=="], - "@next/env": ["@next/env@15.3.5", "", {}, "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="], @@ -1312,7 +1293,7 @@ "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], - "@types/pg": ["@types/pg@8.11.11", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw=="], + "@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], "@types/prettier": ["@types/prettier@1.19.1", "", {}, "sha512-5qOlnZscTn4xxM5MeGXAMOsIOIKIbh9e85zJWfBRVPlRMEVawzoPhINYbRGkBZCI8LxvBe7tJCdWiarA99OZfQ=="], @@ -1430,10 +1411,6 @@ "@wdio/utils": ["@wdio/utils@9.11.0", "", { "dependencies": { "@puppeteer/browsers": "^2.2.0", "@wdio/logger": "9.4.4", "@wdio/types": "9.10.1", "decamelize": "^6.0.0", "deepmerge-ts": "^7.0.3", "edgedriver": "^6.1.1", "geckodriver": "^5.0.0", "get-port": "^7.0.0", "import-meta-resolve": "^4.0.0", "locate-app": "^2.2.24", "safaridriver": "^1.0.0", "split2": "^4.2.0", "wait-port": "^1.1.0" } }, "sha512-chVbHqrjDlIKCLoAPLdrFK8Qozu/S+fbubqlyazohAKnouCUCa2goYs7faYR0lkmLqm92PllJS+KBRAha9V/tg=="], - "@xata.io/client": ["@xata.io/client@0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", "", { "peerDependencies": { "typescript": ">=4.5" } }, "sha512-4Js4SAKwmmOPmZVIS1l2K8XVGGkUOi8L1jXuagDfeUX56n95wfA4xYMSmsVS0RLMmRWI4UM4bp5UcFJxwbFYGw=="], - - "@xata.io/kysely": ["@xata.io/kysely@0.2.1", "", { "dependencies": { "@xata.io/client": "0.30.1" }, "peerDependencies": { "kysely": "*" } }, "sha512-0+WBcFkBSNEu11wVTyJyeNMOPUuolDKJMjXQr1nheHTNZLfsL0qKshTZOKIC/bGInjepGA7DQ/HFeKDHe5CDpA=="], - "@xyflow/react": ["@xyflow/react@12.9.2", "", { "dependencies": { "@xyflow/system": "0.0.72", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw=="], "@xyflow/system": ["@xyflow/system@0.0.72", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA=="], @@ -2578,8 +2555,6 @@ "kysely-generic-sqlite": ["kysely-generic-sqlite@1.2.1", "", { "peerDependencies": { "kysely": ">=0.26" } }, "sha512-/Bs3/Uktn04nQ9g/4oSphLMEtSHkS5+j5hbKjK5gMqXQfqr/v3V3FKtoN4pLTmo2W35hNdrIpQnBukGL1zZc6g=="], - "kysely-neon": ["kysely-neon@1.3.0", "", { "peerDependencies": { "@neondatabase/serverless": "^0.4.3", "kysely": "0.x.x", "ws": "^8.13.0" }, "optionalPeers": ["ws"] }, "sha512-CIIlbmqpIXVJDdBEYtEOwbmALag0jmqYrGfBeM4cHKb9AgBGs+X1SvXUZ8TqkDacQEqEZN2XtsDoUkcMIISjHw=="], - "kysely-postgres-js": ["kysely-postgres-js@2.0.0", "", { "peerDependencies": { "kysely": ">= 0.24.0 < 1", "postgres": ">= 3.4.0 < 4" } }, "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ=="], "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], @@ -2844,8 +2819,6 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], - "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -2930,21 +2903,19 @@ "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], - "pg": ["pg@8.14.0", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-nXbVpyoaXVmdqlKEzToFf37qzyeeh7mbiXsnoWvstSqohj88yaa/I/Rq/HEVn2QPSZEuLIJa/jSpRDyzjEx4FQ=="], + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], - "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="], + "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], - "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="], + "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], - "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], + "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], - "pg-pool": ["pg-pool@3.8.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw=="], + "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], - "pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="], - - "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], @@ -2998,15 +2969,13 @@ "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], - "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], + "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], - "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], - - "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], "posthog-js-lite": ["posthog-js-lite@3.6.0", "", {}, "sha512-4NrnGwBna7UZ0KARVdHE7Udm/os9HoYRJDHWC55xj1UebBkFRDM+fIxCRovVCmEtuF27oNoDH+pTc81iWAyK7g=="], @@ -3484,7 +3453,7 @@ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + "tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="], "tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="], @@ -3896,7 +3865,7 @@ "@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], - "@bknd/plasmic/@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + "@bknd/plasmic/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], "@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=="], @@ -3956,8 +3925,6 @@ "@libsql/hrana-client/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "@neondatabase/serverless/@types/pg": ["@types/pg@8.6.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw=="], - "@plasmicapp/nextjs-app-router/cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "@plasmicapp/query/swr": ["swr@1.3.0", "", { "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0" } }, "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw=="], @@ -4128,7 +4095,7 @@ "@types/graceful-fs/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], - "@types/pg/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], + "@types/pg/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], "@types/resolve/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], @@ -4178,8 +4145,6 @@ "@vitest/mocker/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], - "@vitest/ui/tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="], - "@vitest/ui/vitest": ["vitest@3.0.8", "", { "dependencies": { "@vitest/expect": "3.0.8", "@vitest/mocker": "3.0.8", "@vitest/pretty-format": "^3.0.8", "@vitest/runner": "3.0.8", "@vitest/snapshot": "3.0.8", "@vitest/spy": "3.0.8", "@vitest/utils": "3.0.8", "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.8", "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.8", "@vitest/ui": "3.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA=="], "@wdio/config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], @@ -4192,8 +4157,6 @@ "@wdio/types/@types/node": ["@types/node@20.17.24", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA=="], - "@xata.io/kysely/@xata.io/client": ["@xata.io/client@0.30.1", "", { "peerDependencies": { "typescript": ">=4.5" } }, "sha512-dAzDPHmIfenVIpF39m1elmW5ngjWu2mO8ZqJBN7dmYdXr98uhPANfLdVZnc3mUNG+NH37LqY1dSO862hIo2oRw=="], - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "acorn-globals/acorn": ["acorn@6.4.2", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ=="], @@ -4526,8 +4489,6 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - "pino/process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -4536,6 +4497,8 @@ "postcss-mixins/sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="], + "postcss-mixins/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + "pretty-format/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "progress-estimator/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], @@ -4684,7 +4647,7 @@ "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "tinyglobby/fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="], + "tinyglobby/fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -4706,6 +4669,8 @@ "tsup/rollup": ["rollup@4.35.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.35.0", "@rollup/rollup-android-arm64": "4.35.0", "@rollup/rollup-darwin-arm64": "4.35.0", "@rollup/rollup-darwin-x64": "4.35.0", "@rollup/rollup-freebsd-arm64": "4.35.0", "@rollup/rollup-freebsd-x64": "4.35.0", "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", "@rollup/rollup-linux-arm-musleabihf": "4.35.0", "@rollup/rollup-linux-arm64-gnu": "4.35.0", "@rollup/rollup-linux-arm64-musl": "4.35.0", "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", "@rollup/rollup-linux-riscv64-gnu": "4.35.0", "@rollup/rollup-linux-s390x-gnu": "4.35.0", "@rollup/rollup-linux-x64-gnu": "4.35.0", "@rollup/rollup-linux-x64-musl": "4.35.0", "@rollup/rollup-win32-arm64-msvc": "4.35.0", "@rollup/rollup-win32-ia32-msvc": "4.35.0", "@rollup/rollup-win32-x64-msvc": "4.35.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg=="], + "tsup/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + "union-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], "unset-value/has-value": ["has-value@0.3.1", "", { "dependencies": { "get-value": "^2.0.3", "has-values": "^0.1.4", "isobject": "^2.0.0" } }, "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q=="], @@ -4794,7 +4759,7 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], - "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], @@ -4814,10 +4779,6 @@ "@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@neondatabase/serverless/@types/pg/@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], - - "@neondatabase/serverless/@types/pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - "@plasmicapp/nextjs-app-router/cross-spawn/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "@plasmicapp/nextjs-app-router/cross-spawn/shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -4882,7 +4843,7 @@ "@types/graceful-fs/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@types/pg/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "@types/pg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/resolve/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], @@ -4924,9 +4885,7 @@ "@vitest/mocker/vite/rollup": ["rollup@4.35.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.35.0", "@rollup/rollup-android-arm64": "4.35.0", "@rollup/rollup-darwin-arm64": "4.35.0", "@rollup/rollup-darwin-x64": "4.35.0", "@rollup/rollup-freebsd-arm64": "4.35.0", "@rollup/rollup-freebsd-x64": "4.35.0", "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", "@rollup/rollup-linux-arm-musleabihf": "4.35.0", "@rollup/rollup-linux-arm64-gnu": "4.35.0", "@rollup/rollup-linux-arm64-musl": "4.35.0", "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", "@rollup/rollup-linux-riscv64-gnu": "4.35.0", "@rollup/rollup-linux-s390x-gnu": "4.35.0", "@rollup/rollup-linux-x64-gnu": "4.35.0", "@rollup/rollup-linux-x64-musl": "4.35.0", "@rollup/rollup-win32-arm64-msvc": "4.35.0", "@rollup/rollup-win32-ia32-msvc": "4.35.0", "@rollup/rollup-win32-x64-msvc": "4.35.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg=="], - "@vitest/ui/tinyglobby/fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], - - "@vitest/ui/tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "@vitest/mocker/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "@vitest/ui/vitest/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], @@ -5140,13 +5099,9 @@ "object-copy/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=="], - "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + "postcss-mixins/tinyglobby/fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="], - "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], - - "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - - "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "postcss-mixins/tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "progress-estimator/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -5212,6 +5167,10 @@ "tsc-alias/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "tsup/tinyglobby/fdir": ["fdir@6.4.5", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw=="], + + "tsup/tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + "unset-value/has-value/has-values": ["has-values@0.1.4", "", {}, "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ=="], "unset-value/has-value/isobject": ["isobject@2.1.0", "", { "dependencies": { "isarray": "1.0.0" } }, "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA=="], @@ -5312,16 +5271,6 @@ "@cloudflare/vitest-pool-workers/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], - "@neondatabase/serverless/@types/pg/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@neondatabase/serverless/@types/pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - - "@neondatabase/serverless/@types/pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], - - "@neondatabase/serverless/@types/pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - - "@neondatabase/serverless/@types/pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "@plasmicapp/nextjs-app-router/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "@vitejs/plugin-react/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], diff --git a/bunfig.toml b/bunfig.toml index 9268116d..22c04153 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,2 @@ [install] -#linker = "hoisted" \ No newline at end of file +linker = "isolated" \ No newline at end of file diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx index 850629de..df203270 100644 --- a/docs/content/docs/(documentation)/extending/plugins.mdx +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -261,3 +261,77 @@ export default { ``` +### `emailOTP` + + + Make sure to setup proper permissions to restrict reading from the OTP entity. Also, this plugin requires the `email` driver to be registered. + + + +A plugin that adds email OTP functionality to your app. It will add two endpoints to your app: +- `POST /api/auth/otp/login` to login a user with an OTP code +- `POST /api/auth/otp/register` to register a user with an OTP code + +Both endpoints accept a JSON body with `email` (required) and `code` (optional). If `code` is provided, the OTP code will be validated and the user will be logged in or registered. If `code` is not provided, a new OTP code will be generated and sent to the user's email. + +For example, to login an existing user with an OTP code, two requests are needed. The first one only with the email to generate and send the OTP code, and the second to send the users' email along with the OTP code. The last request will authenticate the user. + +```http title="Generate OTP code to login" +POST /api/auth/otp/login +Content-Type: application/json + +{ + "email": "test@example.com" +} +``` + +If the user exists, an email will be sent with the OTP code, and the response will be a `201 Created`. + +```http title="Login with OTP code" +POST /api/auth/otp/login +Content-Type: application/json + +{ + "email": "test@example.com", + "code": "123456" +} +``` + +If the code is valid, the user will be authenticated by sending a `Set-Cookie` header and a body property `token` with the JWT token (equally to the login endpoint). + + +```typescript title="bknd.config.ts" +import { emailOTP } from "bknd/plugins"; +import { resendEmail } from "bknd"; + +export default { + options: { + drivers: { + // an email driver is required + email: resendEmail({ /* ... */}), + }, + plugins: [ + // all options are optional + emailOTP({ + // the base path for the API endpoints + apiBasePath: "/api/auth/otp", + // the TTL for the OTP tokens in seconds + ttl: 600, + // the name of the OTP entity + entity: "users_otp", + // customize the email content + generateEmail: (otp) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + }), + // customize the code generation + generateCode: (user) => { + return Math.floor(100000 + Math.random() * 900000).toString(); + }, + }) + ], + }, +} satisfies BkndConfig; +``` + + diff --git a/docs/content/docs/(documentation)/usage/database.mdx b/docs/content/docs/(documentation)/usage/database.mdx index 9a2fe11f..550f615a 100644 --- a/docs/content/docs/(documentation)/usage/database.mdx +++ b/docs/content/docs/(documentation)/usage/database.mdx @@ -196,7 +196,7 @@ npm install @bknd/sqlocal This package uses `sqlocal` under the hood. Consult the [sqlocal documentation](https://sqlocal.dallashoffman.com/guide/setup) for connection options: -```js +```ts import { createApp } from "bknd"; import { SQLocalConnection } from "@bknd/sqlocal"; @@ -210,42 +210,39 @@ const app = createApp({ ## PostgreSQL -To use bknd with Postgres, you need to install the `@bknd/postgres` package. You can do so by running the following command: - -```bash -npm install @bknd/postgres -``` - -You can connect to your Postgres database using `pg` or `postgres` dialects. Additionally, you may also define your custom connection. +Postgres is built-in to bknd, you can connect to your Postgres database using `pg` or `postgres` dialects. Additionally, you may also define your custom connection. ### Using `pg` -To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package. +To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package. Wrap the `Pool` in the `pg` function to create a connection. -```js +```ts import { serve } from "bknd/adapter/node"; -import { pg } from "@bknd/postgres"; +import { pg } from "bknd"; +import { Pool } from "pg"; -/** @type {import("bknd/adapter/node").NodeBkndConfig} */ -const config = { +serve({ connection: pg({ - connectionString: "postgresql://user:password@localhost:5432/database", + pool: new Pool({ + connectionString: "postgresql://user:password@localhost:5432/database", + }), }), -}; - -serve(config); +}); ``` ### Using `postgres` -To establish a connection to your database, you can use any connection options available on the [`postgres`](https://github.com/porsager/postgres) package. +To establish a connection to your database, you can use any connection options available on the [`postgres`](https://github.com/porsager/postgres) package. Wrap the `Sql` in the `postgresJs` function to create a connection. -```js +```ts import { serve } from "bknd/adapter/node"; -import { postgresJs } from "@bknd/postgres"; +import { postgresJs } from "bknd"; +import postgres from 'postgres' serve({ - connection: postgresJs("postgresql://user:password@localhost:5432/database"), + connection: postgresJs({ + postgres: postgres("postgresql://user:password@localhost:5432/database"), + }), }); ``` @@ -255,8 +252,8 @@ Several Postgres hosting providers offer their own clients to connect to their d Example using `@neondatabase/serverless`: -```js -import { createCustomPostgresConnection } from "@bknd/postgres"; +```ts +import { createCustomPostgresConnection } from "bknd"; import { NeonDialect } from "kysely-neon"; const neon = createCustomPostgresConnection("neon", NeonDialect); @@ -270,8 +267,8 @@ serve({ Example using `@xata.io/client`: -```js -import { createCustomPostgresConnection } from "@bknd/postgres"; +```ts +import { createCustomPostgresConnection } from "bknd"; import { XataDialect } from "@xata.io/kysely"; import { buildClient } from "@xata.io/client"; diff --git a/packages/postgres/examples/neon.ts b/examples/postgres/neon.ts similarity index 72% rename from packages/postgres/examples/neon.ts rename to examples/postgres/neon.ts index f4efd02c..0261f90d 100644 --- a/packages/postgres/examples/neon.ts +++ b/examples/postgres/neon.ts @@ -1,8 +1,8 @@ import { serve } from "bknd/adapter/bun"; -import { createCustomPostgresConnection } from "../src"; +import { createCustomPostgresConnection } from "bknd"; import { NeonDialect } from "kysely-neon"; -const neon = createCustomPostgresConnection(NeonDialect); +const neon = createCustomPostgresConnection("neon", NeonDialect); export default serve({ connection: neon({ diff --git a/examples/postgres/package.json b/examples/postgres/package.json new file mode 100644 index 00000000..ea1ad6b5 --- /dev/null +++ b/examples/postgres/package.json @@ -0,0 +1,20 @@ +{ + "name": "postgres", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "pg": "^8.14.0", + "postgres": "^3.4.7", + "@xata.io/client": "^0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", + "@xata.io/kysely": "^0.2.1", + "kysely-neon": "^1.3.0", + "bknd": "file:../app", + "kysely": "0.27.6" + }, + "devDependencies": { + "@types/bun": "^1.2.5", + "@types/node": "^22.13.10", + "@types/pg": "^8.11.11" + } +} diff --git a/packages/postgres/tsconfig.json b/examples/postgres/tsconfig.json similarity index 92% rename from packages/postgres/tsconfig.json rename to examples/postgres/tsconfig.json index 5bb10f66..7fb1ec56 100644 --- a/packages/postgres/tsconfig.json +++ b/examples/postgres/tsconfig.json @@ -25,7 +25,8 @@ "skipLibCheck": true, "baseUrl": ".", "paths": { - "$bknd/*": ["../../app/src/*"] + "bknd": ["../app/src/index.ts"], + "bknd/*": ["../app/src/*"] } }, "include": ["./src/**/*.ts"], diff --git a/packages/postgres/examples/xata.ts b/examples/postgres/xata.ts similarity index 72% rename from packages/postgres/examples/xata.ts rename to examples/postgres/xata.ts index 75e2a32d..d6b5c079 100644 --- a/packages/postgres/examples/xata.ts +++ b/examples/postgres/xata.ts @@ -1,23 +1,23 @@ import { serve } from "bknd/adapter/bun"; -import { createCustomPostgresConnection } from "../src"; +import { createCustomPostgresConnection } from "bknd"; import { XataDialect } from "@xata.io/kysely"; import { buildClient } from "@xata.io/client"; const client = buildClient(); -const xata = new client({ +const xataClient = new client({ databaseURL: process.env.XATA_URL, apiKey: process.env.XATA_API_KEY, branch: process.env.XATA_BRANCH, }); -const connection = createCustomPostgresConnection(XataDialect, { +const xata = createCustomPostgresConnection("xata", XataDialect, { supports: { batching: false, }, -})({ xata }); +}); export default serve({ - connection, + connection: xata(xataClient), // ignore this, it's only required within this repository // because bknd is installed via "workspace:*" distPath: "../../../app/dist", diff --git a/package.json b/package.json index 83b56c29..189c80d6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "sideEffects": false, "type": "module", - "packageManager": "bun@1.3.2", + "packageManager": "bun@1.3.3", "engines": { "node": ">=22.13" }, diff --git a/packages/postgres/README.md b/packages/postgres/README.md deleted file mode 100644 index ed5d1e65..00000000 --- a/packages/postgres/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# Postgres adapter for `bknd` (experimental) -This packages adds an adapter to use a Postgres database with [`bknd`](https://github.com/bknd-io/bknd). It works with both `pg` and `postgres` drivers, and supports custom postgres connections. -* works with any Postgres database (tested with Supabase, Neon, Xata, and RDS) -* choose between `pg` and `postgres` drivers -* create custom postgres connections with any kysely postgres dialect - -## Installation -Install the adapter with: -```bash -npm install @bknd/postgres -``` - -## Using `pg` driver -Install the [`pg`](https://github.com/brianc/node-postgres) driver with: -```bash -npm install pg -``` - -Create a connection: - -```ts -import { pg } from "@bknd/postgres"; - -// accepts `pg` configuration -const connection = pg({ - host: "localhost", - port: 5432, - user: "postgres", - password: "postgres", - database: "postgres", -}); - -// or with a connection string -const connection = pg({ - connectionString: "postgres://postgres:postgres@localhost:5432/postgres", -}); -``` - -## Using `postgres` driver - -Install the [`postgres`](https://github.com/porsager/postgres) driver with: -```bash -npm install postgres -``` - -Create a connection: - -```ts -import { postgresJs } from "@bknd/postgres"; - -// accepts `postgres` configuration -const connection = postgresJs("postgres://postgres:postgres@localhost:5432/postgres"); -``` - -## Using custom postgres dialects - -You can create a custom kysely postgres dialect by using the `createCustomPostgresConnection` function. - -```ts -import { createCustomPostgresConnection } from "@bknd/postgres"; - -const connection = createCustomPostgresConnection("my_postgres_dialect", MyDialect)({ - // your custom dialect configuration - supports: { - batching: true - }, - excludeTables: ["my_table"], - plugins: [new MyKyselyPlugin()], -}); -``` - -### Custom `neon` connection - -```typescript -import { createCustomPostgresConnection } from "@bknd/postgres"; -import { NeonDialect } from "kysely-neon"; - -const connection = createCustomPostgresConnection("neon", NeonDialect)({ - connectionString: process.env.NEON, -}); -``` - -### Custom `xata` connection - -```typescript -import { createCustomPostgresConnection } from "@bknd/postgres"; -import { XataDialect } from "@xata.io/kysely"; -import { buildClient } from "@xata.io/client"; - -const client = buildClient(); -const xata = new client({ - databaseURL: process.env.XATA_URL, - apiKey: process.env.XATA_API_KEY, - branch: process.env.XATA_BRANCH, -}); - -const connection = createCustomPostgresConnection("xata", XataDialect, { - supports: { - batching: false, - }, -})({ xata }); -``` \ No newline at end of file diff --git a/packages/postgres/package.json b/packages/postgres/package.json deleted file mode 100644 index 036d6a9a..00000000 --- a/packages/postgres/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@bknd/postgres", - "version": "0.2.0", - "type": "module", - "main": "dist/index.js", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "tsup", - "test": "bun test", - "typecheck": "tsc --noEmit", - "updater": "bun x npm-check-updates -ui", - "prepublishOnly": "bun run typecheck && bun run test && bun run build", - "docker:start": "docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=bknd -p 5430:5432 postgres:17", - "docker:stop": "docker stop bknd-test-postgres" - }, - "optionalDependencies": { - "kysely": "^0.27.6", - "kysely-postgres-js": "^2.0.0", - "pg": "^8.14.0", - "postgres": "^3.4.7" - }, - "devDependencies": { - "@types/bun": "^1.2.5", - "@types/node": "^22.13.10", - "@types/pg": "^8.11.11", - "@xata.io/client": "^0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", - "@xata.io/kysely": "^0.2.1", - "bknd": "workspace:*", - "kysely-neon": "^1.3.0", - "tsup": "^8.4.0" - }, - "tsup": { - "entry": ["src/index.ts"], - "format": ["esm"], - "target": "es2022", - "metafile": true, - "clean": true, - "minify": true, - "dts": true, - "external": ["bknd", "pg", "postgres", "kysely", "kysely-postgres-js"] - }, - "files": ["dist", "README.md", "!*.map", "!metafile*.json"] -} diff --git a/packages/postgres/src/PgPostgresConnection.ts b/packages/postgres/src/PgPostgresConnection.ts deleted file mode 100644 index c96e693d..00000000 --- a/packages/postgres/src/PgPostgresConnection.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Kysely, PostgresDialect } from "kysely"; -import { PostgresIntrospector } from "./PostgresIntrospector"; -import { PostgresConnection, plugins } from "./PostgresConnection"; -import { customIntrospector } from "bknd"; -import $pg from "pg"; - -export type PgPostgresConnectionConfig = $pg.PoolConfig; - -export class PgPostgresConnection extends PostgresConnection { - override name = "pg"; - private pool: $pg.Pool; - - constructor(config: PgPostgresConnectionConfig) { - const pool = new $pg.Pool(config); - const kysely = new Kysely({ - dialect: customIntrospector(PostgresDialect, PostgresIntrospector, { - excludeTables: [], - }).create({ pool }), - plugins, - }); - - super(kysely); - this.pool = pool; - } - - override async close(): Promise { - await this.pool.end(); - } -} - -export function pg(config: PgPostgresConnectionConfig): PgPostgresConnection { - return new PgPostgresConnection(config); -} diff --git a/packages/postgres/src/PostgresJsConnection.ts b/packages/postgres/src/PostgresJsConnection.ts deleted file mode 100644 index deff210c..00000000 --- a/packages/postgres/src/PostgresJsConnection.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Kysely } from "kysely"; -import { PostgresIntrospector } from "./PostgresIntrospector"; -import { PostgresConnection, plugins } from "./PostgresConnection"; -import { customIntrospector } from "bknd"; -import { PostgresJSDialect } from "kysely-postgres-js"; -import $postgresJs, { type Sql, type Options, type PostgresType } from "postgres"; - -export type PostgresJsConfig = Options>; - -export class PostgresJsConnection extends PostgresConnection { - override name = "postgres-js"; - - private postgres: Sql; - - constructor(opts: { postgres: Sql }) { - const kysely = new Kysely({ - dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, { - excludeTables: [], - }).create({ postgres: opts.postgres }), - plugins, - }); - - super(kysely); - this.postgres = opts.postgres; - } - - override async close(): Promise { - await this.postgres.end(); - } -} - -export function postgresJs( - connectionString: string, - config?: PostgresJsConfig, -): PostgresJsConnection; -export function postgresJs(config: PostgresJsConfig): PostgresJsConnection; -export function postgresJs( - first: PostgresJsConfig | string, - second?: PostgresJsConfig, -): PostgresJsConnection { - const postgres = typeof first === "string" ? $postgresJs(first, second) : $postgresJs(first); - return new PostgresJsConnection({ postgres }); -} diff --git a/packages/postgres/test/pg.test.ts b/packages/postgres/test/pg.test.ts deleted file mode 100644 index c6ac89a7..00000000 --- a/packages/postgres/test/pg.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe } from "bun:test"; -import { pg } from "../src/PgPostgresConnection"; -import { testSuite } from "./suite"; - -describe("pg", () => { - testSuite({ - createConnection: () => - pg({ - host: "localhost", - port: 5430, - user: "postgres", - password: "postgres", - database: "bknd", - }), - }); -}); diff --git a/packages/postgres/test/postgresjs.test.ts b/packages/postgres/test/postgresjs.test.ts deleted file mode 100644 index 5a1f5a4d..00000000 --- a/packages/postgres/test/postgresjs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe } from "bun:test"; -import { postgresJs } from "../src/PostgresJsConnection"; -import { testSuite } from "./suite"; - -describe("postgresjs", () => { - testSuite({ - createConnection: () => - postgresJs({ - host: "localhost", - port: 5430, - user: "postgres", - password: "postgres", - database: "bknd", - }), - }); -}); diff --git a/packages/postgres/test/suite.ts b/packages/postgres/test/suite.ts deleted file mode 100644 index ec729879..00000000 --- a/packages/postgres/test/suite.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, beforeAll, afterAll, expect, it, afterEach } from "bun:test"; -import type { PostgresConnection } from "../src"; -import { createApp, em, entity, text } from "bknd"; -import { disableConsoleLog, enableConsoleLog } from "bknd/utils"; -// @ts-ignore -import { connectionTestSuite } from "$bknd/data/connection/connection-test-suite"; -// @ts-ignore -import { bunTestRunner } from "$bknd/adapter/bun/test"; - -export type TestSuiteConfig = { - createConnection: () => InstanceType; - cleanDatabase?: (connection: InstanceType) => Promise; -}; - -export async function defaultCleanDatabase(connection: InstanceType) { - const kysely = connection.kysely; - - // drop all tables+indexes & create new schema - await kysely.schema.dropSchema("public").ifExists().cascade().execute(); - await kysely.schema.dropIndex("public").ifExists().cascade().execute(); - await kysely.schema.createSchema("public").execute(); -} - -async function cleanDatabase( - connection: InstanceType, - config: TestSuiteConfig, -) { - if (config.cleanDatabase) { - await config.cleanDatabase(connection); - } else { - await defaultCleanDatabase(connection); - } -} - -export function testSuite(config: TestSuiteConfig) { - beforeAll(() => disableConsoleLog(["log", "warn", "error"])); - afterAll(() => enableConsoleLog()); - - // @todo: postgres seems to add multiple indexes, thus failing the test suite - /* describe("test suite", () => { - connectionTestSuite(bunTestRunner, { - makeConnection: () => { - const connection = config.createConnection(); - return { - connection, - dispose: async () => { - await cleanDatabase(connection, config); - await connection.close(); - }, - }; - }, - rawDialectDetails: [], - }); - }); */ - - describe("base", () => { - it("should connect to the database", async () => { - const connection = config.createConnection(); - expect(await connection.ping()).toBe(true); - }); - - it("should clean the database", async () => { - const connection = config.createConnection(); - await cleanDatabase(connection, config); - - const tables = await connection.getIntrospector().getTables(); - expect(tables).toEqual([]); - }); - }); - - describe("integration", () => { - let connection: PostgresConnection; - beforeAll(async () => { - connection = config.createConnection(); - await cleanDatabase(connection, config); - }); - - afterEach(async () => { - await cleanDatabase(connection, config); - }); - - afterAll(async () => { - await connection.close(); - }); - - it("should create app and ping", async () => { - const app = createApp({ - connection, - }); - await app.build(); - - expect(app.version()).toBeDefined(); - expect(await app.em.ping()).toBe(true); - }); - - it("should create a basic schema", async () => { - const schema = em( - { - posts: entity("posts", { - title: text().required(), - content: text(), - }), - comments: entity("comments", { - content: text(), - }), - }, - (fns, s) => { - fns.relation(s.comments).manyToOne(s.posts); - fns.index(s.posts).on(["title"], true); - }, - ); - - const app = createApp({ - connection, - config: { - data: schema.toJSON(), - }, - }); - - await app.build(); - - expect(app.em.entities.length).toBe(2); - expect(app.em.entities.map((e) => e.name)).toEqual(["posts", "comments"]); - - const api = app.getApi(); - - expect( - ( - await api.data.createMany("posts", [ - { - title: "Hello", - content: "World", - }, - { - title: "Hello 2", - content: "World 2", - }, - ]) - ).data, - ).toEqual([ - { - id: 1, - title: "Hello", - content: "World", - }, - { - id: 2, - title: "Hello 2", - content: "World 2", - }, - ] as any); - - // try to create an existing - expect( - ( - await api.data.createOne("posts", { - title: "Hello", - }) - ).ok, - ).toBe(false); - - // add a comment to a post - await api.data.createOne("comments", { - content: "Hello", - posts_id: 1, - }); - - // and then query using a `with` property - const result = await api.data.readMany("posts", { with: ["comments"] }); - expect(result.length).toBe(2); - expect(result[0].comments.length).toBe(1); - expect(result[0].comments[0].content).toBe("Hello"); - expect(result[1].comments.length).toBe(0); - }); - - it("should support uuid", async () => { - const schema = em( - { - posts: entity( - "posts", - { - title: text().required(), - content: text(), - }, - { - primary_format: "uuid", - }, - ), - comments: entity("comments", { - content: text(), - }), - }, - (fns, s) => { - fns.relation(s.comments).manyToOne(s.posts); - fns.index(s.posts).on(["title"], true); - }, - ); - - const app = createApp({ - connection, - config: { - data: schema.toJSON(), - }, - }); - - await app.build(); - const config = app.toJSON(); - // @ts-expect-error - expect(config.data.entities?.posts.fields?.id.config?.format).toBe("uuid"); - - const $em = app.em; - const mutator = $em.mutator($em.entity("posts")); - const data = await mutator.insertOne({ title: "Hello", content: "World" }); - expect(data.data.id).toBeString(); - expect(String(data.data.id).length).toBe(36); - }); - }); -}