mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +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://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.
|
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||||
|
|
||||||
## Size
|
## 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.
|
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
|
## 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.
|
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);
|
new EntityManager([entity, entity2], connection);
|
||||||
}).toThrow();
|
}).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.transformPersist(1)).rejects.toThrow();
|
||||||
expect(field.transformRetrieve(1)).toBe(1);
|
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",
|
"oauth4webapi": "^2.11.1",
|
||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
"swr": "^2.3.3"
|
"swr": "^2.3.3",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonv-ts": "^0.0.14-alpha.6",
|
"jsonv-ts": "^0.1.0",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
@@ -123,7 +124,8 @@
|
|||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.9",
|
"vitest": "^3.0.9",
|
||||||
"wouter": "^3.6.0"
|
"wouter": "^3.6.0",
|
||||||
|
"@cloudflare/workers-types": "^4.20250606.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.14.3"
|
"@hono/node-server": "^1.14.3"
|
||||||
|
|||||||
@@ -253,6 +253,11 @@ export class App {
|
|||||||
break;
|
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 { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { makeApp } from "./modes/fresh";
|
import { makeApp } from "./modes/fresh";
|
||||||
import { makeConfig } from "./config";
|
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
@@ -23,7 +23,7 @@ describe("cf adapter", () => {
|
|||||||
{
|
{
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
{},
|
$ctx({ DB_URL }),
|
||||||
),
|
),
|
||||||
).toEqual({ connection: { url: DB_URL } });
|
).toEqual({ connection: { url: DB_URL } });
|
||||||
|
|
||||||
@@ -34,15 +34,15 @@ describe("cf adapter", () => {
|
|||||||
connection: { url: env.DB_URL },
|
connection: { url: env.DB_URL },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
$ctx({ DB_URL }),
|
||||||
DB_URL,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
).toEqual({ connection: { url: DB_URL } });
|
).toEqual({ connection: { url: DB_URL } });
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
|
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
||||||
makeApp,
|
makeApp: async (c, a, o) => {
|
||||||
|
return await makeApp(c, { env: a } as any, o);
|
||||||
|
},
|
||||||
makeHandler: (c, a, o) => {
|
makeHandler: (c, a, o) => {
|
||||||
return async (request: any) => {
|
return async (request: any) => {
|
||||||
const app = await makeApp(
|
const app = await makeApp(
|
||||||
@@ -50,7 +50,7 @@ describe("cf adapter", () => {
|
|||||||
c ?? {
|
c ?? {
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
a,
|
a!,
|
||||||
o,
|
o,
|
||||||
);
|
);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import { getDurable } from "./modes/durable";
|
|||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { $console } from "core";
|
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> & {
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
bindings?: (args: Env) => {
|
bindings?: (args: Env) => {
|
||||||
@@ -17,6 +23,11 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
|||||||
dobj?: DurableObjectNamespace;
|
dobj?: DurableObjectNamespace;
|
||||||
db?: D1Database;
|
db?: D1Database;
|
||||||
};
|
};
|
||||||
|
d1?: {
|
||||||
|
session?: boolean;
|
||||||
|
transport?: "header" | "cookie";
|
||||||
|
first?: D1SessionConstraint;
|
||||||
|
};
|
||||||
static?: "kv" | "assets";
|
static?: "kv" | "assets";
|
||||||
key?: string;
|
key?: string;
|
||||||
keepAliveSeconds?: number;
|
keepAliveSeconds?: number;
|
||||||
|
|||||||
@@ -1,37 +1,117 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import { registerMedia } from "./storage/StorageR2Adapter";
|
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||||
import { getBinding } from "./bindings";
|
import { getBinding } from "./bindings";
|
||||||
import { D1Connection } from "./D1Connection";
|
import { D1Connection } from "./connection/D1Connection";
|
||||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||||
import type { ExecutionContext } from "hono";
|
import type { Context, ExecutionContext } from "hono";
|
||||||
import { $console } from "core";
|
import { $console } from "core";
|
||||||
|
import { setCookie } from "hono/cookie";
|
||||||
|
|
||||||
export const constants = {
|
export const constants = {
|
||||||
exec_async_event_id: "cf_register_waituntil",
|
exec_async_event_id: "cf_register_waituntil",
|
||||||
cache_endpoint: "/__bknd/cache",
|
cache_endpoint: "/__bknd/cache",
|
||||||
do_endpoint: "/__bknd/do",
|
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;
|
let media_registered: boolean = false;
|
||||||
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args: Env = {} as Env,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
) {
|
) {
|
||||||
if (!media_registered) {
|
if (!media_registered) {
|
||||||
registerMedia(args as any);
|
registerMedia(args as any);
|
||||||
media_registered = true;
|
media_registered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = makeAdapterConfig(config, args);
|
const appConfig = makeAdapterConfig(config, args?.env);
|
||||||
const bindings = config.bindings?.(args);
|
|
||||||
|
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) {
|
if (!appConfig.connection) {
|
||||||
let db: D1Database | undefined;
|
let db: D1Database | undefined;
|
||||||
if (bindings?.db) {
|
if (bindings?.db) {
|
||||||
$console.log("Using database from bindings");
|
$console.log("Using database from bindings");
|
||||||
db = bindings.db;
|
db = bindings.db;
|
||||||
} else if (Object.keys(args).length > 0) {
|
} else if (Object.keys(args).length > 0) {
|
||||||
const binding = getBinding(args, "D1Database");
|
const binding = getBinding(args.env, "D1Database");
|
||||||
if (binding) {
|
if (binding) {
|
||||||
$console.log(`Using database from env "${binding.key}"`);
|
$console.log(`Using database from env "${binding.key}"`);
|
||||||
db = binding.value;
|
db = binding.value;
|
||||||
@@ -39,12 +119,33 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (db) {
|
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 });
|
appConfig.connection = new D1Connection({ binding: db });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("No database connection given");
|
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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return appConfig;
|
return appConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { QB } from "data/connection/Connection";
|
|||||||
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||||
import { D1Dialect } from "kysely-d1";
|
import { D1Dialect } from "kysely-d1";
|
||||||
|
|
||||||
export type D1ConnectionConfig = {
|
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
|
||||||
binding: D1Database;
|
binding: DB;
|
||||||
};
|
};
|
||||||
|
|
||||||
class CustomD1Dialect extends D1Dialect {
|
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 = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private config: D1ConnectionConfig) {
|
constructor(private config: D1ConnectionConfig<DB>) {
|
||||||
const plugins = [new ParseJSONResultsPlugin()];
|
const plugins = [new ParseJSONResultsPlugin()];
|
||||||
|
|
||||||
const kysely = new Kysely({
|
const kysely = new Kysely({
|
||||||
dialect: new CustomD1Dialect({ database: config.binding }),
|
dialect: new CustomD1Dialect({ database: config.binding as D1Database }),
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
super(kysely, {}, plugins);
|
super(kysely, {}, plugins);
|
||||||
}
|
}
|
||||||
|
|
||||||
get client(): D1Database {
|
get client(): DB {
|
||||||
return this.config.binding;
|
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 * from "./cloudflare-workers.adapter";
|
||||||
export { makeApp, getFresh } from "./modes/fresh";
|
export { makeApp, getFresh } from "./modes/fresh";
|
||||||
@@ -12,6 +12,7 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
|
export { constants } from "./config";
|
||||||
|
|
||||||
export function d1(config: D1ConnectionConfig) {
|
export function d1(config: D1ConnectionConfig) {
|
||||||
return new D1Connection(config);
|
return new D1Connection(config);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
|
|||||||
|
|
||||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
{ env, ctx, ...args }: Context<Env>,
|
args: Context<Env>,
|
||||||
) {
|
) {
|
||||||
|
const { env, ctx } = args;
|
||||||
const { kv } = config.bindings?.(env)!;
|
const { kv } = config.bindings?.(env)!;
|
||||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||||
const key = config.key ?? "app";
|
const key = config.key ?? "app";
|
||||||
@@ -20,7 +21,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
|
|
||||||
const app = await createRuntimeApp(
|
const app = await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...makeConfig(config, env),
|
...makeConfig(config, args),
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
registerAsyncsExecutionContext(app, ctx);
|
registerAsyncsExecutionContext(app, ctx);
|
||||||
@@ -41,7 +42,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
await config.beforeBuild?.(app);
|
await config.beforeBuild?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ env, ctx, ...args },
|
args,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!cachedConfig) {
|
if (!cachedConfig) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
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>(
|
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args: Env = {} as Env,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
opts?: RuntimeOptions,
|
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>(
|
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
await config.onBuilt?.(app);
|
await config.onBuilt?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ctx.env,
|
ctx,
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
import type { Generated } from "kysely";
|
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>;
|
id: PrimaryFieldType<IdType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
import { v4, v7 } from "uuid";
|
||||||
|
|
||||||
// generates v4
|
// generates v4
|
||||||
export function uuid(): string {
|
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 hono = this.create();
|
||||||
|
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
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
|
* Function endpoints
|
||||||
@@ -333,7 +335,7 @@ export class DataController extends Controller {
|
|||||||
"param",
|
"param",
|
||||||
s.object({
|
s.object({
|
||||||
entity: entitiesEnum,
|
entity: entitiesEnum,
|
||||||
id: s.string(),
|
id: idType,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
jsc("query", repoQuery, { skipOpenAPI: true }),
|
jsc("query", repoQuery, { skipOpenAPI: true }),
|
||||||
@@ -342,8 +344,9 @@ export class DataController extends Controller {
|
|||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
|
console.log("id", id);
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
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 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -362,7 +365,7 @@ export class DataController extends Controller {
|
|||||||
"param",
|
"param",
|
||||||
s.object({
|
s.object({
|
||||||
entity: entitiesEnum,
|
entity: entitiesEnum,
|
||||||
id: s.string(),
|
id: idType,
|
||||||
reference: s.string(),
|
reference: s.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -376,7 +379,7 @@ export class DataController extends Controller {
|
|||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
const result = await this.em
|
const result = await this.em
|
||||||
.repository(entity)
|
.repository(entity)
|
||||||
.findManyByReference(Number(id), reference, options);
|
.findManyByReference(id, reference, options);
|
||||||
|
|
||||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||||
},
|
},
|
||||||
@@ -485,7 +488,7 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
|
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||||
jsc("json", s.object({})),
|
jsc("json", s.object({})),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
@@ -493,7 +496,7 @@ export class DataController extends Controller {
|
|||||||
return this.notFound(c);
|
return this.notFound(c);
|
||||||
}
|
}
|
||||||
const body = (await c.req.json()) as EntityData;
|
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));
|
return c.json(this.mutatorResult(result));
|
||||||
},
|
},
|
||||||
@@ -507,13 +510,13 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
|
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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));
|
return c.json(this.mutatorResult(result));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export class SqliteConnection extends Connection {
|
|||||||
type,
|
type,
|
||||||
(col: ColumnDefinitionBuilder) => {
|
(col: ColumnDefinitionBuilder) => {
|
||||||
if (spec.primary) {
|
if (spec.primary) {
|
||||||
|
if (spec.type === "integer") {
|
||||||
return col.primaryKey().notNull().autoIncrement();
|
return col.primaryKey().notNull().autoIncrement();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return col.primaryKey().notNull();
|
||||||
|
}
|
||||||
if (spec.references) {
|
if (spec.references) {
|
||||||
let relCol = col.references(spec.references);
|
let relCol = col.references(spec.references);
|
||||||
if (spec.onDelete) relCol = relCol.onDelete(spec.onDelete);
|
if (spec.onDelete) relCol = relCol.onDelete(spec.onDelete);
|
||||||
|
|||||||
@@ -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 * as tb from "@sinclair/typebox";
|
||||||
import {
|
import {
|
||||||
FieldClassMap,
|
FieldClassMap,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
entityTypes,
|
entityTypes,
|
||||||
} from "data";
|
} from "data";
|
||||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||||
|
import { primaryFieldTypes } from "./fields";
|
||||||
|
|
||||||
export const FIELDS = {
|
export const FIELDS = {
|
||||||
...FieldClassMap,
|
...FieldClassMap,
|
||||||
@@ -72,6 +73,9 @@ export const indicesSchema = tb.Type.Object(
|
|||||||
export const dataConfigSchema = tb.Type.Object(
|
export const dataConfigSchema = tb.Type.Object(
|
||||||
{
|
{
|
||||||
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
|
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: {} })),
|
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
|
||||||
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
|
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
|
||||||
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
|
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
snakeToPascalWithSpaces,
|
snakeToPascalWithSpaces,
|
||||||
transformObject,
|
transformObject,
|
||||||
} from "core/utils";
|
} 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";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
@@ -18,6 +24,7 @@ export const entityConfigSchema = Type.Object(
|
|||||||
description: Type.Optional(Type.String()),
|
description: Type.Optional(Type.String()),
|
||||||
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
||||||
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
|
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
|
||||||
|
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -68,7 +75,14 @@ export class Entity<
|
|||||||
if (primary_count > 1) {
|
if (primary_count > 1) {
|
||||||
throw new Error(`Entity "${name}" has more than one primary field`);
|
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) {
|
if (fields) {
|
||||||
fields.forEach((field) => this.addField(field));
|
fields.forEach((field) => this.addField(field));
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class Mutator<
|
|||||||
|
|
||||||
// if listener returned, take what's returned
|
// if listener returned, take what's returned
|
||||||
const _data = result.returned ? result.params.data : data;
|
const _data = result.returned ? result.params.data : data;
|
||||||
const validatedData = {
|
let validatedData = {
|
||||||
...entity.getDefaultObject(),
|
...entity.getDefaultObject(),
|
||||||
...(await this.getValidatedData(_data, "create")),
|
...(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
|
const query = this.conn
|
||||||
.insertInto(entity.name)
|
.insertInto(entity.name)
|
||||||
.values(validatedData)
|
.values(validatedData)
|
||||||
@@ -175,7 +185,7 @@ export class Mutator<
|
|||||||
|
|
||||||
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
if (!Number.isInteger(id)) {
|
if (!id) {
|
||||||
throw new Error("ID must be provided for update");
|
throw new Error("ID must be provided for update");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +222,7 @@ export class Mutator<
|
|||||||
|
|
||||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
if (!Number.isInteger(id)) {
|
if (!id) {
|
||||||
throw new Error("ID must be provided for deletion");
|
throw new Error("ID must be provided for deletion");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { config } from "core";
|
import { config } from "core";
|
||||||
import type { Static } from "core/utils";
|
import { StringEnum, uuidv7, type Static } from "core/utils";
|
||||||
import { Field, baseFieldConfigSchema } from "./Field";
|
import { Field, baseFieldConfigSchema } from "./Field";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
|
export const primaryFieldTypes = ["integer", "uuid"] as const;
|
||||||
|
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
|
||||||
|
|
||||||
export const primaryFieldConfigSchema = Type.Composite([
|
export const primaryFieldConfigSchema = Type.Composite([
|
||||||
Type.Omit(baseFieldConfigSchema, ["required"]),
|
Type.Omit(baseFieldConfigSchema, ["required"]),
|
||||||
Type.Object({
|
Type.Object({
|
||||||
|
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
|
||||||
required: Type.Optional(Type.Literal(false)),
|
required: Type.Optional(Type.Literal(false)),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -21,8 +25,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
|||||||
> {
|
> {
|
||||||
override readonly type = "primary";
|
override readonly type = "primary";
|
||||||
|
|
||||||
constructor(name: string = config.data.default_primary_field) {
|
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
|
||||||
super(name, { fillable: false, required: false });
|
super(name, { fillable: false, required: false, ...cfg });
|
||||||
}
|
}
|
||||||
|
|
||||||
override isRequired(): boolean {
|
override isRequired(): boolean {
|
||||||
@@ -30,32 +34,53 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getSchema() {
|
protected getSchema() {
|
||||||
return baseFieldConfigSchema;
|
return primaryFieldConfigSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
get format() {
|
||||||
|
return this.config.format ?? "integer";
|
||||||
|
}
|
||||||
|
|
||||||
|
get fieldType() {
|
||||||
|
return this.format === "integer" ? "integer" : "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
override schema() {
|
override schema() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
type: "integer",
|
type: this.fieldType,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
primary: true,
|
primary: true,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNewValue(): any {
|
||||||
|
if (this.format === "uuid") {
|
||||||
|
return uuidv7();
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
override async transformPersist(value: any): Promise<number> {
|
override async transformPersist(value: any): Promise<number> {
|
||||||
throw new Error("PrimaryField: This function should not be called");
|
throw new Error("PrimaryField: This function should not be called");
|
||||||
}
|
}
|
||||||
|
|
||||||
override toJsonSchema() {
|
override toJsonSchema() {
|
||||||
|
if (this.format === "uuid") {
|
||||||
|
return this.toSchemaWrapIfRequired(Type.String({ writeOnly: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
|
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
|
||||||
}
|
}
|
||||||
|
|
||||||
override toType(): TFieldTSType {
|
override toType(): TFieldTSType {
|
||||||
|
const type = this.format === "integer" ? "number" : "string";
|
||||||
return {
|
return {
|
||||||
...super.toType(),
|
...super.toType(),
|
||||||
required: true,
|
required: true,
|
||||||
import: [{ package: "kysely", name: "Generated" }],
|
import: [{ package: "kysely", name: "Generated" }],
|
||||||
type: "Generated<number>",
|
type: `Generated<${type}>`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import type { RepoQuery } from "../server/query";
|
import type { RepoQuery } from "../server/query";
|
||||||
import type { RelationType } from "./relation-types";
|
import type { RelationType } from "./relation-types";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
import type { PrimaryFieldType } from "core";
|
||||||
const { Type } = tbbox;
|
const { Type } = tbbox;
|
||||||
|
|
||||||
const directions = ["source", "target"] as const;
|
const directions = ["source", "target"] as const;
|
||||||
@@ -72,7 +73,7 @@ export abstract class EntityRelation<
|
|||||||
reference: string,
|
reference: string,
|
||||||
): KyselyQueryBuilder;
|
): KyselyQueryBuilder;
|
||||||
|
|
||||||
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Static, StringEnum } from "core/utils";
|
import { type Static, StringEnum } from "core/utils";
|
||||||
import type { EntityManager } from "../entities";
|
import type { EntityManager } from "../entities";
|
||||||
import { Field, baseFieldConfigSchema } from "../fields";
|
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
|
||||||
import type { EntityRelation } from "./EntityRelation";
|
import type { EntityRelation } from "./EntityRelation";
|
||||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
@@ -15,6 +15,7 @@ export const relationFieldConfigSchema = Type.Composite([
|
|||||||
reference: Type.String(),
|
reference: Type.String(),
|
||||||
target: Type.String(), // @todo: potentially has to be an instance!
|
target: Type.String(), // @todo: potentially has to be an instance!
|
||||||
target_field: Type.Optional(Type.String({ default: "id" })),
|
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" })),
|
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -45,6 +46,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
reference: target.reference,
|
reference: target.reference,
|
||||||
target: target.entity.name,
|
target: target.entity.name,
|
||||||
target_field: target.entity.getPrimaryField().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() {
|
override schema() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...super.schema()!,
|
...super.schema()!,
|
||||||
type: "integer",
|
type: this.config.target_field_type ?? "integer",
|
||||||
references: `${this.config.target}.${this.config.target_field}`,
|
references: `${this.config.target}.${this.config.target_field}`,
|
||||||
onDelete: this.config.on_delete ?? "set null",
|
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 { IndexMetadata, SchemaResponse } from "../connection/Connection";
|
||||||
import type { Entity, EntityManager } from "../entities";
|
import type { Entity, EntityManager } from "../entities";
|
||||||
import { PrimaryField } from "../fields";
|
import { PrimaryField } from "../fields";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
type IntrospectedTable = TableMetadata & {
|
type IntrospectedTable = TableMetadata & {
|
||||||
indices: IndexMetadata[];
|
indices: IndexMetadata[];
|
||||||
@@ -332,6 +333,7 @@ export class SchemaManager {
|
|||||||
|
|
||||||
if (config.force) {
|
if (config.force) {
|
||||||
try {
|
try {
|
||||||
|
$console.info("[SchemaManager]", sql, parameters);
|
||||||
await qb.execute();
|
await qb.execute();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Api } from "bknd/client";
|
import type { Api } from "bknd/client";
|
||||||
|
import type { PrimaryFieldType } from "core";
|
||||||
import type { RepoQueryIn } from "data";
|
import type { RepoQueryIn } from "data";
|
||||||
import type { MediaFieldSchema } from "media/AppMedia";
|
import type { MediaFieldSchema } from "media/AppMedia";
|
||||||
import type { TAppMediaConfig } from "media/media-schema";
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
import { useId, useEffect, useRef, useState } from "react";
|
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 { useEvent } from "ui/hooks/use-event";
|
||||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||||
import { mediaItemsToFileStates } from "./helper";
|
import { mediaItemsToFileStates } from "./helper";
|
||||||
@@ -14,7 +15,7 @@ export type DropzoneContainerProps = {
|
|||||||
infinite?: boolean;
|
infinite?: boolean;
|
||||||
entity?: {
|
entity?: {
|
||||||
name: string;
|
name: string;
|
||||||
id: number;
|
id: PrimaryFieldType;
|
||||||
field: string;
|
field: string;
|
||||||
};
|
};
|
||||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
|||||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||||
import { Alert } from "ui/components/display/Alert";
|
import { Alert } from "ui/components/display/Alert";
|
||||||
import { bkndModals } from "ui/modals";
|
import { bkndModals } from "ui/modals";
|
||||||
|
import type { PrimaryFieldType } from "core";
|
||||||
|
|
||||||
// simplify react form types 🤦
|
// simplify react form types 🤦
|
||||||
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
|
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 = {
|
type EntityFormProps = {
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
entityId?: number;
|
entityId?: PrimaryFieldType;
|
||||||
data?: EntityData;
|
data?: EntityData;
|
||||||
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
fieldsDisabled: boolean;
|
fieldsDisabled: boolean;
|
||||||
@@ -225,7 +226,7 @@ function EntityMediaFormField({
|
|||||||
formApi: FormApi;
|
formApi: FormApi;
|
||||||
field: MediaField;
|
field: MediaField;
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
entityId?: number;
|
entityId?: PrimaryFieldType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
type EntityFieldsFormRef,
|
type EntityFieldsFormRef,
|
||||||
} from "ui/routes/data/forms/entity.fields.form";
|
} from "ui/routes/data/forms/entity.fields.form";
|
||||||
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
|
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
|
||||||
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
|
|
||||||
const schema = entitiesSchema;
|
const schema = entitiesSchema;
|
||||||
type Schema = Static<typeof schema>;
|
type Schema = Static<typeof schema>;
|
||||||
|
|
||||||
export function StepEntityFields() {
|
export function StepEntityFields() {
|
||||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||||
|
const { config } = useBkndData();
|
||||||
const entity = state.entities?.create?.[0]!;
|
const entity = state.entities?.create?.[0]!;
|
||||||
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
const defaultFields = { id: { type: "primary", name: "id" } } as const;
|
||||||
const ref = useRef<EntityFieldsFormRef>(null);
|
const ref = useRef<EntityFieldsFormRef>(null);
|
||||||
@@ -82,6 +84,8 @@ export function StepEntityFields() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
fields={initial.fields as any}
|
fields={initial.fields as any}
|
||||||
onChange={updateListener}
|
onChange={updateListener}
|
||||||
|
defaultPrimaryFormat={config?.default_primary_format}
|
||||||
|
isNew={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
entitySchema,
|
entitySchema,
|
||||||
useStepContext,
|
useStepContext,
|
||||||
} from "./CreateModal";
|
} from "./CreateModal";
|
||||||
|
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
|
||||||
|
|
||||||
export function StepEntity() {
|
export function StepEntity() {
|
||||||
const focusTrapRef = useFocusTrap();
|
const focusTrapRef = useFocusTrap();
|
||||||
|
|
||||||
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
|
||||||
const { register, handleSubmit, formState, watch } = useForm({
|
const { register, handleSubmit, formState, watch, control } = useForm({
|
||||||
mode: "onTouched",
|
mode: "onTouched",
|
||||||
resolver: typeboxResolver(entitySchema),
|
resolver: typeboxResolver(entitySchema),
|
||||||
defaultValues: state.entities?.create?.[0] ?? {},
|
defaultValues: state.entities?.create?.[0] ?? {},
|
||||||
@@ -56,7 +57,6 @@ export function StepEntity() {
|
|||||||
label="What's the name of the entity?"
|
label="What's the name of the entity?"
|
||||||
description="Use plural form, and all lowercase. It will be used as the database table."
|
description="Use plural form, and all lowercase. It will be used as the database table."
|
||||||
/>
|
/>
|
||||||
{/*<input type="submit" value="submit" />*/}
|
|
||||||
<TextInput
|
<TextInput
|
||||||
{...register("config.name")}
|
{...register("config.name")}
|
||||||
error={formState.errors.config?.name?.message}
|
error={formState.errors.config?.name?.message}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { PrimaryFieldType } from "core";
|
||||||
import { ucFirst } from "core/utils";
|
import { ucFirst } from "core/utils";
|
||||||
import type { Entity, EntityData, EntityRelation } from "data";
|
import type { Entity, EntityData, EntityRelation } from "data";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
@@ -24,7 +25,7 @@ export function DataEntityUpdate({ params }) {
|
|||||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
|
useBrowserTitle(["Data", entity.label, `#${entityId}`]);
|
||||||
@@ -202,7 +203,7 @@ function EntityDetailRelations({
|
|||||||
entity,
|
entity,
|
||||||
relations,
|
relations,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: PrimaryFieldType;
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
relations: EntityRelation[];
|
relations: EntityRelation[];
|
||||||
}) {
|
}) {
|
||||||
@@ -250,7 +251,7 @@ function EntityDetailInner({
|
|||||||
entity,
|
entity,
|
||||||
relation,
|
relation,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: PrimaryFieldType;
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
relation: EntityRelation;
|
relation: EntityRelation;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function DataSchemaEntity({ params }) {
|
|||||||
const Fields = ({ entity }: { entity: Entity }) => {
|
const Fields = ({ entity }: { entity: Entity }) => {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [updates, setUpdates] = useState(0);
|
const [updates, setUpdates] = useState(0);
|
||||||
const { actions, $data } = useBkndData();
|
const { actions, $data, config } = useBkndData();
|
||||||
const [res, setRes] = useState<any>();
|
const [res, setRes] = useState<any>();
|
||||||
const ref = useRef<EntityFieldsFormRef>(null);
|
const ref = useRef<EntityFieldsFormRef>(null);
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
@@ -201,6 +201,8 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))}
|
}))}
|
||||||
|
defaultPrimaryFormat={config?.default_primary_format}
|
||||||
|
isNew={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isDebug() && (
|
{isDebug() && (
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { type TFieldSpec, fieldSpecs } from "ui/modules/data/components/fields-s
|
|||||||
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
import { dataFieldsUiSchema } from "../../settings/routes/data.settings";
|
||||||
import * as tbbox from "@sinclair/typebox";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import { useRoutePathState } from "ui/hooks/use-route-path-state";
|
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 { Type } = tbbox;
|
||||||
|
|
||||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||||
@@ -65,6 +67,8 @@ export type EntityFieldsFormProps = {
|
|||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
additionalFieldTypes?: (TFieldSpec & { onClick: () => void })[];
|
||||||
routePattern?: string;
|
routePattern?: string;
|
||||||
|
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
||||||
|
isNew?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityFieldsFormRef = {
|
export type EntityFieldsFormRef = {
|
||||||
@@ -77,7 +81,7 @@ export type EntityFieldsFormRef = {
|
|||||||
|
|
||||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||||
function EntityFieldsForm(
|
function EntityFieldsForm(
|
||||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, ...props },
|
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||||
@@ -172,6 +176,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
remove={remove}
|
remove={remove}
|
||||||
dnd={dnd}
|
dnd={dnd}
|
||||||
routePattern={routePattern}
|
routePattern={routePattern}
|
||||||
|
primary={{
|
||||||
|
defaultFormat: props.defaultPrimaryFormat,
|
||||||
|
editable: isNew,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -186,6 +194,10 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
errors={errors}
|
errors={errors}
|
||||||
remove={remove}
|
remove={remove}
|
||||||
routePattern={routePattern}
|
routePattern={routePattern}
|
||||||
|
primary={{
|
||||||
|
defaultFormat: props.defaultPrimaryFormat,
|
||||||
|
editable: isNew,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +293,7 @@ function EntityField({
|
|||||||
errors,
|
errors,
|
||||||
dnd,
|
dnd,
|
||||||
routePattern,
|
routePattern,
|
||||||
|
primary,
|
||||||
}: {
|
}: {
|
||||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -292,6 +305,10 @@ function EntityField({
|
|||||||
errors: any;
|
errors: any;
|
||||||
dnd?: SortableItemProps;
|
dnd?: SortableItemProps;
|
||||||
routePattern?: string;
|
routePattern?: string;
|
||||||
|
primary?: {
|
||||||
|
defaultFormat?: TPrimaryFieldFormat;
|
||||||
|
editable?: boolean;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const prefix = `fields.${index}.field` as const;
|
const prefix = `fields.${index}.field` as const;
|
||||||
const type = field.field.type;
|
const type = field.field.type;
|
||||||
@@ -363,15 +380,29 @@ function EntityField({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-col gap-1 hidden md:flex">
|
<div className="flex-col gap-1 hidden md:flex">
|
||||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
|
||||||
{is_primary ? (
|
{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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||||
<MantineSwitch
|
<MantineSwitch
|
||||||
size="sm"
|
size="sm"
|
||||||
name={`${prefix}.config.required`}
|
name={`${prefix}.config.required`}
|
||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["bun-types", "@cloudflare/workers-types"],
|
"types": ["bun-types"],
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
@@ -30,7 +30,14 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"outDir": "./dist/types",
|
"outDir": "./dist/types",
|
||||||
"paths": {
|
"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": [
|
"include": [
|
||||||
|
|||||||
13
bun.lock
13
bun.lock
@@ -6,7 +6,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@clack/prompts": "^0.10.0",
|
"@clack/prompts": "^0.10.0",
|
||||||
"@cloudflare/workers-types": "^4.20240620.0",
|
|
||||||
"@tsconfig/strictest": "^2.0.5",
|
"@tsconfig/strictest": "^2.0.5",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"bun-types": "^1.1.18",
|
"bun-types": "^1.1.18",
|
||||||
@@ -55,10 +54,12 @@
|
|||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
"@bluwy/giget-core": "^0.1.2",
|
"@bluwy/giget-core": "^0.1.2",
|
||||||
|
"@cloudflare/workers-types": "^4.20250606.0",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hono/typebox-validator": "^0.3.3",
|
"@hono/typebox-validator": "^0.3.3",
|
||||||
"@hono/vite-dev-server": "^0.19.1",
|
"@hono/vite-dev-server": "^0.19.1",
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
"hono": "4.7.11",
|
"hono": "4.7.11",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonv-ts": "^0.0.14-alpha.6",
|
"jsonv-ts": "^0.1.0",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||||
|
|||||||
@@ -206,3 +206,33 @@ tag = "v2"
|
|||||||
renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}]
|
renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}]
|
||||||
deleted_classes = ["DurableBkndApp"]
|
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": {
|
"dependencies": {
|
||||||
"bknd": "file:../../app",
|
"bknd": "file:../../app",
|
||||||
"kysely-d1": "^0.3.0"
|
"kysely-d1": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20240620.0",
|
"typescript": "^5.8.3",
|
||||||
"typescript": "^5.5.3",
|
"wrangler": "^4.19.1"
|
||||||
"wrangler": "^4.4.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
import { type D1Connection, serve } from "bknd/adapter/cloudflare";
|
||||||
|
|
||||||
import { serve } from "bknd/adapter/cloudflare";
|
|
||||||
|
|
||||||
export default serve({
|
export default serve({
|
||||||
mode: "warm",
|
mode: "warm",
|
||||||
|
d1: {
|
||||||
|
session: true,
|
||||||
|
},
|
||||||
onBuilt: async (app) => {
|
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",
|
"jsx": "react-jsx",
|
||||||
"module": "es2022",
|
"module": "es2022",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"types": ["@cloudflare/workers-types/2023-07-01"],
|
"types": ["./worker-configuration.d.ts"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": false,
|
"checkJs": false,
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
// Generated by Wrangler
|
// placeholder, run generation again
|
||||||
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
|
declare namespace Cloudflare {
|
||||||
|
|
||||||
interface Env {
|
interface Env {
|
||||||
DB_URL: string;
|
BUCKET: R2Bucket;
|
||||||
DB_TOKEN: string;
|
DB: D1Database;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "__STATIC_CONTENT_MANIFEST" {
|
|
||||||
const value: string;
|
|
||||||
export default value;
|
|
||||||
}
|
}
|
||||||
|
interface Env extends Cloudflare.Env {}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
"database_name": "bknd-cf-example",
|
"database_name": "bknd-dev-weur",
|
||||||
"database_id": "7ad67953-2bbf-47fc-8696-f4517dbfe674"
|
"database_id": "81d8dfcc-4eaf-4453-8f0f-8f6d463fb867"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@clack/prompts": "^0.10.0",
|
"@clack/prompts": "^0.10.0",
|
||||||
"@cloudflare/workers-types": "^4.20240620.0",
|
|
||||||
"@tsconfig/strictest": "^2.0.5",
|
"@tsconfig/strictest": "^2.0.5",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"bun-types": "^1.1.18",
|
"bun-types": "^1.1.18",
|
||||||
@@ -42,8 +41,5 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": ["app", "packages/*"]
|
||||||
"app",
|
|
||||||
"packages/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user