mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Merge remote-tracking branch 'origin/release/0.14' into feat/postgres-improvements
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
[](https://npmjs.org/package/bknd)
|
||||
[](https://www.npmjs.com/package/bknd)
|
||||
|
||||

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

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>",
|
||||
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<number>",
|
||||
import: [{ package: "kysely", name: "Generated" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -253,6 +253,11 @@ export class App {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// call server init if set
|
||||
if (this.options?.manager?.onServerInit) {
|
||||
this.options.manager.onServerInit(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CloudflareBkndConfig, object>(bunTestRunner, {
|
||||
makeApp,
|
||||
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(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);
|
||||
|
||||
@@ -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<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (args: Env) => {
|
||||
@@ -17,6 +23,11 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
||||
dobj?: DurableObjectNamespace;
|
||||
db?: D1Database;
|
||||
};
|
||||
d1?: {
|
||||
session?: boolean;
|
||||
transport?: "header" | "cookie";
|
||||
first?: D1SessionConstraint;
|
||||
};
|
||||
static?: "kv" | "assets";
|
||||
key?: string;
|
||||
keepAliveSeconds?: number;
|
||||
|
||||
@@ -1,47 +1,148 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
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 extends CloudflareEnv = CloudflareEnv> = {
|
||||
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<any>) {
|
||||
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<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
) {
|
||||
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();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DB extends D1Database | D1DatabaseSession = D1Database> = {
|
||||
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<DB>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
|
||||
|
||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
{ env, ctx, ...args }: Context<Env>,
|
||||
args: Context<Env>,
|
||||
) {
|
||||
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<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
|
||||
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<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
await config.beforeBuild?.(app);
|
||||
},
|
||||
},
|
||||
{ env, ctx, ...args },
|
||||
args,
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
|
||||
@@ -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<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
|
||||
}
|
||||
|
||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
},
|
||||
ctx.env,
|
||||
ctx,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType<IdType extends number = number> = IdType | Generated<IdType>;
|
||||
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
|
||||
|
||||
export interface AppEntity<IdType extends number = number> {
|
||||
export interface AppEntity<IdType = number | string> {
|
||||
id: PrimaryFieldType<IdType>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {} })),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<Input>): Promise<MutatorResponse<Output>> {
|
||||
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<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
if (!id) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Required extends true | false = false> 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<Required extends true | false = false> 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<number> {
|
||||
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<number>",
|
||||
type: `Generated<${type}>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RepoQuery> {
|
||||
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RelationFieldConfig> {
|
||||
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<RelationFieldConfig> {
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<TAppMediaConfig, "entity_name" | "storage">;
|
||||
|
||||
@@ -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<any, any, any, any, any, any, any, any, any, any>;
|
||||
@@ -30,7 +31,7 @@ export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, an
|
||||
|
||||
type EntityFormProps = {
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
entityId?: PrimaryFieldType;
|
||||
data?: EntityData;
|
||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
fieldsDisabled: boolean;
|
||||
@@ -225,7 +226,7 @@ function EntityMediaFormField({
|
||||
formApi: FormApi;
|
||||
field: MediaField;
|
||||
entity: Entity;
|
||||
entityId?: number;
|
||||
entityId?: PrimaryFieldType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
export function StepEntityFields() {
|
||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||
const { config } = useBkndData();
|
||||
const entity = state.entities?.create?.[0]!;
|
||||
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
@@ -82,6 +84,8 @@ export function StepEntityFields() {
|
||||
ref={ref}
|
||||
fields={initial.fields as any}
|
||||
onChange={updateListener}
|
||||
defaultPrimaryFormat={config?.default_primary_format}
|
||||
isNew={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<TCreateModalSchema>();
|
||||
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."
|
||||
/>
|
||||
{/*<input type="submit" value="submit" />*/}
|
||||
<TextInput
|
||||
{...register("config.name")}
|
||||
error={formState.errors.config?.name?.message}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { ucFirst } from "core/utils";
|
||||
import type { Entity, EntityData, EntityRelation } from "data";
|
||||
import { Fragment, useState } from "react";
|
||||
@@ -24,7 +25,7 @@ export function DataEntityUpdate({ params }) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
}
|
||||
|
||||
const entityId = Number.parseInt(params.id as string);
|
||||
const entityId = params.id as PrimaryFieldType;
|
||||
const [error, setError] = useState<string | null>(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;
|
||||
}) {
|
||||
|
||||
@@ -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<any>();
|
||||
const ref = useRef<EntityFieldsFormRef>(null);
|
||||
async function handleUpdate() {
|
||||
@@ -201,6 +201,8 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
||||
}
|
||||
},
|
||||
}))}
|
||||
defaultPrimaryFormat={config?.default_primary_format}
|
||||
isNew={false}
|
||||
/>
|
||||
|
||||
{isDebug() && (
|
||||
|
||||
@@ -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<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||
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<EntityFieldsFormRef, EntityFieldsForm
|
||||
remove={remove}
|
||||
dnd={dnd}
|
||||
routePattern={routePattern}
|
||||
primary={{
|
||||
defaultFormat: props.defaultPrimaryFormat,
|
||||
editable: isNew,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -186,6 +194,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
||||
errors={errors}
|
||||
remove={remove}
|
||||
routePattern={routePattern}
|
||||
primary={{
|
||||
defaultFormat: props.defaultPrimaryFormat,
|
||||
editable: isNew,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -281,6 +293,7 @@ function EntityField({
|
||||
errors,
|
||||
dnd,
|
||||
routePattern,
|
||||
primary,
|
||||
}: {
|
||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-col gap-1 hidden md:flex">
|
||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||
{is_primary ? (
|
||||
<Switch size="sm" defaultChecked disabled />
|
||||
<>
|
||||
<MantineSelect
|
||||
data={["integer", "uuid"]}
|
||||
defaultValue={primary?.defaultFormat}
|
||||
disabled={!primary?.editable}
|
||||
placeholder="Select format"
|
||||
name={`${prefix}.config.format`}
|
||||
allowDeselect={false}
|
||||
control={control}
|
||||
size="xs"
|
||||
className="w-20"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<MantineSwitch
|
||||
size="sm"
|
||||
name={`${prefix}.config.required`}
|
||||
control={control}
|
||||
/>
|
||||
<>
|
||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||
<MantineSwitch
|
||||
size="sm"
|
||||
name={`${prefix}.config.required`}
|
||||
control={control}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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": [
|
||||
|
||||
13
bun.lock
13
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=="],
|
||||
|
||||
@@ -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: <bookmark>" ...
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
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 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
"workspaces": ["app", "packages/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user