diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 224f18f..d9f51ee 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 1b0bfa7..02a35a6 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 26fb8e9..003c1e4 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 de52198..63f6880 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 caa5566..2f920ff 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 6ff0c3e..5b796c2 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 0000000..0a4fc52 --- /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 40e6493..0000000 --- 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 0000000..7974a0d --- /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 0000000..7ec79f9 --- /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 0000000..f9016cd --- /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 d9b2dc2..efb987d 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 ee46b7b..a884674 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 bf62599..3eae83e 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 89872de..14cc5c5 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 fb5464a..5a1f6ce 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 b85ebbf..657ab06 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 de5c889..5448c78 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 3de4e2a..599ce82 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 1870b71..8e36a10 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 76a067e..7e8bc75 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 33c6a43..afdf183 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 5b943ff..e43a8ad 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 082ae0c..531be0c 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 270ccb0..bf45e0e 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 0000000..f9b5466 --- /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 ff67991..b55df6d 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 4b1c928..c4b52b3 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 0000000..710b033 --- /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 9d626a0..22e7809 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 3335133..21fe5a5 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 8ff2688..2ce4345 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 3d8f432..218f25e 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 b2e4516..0f7481f 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 12c531b..12120d8 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 43df6a1..0741f95 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 eb2eb2b..8a336b5 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 e30af8a..81b4c9a 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 92973dc..6b226c2 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 152072d..0a58180 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 3ae6cd2..45988ad 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 0000000..cadc0a3 --- /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 0000000..13f9a93 --- /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 b0090ff..fcb23c1 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 3ac5d5d..056e52d 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 88a54c1..21866c3 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 573b990..a93aaf2 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 1042344..7edeaf1 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 2fb520e..f611752 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 291c963..3db502d 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 5907cf6..9bcdf57 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 b7cb384..896807f 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 2a99a5f..bd412dc 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 6c3cb87..4e7927c 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 f53beb3..5d978c8 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 bfce4f1..4ed787c 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 f3d20ec..c0062b6 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 aa331bd..1a4300e 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 9379606..e7c5bfc 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 d24ac9b..477ca9e 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 30669e9..204c41b 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 20b67ca..a1c3bfd 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 5945f37..91cfbd9 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 4272f89..deba43c 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 184c903..4438362 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 0f20428..92b0b10 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 10260b4..d6060d9 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 278fd1c..f1d462b 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 8690613..1a3d426 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 9268116..22c0415 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 850629d..df20327 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 9a2fe11..550f615 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 f4efd02..0261f90 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 0000000..ea1ad6b --- /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 5bb10f6..7fb1ec5 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 75e2a32..d6b5c07 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 83b56c2..189c80d 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 ed5d1e6..0000000 --- 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 036d6a9..0000000 --- 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 c96e693..0000000 --- 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 deff210..0000000 --- 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 c6ac89a..0000000 --- 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 5a1f5a4..0000000 --- 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 ec72987..0000000 --- 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); - }); - }); -}