Merge branch 'release/0.15' into feat/jsonv-refactor

# Conflicts:
#	app/build.ts
#	app/package.json
#	app/src/App.ts
#	app/src/adapter/cloudflare/storage/StorageR2Adapter.ts
#	app/src/auth/authenticate/Authenticator.ts
#	app/src/auth/authenticate/strategies/PasswordStrategy.ts
#	app/src/data/entities/Entity.ts
#	app/src/data/fields/DateField.ts
#	app/src/data/server/query.ts
#	app/src/flows/flows/triggers/EventTrigger.ts
#	app/src/flows/tasks/presets/LogTask.ts
#	app/src/media/AppMedia.ts
#	app/src/modules/server/AppServer.ts
#	app/src/modules/server/SystemController.ts
#	app/vite.dev.ts
#	bun.lock
This commit is contained in:
dswbx
2025-07-02 17:18:12 +02:00
84 changed files with 1027 additions and 626 deletions
@@ -6,7 +6,10 @@ import { Database } from "bun:sqlite";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => bunSqlite({ database: new Database(":memory:") }),
makeConnection: () => ({
connection: bunSqlite({ database: new Database(":memory:") }),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
@@ -1,8 +1,5 @@
import { Database } from "bun:sqlite";
import {
genericSqlite,
type GenericSqliteConnection,
} from "data/connection/sqlite/GenericSqliteConnection";
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = {
+4 -1
View File
@@ -1,8 +1,11 @@
import { expect, test, mock, describe } from "bun:test";
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
export const bunTestRunner = {
describe,
expect,
test,
mock,
beforeEach,
afterEach,
afterAll,
};
@@ -7,7 +7,7 @@ import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core";
import { $console } from "core/utils";
declare global {
namespace Cloudflare {
@@ -33,6 +33,7 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
registerMedia?: boolean | ((env: Env) => void);
};
export type Context<Env = CloudflareEnv> = {
+18 -10
View File
@@ -2,12 +2,13 @@
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { D1Connection } from "./connection/D1Connection";
import { d1Sqlite } from "./connection/D1Connection";
import { Connection } from "bknd/data";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { Context, ExecutionContext } from "hono";
import { $console } from "core";
import { $console } from "core/utils";
import { setCookie } from "hono/cookie";
import { sqlite } from "bknd/adapter/sqlite";
@@ -92,8 +93,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
) {
if (!media_registered) {
registerMedia(args?.env as any);
if (!media_registered && config.registerMedia !== false) {
if (typeof config.registerMedia === "function") {
config.registerMedia(args?.env as any);
} else {
registerMedia(args?.env as any);
}
media_registered = true;
}
@@ -101,7 +106,7 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
// if connection instance is given, don't do anything
// other than checking if D1 session is defined
if (D1Connection.isConnection(appConfig.connection)) {
if (Connection.isConnection(appConfig.connection)) {
if (config.d1?.session) {
// we cannot guarantee that db was opened with session
throw new Error(
@@ -123,14 +128,14 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
// if db is given in bindings, use it
if (bindings?.db) {
$console.log("Using database from bindings");
$console.debug("Using database from bindings");
db = bindings.db;
// scan for D1Database in args
} else {
const binding = getBinding(args.env, "D1Database");
if (binding) {
$console.log(`Using database from env "${binding.key}"`);
$console.debug(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
@@ -139,8 +144,11 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
if (db) {
if (config.d1?.session) {
session = db.withSession(sessionId ?? config.d1?.first);
if (!session) {
throw new Error("Couldn't create session");
}
appConfig.connection = new D1Connection({ binding: session });
appConfig.connection = d1Sqlite({ binding: session });
appConfig.options = {
...appConfig.options,
manager: {
@@ -154,12 +162,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
},
};
} else {
appConfig.connection = new D1Connection({ binding: db });
appConfig.connection = d1Sqlite({ binding: db });
}
}
}
if (!D1Connection.isConnection(appConfig.connection)) {
if (!Connection.isConnection(appConfig.connection)) {
throw new Error("Couldn't find database connection");
}
@@ -1,42 +1,75 @@
/// <reference types="@cloudflare/workers-types" />
import { SqliteConnection } from "bknd/data";
import type { ConnQuery, ConnQueryResults } from "data/connection/Connection";
import { D1Dialect } from "kysely-d1";
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB;
};
export class D1Connection<
DB extends D1Database | D1DatabaseSession = D1Database,
> extends SqliteConnection<DB> {
override name = "sqlite-d1";
export function d1Sqlite<DB extends D1Database | D1DatabaseSession = D1Database>(
config: D1ConnectionConfig<DB>,
) {
const db = config.binding;
protected override readonly supported = {
batching: true,
softscans: false,
};
return genericSqlite(
"d1-sqlite",
db,
(utils) => {
const getStmt = (sql: string, parameters?: any[] | readonly any[]) =>
db.prepare(sql).bind(...(parameters || []));
constructor(private config: D1ConnectionConfig<DB>) {
super({
const mapResult = (res: D1Result<any>): QueryResult<any> => {
if (res.error) {
throw new Error(res.error);
}
const numAffectedRows =
res.meta.changes > 0 ? utils.parseBigInt(res.meta.changes) : undefined;
const insertId = res.meta.last_row_id
? utils.parseBigInt(res.meta.last_row_id)
: undefined;
return {
insertId,
numAffectedRows,
rows: res.results,
// @ts-ignore
meta: res.meta,
};
};
return {
db,
batch: async (stmts) => {
const res = await db.batch(
stmts.map(({ sql, parameters }) => {
return getStmt(sql, parameters);
}),
);
return res.map(mapResult);
},
query: utils.buildQueryFn({
all: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep.all()).rows;
},
run: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep.run());
},
}),
close: () => {},
};
},
{
supports: {
batching: true,
softscans: false,
},
excludeTables: ["_cf_KV", "_cf_METADATA"],
dialect: D1Dialect,
dialectArgs: [{ database: config.binding as D1Database }],
});
}
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
const compiled = this.getCompiled(...qbs);
const db = this.config.binding;
const res = await db.batch(
compiled.map(({ sql, parameters }) => {
return db.prepare(sql).bind(...parameters);
}),
);
return this.withTransformedRows(res, "results") as any;
}
},
);
}
@@ -0,0 +1,33 @@
import { describe, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { d1Sqlite } from "./D1Connection";
describe("d1Sqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
d1Databases: ["DB"],
});
const binding = (await mf.getD1Database("DB")) as D1Database;
return {
connection: d1Sqlite({ binding }),
dispose: () => mf.dispose(),
};
},
rawDialectDetails: [
"meta.served_by",
"meta.duration",
"meta.changes",
"meta.changed_db",
"meta.size_after",
"meta.rows_read",
"meta.rows_written",
],
});
});
@@ -0,0 +1,80 @@
/// <reference types="@cloudflare/workers-types" />
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
export type D1ConnectionConfig<DB extends DurableObjecSql> =
| DurableObjectState
| {
sql: DB;
};
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
const db = "sql" in config ? config.sql : config.storage.sql;
return genericSqlite(
"do-sqlite",
db,
(utils) => {
// must be async to work with the miniflare mock
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
await db.exec(sql, ...(parameters || []));
const mapResult = (
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
): QueryResult<any> => {
const numAffectedRows =
cursor.rowsWritten > 0 ? utils.parseBigInt(cursor.rowsWritten) : undefined;
const insertId = undefined;
const obj = {
insertId,
numAffectedRows,
rows: cursor.toArray() || [],
// @ts-ignore
meta: {
rowsWritten: cursor.rowsWritten,
rowsRead: cursor.rowsRead,
databaseSize: db.databaseSize,
},
};
//console.info("mapResult", obj);
return obj;
};
return {
db,
batch: async (stmts) => {
// @todo: maybe wrap in a transaction?
// because d1 implicitly does a transaction on batch
return Promise.all(
stmts.map(async (stmt) => {
return mapResult(await getStmt(stmt.sql, stmt.parameters));
}),
);
},
query: utils.buildQueryFn({
all: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep).rows;
},
run: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep);
},
}),
close: () => {},
};
},
{
supports: {
batching: true,
softscans: false,
},
excludeTables: ["_cf_KV", "_cf_METADATA"],
},
);
}
@@ -0,0 +1,92 @@
/// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection";
const script = `
import { DurableObject } from "cloudflare:workers";
export class TestObject extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.storage = ctx.storage;
}
async exec(sql, ...parameters) {
//return { sql, parameters }
const cursor = this.storage.sql.exec(sql, ...parameters);
return {
rows: cursor.toArray() || [],
rowsWritten: cursor.rowsWritten,
rowsRead: cursor.rowsRead,
databaseSize: this.storage.sql.databaseSize,
}
}
async databaseSize() {
return this.storage.sql.databaseSize;
}
}
export default {
async fetch(request, env) {
const stub = env.TEST_OBJECT.get(env.TEST_OBJECT.idFromName("test"));
return stub.fetch(request);
}
}
`;
describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
const mf = new Miniflare({
modules: true,
durableObjects: { TEST_OBJECT: { className: "TestObject", useSQLite: true } },
script,
});
const ns = await mf.getDurableObjectNamespace("TEST_OBJECT");
const id = ns.idFromName("test");
const stub = ns.get(id) as unknown as DurableObjectStub<
Rpc.DurableObjectBranded & {
exec: (sql: string, ...parameters: any[]) => Promise<any>;
}
>;
const stubs: any[] = [];
const mock = {
databaseSize: 0,
exec: async function (sql: string, ...parameters: any[]) {
// @ts-ignore
const result = (await stub.exec(sql, ...parameters)) as any;
this.databaseSize = result.databaseSize;
stubs.push(result);
return {
toArray: () => result.rows,
rowsWritten: result.rowsWritten,
rowsRead: result.rowsRead,
};
},
};
return {
connection: doSqlite({ sql: mock as any }),
dispose: async () => {
await Promise.all(
stubs.map((stub) => {
try {
return stub[Symbol.dispose]();
} catch (e) {}
}),
);
await mf.dispose();
},
};
},
rawDialectDetails: ["meta.rowsWritten", "meta.rowsRead", "meta.databaseSize"],
});
});
+9 -4
View File
@@ -1,10 +1,10 @@
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig };
export { d1Sqlite, type D1ConnectionConfig };
export {
getBinding,
getBindings,
@@ -13,7 +13,12 @@ export {
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export { StorageR2Adapter } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
export function d1(config: D1ConnectionConfig) {
return new D1Connection(config);
// for compatibility with old code
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
config: D1ConnectionConfig<DB>,
) {
return d1Sqlite<DB>(config);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core";
import { $console } from "core/utils";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
@@ -61,46 +61,49 @@ export class StorageR2Adapter extends StorageAdapter {
async putObject(key: string, body: FileBody) {
try {
const res = await this.bucket.put(key, body);
const res = await this.bucket.put(this.getKey(key), body);
return res?.etag;
} catch (e) {
return undefined;
}
}
async listObjects(
prefix?: string,
): Promise<{ key: string; last_modified: Date; size: number }[]> {
const list = await this.bucket.list({ limit: 50 });
async listObjects(prefix = ""): Promise<{ key: string; last_modified: Date; size: number }[]> {
const list = await this.bucket.list({ limit: 50, prefix: this.getKey(prefix) });
return list.objects.map((item) => ({
key: item.key,
key: item.key.replace(this.getKey(""), ""),
size: item.size,
last_modified: item.uploaded,
}));
}
private async headObject(key: string): Promise<R2Object | null> {
return await this.bucket.head(key);
return await this.bucket.head(this.getKey(key));
}
async objectExists(key: string): Promise<boolean> {
return (await this.headObject(key)) !== null;
}
async getObject(key: string, headers: Headers): Promise<Response> {
async getObject(_key: string, headers: Headers): Promise<Response> {
let object: R2ObjectBody | null;
const key = this.getKey(_key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": guess(key),
});
const range = headers.has("range");
//console.log("getObject:headers", headersToObject(headers));
if (headers.has("range")) {
if (range) {
const options = isDebug()
? {} // miniflare doesn't support range requests
: {
range: headers,
onlyIf: headers,
};
object = (await this.bucket.get(key, options)) as R2ObjectBody;
if (!object) {
@@ -128,13 +131,14 @@ export class StorageR2Adapter extends StorageAdapter {
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
return new Response(object.body, {
status: object.range ? 206 : 200,
status: range ? 206 : 200,
headers: responseHeaders,
});
}
private writeHttpMetadata(headers: Headers, object: R2Object | R2ObjectBody): void {
let metadata = object.httpMetadata;
if (!metadata || Object.keys(metadata).length === 0) {
// guessing is especially required for dev environment (miniflare)
metadata = {
@@ -161,13 +165,17 @@ export class StorageR2Adapter extends StorageAdapter {
}
async deleteObject(key: string): Promise<void> {
await this.bucket.delete(key);
await this.bucket.delete(this.getKey(key));
}
getObjectUrl(key: string): string {
throw new Error("Method getObjectUrl not implemented.");
}
protected getKey(key: string) {
return key;
}
toJSON(secrets?: boolean) {
return {
type: this.getName(),
+2 -1
View File
@@ -1,5 +1,6 @@
import { App, type CreateAppConfig } from "bknd";
import { config as $config, $console } from "bknd/core";
import { config as $config } from "bknd/core";
import { $console } from "bknd/utils";
import type { MiddlewareHandler } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController";
import { Connection } from "bknd/data";
@@ -1,4 +1,4 @@
import { genericSqlite } from "data/connection/sqlite/GenericSqliteConnection";
import { genericSqlite } from "bknd/data";
import { DatabaseSync } from "node:sqlite";
export type NodeSqliteConnectionConfig = {
@@ -17,32 +17,41 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
db = new DatabaseSync(":memory:");
}
return genericSqlite("node-sqlite", db, (utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return genericSqlite(
"node-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
run: (sql, parameters = []) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
run: (sql, parameters = []) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
iterator: (isSelect, sql, parameters = []) => {
if (!isSelect) {
throw new Error("Only support select in stream()");
}
return getStmt(sql).iterate(...parameters) as any;
},
}),
close: () => db.close(),
iterator: (isSelect, sql, parameters = []) => {
if (!isSelect) {
throw new Error("Only support select in stream()");
}
return getStmt(sql).iterate(...parameters) as any;
};
},
{
supports: {
batching: false,
},
};
});
},
);
}
@@ -1,11 +1,15 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe, test, expect } from "vitest";
import { describe } from "vitest";
import { viTestRunner } from "../vitest";
describe("NodeSqliteConnection", () => {
connectionTestSuite({ describe, test, expect } as any, {
makeConnection: () => nodeSqlite({ database: new DatabaseSync(":memory:") }),
connectionTestSuite(viTestRunner, {
makeConnection: () => ({
connection: nodeSqlite({ database: new DatabaseSync(":memory:") }),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core";
import { $console } from "core";
import { $console } from "core/utils";
import type { App } from "App";
type NodeEnv = NodeJS.ProcessEnv;
+4 -1
View File
@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict";
import { test, describe } from "node:test";
import { test, describe, beforeEach, afterEach } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
@@ -97,4 +97,7 @@ export const nodeTestRunner: TestRunner = {
reject: (r) => nodeTestMatcher(r, failMsg),
}),
}),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: () => {},
};
+4 -1
View File
@@ -1,5 +1,5 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi } from "vitest";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
@@ -47,4 +47,7 @@ export const viTestRunner: TestRunner = {
test: vitestTest,
expect: vitestExpect as any,
mock: (fn) => vi.fn(fn),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: afterAll,
};
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Connection } from "bknd/data";
import { libsql } from "../../data/connection/sqlite/LibsqlConnection";
import { type Connection, libsql } from "bknd/data";
export function sqlite(config: { url: string }): Connection {
return libsql(config);
-3
View File
@@ -1,3 +0,0 @@
import type { Connection } from "bknd/data";
export type SqliteConnection = (config: { url: string }) => Connection;