mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-03 16:46:00 +00:00
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:
+6
-3
@@ -1,5 +1,5 @@
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { $console } from "core/console";
|
||||
import { $console } from "core/utils";
|
||||
import { Event } from "core/events";
|
||||
import type { em as prototypeEm } from "data/prototype";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
@@ -34,7 +34,10 @@ export type AppPluginConfig = {
|
||||
export type AppPlugin = (app: App) => AppPluginConfig;
|
||||
|
||||
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
|
||||
export class AppConfigUpdatedEvent extends AppEvent {
|
||||
export class AppConfigUpdatedEvent extends AppEvent<{
|
||||
module: string;
|
||||
config: ModuleConfigs[keyof ModuleConfigs];
|
||||
}> {
|
||||
static override slug = "app-config-updated";
|
||||
}
|
||||
export class AppBuiltEvent extends AppEvent {
|
||||
@@ -265,7 +268,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
$console.log("App config updated", module);
|
||||
// @todo: potentially double syncing
|
||||
await this.build({ sync: true });
|
||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this, module, config }));
|
||||
}
|
||||
|
||||
protected async onFirstBoot() {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => {},
|
||||
};
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { Connection } from "bknd/data";
|
||||
|
||||
export type SqliteConnection = (config: { url: string }) => Connection;
|
||||
+5
-15
@@ -1,14 +1,15 @@
|
||||
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { $console, type DB } from "core";
|
||||
import { secureRandomString, transformObject } from "core/utils";
|
||||
import type { DB } from "core";
|
||||
import { $console, secureRandomString, transformObject } from "core/utils";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
|
||||
import { em, entity, enumm, type FieldSchema } from "data/prototype";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
||||
import { AppUserPool } from "auth/AppUserPool";
|
||||
import type { AppEntity } from "core/config";
|
||||
import { usersFields } from "./auth-entities";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare module "core" {
|
||||
@@ -125,18 +126,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
||||
return this.em.entity(entity_name) as any;
|
||||
}
|
||||
|
||||
static usersFields = {
|
||||
email: text().required(),
|
||||
strategy: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["update", "form"],
|
||||
}).required(),
|
||||
strategy_value: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["read", "table", "update", "form"],
|
||||
}).required(),
|
||||
role: text(),
|
||||
};
|
||||
static usersFields = usersFields;
|
||||
|
||||
registerEntities() {
|
||||
const users = this.getUsersEntity(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppAuth } from "auth/AppAuth";
|
||||
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { pick } from "lodash-es";
|
||||
import {
|
||||
InvalidConditionsException,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { text } from "data/prototype";
|
||||
|
||||
export const usersFields = {
|
||||
email: text().required(),
|
||||
strategy: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["update", "form"],
|
||||
}).required(),
|
||||
strategy_value: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["read", "table", "update", "form"],
|
||||
}).required(),
|
||||
role: text(),
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { $console, type DB, Exception } from "core";
|
||||
import { type DB, Exception } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import { runtimeSupports, truncate } from "core/utils";
|
||||
import { runtimeSupports, truncate, $console } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
|
||||
import { $console } from "core";
|
||||
import { hash } from "core/utils";
|
||||
import { hash, $console } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||
import { Strategy } from "./Strategy";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { $console, Exception, Permission } from "core";
|
||||
import { objectTransform } from "core/utils";
|
||||
import { Exception, Permission } from "core";
|
||||
import { $console, objectTransform } from "core/utils";
|
||||
import type { Context } from "hono";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import { Role } from "./Role";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { $console, type Permission } from "core";
|
||||
import { patternMatch } from "core/utils";
|
||||
import type { Permission } from "core";
|
||||
import { $console, patternMatch } from "core/utils";
|
||||
import type { Context } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
|
||||
@@ -29,30 +29,8 @@ export const cloudflare = {
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
|
||||
const db = ctx.skip
|
||||
? "d1"
|
||||
: await $p.select({
|
||||
message: "What database do you want to use?",
|
||||
options: [
|
||||
{ label: "Cloudflare D1", value: "d1" },
|
||||
{ label: "LibSQL", value: "libsql" },
|
||||
],
|
||||
});
|
||||
if ($p.isCancel(db)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (db) {
|
||||
case "d1":
|
||||
await createD1(ctx);
|
||||
break;
|
||||
case "libsql":
|
||||
await createLibsql(ctx);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid database");
|
||||
}
|
||||
await createD1(ctx);
|
||||
} catch (e) {
|
||||
const message = (e as any).message || "An error occurred";
|
||||
$p.log.warn(
|
||||
@@ -60,7 +38,14 @@ export const cloudflare = {
|
||||
);
|
||||
}
|
||||
|
||||
await createR2(ctx);
|
||||
try {
|
||||
await createR2(ctx);
|
||||
} catch (e) {
|
||||
const message = (e as any).message || "An error occurred";
|
||||
$p.log.warn(
|
||||
"Couldn't add R2 bucket. You can add it manually later. Error: " + c.red(message),
|
||||
);
|
||||
}
|
||||
},
|
||||
} as const satisfies Template;
|
||||
|
||||
@@ -89,6 +74,21 @@ async function createD1(ctx: TemplateSetupCtx) {
|
||||
})(),
|
||||
);
|
||||
|
||||
await overrideJson(
|
||||
WRANGLER_FILE,
|
||||
(json) => ({
|
||||
...json,
|
||||
d1_databases: [
|
||||
{
|
||||
binding: "DB",
|
||||
database_name: name,
|
||||
database_id: "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
|
||||
if (!ctx.skip) {
|
||||
exec(`npx wrangler d1 create ${name}`);
|
||||
|
||||
@@ -98,62 +98,6 @@ async function createD1(ctx: TemplateSetupCtx) {
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
await overrideJson(
|
||||
WRANGLER_FILE,
|
||||
(json) => ({
|
||||
...json,
|
||||
d1_databases: [
|
||||
{
|
||||
binding: "DB",
|
||||
database_name: name,
|
||||
database_id: uuid(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
}
|
||||
|
||||
async function createLibsql(ctx: TemplateSetupCtx) {
|
||||
await overrideJson(
|
||||
WRANGLER_FILE,
|
||||
(json) => ({
|
||||
...json,
|
||||
vars: {
|
||||
DB_URL: "http://127.0.0.1:8080",
|
||||
},
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
|
||||
await overridePackageJson(
|
||||
(pkg) => ({
|
||||
...pkg,
|
||||
scripts: {
|
||||
...pkg.scripts,
|
||||
db: "turso dev",
|
||||
dev: "npm run db && wrangler dev",
|
||||
},
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
|
||||
await $p.stream.info(
|
||||
(async function* () {
|
||||
yield* typewriter("Database set to LibSQL");
|
||||
await wait();
|
||||
yield* typewriter(
|
||||
`\nYou can now run ${c.cyan("npm run db")} to start the database and ${c.cyan("npm run dev")} to start the worker.`,
|
||||
c.dim,
|
||||
);
|
||||
await wait();
|
||||
yield* typewriter(
|
||||
`\nAlso make sure you have Turso's CLI installed. Check their docs on how to install at ${c.cyan("https://docs.turso.tech/cli/introduction")}`,
|
||||
c.dim,
|
||||
);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
async function createR2(ctx: TemplateSetupCtx) {
|
||||
@@ -197,9 +141,11 @@ async function createR2(ctx: TemplateSetupCtx) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!ctx.skip) {
|
||||
exec(`npx wrangler r2 bucket create ${name}`);
|
||||
}
|
||||
await $p.stream.info(
|
||||
(async function* () {
|
||||
yield* typewriter("Now running wrangler to create a R2 bucket...");
|
||||
})(),
|
||||
);
|
||||
|
||||
await overrideJson(
|
||||
WRANGLER_FILE,
|
||||
@@ -214,4 +160,8 @@ async function createR2(ctx: TemplateSetupCtx) {
|
||||
}),
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
|
||||
if (!ctx.skip) {
|
||||
exec(`npx wrangler r2 bucket create ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import open from "open";
|
||||
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { App, CreateAppConfig } from "App";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { colorizeConsole, config } from "core";
|
||||
import { config } from "core";
|
||||
import dotenv from "dotenv";
|
||||
import { registries } from "modules/registries";
|
||||
import c from "picocolors";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
startServer,
|
||||
} from "./platform";
|
||||
import { createRuntimeApp, makeConfig } from "adapter";
|
||||
import { isBun } from "core/utils";
|
||||
import { colorizeConsole, isBun } from "core/utils";
|
||||
|
||||
const env_files = [".env", ".dev.vars"];
|
||||
dotenv.config({
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { makeAppFromEnv } from "cli/commands/run";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { Argument } from "commander";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import c from "picocolors";
|
||||
import { isBun } from "core/utils";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { execSync, exec as nodeExec } from "node:child_process";
|
||||
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PostHog } from "posthog-js-lite";
|
||||
import { getVersion } from "cli/utils/sys";
|
||||
import { $console, env, isDebug } from "core";
|
||||
import { env, isDebug } from "core";
|
||||
import { $console } from "core/utils";
|
||||
|
||||
type Properties = { [p: string]: any };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Event, type EventClass, InvalidEventReturn } from "./Event";
|
||||
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
|
||||
export type RegisterListenerConfig =
|
||||
| ListenerMode
|
||||
|
||||
@@ -38,7 +38,6 @@ export { getFlashMessage } from "./server/flash";
|
||||
} from "./object/schema"; */
|
||||
|
||||
export * from "./drivers";
|
||||
export * from "./console";
|
||||
export * from "./events";
|
||||
|
||||
// compatibility
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MaybePromise } from "core/types";
|
||||
|
||||
export type Matcher<T = unknown> = {
|
||||
toEqual: (expected: T, failMsg?: string) => void;
|
||||
toBe: (expected: T, failMsg?: string) => void;
|
||||
@@ -16,7 +18,7 @@ export interface Test {
|
||||
skipIf: (condition: boolean) => (label: string, fn: TestFn) => void;
|
||||
}
|
||||
export type TestRunner = {
|
||||
describe: (label: string, asyncFn: () => Promise<void>) => void;
|
||||
describe: (label: string, asyncFn: () => MaybePromise<void>) => void;
|
||||
test: Test;
|
||||
mock: <T extends (...args: any[]) => any>(fn: T) => T | any;
|
||||
expect: <T = unknown>(
|
||||
@@ -26,6 +28,9 @@ export type TestRunner = {
|
||||
resolves: Matcher<Awaited<T>>;
|
||||
rejects: Matcher<Awaited<T>>;
|
||||
};
|
||||
beforeEach: (fn: () => MaybePromise<void>) => void;
|
||||
afterEach: (fn: () => MaybePromise<void>) => void;
|
||||
afterAll: (fn: () => MaybePromise<void>) => void;
|
||||
};
|
||||
|
||||
export async function retry<T>(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
|
||||
import { randomString } from "core/utils/strings";
|
||||
import type { Context } from "hono";
|
||||
import { invariant } from "core/utils/runtime";
|
||||
import { $console } from "../console";
|
||||
import { $console } from "./console";
|
||||
|
||||
export function getContentName(request: Request): string | undefined;
|
||||
export function getContentName(contentDisposition: string): string | undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./console";
|
||||
export * from "./browser";
|
||||
export * from "./objects";
|
||||
export * from "./strings";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $console } from "core";
|
||||
import { $console } from "./console";
|
||||
|
||||
type ConsoleSeverity = "log" | "warn" | "error";
|
||||
const _oldConsoles = {
|
||||
@@ -36,14 +36,14 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
$console.setLevel("critical");
|
||||
$console?.setLevel("critical");
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
$console.resetLevel();
|
||||
$console?.resetLevel();
|
||||
}
|
||||
|
||||
export function formatMemoryUsage() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { $console, isDebug } from "core";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { TestRunner } from "core/test";
|
||||
import { Connection, type FieldSpec } from "./Connection";
|
||||
import { getPath } from "core/utils";
|
||||
import * as proto from "data/prototype";
|
||||
import { createApp } from "App";
|
||||
import type { MaybePromise } from "core/types";
|
||||
|
||||
// @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc.
|
||||
// @todo: add toDriver/fromDriver tests on all types and fields
|
||||
@@ -10,77 +14,92 @@ export function connectionTestSuite(
|
||||
makeConnection,
|
||||
rawDialectDetails,
|
||||
}: {
|
||||
makeConnection: () => Connection;
|
||||
makeConnection: () => MaybePromise<{
|
||||
connection: Connection;
|
||||
dispose: () => MaybePromise<void>;
|
||||
}>;
|
||||
rawDialectDetails: string[];
|
||||
},
|
||||
) {
|
||||
const { test, expect, describe } = testRunner;
|
||||
const { test, expect, describe, beforeEach, afterEach, afterAll } = testRunner;
|
||||
|
||||
test("pings", async () => {
|
||||
const connection = makeConnection();
|
||||
const res = await connection.ping();
|
||||
expect(res).toBe(true);
|
||||
});
|
||||
describe("base", () => {
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
beforeEach(async () => {
|
||||
ctx = await makeConnection();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
test("initializes", async () => {
|
||||
const connection = makeConnection();
|
||||
await connection.init();
|
||||
// @ts-expect-error
|
||||
expect(connection.initialized).toBe(true);
|
||||
expect(connection.client).toBeDefined();
|
||||
});
|
||||
test("pings", async () => {
|
||||
const res = await ctx.connection.ping();
|
||||
expect(res).toBe(true);
|
||||
});
|
||||
|
||||
test("isConnection", async () => {
|
||||
const connection = makeConnection();
|
||||
expect(Connection.isConnection(connection)).toBe(true);
|
||||
});
|
||||
|
||||
test("getFieldSchema", async () => {
|
||||
const c = makeConnection();
|
||||
const specToNode = (spec: FieldSpec) => {
|
||||
test("initializes", async () => {
|
||||
await ctx.connection.init();
|
||||
// @ts-expect-error
|
||||
const schema = c.kysely.schema.createTable("test").addColumn(...c.getFieldSchema(spec));
|
||||
return schema.toOperationNode();
|
||||
};
|
||||
expect(ctx.connection.initialized).toBe(true);
|
||||
expect(ctx.connection.client).toBeDefined();
|
||||
});
|
||||
|
||||
{
|
||||
// primary
|
||||
const node = specToNode({
|
||||
type: "integer",
|
||||
name: "id",
|
||||
primary: true,
|
||||
});
|
||||
const col = node.columns[0]!;
|
||||
expect(col.primaryKey).toBe(true);
|
||||
expect(col.notNull).toBe(true);
|
||||
}
|
||||
test("isConnection", async () => {
|
||||
expect(Connection.isConnection(ctx.connection)).toBe(true);
|
||||
});
|
||||
|
||||
{
|
||||
// normal
|
||||
const node = specToNode({
|
||||
type: "text",
|
||||
name: "text",
|
||||
});
|
||||
const col = node.columns[0]!;
|
||||
expect(!col.primaryKey).toBe(true);
|
||||
expect(!col.notNull).toBe(true);
|
||||
}
|
||||
test("getFieldSchema", async () => {
|
||||
const specToNode = (spec: FieldSpec) => {
|
||||
const schema = ctx.connection.kysely.schema
|
||||
.createTable("test")
|
||||
// @ts-expect-error
|
||||
.addColumn(...ctx.connection.getFieldSchema(spec));
|
||||
return schema.toOperationNode();
|
||||
};
|
||||
|
||||
{
|
||||
// nullable (expect to be same as normal)
|
||||
const node = specToNode({
|
||||
type: "text",
|
||||
name: "text",
|
||||
nullable: true,
|
||||
});
|
||||
const col = node.columns[0]!;
|
||||
expect(!col.primaryKey).toBe(true);
|
||||
expect(!col.notNull).toBe(true);
|
||||
}
|
||||
{
|
||||
// primary
|
||||
const node = specToNode({
|
||||
type: "integer",
|
||||
name: "id",
|
||||
primary: true,
|
||||
});
|
||||
const col = node.columns[0]!;
|
||||
expect(col.primaryKey).toBe(true);
|
||||
expect(col.notNull).toBe(true);
|
||||
}
|
||||
|
||||
{
|
||||
// normal
|
||||
const node = specToNode({
|
||||
type: "text",
|
||||
name: "text",
|
||||
});
|
||||
const col = node.columns[0]!;
|
||||
expect(!col.primaryKey).toBe(true);
|
||||
expect(!col.notNull).toBe(true);
|
||||
}
|
||||
|
||||
{
|
||||
// nullable (expect to be same as normal)
|
||||
const node = specToNode({
|
||||
type: "text",
|
||||
name: "text",
|
||||
nullable: true,
|
||||
});
|
||||
const col = node.columns[0]!;
|
||||
expect(!col.primaryKey).toBe(true);
|
||||
expect(!col.notNull).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema", async () => {
|
||||
const connection = makeConnection();
|
||||
const { connection, dispose } = await makeConnection();
|
||||
afterAll(async () => {
|
||||
await dispose();
|
||||
});
|
||||
|
||||
const fields = [
|
||||
{
|
||||
type: "integer",
|
||||
@@ -118,14 +137,16 @@ export function connectionTestSuite(
|
||||
const qb = connection.kysely.selectFrom("test").selectAll();
|
||||
const res = await connection.executeQuery(qb);
|
||||
expect(res.rows).toEqual([expected]);
|
||||
expect(rawDialectDetails.every((detail) => detail in res)).toBe(true);
|
||||
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
|
||||
|
||||
{
|
||||
const res = await connection.executeQueries(qb, qb);
|
||||
expect(res.length).toBe(2);
|
||||
res.map((r) => {
|
||||
expect(r.rows).toEqual([expected]);
|
||||
expect(rawDialectDetails.every((detail) => detail in r)).toBe(true);
|
||||
expect(rawDialectDetails.every((detail) => getPath(r, detail) !== undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -187,4 +208,146 @@ export function connectionTestSuite(
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("integration", async () => {
|
||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||
beforeEach(async () => {
|
||||
ctx = await makeConnection();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
test("should create app and ping", async () => {
|
||||
const app = createApp({
|
||||
connection: ctx.connection,
|
||||
});
|
||||
await app.build();
|
||||
|
||||
expect(app.version()).toBeDefined();
|
||||
expect(await app.em.ping()).toBe(true);
|
||||
});
|
||||
|
||||
test("should create a basic schema", async () => {
|
||||
const schema = proto.em(
|
||||
{
|
||||
posts: proto.entity("posts", {
|
||||
title: proto.text().required(),
|
||||
content: proto.text(),
|
||||
}),
|
||||
comments: proto.entity("comments", {
|
||||
content: proto.text(),
|
||||
}),
|
||||
},
|
||||
(fns, s) => {
|
||||
fns.relation(s.comments).manyToOne(s.posts);
|
||||
fns.index(s.posts).on(["title"], true);
|
||||
},
|
||||
);
|
||||
|
||||
const app = createApp({
|
||||
connection: ctx.connection,
|
||||
initialConfig: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
await app.build();
|
||||
|
||||
expect(app.em.entities.length).toBe(2);
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual(["posts", "comments"]);
|
||||
|
||||
const api = app.getApi();
|
||||
|
||||
expect(
|
||||
(
|
||||
await api.data.createMany("posts", [
|
||||
{
|
||||
title: "Hello",
|
||||
content: "World",
|
||||
},
|
||||
{
|
||||
title: "Hello 2",
|
||||
content: "World 2",
|
||||
},
|
||||
])
|
||||
).data,
|
||||
).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
title: "Hello",
|
||||
content: "World",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Hello 2",
|
||||
content: "World 2",
|
||||
},
|
||||
] as any);
|
||||
|
||||
// try to create an existing
|
||||
expect(
|
||||
(
|
||||
await api.data.createOne("posts", {
|
||||
title: "Hello",
|
||||
})
|
||||
).ok,
|
||||
).toBe(false);
|
||||
|
||||
// add a comment to a post
|
||||
await api.data.createOne("comments", {
|
||||
content: "Hello",
|
||||
posts_id: 1,
|
||||
});
|
||||
|
||||
// and then query using a `with` property
|
||||
const result = await api.data.readMany("posts", { with: ["comments"] });
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]?.comments?.length).toBe(1);
|
||||
expect(result[0]?.comments?.[0]?.content).toBe("Hello");
|
||||
expect(result[1]?.comments?.length).toBe(0);
|
||||
});
|
||||
|
||||
test("should support uuid", async () => {
|
||||
const schema = proto.em(
|
||||
{
|
||||
posts: proto.entity(
|
||||
"posts",
|
||||
{
|
||||
title: proto.text().required(),
|
||||
content: proto.text(),
|
||||
},
|
||||
{
|
||||
primary_format: "uuid",
|
||||
},
|
||||
),
|
||||
comments: proto.entity("comments", {
|
||||
content: proto.text(),
|
||||
}),
|
||||
},
|
||||
(fns, s) => {
|
||||
fns.relation(s.comments).manyToOne(s.posts);
|
||||
fns.index(s.posts).on(["title"], true);
|
||||
},
|
||||
);
|
||||
|
||||
const app = createApp({
|
||||
connection: ctx.connection,
|
||||
initialConfig: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
});
|
||||
|
||||
await app.build();
|
||||
const config = app.toJSON();
|
||||
// @ts-expect-error
|
||||
expect(config.data.entities?.posts.fields?.id.config?.format).toBe("uuid");
|
||||
|
||||
const em = app.em;
|
||||
const mutator = em.mutator(em.entity("posts"));
|
||||
const data = await mutator.insertOne({ title: "Hello", content: "World" });
|
||||
expect(data.data.id).toBeString();
|
||||
expect(String(data.data.id).length).toBe(36);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KyselyPlugin } from "kysely";
|
||||
import type { KyselyPlugin, QueryResult } from "kysely";
|
||||
import {
|
||||
type IGenericSqlite,
|
||||
type OnCreateConnection,
|
||||
@@ -8,11 +8,16 @@ import {
|
||||
GenericSqliteDialect,
|
||||
} from "kysely-generic-sqlite";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import type { Features } from "../Connection";
|
||||
import type { ConnQuery, ConnQueryResults, Features } from "../Connection";
|
||||
|
||||
export type { IGenericSqlite };
|
||||
export type TStatement = { sql: string; parameters?: any[] | readonly any[] };
|
||||
export interface IGenericCustomSqlite<DB = unknown> extends IGenericSqlite<DB> {
|
||||
batch?: (stmts: TStatement[]) => Promisable<QueryResult<any>[]>;
|
||||
}
|
||||
|
||||
export type GenericSqliteConnectionConfig = {
|
||||
name: string;
|
||||
name?: string;
|
||||
additionalPlugins?: KyselyPlugin[];
|
||||
excludeTables?: string[];
|
||||
onCreateConnection?: OnCreateConnection;
|
||||
@@ -21,10 +26,11 @@ export type GenericSqliteConnectionConfig = {
|
||||
|
||||
export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB> {
|
||||
override name = "generic-sqlite";
|
||||
#executor: IGenericCustomSqlite<DB> | undefined;
|
||||
|
||||
constructor(
|
||||
db: DB,
|
||||
executor: () => Promisable<IGenericSqlite>,
|
||||
public db: DB,
|
||||
private executor: () => Promisable<IGenericCustomSqlite<DB>>,
|
||||
config?: GenericSqliteConnectionConfig,
|
||||
) {
|
||||
super({
|
||||
@@ -39,18 +45,43 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
|
||||
}
|
||||
if (config?.supports) {
|
||||
for (const [key, value] of Object.entries(config.supports)) {
|
||||
if (value) {
|
||||
if (value !== undefined) {
|
||||
this.supported[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private async getExecutor() {
|
||||
if (!this.#executor) {
|
||||
this.#executor = await this.executor();
|
||||
}
|
||||
return this.#executor;
|
||||
}
|
||||
|
||||
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
|
||||
const executor = await this.getExecutor();
|
||||
if (!executor.batch) {
|
||||
//$console.debug("Batching is not supported by this database");
|
||||
return super.executeQueries(...qbs);
|
||||
}
|
||||
|
||||
const compiled = this.getCompiled(...qbs);
|
||||
const stms: TStatement[] = compiled.map((q) => {
|
||||
return {
|
||||
sql: q.sql,
|
||||
parameters: q.parameters as any[],
|
||||
};
|
||||
});
|
||||
|
||||
const results = await executor.batch(stms);
|
||||
return this.withTransformedRows(results) as any;
|
||||
}
|
||||
}
|
||||
|
||||
export function genericSqlite<DB>(
|
||||
name: string,
|
||||
db: DB,
|
||||
executor: (utils: typeof genericSqliteUtils) => Promisable<IGenericSqlite<DB>>,
|
||||
executor: (utils: typeof genericSqliteUtils) => Promisable<IGenericCustomSqlite<DB>>,
|
||||
config?: GenericSqliteConnectionConfig,
|
||||
) {
|
||||
return new GenericSqliteConnection(db, () => executor(genericSqliteUtils), {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { connectionTestSuite } from "../connection-test-suite";
|
||||
import { LibsqlConnection } from "./LibsqlConnection";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { describe } from "bun:test";
|
||||
import { createClient } from "@libsql/client";
|
||||
|
||||
describe("LibsqlConnection", () => {
|
||||
connectionTestSuite(bunTestRunner, {
|
||||
makeConnection: () => new LibsqlConnection(createClient({ url: ":memory:" })),
|
||||
rawDialectDetails: ["rowsAffected", "lastInsertRowid"],
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Client, Config, InStatement } from "@libsql/client";
|
||||
import { createClient } from "libsql-stateless-easy";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin";
|
||||
import { type ConnQuery, type ConnQueryResults, SqliteConnection } from "bknd/data";
|
||||
|
||||
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
|
||||
export type LibSqlCredentials = Config & {
|
||||
protocol?: (typeof LIBSQL_PROTOCOLS)[number];
|
||||
};
|
||||
|
||||
function getClient(clientOrCredentials: Client | LibSqlCredentials): Client {
|
||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||
let { url, authToken, protocol } = clientOrCredentials;
|
||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||
console.info("changing protocol to", protocol);
|
||||
const [, rest] = url.split("://");
|
||||
url = `${protocol}://${rest}`;
|
||||
}
|
||||
|
||||
return createClient({ url, authToken });
|
||||
}
|
||||
|
||||
return clientOrCredentials as Client;
|
||||
}
|
||||
|
||||
export class LibsqlConnection extends SqliteConnection<Client> {
|
||||
override name = "libsql";
|
||||
protected override readonly supported = {
|
||||
batching: true,
|
||||
softscans: true,
|
||||
};
|
||||
|
||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||
const client = getClient(clientOrCredentials);
|
||||
|
||||
super({
|
||||
excludeTables: ["libsql_wasm_func_table"],
|
||||
dialect: LibsqlDialect,
|
||||
dialectArgs: [{ client }],
|
||||
additionalPlugins: [new FilterNumericKeysPlugin()],
|
||||
});
|
||||
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
|
||||
const compiled = this.getCompiled(...qbs);
|
||||
const stms: InStatement[] = compiled.map((q) => {
|
||||
return {
|
||||
sql: q.sql,
|
||||
args: q.parameters as any[],
|
||||
};
|
||||
});
|
||||
|
||||
return this.withTransformedRows(await this.client.batch(stms)) as any;
|
||||
}
|
||||
}
|
||||
|
||||
export function libsql(credentials: Client | LibSqlCredentials): LibsqlConnection {
|
||||
return new LibsqlConnection(credentials);
|
||||
}
|
||||
@@ -68,32 +68,34 @@ export class SqliteIntrospector extends BaseIntrospector {
|
||||
return tables.map((table) => ({
|
||||
name: table.name,
|
||||
isView: table.type === "view",
|
||||
columns: table.columns.map((col) => {
|
||||
const autoIncrementCol = table.sql
|
||||
?.split(/[\(\),]/)
|
||||
?.find((it) => it.toLowerCase().includes("autoincrement"))
|
||||
?.trimStart()
|
||||
?.split(/\s+/)?.[0]
|
||||
?.replace(/["`]/g, "");
|
||||
columns:
|
||||
table.columns?.map((col) => {
|
||||
const autoIncrementCol = table.sql
|
||||
?.split(/[\(\),]/)
|
||||
?.find((it) => it.toLowerCase().includes("autoincrement"))
|
||||
?.trimStart()
|
||||
?.split(/\s+/)?.[0]
|
||||
?.replace(/["`]/g, "");
|
||||
|
||||
return {
|
||||
name: col.name,
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
};
|
||||
}),
|
||||
indices: table.indices.map((index) => ({
|
||||
name: index.name,
|
||||
table: table.name,
|
||||
isUnique: index.sql?.match(/unique/i) != null,
|
||||
columns: index.columns.map((col) => ({
|
||||
name: col.name,
|
||||
order: col.seqno,
|
||||
})),
|
||||
})),
|
||||
return {
|
||||
name: col.name,
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
};
|
||||
}) ?? [],
|
||||
indices:
|
||||
table.indices?.map((index) => ({
|
||||
name: index.name,
|
||||
table: table.name,
|
||||
isUnique: index.sql?.match(/unique/i) != null,
|
||||
columns: index.columns.map((col) => ({
|
||||
name: col.name,
|
||||
order: col.seqno,
|
||||
})),
|
||||
})) ?? [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { connectionTestSuite } from "../../connection-test-suite";
|
||||
import { libsql } from "./LibsqlConnection";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { describe } from "bun:test";
|
||||
import { createClient } from "@libsql/client";
|
||||
|
||||
describe("LibsqlConnection", () => {
|
||||
connectionTestSuite(bunTestRunner, {
|
||||
makeConnection: () => ({
|
||||
connection: libsql(createClient({ url: ":memory:" })),
|
||||
dispose: async () => {},
|
||||
}),
|
||||
rawDialectDetails: [],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Client, Config, ResultSet } from "@libsql/client";
|
||||
import { createClient } from "libsql-stateless-easy";
|
||||
import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin";
|
||||
import {
|
||||
genericSqlite,
|
||||
type GenericSqliteConnection,
|
||||
} from "data/connection/sqlite/GenericSqliteConnection";
|
||||
import type { QueryResult } from "kysely";
|
||||
|
||||
export type LibsqlConnection = GenericSqliteConnection<Client>;
|
||||
export type LibSqlCredentials = Config;
|
||||
|
||||
function getClient(clientOrCredentials: Client | LibSqlCredentials): Client {
|
||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||
const { url, authToken } = clientOrCredentials;
|
||||
return createClient({ url, authToken });
|
||||
}
|
||||
|
||||
return clientOrCredentials as Client;
|
||||
}
|
||||
|
||||
export function libsql(config: LibSqlCredentials | Client) {
|
||||
const db = getClient(config);
|
||||
|
||||
return genericSqlite(
|
||||
"libsql",
|
||||
db,
|
||||
(utils) => {
|
||||
const mapResult = (result: ResultSet): QueryResult<any> => ({
|
||||
insertId: result.lastInsertRowid,
|
||||
numAffectedRows: BigInt(result.rowsAffected),
|
||||
rows: result.rows,
|
||||
});
|
||||
const execute = async (sql: string, parameters?: any[] | readonly any[]) => {
|
||||
const result = await db.execute({ sql, args: [...(parameters || [])] });
|
||||
return mapResult(result);
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
batch: async (stmts) => {
|
||||
const results = await db.batch(
|
||||
stmts.map(({ sql, parameters }) => ({
|
||||
sql,
|
||||
args: parameters as any[],
|
||||
})),
|
||||
);
|
||||
return results.map(mapResult);
|
||||
},
|
||||
query: utils.buildQueryFn({
|
||||
all: async (sql, parameters) => {
|
||||
return (await execute(sql, parameters)).rows;
|
||||
},
|
||||
run: execute,
|
||||
}),
|
||||
close: () => db.close(),
|
||||
};
|
||||
},
|
||||
{
|
||||
supports: {
|
||||
batching: true,
|
||||
softscans: true,
|
||||
},
|
||||
additionalPlugins: [new FilterNumericKeysPlugin()],
|
||||
excludeTables: ["libsql_wasm_func_table"],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { $console, config } from "core";
|
||||
import { snakeToPascalWithSpaces, transformObject } from "core/utils";
|
||||
import { config } from "core";
|
||||
import { snakeToPascalWithSpaces, transformObject, $console } from "core/utils";
|
||||
import {
|
||||
type Field,
|
||||
PrimaryField,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { $console, type DB as DefaultDB } from "core";
|
||||
import type { DB as DefaultDB } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { EventManager } from "core/events";
|
||||
import { sql } from "kysely";
|
||||
import { Connection } from "../connection/Connection";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Entity, EntityManager, EntityRelation, TEntityType } from "data";
|
||||
import { autoFormatString } from "core/utils";
|
||||
import { AppAuth, AppMedia } from "modules";
|
||||
import { usersFields } from "auth/auth-entities";
|
||||
import { mediaFields } from "media/media-entities";
|
||||
|
||||
export type TEntityTSType = {
|
||||
name: string;
|
||||
@@ -32,8 +33,8 @@ export type EntityTypescriptOptions = {
|
||||
|
||||
// keep a local copy here until properties have a type
|
||||
const systemEntities = {
|
||||
users: AppAuth.usersFields,
|
||||
media: AppMedia.mediaFields,
|
||||
users: usersFields,
|
||||
media: mediaFields,
|
||||
};
|
||||
|
||||
export class EntityTypescript {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { $console } from "core/console";
|
||||
import { $console } from "core/utils";
|
||||
import type { Entity, EntityData } from "../Entity";
|
||||
import type { EntityManager } from "../EntityManager";
|
||||
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
||||
@@ -32,6 +32,7 @@ export class MutatorResult<T = EntityData[]> extends Result<T> {
|
||||
onError: (error) => {
|
||||
if (!options?.silent) {
|
||||
$console.error("[ERROR] Mutator:", error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { InvalidSearchParamsException } from "../../errors";
|
||||
@@ -57,7 +57,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
}
|
||||
}
|
||||
|
||||
getValidOptions(options?: RepoQuery): RepoQuery {
|
||||
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
const entity = this.entity;
|
||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||
const validated = {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { $console } from "core/console";
|
||||
import type { Entity, EntityData } from "../Entity";
|
||||
import type { EntityManager } from "../EntityManager";
|
||||
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
||||
import type { Compilable, SelectQueryBuilder } from "kysely";
|
||||
import { ensureInt } from "core/utils";
|
||||
import { $console, ensureInt } from "core/utils";
|
||||
|
||||
export type RepositoryResultOptions = ResultOptions & {
|
||||
silent?: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { $console, type PrimaryFieldType } from "core";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { Event, InvalidEventReturn } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { dayjs, omitKeys } from "core/utils";
|
||||
import { dayjs } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
|
||||
@@ -30,9 +30,16 @@ export * as DataPermissions from "./permissions";
|
||||
|
||||
export { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
|
||||
export { libsql } from "./connection/sqlite/LibsqlConnection";
|
||||
export { libsql } from "./connection/sqlite/libsql/LibsqlConnection";
|
||||
export {
|
||||
genericSqlite,
|
||||
genericSqliteUtils,
|
||||
type GenericSqliteConnection,
|
||||
} from "./connection/sqlite/GenericSqliteConnection";
|
||||
|
||||
export {
|
||||
EntityTypescript,
|
||||
type EntityTypescriptOptions,
|
||||
type TEntityTSType,
|
||||
type TFieldTSType,
|
||||
} from "./entities/EntityTypescript";
|
||||
|
||||
@@ -3,16 +3,13 @@ import { EntityManager } from "data/entities/EntityManager";
|
||||
import type { Generated } from "kysely";
|
||||
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
|
||||
import {
|
||||
BooleanField,
|
||||
type BooleanFieldConfig,
|
||||
type Connection,
|
||||
DateField,
|
||||
type DateFieldConfig,
|
||||
Entity,
|
||||
type EntityConfig,
|
||||
EntityIndex,
|
||||
type EntityRelation,
|
||||
EnumField,
|
||||
type EnumFieldConfig,
|
||||
type Field,
|
||||
@@ -20,20 +17,27 @@ import {
|
||||
type JsonFieldConfig,
|
||||
JsonSchemaField,
|
||||
type JsonSchemaFieldConfig,
|
||||
NumberField,
|
||||
type NumberFieldConfig,
|
||||
TextField,
|
||||
type TextFieldConfig,
|
||||
} from "data/fields";
|
||||
|
||||
import { Entity, type EntityConfig, type TEntityType } from "data/entities";
|
||||
|
||||
import type { Connection } from "data/connection";
|
||||
|
||||
import {
|
||||
type EntityRelation,
|
||||
ManyToManyRelation,
|
||||
type ManyToManyRelationConfig,
|
||||
ManyToOneRelation,
|
||||
type ManyToOneRelationConfig,
|
||||
NumberField,
|
||||
type NumberFieldConfig,
|
||||
OneToOneRelation,
|
||||
type OneToOneRelationConfig,
|
||||
PolymorphicRelation,
|
||||
type PolymorphicRelationConfig,
|
||||
type TEntityType,
|
||||
TextField,
|
||||
type TextFieldConfig,
|
||||
} from "../index";
|
||||
} from "data/relations";
|
||||
|
||||
type Options<Config = any> = {
|
||||
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
||||
@@ -61,6 +65,46 @@ const FieldMap = {
|
||||
} as const;
|
||||
type TFieldType = keyof typeof FieldMap;
|
||||
|
||||
export class FieldPrototype {
|
||||
constructor(
|
||||
public type: TFieldType,
|
||||
public config: any,
|
||||
public is_required: boolean,
|
||||
) {}
|
||||
|
||||
required() {
|
||||
this.is_required = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
getField(o: Options): Field {
|
||||
if (!FieldMap[this.type]) {
|
||||
throw new Error(`Unknown field type: ${this.type}`);
|
||||
}
|
||||
try {
|
||||
return FieldMap[this.type](o) as unknown as Field;
|
||||
} catch (e) {
|
||||
throw new Error(`Faild to construct field "${this.type}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
make(field_name: string): Field {
|
||||
if (!FieldMap[this.type]) {
|
||||
throw new Error(`Unknown field type: ${this.type}`);
|
||||
}
|
||||
try {
|
||||
return FieldMap[this.type]({
|
||||
entity: { name: "unknown", fields: {} },
|
||||
field_name,
|
||||
config: this.config,
|
||||
is_required: this.is_required,
|
||||
}) as unknown as Field;
|
||||
} catch (e) {
|
||||
throw new Error(`Faild to construct field "${this.type}": ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function text(
|
||||
config?: Omit<TextFieldConfig, "required">,
|
||||
): TextField<false> & { required: () => TextField<true> } {
|
||||
@@ -132,46 +176,6 @@ export function make<Actual extends Field<any, any>>(name: string, field: Actual
|
||||
throw new Error("Invalid field");
|
||||
}
|
||||
|
||||
export class FieldPrototype {
|
||||
constructor(
|
||||
public type: TFieldType,
|
||||
public config: any,
|
||||
public is_required: boolean,
|
||||
) {}
|
||||
|
||||
required() {
|
||||
this.is_required = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
getField(o: Options): Field {
|
||||
if (!FieldMap[this.type]) {
|
||||
throw new Error(`Unknown field type: ${this.type}`);
|
||||
}
|
||||
try {
|
||||
return FieldMap[this.type](o) as unknown as Field;
|
||||
} catch (e) {
|
||||
throw new Error(`Faild to construct field "${this.type}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
make(field_name: string): Field {
|
||||
if (!FieldMap[this.type]) {
|
||||
throw new Error(`Unknown field type: ${this.type}`);
|
||||
}
|
||||
try {
|
||||
return FieldMap[this.type]({
|
||||
entity: { name: "unknown", fields: {} },
|
||||
field_name,
|
||||
config: this.config,
|
||||
is_required: this.is_required,
|
||||
}) as unknown as Field;
|
||||
} catch (e) {
|
||||
throw new Error(`Faild to construct field "${this.type}": ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function entity<
|
||||
EntityName extends string,
|
||||
Fields extends Record<string, Field<any, any, any>>,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CompiledQuery, TableMetadata } from "kysely";
|
||||
import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { PrimaryField } from "../fields";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
|
||||
type IntrospectedTable = TableMetadata & {
|
||||
indices: IndexMetadata[];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { s } from "core/object/schema";
|
||||
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
|
||||
import { $console } from "core";
|
||||
import { isObject } from "core/utils";
|
||||
import { isObject, $console } from "core/utils";
|
||||
import type { anyOf, CoercionOptions, Schema } from "jsonv-ts";
|
||||
|
||||
// -------
|
||||
@@ -157,7 +156,9 @@ export type RepoQueryIn = {
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
|
||||
export type RepoQuery = s.StaticCoerced<typeof repoQuery> & {
|
||||
sort: SortSchema;
|
||||
};
|
||||
|
||||
//export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
|
||||
// @todo: CURRENT WORKAROUND
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events";
|
||||
import type { EmitsEvents } from "core/events";
|
||||
import type { Task, TaskResult } from "../tasks/Task";
|
||||
import type { Flow } from "./Flow";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
|
||||
export type TaskLog = TaskResult & {
|
||||
task: Task;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { objectTransform, transformObject } from "core/utils";
|
||||
import { $console, transformObject } from "core/utils";
|
||||
import { type TaskMapType, TriggerMap } from "../index";
|
||||
import type { Task } from "../tasks/Task";
|
||||
import { Condition, TaskConnection } from "../tasks/TaskConnection";
|
||||
import { Execution } from "./Execution";
|
||||
import { FlowTaskConnector } from "./FlowTaskConnector";
|
||||
import { Trigger } from "./triggers/Trigger";
|
||||
import { $console } from "core";
|
||||
|
||||
type Jsoned<T extends { toJSON: () => object }> = ReturnType<T["toJSON"]>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Task } from "../../tasks/Task";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
|
||||
export class RuntimeExecutor {
|
||||
async run(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { EventManager } from "core/events";
|
||||
import type { Flow } from "../Flow";
|
||||
import { Trigger } from "./Trigger";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
export class EventTrigger extends Trigger<typeof EventTrigger.schema> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Task } from "../Task";
|
||||
import { $console } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
export class LogTask extends Task<typeof LogTask.schema> {
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
try {
|
||||
/**
|
||||
* Adding this to avoid warnings from node:sqlite being experimental
|
||||
*/
|
||||
const { emitWarning } = process;
|
||||
process.emitWarning = (warning: string, ...args: any[]) => {
|
||||
if (warning.includes("SQLite is an experimental feature")) return;
|
||||
return emitWarning(warning, ...args);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
export {
|
||||
App,
|
||||
createApp,
|
||||
@@ -16,6 +27,7 @@ export {
|
||||
type ModuleManagerOptions,
|
||||
type ModuleBuildContext,
|
||||
type InitialModuleConfigs,
|
||||
ModuleManagerEvents,
|
||||
} from "./modules/ModuleManager";
|
||||
|
||||
export type { ServerEnv } from "modules/Controller";
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { $console, type AppEntity } from "core";
|
||||
import type { AppEntity } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media";
|
||||
import { Module } from "modules/Module";
|
||||
import {
|
||||
type FieldSchema,
|
||||
boolean,
|
||||
datetime,
|
||||
em,
|
||||
entity,
|
||||
json,
|
||||
number,
|
||||
text,
|
||||
} from "../data/prototype";
|
||||
import { type FieldSchema, em, entity } from "../data/prototype";
|
||||
import { MediaController } from "./api/MediaController";
|
||||
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
|
||||
import { buildMediaSchema, type mediaConfigSchema, registry, type TAppMediaConfig } from "./media-schema";
|
||||
import { mediaFields } from "./media-entities";
|
||||
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
declare module "core" {
|
||||
@@ -95,18 +88,7 @@ export class AppMedia extends Module<Required<TAppMediaConfig>> {
|
||||
};
|
||||
}
|
||||
|
||||
static mediaFields = {
|
||||
path: text().required(),
|
||||
folder: boolean({ default_value: false, hidden: true, fillable: ["create"] }),
|
||||
mime_type: text(),
|
||||
size: number(),
|
||||
scope: text({ hidden: true, fillable: ["create"] }),
|
||||
etag: text(),
|
||||
modified_at: datetime(),
|
||||
reference: text(),
|
||||
entity_id: number(),
|
||||
metadata: json(),
|
||||
};
|
||||
static mediaFields = mediaFields;
|
||||
|
||||
getMediaEntity(forceCreate?: boolean): Entity<"media", typeof AppMedia.mediaFields> {
|
||||
const entity_name = this.config.entity_name;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { boolean, datetime, json, number, text } from "data/prototype";
|
||||
|
||||
export const mediaFields = {
|
||||
path: text().required(),
|
||||
folder: boolean({ default_value: false, hidden: true, fillable: ["create"] }),
|
||||
mime_type: text(),
|
||||
size: number(),
|
||||
scope: text({ hidden: true, fillable: ["create"] }),
|
||||
etag: text(),
|
||||
modified_at: datetime(),
|
||||
reference: text(),
|
||||
entity_id: number(),
|
||||
metadata: json(),
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { isFile, detectImageDimensions } from "core/utils";
|
||||
import { $console, isFile, detectImageDimensions } from "core/utils";
|
||||
import { isMimeType } from "media/storage/mime-types-tiny";
|
||||
import * as StorageEvents from "./events";
|
||||
import type { FileUploadedEventData } from "./events";
|
||||
import { $console } from "core";
|
||||
import type { StorageAdapter } from "./StorageAdapter";
|
||||
|
||||
export type FileListObject = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { $console, type PrimaryFieldType } from "core";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { isDebug } from "core/env";
|
||||
import { encodeSearch } from "core/utils/reqres";
|
||||
import type { ApiFetcher } from "Api";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Guard } from "auth";
|
||||
import { $console, BkndError, DebugLogger, env } from "core";
|
||||
import { EventManager } from "core/events";
|
||||
import { BkndError, DebugLogger, env } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { EventManager, Event } from "core/events";
|
||||
import * as $diff from "core/object/diff";
|
||||
import { objectEach, transformObject } from "core/utils";
|
||||
import type { Connection, Schema } from "data";
|
||||
@@ -117,9 +118,24 @@ interface T_INTERNAL_EM {
|
||||
|
||||
const debug_modules = env("modules_debug");
|
||||
|
||||
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
||||
export class ModuleManagerConfigUpdateEvent<
|
||||
Module extends keyof ModuleConfigs,
|
||||
> extends ModuleManagerEvent<{
|
||||
module: Module;
|
||||
config: ModuleConfigs[Module];
|
||||
}> {
|
||||
static override slug = "mm-config-update";
|
||||
}
|
||||
export const ModuleManagerEvents = {
|
||||
ModuleManagerConfigUpdateEvent,
|
||||
};
|
||||
|
||||
// @todo: cleanup old diffs on upgrade
|
||||
// @todo: cleanup multiple backups on upgrade
|
||||
export class ModuleManager {
|
||||
static Events = ModuleManagerEvents;
|
||||
|
||||
protected modules: Modules;
|
||||
// internal em for __bknd config table
|
||||
__em!: EntityManager<T_INTERNAL_EM>;
|
||||
@@ -142,7 +158,7 @@ export class ModuleManager {
|
||||
) {
|
||||
this.__em = new EntityManager([__bknd], this.connection);
|
||||
this.modules = {} as Modules;
|
||||
this.emgr = new EventManager();
|
||||
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
||||
this.logger = new DebugLogger(debug_modules);
|
||||
let initial = {} as Partial<ModuleConfigs>;
|
||||
|
||||
@@ -619,6 +635,13 @@ export class ModuleManager {
|
||||
try {
|
||||
// overwrite listener to run build inside this try/catch
|
||||
module.setListener(async () => {
|
||||
await this.emgr.emit(
|
||||
new ModuleManagerConfigUpdateEvent({
|
||||
ctx: this.ctx(),
|
||||
module: name,
|
||||
config: module.config as any,
|
||||
}),
|
||||
);
|
||||
await this.buildModules();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import type { App } from "App";
|
||||
import { $console, config, isDebug } from "core";
|
||||
import { config, isDebug } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import { html } from "hono/html";
|
||||
import { Fragment } from "hono/jsx";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Exception, isDebug, $console } from "core";
|
||||
import { Exception, isDebug } from "core";
|
||||
import { $console } from "core/utils";
|
||||
import { cors } from "hono/cors";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthException } from "auth/errors";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import type { App } from "App";
|
||||
import { $console } from "core";
|
||||
import { datetimeStringLocal, datetimeStringUTC, getTimezone, getTimezoneOffset } from "core/utils";
|
||||
import { datetimeStringLocal, datetimeStringUTC, getTimezone, getTimezoneOffset, $console } from "core/utils";
|
||||
import { getRuntimeKey } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, type AppPlugin } from "bknd";
|
||||
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||
import { EntityTypescript } from "bknd/data";
|
||||
|
||||
export type SyncTypesOptions = {
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -124,7 +124,8 @@ const Icons = {
|
||||
};
|
||||
|
||||
const AdapterIcon = ({ type }: { type: string }) => {
|
||||
const Icon = Icons[type];
|
||||
// find icon whose name starts with type
|
||||
const Icon = Object.entries(Icons).find(([key]) => type.startsWith(key))?.[1];
|
||||
if (!Icon) return null;
|
||||
return <Icon />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user