diff --git a/README.md b/README.md index d5070c9..d473e36 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd) -[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd) ![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) @@ -18,14 +17,14 @@ bknd simplifies app development by providing a fully functional backend for data > and therefore full backward compatibility is not guaranteed before reaching v1.0.0. ## Size -![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd) +![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) ![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) ![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui) The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets. -Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker). +Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker). ## Motivation Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them. diff --git a/app/__test__/data/data.test.ts b/app/__test__/data/data.test.ts index 886f2aa..79b4301 100644 --- a/app/__test__/data/data.test.ts +++ b/app/__test__/data/data.test.ts @@ -110,4 +110,18 @@ describe("some tests", async () => { new EntityManager([entity, entity2], connection); }).toThrow(); }); + + test("primary uuid", async () => { + const entity = new Entity("users", [ + new PrimaryField("id", { format: "uuid" }), + new TextField("username"), + ]); + const em = new EntityManager([entity], getDummyConnection().dummyConnection); + await em.schema().sync({ force: true }); + + const mutator = em.mutator(entity); + const data = await mutator.insertOne({ username: "test" }); + expect(data.data.id).toBeDefined(); + expect(data.data.id).toBeString(); + }); }); diff --git a/app/__test__/data/specs/fields/PrimaryField.spec.ts b/app/__test__/data/specs/fields/PrimaryField.spec.ts index 6be0166..c40ee14 100644 --- a/app/__test__/data/specs/fields/PrimaryField.spec.ts +++ b/app/__test__/data/specs/fields/PrimaryField.spec.ts @@ -39,4 +39,28 @@ describe("[data] PrimaryField", async () => { expect(field.transformPersist(1)).rejects.toThrow(); expect(field.transformRetrieve(1)).toBe(1); }); + + test("format", () => { + const uuid = new PrimaryField("uuid", { format: "uuid" }); + expect(uuid.format).toBe("uuid"); + expect(uuid.fieldType).toBe("text"); + expect(uuid.getNewValue()).toBeString(); + expect(uuid.toType()).toEqual({ + required: true, + comment: undefined, + type: "Generated", + import: [{ package: "kysely", name: "Generated" }], + }); + + const integer = new PrimaryField("integer", { format: "integer" }); + expect(integer.format).toBe("integer"); + expect(integer.fieldType).toBe("integer"); + expect(integer.getNewValue()).toBeUndefined(); + expect(integer.toType()).toEqual({ + required: true, + comment: undefined, + type: "Generated", + import: [{ package: "kysely", name: "Generated" }], + }); + }); }); diff --git a/app/package.json b/app/package.json index 314cd65..b441f57 100644 --- a/app/package.json +++ b/app/package.json @@ -70,7 +70,8 @@ "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "radix-ui": "^1.1.3", - "swr": "^2.3.3" + "swr": "^2.3.3", + "uuid": "^11.1.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -99,7 +100,7 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.0.14-alpha.6", + "jsonv-ts": "^0.1.0", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", @@ -123,7 +124,8 @@ "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9", - "wouter": "^3.6.0" + "wouter": "^3.6.0", + "@cloudflare/workers-types": "^4.20250606.0" }, "optionalDependencies": { "@hono/node-server": "^1.14.3" diff --git a/app/src/App.ts b/app/src/App.ts index 639d891..956229c 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -253,6 +253,11 @@ export class App { break; } }); + + // call server init if set + if (this.options?.manager?.onServerInit) { + this.options.manager.onServerInit(server); + } } } diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 22449a4..0c51acb 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { makeApp } from "./modes/fresh"; -import { makeConfig } from "./config"; +import { makeConfig, type CfMakeConfigArgs } from "./config"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { adapterTestSuite } from "adapter/adapter-test-suite"; import { bunTestRunner } from "adapter/bun/test"; @@ -23,7 +23,7 @@ describe("cf adapter", () => { { connection: { url: DB_URL }, }, - {}, + $ctx({ DB_URL }), ), ).toEqual({ connection: { url: DB_URL } }); @@ -34,15 +34,15 @@ describe("cf adapter", () => { connection: { url: env.DB_URL }, }), }, - { - DB_URL, - }, + $ctx({ DB_URL }), ), ).toEqual({ connection: { url: DB_URL } }); }); - adapterTestSuite(bunTestRunner, { - makeApp, + adapterTestSuite>(bunTestRunner, { + makeApp: async (c, a, o) => { + return await makeApp(c, { env: a } as any, o); + }, makeHandler: (c, a, o) => { return async (request: any) => { const app = await makeApp( @@ -50,7 +50,7 @@ describe("cf adapter", () => { c ?? { connection: { url: DB_URL }, }, - a, + a!, o, ); return app.fetch(request); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 8ff1d08..c78eb92 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -9,7 +9,13 @@ import { getDurable } from "./modes/durable"; import type { App } from "bknd"; import { $console } from "core"; -export type CloudflareEnv = object; +declare global { + namespace Cloudflare { + interface Env {} + } +} + +export type CloudflareEnv = Cloudflare.Env; export type CloudflareBkndConfig = RuntimeBkndConfig & { mode?: "warm" | "fresh" | "cache" | "durable"; bindings?: (args: Env) => { @@ -17,6 +23,11 @@ export type CloudflareBkndConfig = RuntimeBkndConfig & dobj?: DurableObjectNamespace; db?: D1Database; }; + d1?: { + session?: boolean; + transport?: "header" | "cookie"; + first?: D1SessionConstraint; + }; static?: "kv" | "assets"; key?: string; keepAliveSeconds?: number; diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 0c97293..d6e75b1 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -1,47 +1,148 @@ +/// + import { registerMedia } from "./storage/StorageR2Adapter"; import { getBinding } from "./bindings"; -import { D1Connection } from "./D1Connection"; +import { D1Connection } from "./connection/D1Connection"; import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; import { makeConfig as makeAdapterConfig } from "bknd/adapter"; -import type { ExecutionContext } from "hono"; +import type { Context, ExecutionContext } from "hono"; import { $console } from "core"; +import { setCookie } from "hono/cookie"; export const constants = { exec_async_event_id: "cf_register_waituntil", cache_endpoint: "/__bknd/cache", do_endpoint: "/__bknd/do", + d1_session: { + cookie: "cf_d1_session", + header: "x-cf-d1-session", + }, }; +export type CfMakeConfigArgs = { + env: Env; + ctx?: ExecutionContext; + request?: Request; +}; + +function getCookieValue(cookies: string | null, name: string) { + if (!cookies) return null; + + for (const cookie of cookies.split("; ")) { + const [key, value] = cookie.split("="); + if (key === name && value) { + return decodeURIComponent(value); + } + } + return null; +} + +export function d1SessionHelper(config: CloudflareBkndConfig) { + const headerKey = constants.d1_session.header; + const cookieKey = constants.d1_session.cookie; + const transport = config.d1?.transport; + + return { + get: (request?: Request): D1SessionBookmark | undefined => { + if (!request || !config.d1?.session) return undefined; + + if (!transport || transport === "cookie") { + const cookies = request.headers.get("Cookie"); + if (cookies) { + const cookie = getCookieValue(cookies, cookieKey); + if (cookie) { + return cookie; + } + } + } + + if (!transport || transport === "header") { + if (request.headers.has(headerKey)) { + return request.headers.get(headerKey) as any; + } + } + + return undefined; + }, + set: (c: Context, d1?: D1DatabaseSession) => { + if (!d1 || !config.d1?.session) return; + + const session = d1.getBookmark(); + if (session) { + if (!transport || transport === "header") { + c.header(headerKey, session); + } + if (!transport || transport === "cookie") { + setCookie(c, cookieKey, session, { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 60 * 5, // 5 minutes + }); + } + } + }, + }; +} + let media_registered: boolean = false; export function makeConfig( config: CloudflareBkndConfig, - args: Env = {} as Env, + args?: CfMakeConfigArgs, ) { if (!media_registered) { registerMedia(args as any); media_registered = true; } - const appConfig = makeAdapterConfig(config, args); - const bindings = config.bindings?.(args); - if (!appConfig.connection) { - let db: D1Database | undefined; - if (bindings?.db) { - $console.log("Using database from bindings"); - db = bindings.db; - } else if (Object.keys(args).length > 0) { - const binding = getBinding(args, "D1Database"); - if (binding) { - $console.log(`Using database from env "${binding.key}"`); - db = binding.value; + const appConfig = makeAdapterConfig(config, args?.env); + + if (args?.env) { + const bindings = config.bindings?.(args?.env); + + const sessionHelper = d1SessionHelper(config); + const sessionId = sessionHelper.get(args.request); + let session: D1DatabaseSession | undefined; + + if (!appConfig.connection) { + let db: D1Database | undefined; + if (bindings?.db) { + $console.log("Using database from bindings"); + db = bindings.db; + } else if (Object.keys(args).length > 0) { + const binding = getBinding(args.env, "D1Database"); + if (binding) { + $console.log(`Using database from env "${binding.key}"`); + db = binding.value; + } + } + + if (db) { + if (config.d1?.session) { + session = db.withSession(sessionId ?? config.d1?.first); + appConfig.connection = new D1Connection({ binding: session }); + } else { + appConfig.connection = new D1Connection({ binding: db }); + } + } else { + throw new Error("No database connection given"); } } - if (db) { - appConfig.connection = new D1Connection({ binding: db }); - } else { - throw new Error("No database connection given"); + if (config.d1?.session) { + appConfig.options = { + ...appConfig.options, + manager: { + ...appConfig.options?.manager, + onServerInit: (server) => { + server.use(async (c, next) => { + sessionHelper.set(c, session); + await next(); + }); + }, + }, + }; } } diff --git a/app/src/adapter/cloudflare/D1Connection.ts b/app/src/adapter/cloudflare/connection/D1Connection.ts similarity index 83% rename from app/src/adapter/cloudflare/D1Connection.ts rename to app/src/adapter/cloudflare/connection/D1Connection.ts index b23febf..ddf6be8 100644 --- a/app/src/adapter/cloudflare/D1Connection.ts +++ b/app/src/adapter/cloudflare/connection/D1Connection.ts @@ -5,8 +5,8 @@ import type { QB } from "data/connection/Connection"; import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely"; import { D1Dialect } from "kysely-d1"; -export type D1ConnectionConfig = { - binding: D1Database; +export type D1ConnectionConfig = { + binding: DB; }; class CustomD1Dialect extends D1Dialect { @@ -17,22 +17,24 @@ class CustomD1Dialect extends D1Dialect { } } -export class D1Connection extends SqliteConnection { +export class D1Connection< + DB extends D1Database | D1DatabaseSession = D1Database, +> extends SqliteConnection { protected override readonly supported = { batching: true, }; - constructor(private config: D1ConnectionConfig) { + constructor(private config: D1ConnectionConfig) { const plugins = [new ParseJSONResultsPlugin()]; const kysely = new Kysely({ - dialect: new CustomD1Dialect({ database: config.binding }), + dialect: new CustomD1Dialect({ database: config.binding as D1Database }), plugins, }); super(kysely, {}, plugins); } - get client(): D1Database { + get client(): DB { return this.config.binding; } diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index f53f908..60e6a77 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,4 +1,4 @@ -import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; +import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection"; export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; @@ -12,6 +12,7 @@ export { type GetBindingType, type BindingMap, } from "./bindings"; +export { constants } from "./config"; export function d1(config: D1ConnectionConfig) { return new D1Connection(config); diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index 3685f7b..fc1d3c4 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config export async function getCached( config: CloudflareBkndConfig, - { env, ctx, ...args }: Context, + args: Context, ) { + const { env, ctx } = args; const { kv } = config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -20,7 +21,7 @@ export async function getCached( const app = await createRuntimeApp( { - ...makeConfig(config, env), + ...makeConfig(config, args), initialConfig, onBuilt: async (app) => { registerAsyncsExecutionContext(app, ctx); @@ -41,7 +42,7 @@ export async function getCached( await config.beforeBuild?.(app); }, }, - { env, ctx, ...args }, + args, ); if (!cachedConfig) { diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index af085d6..7fb37e3 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,13 +1,13 @@ import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { makeConfig, registerAsyncsExecutionContext } from "../config"; +import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config"; export async function makeApp( config: CloudflareBkndConfig, - args: Env = {} as Env, + args?: CfMakeConfigArgs, opts?: RuntimeOptions, ) { - return await createRuntimeApp(makeConfig(config, args), args, opts); + return await createRuntimeApp(makeConfig(config, args), args?.env, opts); } export async function getFresh( @@ -23,7 +23,7 @@ export async function getFresh( await config.onBuilt?.(app); }, }, - ctx.env, + ctx, opts, ); } diff --git a/app/src/core/config.ts b/app/src/core/config.ts index 189b6c9..99a9013 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -3,9 +3,9 @@ */ import type { Generated } from "kysely"; -export type PrimaryFieldType = IdType | Generated; +export type PrimaryFieldType = IdType | Generated; -export interface AppEntity { +export interface AppEntity { id: PrimaryFieldType; } diff --git a/app/src/core/utils/uuid.ts b/app/src/core/utils/uuid.ts index 32397e8..e3112e2 100644 --- a/app/src/core/utils/uuid.ts +++ b/app/src/core/utils/uuid.ts @@ -1,4 +1,10 @@ +import { v4, v7 } from "uuid"; + // generates v4 export function uuid(): string { - return crypto.randomUUID(); + return v4(); +} + +export function uuidv7(): string { + return v7(); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 8c3ab45..e0f6c03 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -233,6 +233,8 @@ export class DataController extends Controller { const hono = this.create(); const entitiesEnum = this.getEntitiesEnum(this.em); + // @todo: make dynamic based on entity + const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any }); /** * Function endpoints @@ -333,7 +335,7 @@ export class DataController extends Controller { "param", s.object({ entity: entitiesEnum, - id: s.string(), + id: idType, }), ), jsc("query", repoQuery, { skipOpenAPI: true }), @@ -342,8 +344,9 @@ export class DataController extends Controller { if (!this.entityExists(entity)) { return this.notFound(c); } + console.log("id", id); const options = c.req.valid("query") as RepoQuery; - const result = await this.em.repository(entity).findId(Number(id), options); + const result = await this.em.repository(entity).findId(id, options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); }, @@ -362,7 +365,7 @@ export class DataController extends Controller { "param", s.object({ entity: entitiesEnum, - id: s.string(), + id: idType, reference: s.string(), }), ), @@ -376,7 +379,7 @@ export class DataController extends Controller { const options = c.req.valid("query") as RepoQuery; const result = await this.em .repository(entity) - .findManyByReference(Number(id), reference, options); + .findManyByReference(id, reference, options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); }, @@ -485,7 +488,7 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityUpdate), - jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), + jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("json", s.object({})), async (c) => { const { entity, id } = c.req.valid("param"); @@ -493,7 +496,7 @@ export class DataController extends Controller { return this.notFound(c); } const body = (await c.req.json()) as EntityData; - const result = await this.em.mutator(entity).updateOne(Number(id), body); + const result = await this.em.mutator(entity).updateOne(id, body); return c.json(this.mutatorResult(result)); }, @@ -507,13 +510,13 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityDelete), - jsc("param", s.object({ entity: entitiesEnum, id: s.number() })), + jsc("param", s.object({ entity: entitiesEnum, id: idType })), async (c) => { const { entity, id } = c.req.valid("param"); if (!this.entityExists(entity)) { return this.notFound(c); } - const result = await this.em.mutator(entity).deleteOne(Number(id)); + const result = await this.em.mutator(entity).deleteOne(id); return c.json(this.mutatorResult(result)); }, diff --git a/app/src/data/connection/sqlite/SqliteConnection.ts b/app/src/data/connection/sqlite/SqliteConnection.ts index a63d49b..33a3fd8 100644 --- a/app/src/data/connection/sqlite/SqliteConnection.ts +++ b/app/src/data/connection/sqlite/SqliteConnection.ts @@ -31,7 +31,11 @@ export class SqliteConnection extends Connection { type, (col: ColumnDefinitionBuilder) => { if (spec.primary) { - return col.primaryKey().notNull().autoIncrement(); + if (spec.type === "integer") { + return col.primaryKey().notNull().autoIncrement(); + } + + return col.primaryKey().notNull(); } if (spec.references) { let relCol = col.references(spec.references); diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 38f272f..c594117 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -1,4 +1,4 @@ -import { type Static, StringRecord, objectTransform } from "core/utils"; +import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils"; import * as tb from "@sinclair/typebox"; import { FieldClassMap, @@ -8,6 +8,7 @@ import { entityTypes, } from "data"; import { MediaField, mediaFieldConfigSchema } from "../media/MediaField"; +import { primaryFieldTypes } from "./fields"; export const FIELDS = { ...FieldClassMap, @@ -72,6 +73,9 @@ export const indicesSchema = tb.Type.Object( export const dataConfigSchema = tb.Type.Object( { basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })), + default_primary_format: tb.Type.Optional( + StringEnum(primaryFieldTypes, { default: "integer" }), + ), entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })), relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })), indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })), diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index b097557..cdcbce6 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -6,7 +6,13 @@ import { snakeToPascalWithSpaces, transformObject, } from "core/utils"; -import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields"; +import { + type Field, + PrimaryField, + primaryFieldTypes, + type TActionContext, + type TRenderContext, +} from "../fields"; import * as tbbox from "@sinclair/typebox"; const { Type } = tbbox; @@ -18,6 +24,7 @@ export const entityConfigSchema = Type.Object( description: Type.Optional(Type.String()), sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })), sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })), + primary_format: Type.Optional(StringEnum(primaryFieldTypes)), }, { additionalProperties: false, @@ -68,7 +75,14 @@ export class Entity< if (primary_count > 1) { throw new Error(`Entity "${name}" has more than one primary field`); } - this.fields = primary_count === 1 ? [] : [new PrimaryField()]; + this.fields = + primary_count === 1 + ? [] + : [ + new PrimaryField(undefined, { + format: this.config.primary_format, + }), + ]; if (fields) { fields.forEach((field) => this.addField(field)); diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index 104ca33..7dcb2dd 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -143,7 +143,7 @@ export class Mutator< // if listener returned, take what's returned const _data = result.returned ? result.params.data : data; - const validatedData = { + let validatedData = { ...entity.getDefaultObject(), ...(await this.getValidatedData(_data, "create")), }; @@ -159,6 +159,16 @@ export class Mutator< } } + // primary + const primary = entity.getPrimaryField(); + const primary_value = primary.getNewValue(); + if (primary_value) { + validatedData = { + [primary.name]: primary_value, + ...validatedData, + }; + } + const query = this.conn .insertInto(entity.name) .values(validatedData) @@ -175,7 +185,7 @@ export class Mutator< async updateOne(id: PrimaryFieldType, data: Partial): Promise> { const entity = this.entity; - if (!Number.isInteger(id)) { + if (!id) { throw new Error("ID must be provided for update"); } @@ -212,7 +222,7 @@ export class Mutator< async deleteOne(id: PrimaryFieldType): Promise> { const entity = this.entity; - if (!Number.isInteger(id)) { + if (!id) { throw new Error("ID must be provided for deletion"); } diff --git a/app/src/data/fields/PrimaryField.ts b/app/src/data/fields/PrimaryField.ts index 2cc2983..2f83c20 100644 --- a/app/src/data/fields/PrimaryField.ts +++ b/app/src/data/fields/PrimaryField.ts @@ -1,13 +1,17 @@ import { config } from "core"; -import type { Static } from "core/utils"; +import { StringEnum, uuidv7, type Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "./Field"; import * as tbbox from "@sinclair/typebox"; import type { TFieldTSType } from "data/entities/EntityTypescript"; const { Type } = tbbox; +export const primaryFieldTypes = ["integer", "uuid"] as const; +export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number]; + export const primaryFieldConfigSchema = Type.Composite([ Type.Omit(baseFieldConfigSchema, ["required"]), Type.Object({ + format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })), required: Type.Optional(Type.Literal(false)), }), ]); @@ -21,8 +25,8 @@ export class PrimaryField extends Field< > { override readonly type = "primary"; - constructor(name: string = config.data.default_primary_field) { - super(name, { fillable: false, required: false }); + constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) { + super(name, { fillable: false, required: false, ...cfg }); } override isRequired(): boolean { @@ -30,32 +34,53 @@ export class PrimaryField extends Field< } protected getSchema() { - return baseFieldConfigSchema; + return primaryFieldConfigSchema; + } + + get format() { + return this.config.format ?? "integer"; + } + + get fieldType() { + return this.format === "integer" ? "integer" : "text"; } override schema() { return Object.freeze({ - type: "integer", + type: this.fieldType, name: this.name, primary: true, nullable: false, }); } + getNewValue(): any { + if (this.format === "uuid") { + return uuidv7(); + } + + return undefined; + } + override async transformPersist(value: any): Promise { throw new Error("PrimaryField: This function should not be called"); } override toJsonSchema() { + if (this.format === "uuid") { + return this.toSchemaWrapIfRequired(Type.String({ writeOnly: undefined })); + } + return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined })); } override toType(): TFieldTSType { + const type = this.format === "integer" ? "number" : "string"; return { ...super.toType(), required: true, import: [{ package: "kysely", name: "Generated" }], - type: "Generated", + type: `Generated<${type}>`, }; } } diff --git a/app/src/data/relations/EntityRelation.ts b/app/src/data/relations/EntityRelation.ts index bf0d8e6..35cef59 100644 --- a/app/src/data/relations/EntityRelation.ts +++ b/app/src/data/relations/EntityRelation.ts @@ -9,6 +9,7 @@ import { import type { RepoQuery } from "../server/query"; import type { RelationType } from "./relation-types"; import * as tbbox from "@sinclair/typebox"; +import type { PrimaryFieldType } from "core"; const { Type } = tbbox; const directions = ["source", "target"] as const; @@ -72,7 +73,7 @@ export abstract class EntityRelation< reference: string, ): KyselyQueryBuilder; - getReferenceQuery(entity: Entity, id: number, reference: string): Partial { + getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial { return {}; } diff --git a/app/src/data/relations/RelationField.ts b/app/src/data/relations/RelationField.ts index 0623987..67be187 100644 --- a/app/src/data/relations/RelationField.ts +++ b/app/src/data/relations/RelationField.ts @@ -1,6 +1,6 @@ import { type Static, StringEnum } from "core/utils"; import type { EntityManager } from "../entities"; -import { Field, baseFieldConfigSchema } from "../fields"; +import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields"; import type { EntityRelation } from "./EntityRelation"; import type { EntityRelationAnchor } from "./EntityRelationAnchor"; import * as tbbox from "@sinclair/typebox"; @@ -15,6 +15,7 @@ export const relationFieldConfigSchema = Type.Composite([ reference: Type.String(), target: Type.String(), // @todo: potentially has to be an instance! target_field: Type.Optional(Type.String({ default: "id" })), + target_field_type: Type.Optional(StringEnum(["integer", "text"], { default: "integer" })), on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })), }), ]); @@ -45,6 +46,7 @@ export class RelationField extends Field { reference: target.reference, target: target.entity.name, target_field: target.entity.getPrimaryField().name, + target_field_type: target.entity.getPrimaryField().fieldType, }); } @@ -63,7 +65,7 @@ export class RelationField extends Field { override schema() { return Object.freeze({ ...super.schema()!, - type: "integer", + type: this.config.target_field_type ?? "integer", references: `${this.config.target}.${this.config.target_field}`, onDelete: this.config.on_delete ?? "set null", }); diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index 7ad1ba1..42c071d 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -2,6 +2,7 @@ import type { CompiledQuery, TableMetadata } from "kysely"; import type { IndexMetadata, SchemaResponse } from "../connection/Connection"; import type { Entity, EntityManager } from "../entities"; import { PrimaryField } from "../fields"; +import { $console } from "core"; type IntrospectedTable = TableMetadata & { indices: IndexMetadata[]; @@ -332,6 +333,7 @@ export class SchemaManager { if (config.force) { try { + $console.info("[SchemaManager]", sql, parameters); await qb.execute(); } catch (e) { throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`); diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index d31f641..1ecd4f2 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -1,9 +1,10 @@ import type { Api } from "bknd/client"; +import type { PrimaryFieldType } from "core"; import type { RepoQueryIn } from "data"; 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 "ui/client"; +import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; import { useEvent } from "ui/hooks/use-event"; import { Dropzone, type DropzoneProps } from "./Dropzone"; import { mediaItemsToFileStates } from "./helper"; @@ -14,7 +15,7 @@ export type DropzoneContainerProps = { infinite?: boolean; entity?: { name: string; - id: number; + id: PrimaryFieldType; field: string; }; media?: Pick; diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 2c7c6a3..50c9feb 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -22,6 +22,7 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField"; import ErrorBoundary from "ui/components/display/ErrorBoundary"; import { Alert } from "ui/components/display/Alert"; import { bkndModals } from "ui/modals"; +import type { PrimaryFieldType } from "core"; // simplify react form types 🤦 export type FormApi = ReactFormExtendedApi; @@ -30,7 +31,7 @@ export type TFieldApi = FieldApi) => void; fieldsDisabled: boolean; @@ -225,7 +226,7 @@ function EntityMediaFormField({ formApi: FormApi; field: MediaField; entity: Entity; - entityId?: number; + entityId?: PrimaryFieldType; disabled?: boolean; }) { if (!entityId) return; diff --git a/app/src/ui/modules/data/components/schema/create-modal/step.entity.fields.tsx b/app/src/ui/modules/data/components/schema/create-modal/step.entity.fields.tsx index a9a90e0..141c9ec 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/step.entity.fields.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/step.entity.fields.tsx @@ -11,12 +11,14 @@ import { type EntityFieldsFormRef, } from "ui/routes/data/forms/entity.fields.form"; import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal"; +import { useBkndData } from "ui/client/schema/data/use-bknd-data"; const schema = entitiesSchema; type Schema = Static; export function StepEntityFields() { const { nextStep, stepBack, state, setState } = useStepContext(); + const { config } = useBkndData(); const entity = state.entities?.create?.[0]!; const defaultFields = { id: { type: "primary", name: "id" } } as const; const ref = useRef(null); @@ -82,6 +84,8 @@ export function StepEntityFields() { ref={ref} fields={initial.fields as any} onChange={updateListener} + defaultPrimaryFormat={config?.default_primary_format} + isNew={true} /> diff --git a/app/src/ui/modules/data/components/schema/create-modal/step.entity.tsx b/app/src/ui/modules/data/components/schema/create-modal/step.entity.tsx index 7824fb1..253fc4f 100644 --- a/app/src/ui/modules/data/components/schema/create-modal/step.entity.tsx +++ b/app/src/ui/modules/data/components/schema/create-modal/step.entity.tsx @@ -10,12 +10,13 @@ import { entitySchema, useStepContext, } from "./CreateModal"; +import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect"; export function StepEntity() { const focusTrapRef = useFocusTrap(); const { nextStep, stepBack, state, setState } = useStepContext(); - const { register, handleSubmit, formState, watch } = useForm({ + const { register, handleSubmit, formState, watch, control } = useForm({ mode: "onTouched", resolver: typeboxResolver(entitySchema), defaultValues: state.entities?.create?.[0] ?? {}, @@ -56,7 +57,6 @@ export function StepEntity() { label="What's the name of the entity?" description="Use plural form, and all lowercase. It will be used as the database table." /> - {/**/} ; } - const entityId = Number.parseInt(params.id as string); + const entityId = params.id as PrimaryFieldType; const [error, setError] = useState(null); const [navigate] = useNavigate(); useBrowserTitle(["Data", entity.label, `#${entityId}`]); @@ -202,7 +203,7 @@ function EntityDetailRelations({ entity, relations, }: { - id: number; + id: PrimaryFieldType; entity: Entity; relations: EntityRelation[]; }) { @@ -250,7 +251,7 @@ function EntityDetailInner({ entity, relation, }: { - id: number; + id: PrimaryFieldType; entity: Entity; relation: EntityRelation; }) { diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index 0831284..3125a34 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -148,7 +148,7 @@ export function DataSchemaEntity({ params }) { const Fields = ({ entity }: { entity: Entity }) => { const [submitting, setSubmitting] = useState(false); const [updates, setUpdates] = useState(0); - const { actions, $data } = useBkndData(); + const { actions, $data, config } = useBkndData(); const [res, setRes] = useState(); const ref = useRef(null); async function handleUpdate() { @@ -201,6 +201,8 @@ const Fields = ({ entity }: { entity: Entity }) => { } }, }))} + defaultPrimaryFormat={config?.default_primary_format} + isNew={false} /> {isDebug() && ( 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 a0ca16c..b0186a6 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -28,6 +28,8 @@ import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-s import { dataFieldsUiSchema } from "../../settings/routes/data.settings"; import * as tbbox from "@sinclair/typebox"; import { useRoutePathState } from "ui/hooks/use-route-path-state"; +import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect"; +import type { TPrimaryFieldFormat } from "data/fields/PrimaryField"; const { Type } = tbbox; const fieldsSchemaObject = originalFieldsSchemaObject; @@ -65,6 +67,8 @@ export type EntityFieldsFormProps = { sortable?: boolean; additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[]; routePattern?: string; + defaultPrimaryFormat?: TPrimaryFieldFormat; + isNew?: boolean; }; export type EntityFieldsFormRef = { @@ -77,7 +81,7 @@ export type EntityFieldsFormRef = { export const EntityFieldsForm = forwardRef( function EntityFieldsForm( - { fields: _fields, sortable, additionalFieldTypes, routePattern, ...props }, + { fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props }, ref, ) { const entityFields = Object.entries(_fields).map(([name, field]) => ({ @@ -172,6 +176,10 @@ export const EntityFieldsForm = forwardRef )} /> @@ -186,6 +194,10 @@ export const EntityFieldsForm = forwardRef ))} @@ -281,6 +293,7 @@ function EntityField({ errors, dnd, routePattern, + primary, }: { field: FieldArrayWithId; index: number; @@ -292,6 +305,10 @@ function EntityField({ errors: any; dnd?: SortableItemProps; routePattern?: string; + primary?: { + defaultFormat?: TPrimaryFieldFormat; + editable?: boolean; + }; }) { const prefix = `fields.${index}.field` as const; const type = field.field.type; @@ -363,15 +380,29 @@ function EntityField({ )}
- Required {is_primary ? ( - + <> + + ) : ( - + <> + Required + + )}
diff --git a/app/tsconfig.json b/app/tsconfig.json index 8b845ec..967533f 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "types": ["bun-types", "@cloudflare/workers-types"], + "types": ["bun-types"], "composite": false, "incremental": true, "module": "ESNext", @@ -30,7 +30,14 @@ "baseUrl": ".", "outDir": "./dist/types", "paths": { - "*": ["./src/*"] + "*": ["./src/*"], + "bknd": ["./src/index.ts"], + "bknd/core": ["./src/core/index.ts"], + "bknd/adapter": ["./src/adapter/index.ts"], + "bknd/client": ["./src/ui/client/index.ts"], + "bknd/data": ["./src/data/index.ts"], + "bknd/media": ["./src/media/index.ts"], + "bknd/auth": ["./src/auth/index.ts"] } }, "include": [ diff --git a/bun.lock b/bun.lock index 08543de..642fcde 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "devDependencies": { "@biomejs/biome": "1.9.4", "@clack/prompts": "^0.10.0", - "@cloudflare/workers-types": "^4.20240620.0", "@tsconfig/strictest": "^2.0.5", "@types/lodash-es": "^4.17.12", "bun-types": "^1.1.18", @@ -55,10 +54,12 @@ "object-path-immutable": "^4.1.2", "radix-ui": "^1.1.3", "swr": "^2.3.3", + "uuid": "^11.1.0", }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", + "@cloudflare/workers-types": "^4.20250606.0", "@dagrejs/dagre": "^1.1.4", "@hono/typebox-validator": "^0.3.3", "@hono/vite-dev-server": "^0.19.1", @@ -84,7 +85,7 @@ "hono": "4.7.11", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.0.14-alpha.6", + "jsonv-ts": "^0.1.0", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", @@ -527,7 +528,7 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250224.0", "", { "os": "win32", "cpu": "x64" }, "sha512-x2iF1CsmYmmPEorWb1GRpAAouX5rRjmhuHMC259ojIlozR4G0LarlB9XfmeLEvtw537Ea0kJ6SOhjvUcWzxSvA=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250310.0", "", {}, "sha512-SNE2ohlL9/VxFbcHQc28n3Nj70FiS1Ea0wrUhCXUIbR2lsr4ceRVndNxhuzhcF9EZd2UXm2wwow34RIS1mm+Mg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250606.0", "", {}, "sha512-9T/Y/Mxe57UVzqgfjJKheiMplnStj/3CmCHlgoZNLU8JW2waRbXvpY3EEeliiYAJfeHZTjeAaKO2pCabxAoyCw=="], "@cnakazawa/watch": ["@cnakazawa/watch@1.0.4", "", { "dependencies": { "exec-sh": "^0.3.2", "minimist": "^1.2.0" }, "bin": { "watch": "cli.js" } }, "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ=="], @@ -2531,7 +2532,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.0.14-alpha.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-pwMpjEbNtyq8Xi6QBXuQ8dOZm7WQAEwvCPu3vVf9b3aU2KRHW+cfTPqO53U01YYdjWSSRkqaTKcLSiYdfwBYRA=="], + "jsonv-ts": ["jsonv-ts@0.1.0", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-wJ+79o49MNie2Xk9w1hPN8ozjqemVWXOfWUTdioLui/SeGDC7C+QKXTDxsmUaIay86lorkjb3CCGo6JDKbyTZQ=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], @@ -3619,7 +3620,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "v8-compile-cache": ["v8-compile-cache@2.4.0", "", {}, "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw=="], @@ -3867,6 +3868,8 @@ "@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=="], + "@cypress/request/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], diff --git a/docs/integration/cloudflare.mdx b/docs/integration/cloudflare.mdx index d88575e..ce4544a 100644 --- a/docs/integration/cloudflare.mdx +++ b/docs/integration/cloudflare.mdx @@ -205,4 +205,34 @@ new_classes = ["DurableBkndApp"] tag = "v2" renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}] deleted_classes = ["DurableBkndApp"] +``` + +## D1 Sessions (experimental) +D1 now supports to enable [global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). This allows to reduce latency by reading from the closest region. In order for this to work, D1 has to be started from a bookmark. You can enable this behavior on bknd by setting the `d1.session` property: + +```typescript src/index.ts +import { serve } from "bknd/adapter/cloudflare"; + +export default serve({ + // currently recommended to use "fresh" mode + // otherwise consecutive requests will use the same bookmark + mode: "fresh", + // ... + d1: { + // enables D1 sessions + session: true, + // (optional) restrict the transport, options: "header" | "cookie" + // if not specified, it supports both + transport: "cookie", + // (optional) choose session constraint if not bookmark present + // options: "first-primary" | "first-unconstrained" + first: "first-primary" + } +}); +``` + +If bknd is used in a stateful user context (like in a browser), it'll automatically send the session cookie to the server to set the correct bookmark. If you need to manually set the bookmark, you can do so by setting the `x-cf-d1-session` header: + +```bash +curl -H "x-cf-d1-session: " ... ``` \ No newline at end of file diff --git a/examples/cloudflare-worker/package.json b/examples/cloudflare-worker/package.json index 9f418a6..2b8a6c3 100644 --- a/examples/cloudflare-worker/package.json +++ b/examples/cloudflare-worker/package.json @@ -9,11 +9,10 @@ }, "dependencies": { "bknd": "file:../../app", - "kysely-d1": "^0.3.0" + "kysely-d1": "^0.4.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240620.0", - "typescript": "^5.5.3", - "wrangler": "^4.4.0" + "typescript": "^5.8.3", + "wrangler": "^4.19.1" } } diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index b9b9e76..5435b30 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -1,10 +1,15 @@ -/// - -import { serve } from "bknd/adapter/cloudflare"; +import { type D1Connection, serve } from "bknd/adapter/cloudflare"; export default serve({ mode: "warm", + d1: { + session: true, + }, onBuilt: async (app) => { - app.modules.server.get("/custom", (c) => c.json({ hello: "world" })); + app.modules.server.get("/custom", async (c) => { + const conn = c.var.app.em.connection as D1Connection; + const res = await conn.client.prepare("select * from __bknd limit 1").all(); + return c.json({ hello: "world", res }); + }); }, }); diff --git a/examples/cloudflare-worker/tsconfig.json b/examples/cloudflare-worker/tsconfig.json index 910d0f1..9879d52 100644 --- a/examples/cloudflare-worker/tsconfig.json +++ b/examples/cloudflare-worker/tsconfig.json @@ -5,7 +5,7 @@ "jsx": "react-jsx", "module": "es2022", "moduleResolution": "Bundler", - "types": ["@cloudflare/workers-types/2023-07-01"], + "types": ["./worker-configuration.d.ts"], "resolveJsonModule": true, "allowJs": true, "checkJs": false, diff --git a/examples/cloudflare-worker/worker-configuration.d.ts b/examples/cloudflare-worker/worker-configuration.d.ts index 6f59c80..9f7bd6c 100644 --- a/examples/cloudflare-worker/worker-configuration.d.ts +++ b/examples/cloudflare-worker/worker-configuration.d.ts @@ -1,12 +1,8 @@ -// Generated by Wrangler -// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` - -interface Env { - DB_URL: string; - DB_TOKEN: string; -} - -declare module "__STATIC_CONTENT_MANIFEST" { - const value: string; - export default value; +// placeholder, run generation again +declare namespace Cloudflare { + interface Env { + BUCKET: R2Bucket; + DB: D1Database; + } } +interface Env extends Cloudflare.Env {} diff --git a/examples/cloudflare-worker/wrangler.json b/examples/cloudflare-worker/wrangler.json index 68b57ee..013a2dd 100644 --- a/examples/cloudflare-worker/wrangler.json +++ b/examples/cloudflare-worker/wrangler.json @@ -15,8 +15,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "bknd-cf-example", - "database_id": "7ad67953-2bbf-47fc-8696-f4517dbfe674" + "database_name": "bknd-dev-weur", + "database_id": "81d8dfcc-4eaf-4453-8f0f-8f6d463fb867" } ], "r2_buckets": [ diff --git a/package.json b/package.json index c885ad0..6339fee 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "devDependencies": { "@biomejs/biome": "1.9.4", "@clack/prompts": "^0.10.0", - "@cloudflare/workers-types": "^4.20240620.0", "@tsconfig/strictest": "^2.0.5", "@types/lodash-es": "^4.17.12", "bun-types": "^1.1.18", @@ -42,8 +41,5 @@ "engines": { "node": ">=20.0.0" }, - "workspaces": [ - "app", - "packages/*" - ] -} \ No newline at end of file + "workspaces": ["app", "packages/*"] +}