Merge remote-tracking branch 'origin/release/0.20' into feat/opfs-and-sqlocal

This commit is contained in:
dswbx
2025-12-02 10:31:39 +01:00
87 changed files with 2060 additions and 824 deletions

View File

@@ -1,3 +1,4 @@
import type { MaybePromise } from "bknd";
import type { Event } from "./Event";
import type { EventClass } from "./EventManager";
@@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event<any, any>> = (
event: E,
slug: string,
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
) => E extends Event<any, infer R> ? MaybePromise<R | void> : never;
export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async";

View File

@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
export function omitKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[],
keys_: readonly K[] | K[] | string[],
): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const keys = new Set(keys_ as readonly K[]);
const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) {

View File

@@ -1,3 +1,4 @@
import type { MaybePromise } from "core/types";
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
/**
@@ -77,3 +78,37 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
return true;
}
}
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
try {
await fn;
return false;
} catch (e) {
if (instance) {
if (e instanceof instance) {
return true;
}
// if instance given but not what expected, throw
throw e;
}
return true;
}
}
export async function $waitUntil(
message: string,
condition: () => MaybePromise<boolean>,
delay = 100,
maxAttempts = 10,
) {
let attempts = 0;
while (attempts < maxAttempts) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, delay));
attempts++;
}
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
}

View File

@@ -96,6 +96,9 @@ export class DataController extends Controller {
// read entity schema
hono.get(
"/schema.json",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
@@ -124,6 +127,9 @@ export class DataController extends Controller {
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
@@ -161,7 +167,7 @@ export class DataController extends Controller {
hono.get(
"/types",
permission(SystemPermissions.schemaRead, {
context: (c) => ({ module: "data" }),
context: (_c) => ({ module: "data" }),
}),
describeRoute({
summary: "Retrieve data typescript definitions",
@@ -182,6 +188,9 @@ export class DataController extends Controller {
*/
hono.get(
"/info/:entity",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),

View File

@@ -14,27 +14,31 @@ export function connectionTestSuite(
{
makeConnection,
rawDialectDetails,
disableConsoleLog: _disableConsoleLog = true,
}: {
makeConnection: () => MaybePromise<{
connection: Connection;
dispose: () => MaybePromise<void>;
}>;
rawDialectDetails: string[];
disableConsoleLog?: boolean;
},
) {
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
if (_disableConsoleLog) {
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
}
describe("base", () => {
let ctx: Awaited<ReturnType<typeof makeConnection>>;
beforeEach(async () => {
ctx = await makeConnection();
});
afterEach(async () => {
await ctx.dispose();
});
let ctx: Awaited<ReturnType<typeof makeConnection>>;
beforeEach(async () => {
ctx = await makeConnection();
});
afterEach(async () => {
await ctx.dispose();
});
describe("base", async () => {
test("pings", async () => {
const res = await ctx.connection.ping();
expect(res).toBe(true);
@@ -98,52 +102,54 @@ export function connectionTestSuite(
});
describe("schema", async () => {
const { connection, dispose } = await makeConnection();
afterAll(async () => {
await dispose();
});
const makeSchema = async () => {
const fields = [
{
type: "integer",
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
const fields = [
{
type: "integer",
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
let b = ctx.connection.kysely.schema.createTable("test");
for (const field of fields) {
// @ts-expect-error
b = b.addColumn(...ctx.connection.getFieldSchema(field));
}
await b.execute();
let b = connection.kysely.schema.createTable("test");
for (const field of fields) {
// @ts-expect-error
b = b.addColumn(...connection.getFieldSchema(field));
}
await b.execute();
// add index
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
// add index
await ctx.connection.kysely.schema
.createIndex("test_index")
.on("test")
.columns(["id"])
.execute();
};
test("executes query", async () => {
await connection.kysely
await makeSchema();
await ctx.connection.kysely
.insertInto("test")
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
.execute();
const expected = { id: 1, text: "test", json: { a: 1 } };
const qb = connection.kysely.selectFrom("test").selectAll();
const res = await connection.executeQuery(qb);
const qb = ctx.connection.kysely.selectFrom("test").selectAll();
const res = await ctx.connection.executeQuery(qb);
expect(res.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
{
const res = await connection.executeQueries(qb, qb);
const res = await ctx.connection.executeQueries(qb, qb);
expect(res.length).toBe(2);
res.map((r) => {
expect(r.rows).toEqual([expected]);
@@ -155,15 +161,21 @@ export function connectionTestSuite(
});
test("introspects", async () => {
const tables = await connection.getIntrospector().getTables({
await makeSchema();
const tables = await ctx.connection.getIntrospector().getTables({
withInternalKyselyTables: false,
});
const clean = tables.map((t) => ({
...t,
columns: t.columns.map((c) => ({
...c,
dataType: undefined,
})),
columns: t.columns
.map((c) => ({
...c,
// ignore data type
dataType: undefined,
// ignore default value if "id"
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
}))
.sort((a, b) => a.name.localeCompare(b.name)),
}));
expect(clean).toEqual([
@@ -176,14 +188,8 @@ export function connectionTestSuite(
dataType: undefined,
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
},
{
name: "text",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
hasDefaultValue: undefined,
comment: undefined,
},
{
name: "json",
@@ -191,25 +197,34 @@ export function connectionTestSuite(
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
{
name: "text",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
],
},
]);
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
},
],
},
]);
});
expect(await connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
},
],
},
]);
});
describe("integration", async () => {

View File

@@ -0,0 +1,33 @@
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
import type { Pool } from "pg";
export type PostgresDialectConfig = Omit<KyselyPostgresDialectConfig, "pool"> & {
pool: Pool;
};
export class PgPostgresConnection extends PostgresConnection<Pool> {
override name = "pg";
constructor(config: PostgresDialectConfig) {
const kysely = new Kysely({
dialect: customIntrospector(PostgresDialect, PostgresIntrospector, {
excludeTables: [],
}).create(config),
plugins,
});
super(kysely);
this.client = config.pool;
}
override async close(): Promise<void> {
await this.client.end();
}
}
export function pg(config: PostgresDialectConfig): PgPostgresConnection {
return new PgPostgresConnection(config);
}

View File

@@ -0,0 +1,89 @@
import {
Connection,
type DbFunctions,
type FieldSpec,
type SchemaResponse,
type ConnQuery,
type ConnQueryResults,
} from "../Connection";
import {
ParseJSONResultsPlugin,
type ColumnDataType,
type ColumnDefinitionBuilder,
type Kysely,
type KyselyPlugin,
type SelectQueryBuilder,
} from "kysely";
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/postgres";
export type QB = SelectQueryBuilder<any, any, any>;
export const plugins = [new ParseJSONResultsPlugin()];
export abstract class PostgresConnection<Client = unknown> extends Connection<Client> {
protected override readonly supported = {
batching: true,
softscans: true,
};
constructor(kysely: Kysely<any>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
super(
kysely,
fn ?? {
jsonArrayFrom,
jsonBuildObject,
jsonObjectFrom,
},
_plugins ?? plugins,
);
}
override getFieldSchema(spec: FieldSpec): SchemaResponse {
this.validateFieldSpecType(spec.type);
let type: ColumnDataType = spec.type;
if (spec.primary) {
if (spec.type === "integer") {
type = "serial";
}
}
switch (spec.type) {
case "blob":
type = "bytea";
break;
case "date":
case "datetime":
// https://www.postgresql.org/docs/17/datatype-datetime.html
type = "timestamp";
break;
case "text":
// https://www.postgresql.org/docs/17/datatype-character.html
type = "varchar";
break;
}
return [
spec.name,
type,
(col: ColumnDefinitionBuilder) => {
if (spec.primary) {
return col.primaryKey().notNull();
}
if (spec.references) {
return col
.references(spec.references)
.onDelete(spec.onDelete ?? "set null")
.onUpdate(spec.onUpdate ?? "no action");
}
return col;
},
];
}
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.kysely.transaction().execute(async (trx) => {
return Promise.all(qbs.map((q) => trx.executeQuery(q)));
}) as any;
}
}

View File

@@ -0,0 +1,128 @@
import { type SchemaMetadata, sql } from "kysely";
import { BaseIntrospector } from "../BaseIntrospector";
type PostgresSchemaSpec = {
name: string;
type: "VIEW" | "BASE TABLE";
columns: {
name: string;
type: string;
notnull: number;
dflt: string;
pk: boolean;
}[];
indices: {
name: string;
origin: string;
partial: number;
sql: string;
columns: { name: string; seqno: number }[];
}[];
};
export class PostgresIntrospector extends BaseIntrospector {
async getSchemas(): Promise<SchemaMetadata[]> {
const rawSchemas = await this.db
.selectFrom("pg_catalog.pg_namespace")
.select("nspname")
.$castTo<{ nspname: string }>()
.execute();
return rawSchemas.map((it) => ({ name: it.nspname }));
}
async getSchemaSpec() {
const query = sql`
WITH tables_and_views AS (
SELECT table_name AS name,
table_type AS type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type IN ('BASE TABLE', 'VIEW')
AND table_name NOT LIKE 'pg_%'
AND table_name NOT IN (${this.getExcludedTableNames().join(", ")})
),
columns_info AS (
SELECT table_name AS name,
json_agg(json_build_object(
'name', column_name,
'type', data_type,
'notnull', (CASE WHEN is_nullable = 'NO' THEN true ELSE false END),
'dflt', column_default,
'pk', (SELECT COUNT(*) > 0
FROM information_schema.table_constraints tc
INNER JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = c.table_name
AND tc.constraint_type = 'PRIMARY KEY'
AND kcu.column_name = c.column_name)
)) AS columns
FROM information_schema.columns c
WHERE table_schema = 'public'
GROUP BY table_name
),
indices_info AS (
SELECT
t.relname AS table_name,
json_agg(json_build_object(
'name', i.relname,
'origin', pg_get_indexdef(i.oid),
'partial', (CASE WHEN ix.indisvalid THEN false ELSE true END),
'sql', pg_get_indexdef(i.oid),
'columns', (
SELECT json_agg(json_build_object(
'name', a.attname,
'seqno', x.ordinal_position
))
FROM unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinal_position)
JOIN pg_attribute a ON a.attnum = x.attnum AND a.attrelid = t.oid
))) AS indices
FROM pg_class t
LEFT JOIN pg_index ix ON t.oid = ix.indrelid
LEFT JOIN pg_class i ON i.oid = ix.indexrelid
WHERE t.relkind IN ('r', 'v') -- r = table, v = view
AND t.relname NOT LIKE 'pg_%'
GROUP BY t.relname
)
SELECT
tv.name,
tv.type,
ci.columns,
ii.indices
FROM tables_and_views tv
LEFT JOIN columns_info ci ON tv.name = ci.name
LEFT JOIN indices_info ii ON tv.name = ii.table_name;
`;
const tables = await this.executeWithPlugins<PostgresSchemaSpec[]>(query);
return tables.map((table) => ({
name: table.name,
isView: table.type === "VIEW",
columns: table.columns.map((col) => ({
name: col.name,
dataType: col.type,
isNullable: !col.notnull,
isAutoIncrementing: col.dflt?.toLowerCase().includes("nextval") ?? false,
hasDefaultValue: col.dflt != null,
comment: undefined,
})),
indices: table.indices
// filter out db-managed primary key index
.filter((index) => index.name !== `${table.name}_pkey`)
.map((index) => ({
name: index.name,
table: table.name,
isUnique: index.sql?.match(/unique/i) != null,
columns: index.columns.map((col) => ({
name: col.name,
// seqno starts at 1
order: col.seqno - 1,
})),
})),
}));
}
}

View File

@@ -0,0 +1,31 @@
import { Kysely } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js";
export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectConfig["postgres"]> {
override name = "postgres-js";
constructor(config: PostgresJSDialectConfig) {
const kysely = new Kysely({
dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, {
excludeTables: [],
}).create(config),
plugins,
});
super(kysely);
this.client = config.postgres;
}
override async close(): Promise<void> {
await this.client.end();
}
}
export function postgresJs(
config: PostgresJSDialectConfig,
): PostgresJsConnection {
return new PostgresJsConnection(config);
}

View File

@@ -0,0 +1,46 @@
import { customIntrospector, type DbFunctions } from "../Connection";
import { Kysely, type Dialect, type KyselyPlugin } from "kysely";
import { plugins, PostgresConnection } from "./PostgresConnection";
import { PostgresIntrospector } from "./PostgresIntrospector";
export type Constructor<T> = new (...args: any[]) => T;
export type CustomPostgresConnection = {
supports?: Partial<PostgresConnection["supported"]>;
fn?: Partial<DbFunctions>;
plugins?: KyselyPlugin[];
excludeTables?: string[];
};
export function createCustomPostgresConnection<
T extends Constructor<Dialect>,
C extends ConstructorParameters<T>[0],
>(
name: string,
dialect: Constructor<Dialect>,
options?: CustomPostgresConnection,
): (config: C) => PostgresConnection {
const supported = {
batching: true,
...((options?.supports ?? {}) as any),
};
return (config: C) =>
new (class extends PostgresConnection {
override name = name;
override readonly supported = supported;
constructor(config: C) {
super(
new Kysely({
dialect: customIntrospector(dialect, PostgresIntrospector, {
excludeTables: options?.excludeTables ?? [],
}).create(config),
plugins: options?.plugins ?? plugins,
}),
options?.fn,
options?.plugins,
);
}
})(config);
}

View File

@@ -0,0 +1,5 @@
export { pg, PgPostgresConnection, type PgPostgresConnectionConfig } from "./PgPostgresConnection";
export { PostgresIntrospector } from "./PostgresIntrospector";
export { PostgresConnection, type QB, plugins } from "./PostgresConnection";
export { postgresJs, PostgresJsConnection, type PostgresJsConfig } from "./PostgresJsConnection";
export { createCustomPostgresConnection } from "./custom";

View File

@@ -13,7 +13,6 @@ import { customIntrospector } from "../Connection";
import { SqliteIntrospector } from "./SqliteIntrospector";
import type { Field } from "data/fields/Field";
// @todo: add pragmas
export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = {

View File

@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
dataType: col.type,
isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.dflt_value != null,
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null,
comment: undefined,
};
}) ?? [],

View File

@@ -103,6 +103,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.with = options.with;
}
// add explicit joins. Implicit joins are added in `where` builder
if (options.join && options.join.length > 0) {
for (const entry of options.join) {
const related = this.em.relationOf(entity.name, entry);
@@ -127,12 +128,28 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
if (field.includes(".")) {
const [alias, prop] = field.split(".") as [string, string];
if (!aliases.includes(alias)) {
// check aliases first (added joins)
if (aliases.includes(alias)) {
this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
}
// check if alias (entity) exists
if (!this.em.hasEntity(alias)) {
return true;
}
// check related fields for auto join
const related = this.em.relationOf(entity.name, alias);
if (related) {
const other = related.other(entity);
if (other.entity.getField(prop)) {
// if related field is found, add join to validated options
validated.join?.push(alias);
this.checkIndex(alias, prop, "where");
return false;
}
}
this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
return true;
}
this.checkIndex(entity.name, field, "where");

View File

@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
switch (context) {
case "submit":
return Number.parseInt(value);
return Number.parseInt(value, 10);
}
return value;

View File

@@ -28,7 +28,7 @@ export function getChangeSet(
const value = _value === "" ? null : _value;
// normalize to null if undefined
const newValue = field.getValue(value, "submit") || null;
const newValue = field.getValue(value, "submit") ?? null;
// @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) {
acc[key] = newValue;

View File

@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
withConnection(connection: Connection): EntityManager<Schema<Entities>> {
withConnection(connection: Connection): EntityManager<Schemas<Entities>> {
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
}
}

View File

@@ -1,8 +1,9 @@
import { test, describe, expect } from "bun:test";
import { test, describe, expect, beforeAll, afterAll } from "bun:test";
import * as q from "./query";
import { parse as $parse, type ParseOptions } from "bknd/utils";
import type { PrimaryFieldType } from "modules";
import type { Generated } from "kysely";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const parse = (v: unknown, o: ParseOptions = {}) =>
$parse(q.repoQuery, v, {
@@ -15,6 +16,9 @@ const decode = (input: any, output: any) => {
expect(parse(input)).toEqual(output);
};
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("server/query", () => {
test("limit & offset", () => {
//expect(() => parse({ limit: false })).toThrow();

View File

@@ -132,6 +132,8 @@ export type * from "data/entities/Entity";
export type { EntityManager } from "data/entities/EntityManager";
export type { SchemaManager } from "data/schema/SchemaManager";
export type * from "data/entities";
// data connection
export {
BaseIntrospector,
Connection,
@@ -144,9 +146,29 @@ export {
type ConnQuery,
type ConnQueryResults,
} from "data/connection";
// data sqlite
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
// data postgres
export {
pg,
PgPostgresConnection,
} from "data/connection/postgres/PgPostgresConnection";
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
export {
postgresJs,
PostgresJsConnection,
} from "data/connection/postgres/PostgresJsConnection";
export {
createCustomPostgresConnection,
type CustomPostgresConnection,
} from "data/connection/postgres/custom";
// data prototype
export {
text,
number,

View File

@@ -87,7 +87,7 @@ export async function makeModeConfig<
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
const isProd = config.isProduction ?? _isProd();
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
const syncSchemaOptions =
typeof config.syncSchema === "object"

View File

@@ -33,3 +33,5 @@ export const schemaRead = new Permission(
);
export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp");
export const info = new Permission("system.info");
export const openapi = new Permission("system.openapi");

View File

@@ -1,5 +1,3 @@
/// <reference types="@cloudflare/workers-types" />
import type { App } from "App";
import {
datetimeStringLocal,
@@ -125,7 +123,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
const hono = this.create();
if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager;
@@ -317,6 +315,11 @@ export class SystemController extends Controller {
summary: "Get the config for a module",
tags: ["system"],
}),
permission(SystemPermissions.configRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
mcpTool("system_config", {
annotations: {
readOnlyHint: true,
@@ -354,7 +357,7 @@ export class SystemController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {}));
this.registerConfigController(hono);
@@ -429,6 +432,9 @@ export class SystemController extends Controller {
hono.get(
"/permissions",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "auth" }),
}),
describeRoute({
summary: "Get the permissions",
tags: ["system"],
@@ -441,6 +447,7 @@ export class SystemController extends Controller {
hono.post(
"/build",
permission(SystemPermissions.build, {}),
describeRoute({
summary: "Build the app",
tags: ["system"],
@@ -471,6 +478,7 @@ export class SystemController extends Controller {
hono.get(
"/info",
permission(SystemPermissions.info, {}),
mcpTool("system_info"),
describeRoute({
summary: "Get the server info",
@@ -504,6 +512,7 @@ export class SystemController extends Controller {
hono.get(
"/openapi.json",
permission(SystemPermissions.openapi, {}),
openAPISpecs(this.ctx.server, {
info: {
title: "bknd API",
@@ -511,7 +520,11 @@ export class SystemController extends Controller {
},
}),
);
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
hono.get(
"/swagger",
permission(SystemPermissions.openapi, {}),
swaggerUI({ url: "/api/system/openapi.json" }),
);
return hono;
}

View File

@@ -0,0 +1,683 @@
import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test";
import { emailOTP } from "./email-otp.plugin";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("otp plugin", () => {
test("should not work if auth is not enabled", async () => {
const app = createApp({
options: {
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
});
test("should require email driver if sendEmail is true", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP()],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
{
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
}
});
test("should prevent mutations of the OTP entity", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
drivers: {
email: {
send: async () => {},
},
},
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const payload = {
email: "test@test.com",
code: "123456",
action: "login",
created_at: new Date(),
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24),
used_at: null,
};
expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow();
expect(
await app
.getApi()
.data.createOne("users_otp", payload)
.then((r) => r.ok),
).toBe(false);
});
test("should generate a token", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ showActualErrors: true })],
drivers: {
email: {
send: async (to) => {
expect(to).toBe("test@test.com");
called();
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("login");
expect(data.data.expires_at).toBeDefined();
{
const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" });
expect(data?.code).toBeDefined();
expect(data?.code?.length).toBe(6);
expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe(
true,
);
expect(data?.email).toBe("test@test.com");
}
expect(called).toHaveBeenCalled();
});
test("should login with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should register with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("register");
expect(data.data.expires_at).toBeDefined();
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should not send email if sendEmail is false", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
drivers: {
email: {
send: async () => {
called();
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
expect(called).not.toHaveBeenCalled();
});
test("should reject invalid codes", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// First send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Try to use an invalid code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: "999999" }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
});
test("should reject code reuse", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (_to, _subject, body) => {
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Use the code successfully
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
}
// Try to use the same code again
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should reject expired codes", async () => {
// Set a fixed system time
const baseTime = Date.now();
setSystemTime(new Date(baseTime));
try {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
ttl: 1, // 1 second TTL
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
const sendRes = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(sendRes.status).toBe(201);
// Get the code from the database
const { data: otpData } = await app.em
.fork()
.repo("users_otp")
.findOne({ email: "test@test.com" });
expect(otpData?.code).toBeDefined();
// Advance system time by more than 1 second to expire the code
setSystemTime(new Date(baseTime + 1100));
// Try to use the expired code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: otpData?.code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
} finally {
// Reset system time
setSystemTime();
}
});
test("should reject codes with different actions", async () => {
let loginCode = "";
let registerCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a login code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the login code
const { data: loginOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } });
loginCode = loginOtp?.code || "";
// Send a register code
await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the register code
const { data: registerOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } });
registerCode = registerOtp?.code || "";
// Try to use login code for register
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: loginCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// Try to use register code for login
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: registerCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should invalidate previous codes when sending new code", async () => {
let firstCode = "";
let secondCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const em = app.em.fork();
// Send first code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the first code
const { data: firstOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
firstCode = firstOtp?.code || "";
expect(firstCode).toBeDefined();
// Send second code (should invalidate the first)
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the second code
const { data: secondOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
secondCode = secondOtp?.code || "";
expect(secondCode).toBeDefined();
expect(secondCode).not.toBe(firstCode);
// Try to use the first code (should fail as it's been invalidated)
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: firstCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// The second code should work
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: secondCode }),
});
expect(res.status).toBe(200);
}
});
});

View File

@@ -0,0 +1,387 @@
import {
datetime,
em,
entity,
enumm,
Exception,
text,
type App,
type AppPlugin,
type DB,
type FieldSchema,
type MaybePromise,
type EntityConfig,
DatabaseEvents,
} from "bknd";
import {
invariant,
s,
jsc,
HttpStatus,
threwAsync,
randomString,
$console,
pickKeys,
} from "bknd/utils";
import { Hono } from "hono";
export type EmailOTPPluginOptions = {
/**
* Customize code generation. If not provided, a random 6-digit code will be generated.
*/
generateCode?: (user: Pick<DB["users"], "email">) => string;
/**
* The base path for the API endpoints.
* @default "/api/auth/otp"
*/
apiBasePath?: string;
/**
* The TTL for the OTP tokens in seconds.
* @default 600 (10 minutes)
*/
ttl?: number;
/**
* The name of the OTP entity.
* @default "users_otp"
*/
entity?: string;
/**
* The config for the OTP entity.
*/
entityConfig?: EntityConfig;
/**
* Customize email content. If not provided, a default email will be sent.
*/
generateEmail?: (
otp: EmailOTPFieldSchema,
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
/**
* Enable debug mode for error messages.
* @default false
*/
showActualErrors?: boolean;
/**
* Allow direct mutations (create/update) of OTP codes outside of this plugin,
* e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows.
* @default false
*/
allowExternalMutations?: boolean;
/**
* Whether to send the email with the OTP code.
* @default true
*/
sendEmail?: boolean;
};
const otpFields = {
action: enumm({
enum: ["login", "register"],
}),
code: text().required(),
email: text().required(),
created_at: datetime(),
expires_at: datetime().required(),
used_at: datetime(),
};
export type EmailOTPFieldSchema = FieldSchema<typeof otpFields>;
class OTPError extends Exception {
override name = "OTPError";
override code = HttpStatus.BAD_REQUEST;
}
export function emailOTP({
generateCode: _generateCode,
apiBasePath = "/api/auth/otp",
ttl = 600,
entity: entityName = "users_otp",
entityConfig,
generateEmail: _generateEmail,
showActualErrors = false,
allowExternalMutations = false,
sendEmail = true,
}: EmailOTPPluginOptions = {}): AppPlugin {
return (app: App) => {
return {
name: "email-otp",
schema: () =>
em(
{
[entityName]: entity(
entityName,
otpFields,
{
name: "Users OTP",
sort_dir: "desc",
primary_format: app.module.data.config.default_primary_format,
...entityConfig,
},
"generated",
),
},
({ index }, schema) => {
const otp = schema[entityName]!;
index(otp).on(["email", "expires_at", "code"]);
},
),
onBuilt: async () => {
const auth = app.module.auth;
invariant(auth && auth.enabled === true, "Auth is not enabled");
invariant(!sendEmail || app.drivers?.email, "Email driver is not registered");
const generateCode =
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
const generateEmail =
_generateEmail ??
((otp: EmailOTPFieldSchema) => ({
subject: "OTP Code",
body: `Your OTP code is: ${otp.code}`,
}));
const em = app.em.fork();
const hono = new Hono()
.post(
"/login",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
const user = await findUser(app, email);
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"login",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
user,
"login",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.post(
"/register",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
// throw if user exists
if (!(await threwAsync(findUser(app, email)))) {
throw new Exception("User already exists", HttpStatus.BAD_REQUEST);
}
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"register",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const user = await app.createUser({
email,
password: randomString(32, true),
});
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
{ email },
"register",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.onError((err) => {
if (showActualErrors || err instanceof OTPError) {
throw err;
}
throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST);
});
app.server.route(apiBasePath, hono);
if (allowExternalMutations !== true) {
registerListeners(app, entityName);
}
},
};
};
}
async function findUser(app: App, email: string) {
const user_entity = app.module.auth.config.entity_name as "users";
const { data: user } = await app.em.repo(user_entity).findOne({ email });
if (!user) {
throw new Exception("User not found", HttpStatus.BAD_REQUEST);
}
return user;
}
async function invalidateAndGenerateCode(
app: App,
opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
action: EmailOTPFieldSchema["action"],
) {
const { generateCode, ttl, entity: entityName } = opts;
const newCode = generateCode?.(user);
if (!newCode) {
throw new OTPError("Failed to generate code");
}
await invalidateAllUserCodes(app, entityName, user.email, ttl);
const { data: otpData } = await app.em
.fork()
.mutator(entityName)
.insertOne({
code: newCode,
email: user.email,
action,
created_at: new Date(),
expires_at: new Date(Date.now() + ttl * 1000),
});
$console.log("[OTP Code]", newCode);
return otpData;
}
async function sendCode(
app: App,
otpData: EmailOTPFieldSchema,
opts: Required<Pick<EmailOTPPluginOptions, "generateEmail">>,
) {
const { generateEmail } = opts;
const { subject, body } = await generateEmail(otpData);
await app.drivers?.email?.send(otpData.email, subject, body);
}
async function getValidatedCode(
app: App,
entityName: string,
email: string,
code: string,
action: EmailOTPFieldSchema["action"],
) {
invariant(email, "[OTP Plugin]: Email is required");
invariant(code, "[OTP Plugin]: Code is required");
const em = app.em.fork();
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
if (!otpData) {
throw new OTPError("Invalid code");
}
if (otpData.expires_at < new Date()) {
throw new OTPError("Code expired");
}
if (otpData.used_at) {
throw new OTPError("Code already used");
}
return otpData;
}
async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) {
invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0");
invariant(email, "[OTP Plugin]: Email is required");
const em = app.em.fork();
await em
.mutator(entityName)
.updateWhere(
{ expires_at: new Date(Date.now() - 1000) },
{ email, used_at: { $isnull: true } },
);
}
function registerListeners(app: App, entityName: string) {
[DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => {
app.emgr.onEvent(
event,
(e: { params: { entity: { name: string } } }) => {
if (e.params.entity.name === entityName) {
throw new OTPError("Mutations of the OTP entity are not allowed");
}
},
{
mode: "sync",
id: "bknd-email-otp",
},
);
});
}

View File

@@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";

View File

@@ -9,7 +9,7 @@ import {
useState,
type ReactNode,
} from "react";
import { useApi } from "ui/client";
import { useApi } from "bknd/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message";

View File

@@ -14,18 +14,20 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = {
children?: ReactNode;
baseUrl?: string;
api?: Api;
} & ApiOptions;
export const ClientProvider = ({
children,
host,
baseUrl: _baseUrl = host,
api: _api,
...props
}: ClientProviderProps) => {
const winCtx = useBkndWindowContext();
const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any = undefined;
let user: any;
if (winCtx) {
user = winCtx.user;
@@ -40,6 +42,7 @@ export const ClientProvider = ({
const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo(
() =>
_api ??
new Api({
...apiProps,
verbose: isDebug(),
@@ -50,7 +53,7 @@ export const ClientProvider = ({
}
},
}),
[JSON.stringify(apiProps)],
[_api, JSON.stringify(apiProps)],
);
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
@@ -64,6 +67,10 @@ export const ClientProvider = ({
export const useApi = (host?: ApiOptions["host"]): Api => {
const context = useContext(ClientContext);
if (!context) {
throw new Error("useApi must be used within a ClientProvider");
}
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
return new Api({ host: host ?? "" });
}

View File

@@ -2,7 +2,7 @@ import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite";
import { useApi } from "ui/client";
import { useApi } from "../ClientProvider";
import { useState } from "react";
export const useApiQuery = <

View File

@@ -10,7 +10,7 @@ import type {
import { objectTransform, encodeSearch } from "bknd/utils";
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "ui/client";
import { type Api, useApi } from "bknd/client";
export class UseEntityApiError<Payload = any> extends Error {
constructor(

View File

@@ -4,6 +4,7 @@ export {
type ClientProviderProps,
useApi,
useBaseUrl,
useClientContext
} from "./ClientProvider";
export * from "./api/use-api";

View File

@@ -1,7 +1,6 @@
import type { AuthState } from "Api";
import type { AuthResponse } from "bknd";
import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
import { useApi, useInvalidate, useClientContext } from "bknd/client";
type LoginData = {
email: string;

View File

@@ -1,9 +1,11 @@
import type { AppAuthSchema } from "auth/auth-schema";
import { useEffect, useState } from "react";
import { useApi } from "ui/client";
import { useApi } from "bknd/client";
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
export const useAuthStrategies = (options?: {
baseUrl?: string;
}): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();

View File

@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
import { type FileWithPath, useDropzone } from "./use-dropzone";
import { checkMaxReached } from "./helper";
import { DropzoneInner } from "./DropzoneInner";
import { createDropzoneStore } from "ui/elements/media/dropzone-state";
import { createDropzoneStore } from "./dropzone-state";
import { useStore } from "zustand";
export type FileState = {

View File

@@ -1,9 +1,8 @@
import type { Api } from "bknd/client";
import type { PrimaryFieldType, RepoQueryIn } from "bknd";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId, useEffect, useRef, useState } from "react";
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper";
@@ -132,26 +131,24 @@ export function DropzoneContainer({
}
return (
<>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
</>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
);
}

View File

@@ -19,8 +19,8 @@ import {
} from "react-icons/tb";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { IconButton } from "ui/components/buttons/IconButton";
import { formatNumber } from "core/utils";
import type { DropzoneRenderProps, FileState } from "ui/elements";
import { formatNumber } from "bknd/utils";
import type { DropzoneRenderProps, FileState } from "./Dropzone";
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
function handleUploadError(e: unknown) {

View File

@@ -10,7 +10,7 @@ import {
TbUser,
TbX,
} from "react-icons/tb";
import { useAuth, useBkndWindowContext } from "ui/client";
import { useAuth, useBkndWindowContext } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button";

View File

@@ -1,6 +1,6 @@
import type { ContextModalProps } from "@mantine/modals";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useEntityQuery } from "ui/client";
import { useEntityQuery } from "bknd/client";
import { type FileState, Media } from "ui/elements";
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
import { twMerge } from "tailwind-merge";

View File

@@ -1,4 +1,4 @@
import { useApi, useInvalidate } from "ui/client";
import { useApi, useInvalidate } from "bknd/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals";

View File

@@ -4,7 +4,7 @@ import type { EntityData } from "bknd";
import type { RelationField } from "data/relations";
import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb";
import { useEntityQuery } from "ui/client";
import { useEntityQuery } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";

View File

@@ -1,6 +1,6 @@
import clsx from "clsx";
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useApiQuery } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";

View File

@@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core";
import { Popover } from "ui/components/overlay/Popover";
import { cn } from "ui/lib/utils";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { mountOnce, useApiQuery } from "ui/client";
import { mountOnce, useApiQuery } from "bknd/client";
import { CodePreview } from "ui/components/code/CodePreview";
import type { JsonError } from "json-schema-library";
import { Alert } from "ui/components/display/Alert";

View File

@@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils";
import type { Entity, EntityData, EntityRelation } from "bknd";
import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery, useEntityQuery } from "ui/client";
import { useApiQuery, useEntityQuery } from "bknd/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";

View File

@@ -1,6 +1,6 @@
import type { EntityData } from "bknd";
import { useState } from "react";
import { useEntityMutate } from "ui/client";
import { useEntityMutate } from "bknd/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { Message } from "ui/components/display/Message";

View File

@@ -2,7 +2,7 @@ import type { Entity } from "bknd";
import { repoQuery } from "data/server/query";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useApiQuery } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";

View File

@@ -5,12 +5,12 @@ import {
ucFirstAllSnakeToPascalWithSpaces,
s,
stringIdentifier,
pickKeys,
} from "bknd/utils";
import {
type TAppDataEntityFields,
fieldsSchemaObject as originalFieldsSchemaObject,
} from "data/data-schema";
import { omit } from "lodash-es";
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
@@ -317,7 +317,6 @@ function EntityField({
const name = watch(`fields.${index}.name`);
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
const specificData = omit(field.field.config, commonProps);
const disabled = fieldSpec.disabled || [];
const hidden = fieldSpec.hidden || [];
const dragDisabled = index === 0;
@@ -476,7 +475,7 @@ function EntityField({
field={field}
onChange={(value) => {
setValue(`${prefix}.config`, {
...getValues([`fields.${index}.config`])[0],
...pickKeys(getValues([`${prefix}.config`])[0], commonProps),
...value,
});
}}
@@ -520,7 +519,7 @@ const SpecificForm = ({
readonly?: boolean;
}) => {
const type = field.field.type;
const specificData = omit(field.field.config, commonProps);
const specificData = omitKeys(field.field.config ?? {}, commonProps);
return (
<JsonSchemaForm

View File

@@ -11,7 +11,7 @@ import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals";
import { useBkndWindowContext } from "ui/client";
import { useBkndWindowContext } from "bknd/client";
import ToolsRoutes from "./tools";
// @ts-ignore

View File

@@ -1,11 +1,12 @@
import { IconPhoto } from "@tabler/icons-react";
import { useBknd } from "ui/client/BkndProvider";
import { Empty } from "ui/components/display/Empty";
import { type FileState, Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
import { bkndModals } from "ui/modals";
import { DropzoneContainer } from "ui/elements/media/DropzoneContainer";
import type { FileState } from "ui/elements/media/Dropzone";
export function MediaIndex() {
const { config } = useBknd();
@@ -35,7 +36,7 @@ export function MediaIndex() {
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
<DropzoneContainer onClick={onClick} infinite query={{ sort: "-id" }} />
</div>
</AppShell.Scrollable>
);

View File

@@ -1,6 +1,6 @@
import { IconHome } from "@tabler/icons-react";
import { useEffect } from "react";
import { useAuth } from "ui/client";
import { useAuth } from "bknd/client";
import { useEffectOnce } from "ui/hooks/use-effect";
import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title";

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useApi, useApiQuery } from "ui/client";
import { useApi, useApiQuery } from "bknd/client";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function Bla() {