mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f18072fe8e | |||
| 821525fba5 |
@@ -1,4 +1,3 @@
|
||||
import type { MaybePromise } from "bknd";
|
||||
import type { Event } from "./Event";
|
||||
import type { EventClass } from "./EventManager";
|
||||
|
||||
@@ -8,7 +7,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> ? MaybePromise<R | void> : never;
|
||||
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
|
||||
|
||||
export class EventListener<E extends Event = Event> {
|
||||
mode: ListenerMode = "async";
|
||||
|
||||
@@ -77,19 +77,3 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
|
||||
|
||||
switch (context) {
|
||||
case "submit":
|
||||
return Number.parseInt(value, 10);
|
||||
return Number.parseInt(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function makeModeConfig<
|
||||
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
|
||||
|
||||
const isProd = config.isProduction ?? _isProd();
|
||||
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
|
||||
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
|
||||
const syncSchemaOptions =
|
||||
typeof config.syncSchema === "object"
|
||||
|
||||
@@ -125,7 +125,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();
|
||||
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
|
||||
|
||||
if (!this.app.isReadOnly()) {
|
||||
const manager = this.app.modules as DbModuleManager;
|
||||
@@ -317,11 +317,6 @@ 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,
|
||||
|
||||
@@ -1,683 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,387 +0,0 @@
|
||||
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",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,858 +0,0 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { sort } from "./sort.plugin";
|
||||
import { em, entity, text, number } from "bknd";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("sort plugin", () => {
|
||||
test("should add sort field to configured entities", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const taskEntity = app.em.entity("tasks");
|
||||
expect(taskEntity).toBeDefined();
|
||||
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||
expect(taskEntity?.field("position")?.type).toBe("number");
|
||||
});
|
||||
|
||||
test("should auto-assign sort values on insert (starting from 0)", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// insert first item
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
expect(task1.position).toBe(0);
|
||||
|
||||
// insert second item
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
expect(task2.position).toBe(1);
|
||||
|
||||
// insert third item
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
expect(task3.position).toBe(2);
|
||||
});
|
||||
|
||||
test("should preserve manually set sort values on insert", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// insert with explicit position
|
||||
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
|
||||
expect(task.position).toBe(10);
|
||||
});
|
||||
|
||||
test("should reorder items via API endpoint", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
|
||||
// move task3 (position 2) to position 0
|
||||
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: task3.id, position: 0 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
|
||||
expect(updatedTask3.position).toBe(0); // moved to position 0
|
||||
expect(updatedTask1.position).toBe(1); // shifted down
|
||||
expect(updatedTask2.position).toBe(2); // shifted down
|
||||
});
|
||||
|
||||
test("should automatically reorder when updating sort field directly", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2, 3
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||
|
||||
// move task4 (position 3) to position 1 by updating directly
|
||||
await mutator.updateOne(task4.id, { position: 1 });
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(0); // unchanged
|
||||
expect(updatedTask2.position).toBe(2); // shifted down
|
||||
expect(updatedTask3.position).toBe(3); // shifted down
|
||||
expect(updatedTask4.position).toBe(1); // moved to position 1
|
||||
});
|
||||
|
||||
test("should automatically reorder when moving items up", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2, 3
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||
|
||||
// move task1 (position 0) to position 2 by updating directly
|
||||
await mutator.updateOne(task1.id, { position: 2 });
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(2); // moved to position 2
|
||||
expect(updatedTask2.position).toBe(0); // shifted up
|
||||
expect(updatedTask3.position).toBe(1); // shifted up
|
||||
expect(updatedTask4.position).toBe(3); // unchanged
|
||||
});
|
||||
|
||||
test("should support scoped sorting", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
project_id: number(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
scope: "project_id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks in project 1
|
||||
const { data: p1t1 } = await mutator.insertOne({
|
||||
title: "P1 Task 1",
|
||||
project_id: 1,
|
||||
});
|
||||
const { data: p1t2 } = await mutator.insertOne({
|
||||
title: "P1 Task 2",
|
||||
project_id: 1,
|
||||
});
|
||||
|
||||
// create tasks in project 2
|
||||
const { data: p2t1 } = await mutator.insertOne({
|
||||
title: "P2 Task 1",
|
||||
project_id: 2,
|
||||
});
|
||||
const { data: p2t2 } = await mutator.insertOne({
|
||||
title: "P2 Task 2",
|
||||
project_id: 2,
|
||||
});
|
||||
|
||||
// positions should be scoped per project
|
||||
expect(p1t1.position).toBe(0);
|
||||
expect(p1t2.position).toBe(1);
|
||||
expect(p2t1.position).toBe(0); // resets for new scope
|
||||
expect(p2t2.position).toBe(1);
|
||||
});
|
||||
|
||||
test("should reorder only within scope", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
project_id: number(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
scope: "project_id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks in project 1
|
||||
const { data: p1t1 } = await mutator.insertOne({
|
||||
title: "P1 Task 1",
|
||||
project_id: 1,
|
||||
});
|
||||
const { data: p1t2 } = await mutator.insertOne({
|
||||
title: "P1 Task 2",
|
||||
project_id: 1,
|
||||
});
|
||||
const { data: p1t3 } = await mutator.insertOne({
|
||||
title: "P1 Task 3",
|
||||
project_id: 1,
|
||||
});
|
||||
|
||||
// create tasks in project 2
|
||||
const { data: p2t1 } = await mutator.insertOne({
|
||||
title: "P2 Task 1",
|
||||
project_id: 2,
|
||||
});
|
||||
const { data: p2t2 } = await mutator.insertOne({
|
||||
title: "P2 Task 2",
|
||||
project_id: 2,
|
||||
});
|
||||
|
||||
// move p1t3 to position 0 (should only affect project 1)
|
||||
await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: p1t3.id, position: 0 }),
|
||||
});
|
||||
|
||||
// verify project 1 tasks
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
|
||||
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
|
||||
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
|
||||
|
||||
expect(updatedP1t3.position).toBe(0); // moved to position 0
|
||||
expect(updatedP1t1.position).toBe(1); // shifted
|
||||
expect(updatedP1t2.position).toBe(2); // shifted
|
||||
|
||||
// verify project 2 tasks are unchanged
|
||||
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
|
||||
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
|
||||
|
||||
expect(updatedP2t1.position).toBe(0); // unchanged
|
||||
expect(updatedP2t2.position).toBe(1); // unchanged
|
||||
});
|
||||
|
||||
test("should recalculate all positions", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks with irregular positions
|
||||
await mutator.insertOne({ title: "Task 1", position: 5 });
|
||||
await mutator.insertOne({ title: "Task 2", position: 10 });
|
||||
await mutator.insertOne({ title: "Task 3", position: 15 });
|
||||
await mutator.insertOne({ title: "Task 4", position: 100 });
|
||||
|
||||
// recalculate
|
||||
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// verify positions are now 0, 1, 2, 3
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
orderBy: [{ position: "asc" }],
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(4);
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].position).toBe(1);
|
||||
expect(tasks[2].position).toBe(2);
|
||||
expect(tasks[3].position).toBe(3);
|
||||
});
|
||||
|
||||
test("should recalculate positions within scope only", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
project_id: number(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
scope: "project_id",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks in project 1 with irregular positions
|
||||
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
|
||||
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
|
||||
|
||||
// create tasks in project 2 with irregular positions
|
||||
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
|
||||
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
|
||||
|
||||
// recalculate only project 1
|
||||
const res = await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ scope: 1 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// verify project 1 tasks are recalculated
|
||||
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
|
||||
where: { project_id: 1 },
|
||||
orderBy: [{ position: "asc" }],
|
||||
});
|
||||
|
||||
expect(p1Tasks.length).toBe(2);
|
||||
expect(p1Tasks[0].position).toBe(0);
|
||||
expect(p1Tasks[1].position).toBe(1);
|
||||
|
||||
// verify project 2 tasks are unchanged
|
||||
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
|
||||
where: { project_id: 2 },
|
||||
orderBy: [{ position: "asc" }],
|
||||
});
|
||||
|
||||
expect(p2Tasks.length).toBe(2);
|
||||
expect(p2Tasks[0].position).toBe(10); // unchanged
|
||||
expect(p2Tasks[1].position).toBe(20); // unchanged
|
||||
});
|
||||
|
||||
test("should handle moving items to the end", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks at positions 0, 1, 2, 3
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
|
||||
|
||||
// move task1 to the end (position 3)
|
||||
await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: task1.id, position: 3 }),
|
||||
});
|
||||
|
||||
// verify positions
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(3); // moved to end
|
||||
expect(updatedTask2.position).toBe(0); // shifted up
|
||||
expect(updatedTask3.position).toBe(1); // shifted up
|
||||
expect(updatedTask4.position).toBe(2); // shifted up
|
||||
});
|
||||
|
||||
test("should return 400 for invalid item id", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const res = await app.server.request("/api/sort/tasks/reorder", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ id: 999999, position: 0 }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("should handle multiple entities with different configurations", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
categories: entity("categories", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
categories: {
|
||||
field: "order",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
// verify both entities have their sort fields
|
||||
const taskEntity = app.em.entity("tasks");
|
||||
const categoryEntity = app.em.entity("categories");
|
||||
|
||||
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
|
||||
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
|
||||
|
||||
// create items in both entities
|
||||
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
|
||||
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
|
||||
|
||||
expect(task.position).toBe(0);
|
||||
expect(category.order).toBe(0);
|
||||
|
||||
// verify both endpoints exist
|
||||
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const catRes = await app.server.request("/api/sort/categories/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
expect(taskRes.status).toBe(200);
|
||||
expect(catRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should not trigger reorder when updating other fields", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
|
||||
|
||||
// update title only (should not trigger reordering)
|
||||
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
|
||||
|
||||
// verify positions are unchanged
|
||||
const repo = app.em.repo("tasks");
|
||||
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
|
||||
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
|
||||
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
|
||||
|
||||
expect(updatedTask1.position).toBe(0);
|
||||
expect(updatedTask2.position).toBe(1);
|
||||
expect(updatedTask2.title).toBe("Task 2 Updated");
|
||||
expect(updatedTask3.position).toBe(2);
|
||||
});
|
||||
|
||||
test("should handle null sort values", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create task with null position (bypass listener by using kysely directly)
|
||||
await app.connection.kysely
|
||||
.insertInto("tasks")
|
||||
.values({ title: "Task with null", position: null })
|
||||
.execute();
|
||||
|
||||
// create normal task
|
||||
await mutator.insertOne({ title: "Task 2" });
|
||||
|
||||
// update the null task to have a position
|
||||
const nullTask = await app.connection.kysely
|
||||
.selectFrom("tasks")
|
||||
.selectAll()
|
||||
.where("title", "=", "Task with null")
|
||||
.executeTakeFirst();
|
||||
|
||||
await mutator.updateOne(nullTask!.id, { position: 0 });
|
||||
|
||||
// verify both tasks have proper positions
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
sort: { by: "position", dir: "asc" },
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(2);
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].position).toBe(1);
|
||||
});
|
||||
|
||||
test("should not create duplicates when moving to an occupied position", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create two tasks at positions 0 and 1
|
||||
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
|
||||
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
|
||||
|
||||
expect(task1.position).toBe(0);
|
||||
expect(task2.position).toBe(1);
|
||||
|
||||
// move task2 (at position 1) to position 0
|
||||
await mutator.updateOne(task2.id, { position: 0 });
|
||||
|
||||
// verify no duplicates
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
sort: { by: "position", dir: "asc" },
|
||||
});
|
||||
|
||||
expect(tasks.length).toBe(2);
|
||||
expect(tasks[0].id).toBe(task2.id);
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].id).toBe(task1.id);
|
||||
expect(tasks[1].position).toBe(1);
|
||||
|
||||
// verify no tasks have the same position
|
||||
const positions = tasks.map((t) => t.position);
|
||||
const uniquePositions = new Set(positions);
|
||||
expect(uniquePositions.size).toBe(positions.length);
|
||||
});
|
||||
|
||||
test("should preserve order when recalculating", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
data: em({
|
||||
tasks: entity("tasks", {
|
||||
title: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
plugins: [
|
||||
sort({
|
||||
entities: {
|
||||
tasks: {
|
||||
field: "position",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const mutator = app.em.mutator("tasks");
|
||||
|
||||
// create tasks with specific positions
|
||||
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
|
||||
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
|
||||
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
|
||||
|
||||
// recalculate
|
||||
await app.server.request("/api/sort/tasks/recalculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// verify order is preserved (B, A, C based on original positions)
|
||||
const { data: tasks } = await app.em.repo("tasks").findMany({
|
||||
sort: { by: "position", dir: "asc" },
|
||||
});
|
||||
|
||||
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
|
||||
expect(tasks[0].position).toBe(0);
|
||||
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
|
||||
expect(tasks[1].position).toBe(1);
|
||||
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
|
||||
expect(tasks[2].position).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,423 +0,0 @@
|
||||
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
|
||||
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
|
||||
import { Hono } from "hono";
|
||||
import { sql } from "kysely";
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
export type SortPluginOptions = {
|
||||
/**
|
||||
* The base path for the API endpoints.
|
||||
* @default "/api/sort"
|
||||
*/
|
||||
apiBasePath?: string;
|
||||
|
||||
/**
|
||||
* Configuration for entities that should have sorting enabled.
|
||||
* Key is the entity name, value is the configuration.
|
||||
*/
|
||||
entities: Record<
|
||||
string,
|
||||
{
|
||||
/**
|
||||
* The name of the sort property (must be a number field).
|
||||
*/
|
||||
field: string;
|
||||
|
||||
/**
|
||||
* Optional scope field name. If provided, sorting will only happen within the same scope.
|
||||
* For example, if scope is "category_id", items will only be sorted within items
|
||||
* that have the same category_id value.
|
||||
*/
|
||||
scope?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* The batch size for recalculating sort order.
|
||||
* @default 1000
|
||||
*/
|
||||
recalculateBatchSize?: number;
|
||||
};
|
||||
|
||||
class SortError extends Exception {
|
||||
override name = "SortError";
|
||||
override code = HttpStatus.BAD_REQUEST;
|
||||
}
|
||||
|
||||
export function sort({
|
||||
apiBasePath = "/api/sort",
|
||||
entities,
|
||||
recalculateBatchSize = DEFAULT_BATCH_SIZE,
|
||||
}: SortPluginOptions): AppPlugin {
|
||||
return (app: App) => {
|
||||
return {
|
||||
name: "sort",
|
||||
schema: () => {
|
||||
return em(
|
||||
Object.fromEntries(
|
||||
Object.entries(entities).map(([entityName, config]) => [
|
||||
entityName,
|
||||
entity(entityName, {
|
||||
[config.field]: number({ default_value: 0 }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
({ index }, schema) => {
|
||||
for (const [entityName, config] of Object.entries(entities)) {
|
||||
const indexed = app.em.getIndexedFields(entityName);
|
||||
if (!indexed.some((f) => f.name === config.field)) {
|
||||
index(schema[entityName]!).on([config.field]);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
onBuilt: async () => {
|
||||
invariant(
|
||||
entities && Object.keys(entities).length > 0,
|
||||
"At least one entity must be configured",
|
||||
);
|
||||
|
||||
// validate entities exist and have the configured fields
|
||||
for (const [entityName, config] of Object.entries(entities)) {
|
||||
const entity = app.em.entity(entityName);
|
||||
invariant(entity, `Entity "${entityName}" not found in schema`);
|
||||
|
||||
const sortFieldSchema = entity.field(config.field)!;
|
||||
invariant(
|
||||
sortFieldSchema,
|
||||
`Sort field "${config.field}" not found in entity "${entityName}"`,
|
||||
);
|
||||
invariant(
|
||||
sortFieldSchema.type === "number",
|
||||
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
|
||||
);
|
||||
|
||||
if (config.scope) {
|
||||
const scopeFieldSchema = entity.field(config.scope);
|
||||
invariant(
|
||||
scopeFieldSchema,
|
||||
`Scope field "${config.scope}" not found in entity "${entityName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hono = new Hono();
|
||||
|
||||
// register recalculate endpoints for each entity
|
||||
for (const [entityName, config] of Object.entries(entities)) {
|
||||
hono.post(
|
||||
`/${entityName}/recalculate`,
|
||||
jsc(
|
||||
"json",
|
||||
s
|
||||
.object({
|
||||
scope: s.any().optional(),
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const scope = body?.scope;
|
||||
|
||||
await recalculateSortOrder(
|
||||
app,
|
||||
entityName,
|
||||
config,
|
||||
recalculateBatchSize,
|
||||
scope,
|
||||
);
|
||||
|
||||
return c.json({ success: true, message: "Sort order recalculated" });
|
||||
},
|
||||
);
|
||||
|
||||
hono.post(
|
||||
`/${entityName}/reorder`,
|
||||
jsc(
|
||||
"json",
|
||||
s.object({
|
||||
id: s.any(),
|
||||
position: s.number(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id, position } = c.req.valid("json");
|
||||
|
||||
await reorderItem(app, entityName, config, id, position);
|
||||
|
||||
return c.json({ success: true, message: "Item reordered" });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.server.route(apiBasePath, hono);
|
||||
|
||||
// register listeners for automatic reordering
|
||||
registerListeners(app, entities);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function reorderItem(
|
||||
app: App,
|
||||
entityName: string,
|
||||
config: SortPluginOptions["entities"][string],
|
||||
id: any,
|
||||
newPosition: number,
|
||||
) {
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const em = app.em.fork();
|
||||
const kysely = em.connection.kysely;
|
||||
|
||||
// get the item
|
||||
const { data: item } = await em.repo(entityName).findOne({ id });
|
||||
if (!item) {
|
||||
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
|
||||
}
|
||||
|
||||
const oldPosition = item[sortField] as number | null | undefined;
|
||||
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||
|
||||
// update the item with new position (using forked em, so no listeners are triggered)
|
||||
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
|
||||
|
||||
// shift other items using kysely
|
||||
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
|
||||
if (newPosition < oldPosition) {
|
||||
// moving up: increment items between newPosition and oldPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where(sortField as any, "<", oldPosition)
|
||||
.where("id", "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
} else {
|
||||
// moving down: decrement items between oldPosition and newPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||
.where(sortField as any, ">", oldPosition)
|
||||
.where(sortField as any, "<=", newPosition)
|
||||
.where("id", "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
} else if (oldPosition === undefined || oldPosition === null) {
|
||||
// new item, shift everything at or after this position
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where("id", "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async function recalculateSortOrder(
|
||||
app: App,
|
||||
entityName: string,
|
||||
config: SortPluginOptions["entities"][string],
|
||||
batchSize: number,
|
||||
scope?: any,
|
||||
) {
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const db = app.connection.kysely;
|
||||
|
||||
const { count } = (await db
|
||||
.selectFrom(entityName)
|
||||
.select((eb) => eb.fn.count<number>("id").as("count"))
|
||||
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||
eb.where(scopeField as any, "=", scope),
|
||||
)
|
||||
.$castTo<{ count: number }>()
|
||||
.executeTakeFirst()) ?? { count: 0 };
|
||||
|
||||
const batches = Math.ceil(count / batchSize);
|
||||
for (let i = 0; i < batches; i++) {
|
||||
// get all items in scope, ordered by current sort value
|
||||
const items = await db
|
||||
.selectFrom(entityName)
|
||||
.select(["id", sortField])
|
||||
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
|
||||
eb.where(scopeField as any, "=", scope),
|
||||
)
|
||||
.orderBy(sortField, "asc")
|
||||
.limit(batchSize)
|
||||
.offset(i * batchSize)
|
||||
.execute();
|
||||
|
||||
const newQbs = items.map((item, index) =>
|
||||
db
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: index })
|
||||
.where("id", "=", item.id),
|
||||
);
|
||||
|
||||
await app.connection.executeQueries(...newQbs);
|
||||
$console.log(
|
||||
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
|
||||
const kysely = app.connection.kysely;
|
||||
|
||||
// handle insert events
|
||||
app.emgr.onEvent(
|
||||
DatabaseEvents.MutatorInsertBefore,
|
||||
async (e) => {
|
||||
const entityName = e.params.entity.name;
|
||||
const config = entities[entityName];
|
||||
if (!config) return e.params.data;
|
||||
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const data = e.params.data;
|
||||
const scopeValue = scopeField ? data[scopeField] : undefined;
|
||||
|
||||
// if no position provided, set to max + 1
|
||||
if (data[sortField] === undefined || data[sortField] === null) {
|
||||
const query = kysely
|
||||
.selectFrom(entityName)
|
||||
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
|
||||
// add scope filter if needed
|
||||
.$if(Boolean(scopeField && scopeValue), (eb) =>
|
||||
eb.where(scopeField as any, "=", scopeValue as any),
|
||||
);
|
||||
|
||||
const result = await query.executeTakeFirst();
|
||||
const max = result?.max ?? -1;
|
||||
|
||||
return {
|
||||
...data,
|
||||
[sortField]: max + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// if position is provided, shift other items at or after that position
|
||||
const newPosition = data[sortField] as number;
|
||||
|
||||
let shiftQuery = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await shiftQuery.execute();
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-sort-insert",
|
||||
},
|
||||
);
|
||||
|
||||
// handle update events
|
||||
app.emgr.onEvent(
|
||||
DatabaseEvents.MutatorUpdateBefore,
|
||||
async (e) => {
|
||||
const entityName = e.params.entity.name;
|
||||
const config = entities[entityName];
|
||||
if (!config) return e.params.data;
|
||||
|
||||
const { field: sortField, scope: scopeField } = config;
|
||||
const data = e.params.data;
|
||||
|
||||
// only handle if sort field is being updated
|
||||
if (!(sortField in data)) return e.params.data;
|
||||
|
||||
const newPosition = data[sortField] as number;
|
||||
const id = e.params.entityId;
|
||||
|
||||
// get the current item to know its old position and scope
|
||||
const item = await kysely
|
||||
.selectFrom(entityName)
|
||||
.selectAll()
|
||||
.where("id" as any, "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!item) return data;
|
||||
|
||||
const oldPosition = item[sortField] as number | null | undefined;
|
||||
const scopeValue = scopeField ? item[scopeField] : undefined;
|
||||
|
||||
// if oldPosition is null or undefined, treat as inserting at newPosition
|
||||
if (oldPosition === null || oldPosition === undefined) {
|
||||
// shift items at or after the new position
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where("id" as any, "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
return data;
|
||||
}
|
||||
|
||||
// shift other items using kysely
|
||||
if (oldPosition !== newPosition) {
|
||||
if (newPosition < oldPosition) {
|
||||
// moving up: increment items between newPosition and oldPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
|
||||
.where(sortField as any, ">=", newPosition)
|
||||
.where(sortField as any, "<", oldPosition)
|
||||
.where("id" as any, "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
} else {
|
||||
// moving down: decrement items between oldPosition and newPosition
|
||||
let query = kysely
|
||||
.updateTable(entityName)
|
||||
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
|
||||
.where(sortField as any, ">", oldPosition)
|
||||
.where(sortField as any, "<=", newPosition)
|
||||
.where("id" as any, "!=", id);
|
||||
|
||||
if (scopeField && scopeValue !== undefined) {
|
||||
query = query.where(scopeField as any, "=", scopeValue);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
mode: "sync",
|
||||
id: "bknd-sort-update",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,5 +8,3 @@ 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";
|
||||
export { sort, type SortPluginOptions } from "./data/sort.plugin";
|
||||
|
||||
+3
-3
@@ -18,11 +18,11 @@
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"indentWidth": 3
|
||||
},
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"formatter": {
|
||||
"indentWidth": 3
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@tsconfig/strictest": "^2.0.7",
|
||||
"@types/bun": "^1.3.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20251122.1",
|
||||
"miniflare": "^3.20250718.2",
|
||||
"typescript": "^5.9.3",
|
||||
"verdaccio": "^6.2.1",
|
||||
@@ -1353,6 +1354,22 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@2.34.0", "", { "dependencies": { "debug": "^4.1.1", "eslint-visitor-keys": "^1.1.0", "glob": "^7.1.6", "is-glob": "^4.0.1", "lodash": "^4.17.15", "semver": "^7.3.2", "tsutils": "^3.17.1" } }, "sha512-OMAr+nJWKdlVM9LOqCqh3pQQPwxHAN7Du8DR6dmwCrAmxtiXQnhHJ6tBNtf+cggqfo51SG/FCwnKhXCIM7hnVg=="],
|
||||
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251122.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251122.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251122.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251122.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251122.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251122.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251122.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251122.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-5JofzSZ1T6WnmRCUMtteMAyAaIcvQMNKlJfSkleJTHuCSUn0pxGUG64CMt0KNVVtuOgCIPscqg4tfr90PuH+Ww=="],
|
||||
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251122.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RReunypvb+gvDYLlR63+LeCVf4u+njjeDzTjkUscEdloZ3BVbrMnXfxp64fj8lXt8vzGjhCvwd9P3+rXjOLZeg=="],
|
||||
|
||||
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20251122.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+IPttsQAp0gxbMnuL/mvU6QkjP9tSCbaSEwXXBwIGh1/SZTuikEmHSq4ZJVr3Q3jJ0kj50WIywwpWVFh6T2QLw=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20251122.1", "", { "os": "linux", "cpu": "arm" }, "sha512-gLfCZmSFpg+32/JbpZNfMbd7SBCDzXc/h0I/2lqvAQD+C090kIDV/xJKBQIa/SM1XeAurMQgcyxNWuAh++A8Yw=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20251122.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uSS0gs3o/hzSTqG5czhyEUSmfq+C5xhejkePW3cb7w2OWBK2saPZWhSeqfnJkgMhKrLd87d76LqRG9+BXqpSSQ=="],
|
||||
|
||||
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20251122.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MyAd9qcFvaSC2I4GGYfwbpdn1PYoQqeWXfpBkUejnkAws0aFRBITCfCwfIBWd9lcE7pHgYa+9vB0UeJegXGPng=="],
|
||||
|
||||
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20251122.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-0eXxif0zA36nymY2C7YWGdJKxj+FK4u8f0pblX++6vRTmO41UDrHERvd549ur4E17m3w1j6YiuFG0/kOQeZLpQ=="],
|
||||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251122.1", "", { "os": "win32", "cpu": "x64" }, "sha512-2AnG7WhjZ5Jv3mu+8cNR4JRe3mJg8ODOBnejbA1uGYJ00EDYFfmX87s9Q9eUywEmsEGabraQfnH7+YTyCBiGsQ=="],
|
||||
|
||||
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-s2fbpdXrSMWEc86moll/d007ZFhu6jzwNu5cWv/2o7egymvLeZO52LWkewgbr+BUCGWGPsoJVWeaejbsb/hLcw=="],
|
||||
|
||||
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.2", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-XP3R1xyE0CP6Q0iR0xf3ed+cJzJnfmbLelgJR6osVVtMStGGZP3pGQjjwDRYptmjGHfEELUyyBLdY25h0BQg7w=="],
|
||||
|
||||
@@ -261,77 +261,3 @@ export default {
|
||||
```
|
||||
|
||||
|
||||
### `emailOTP`
|
||||
|
||||
<Callout type="warning">
|
||||
Make sure to setup proper permissions to restrict reading from the OTP entity. Also, this plugin requires the `email` driver to be registered.
|
||||
</Callout>
|
||||
|
||||
|
||||
A plugin that adds email OTP functionality to your app. It will add two endpoints to your app:
|
||||
- `POST /api/auth/otp/login` to login a user with an OTP code
|
||||
- `POST /api/auth/otp/register` to register a user with an OTP code
|
||||
|
||||
Both endpoints accept a JSON body with `email` (required) and `code` (optional). If `code` is provided, the OTP code will be validated and the user will be logged in or registered. If `code` is not provided, a new OTP code will be generated and sent to the user's email.
|
||||
|
||||
For example, to login an existing user with an OTP code, two requests are needed. The first one only with the email to generate and send the OTP code, and the second to send the users' email along with the OTP code. The last request will authenticate the user.
|
||||
|
||||
```http title="Generate OTP code to login"
|
||||
POST /api/auth/otp/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "test@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
If the user exists, an email will be sent with the OTP code, and the response will be a `201 Created`.
|
||||
|
||||
```http title="Login with OTP code"
|
||||
POST /api/auth/otp/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
If the code is valid, the user will be authenticated by sending a `Set-Cookie` header and a body property `token` with the JWT token (equally to the login endpoint).
|
||||
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import { emailOTP } from "bknd/plugins";
|
||||
import { resendEmail } from "bknd";
|
||||
|
||||
export default {
|
||||
options: {
|
||||
drivers: {
|
||||
// an email driver is required
|
||||
email: resendEmail({ /* ... */}),
|
||||
},
|
||||
plugins: [
|
||||
// all options are optional
|
||||
emailOTP({
|
||||
// the base path for the API endpoints
|
||||
apiBasePath: "/api/auth/otp",
|
||||
// the TTL for the OTP tokens in seconds
|
||||
ttl: 600,
|
||||
// the name of the OTP entity
|
||||
entity: "users_otp",
|
||||
// customize the email content
|
||||
generateEmail: (otp) => ({
|
||||
subject: "OTP Code",
|
||||
body: `Your OTP code is: ${otp.code}`,
|
||||
}),
|
||||
// customize the code generation
|
||||
generateCode: (user) => {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
},
|
||||
})
|
||||
],
|
||||
},
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
<AutoTypeTable path="../app/src/plugins/auth/email-otp.plugin.ts" name="EmailOTPPluginOptions" />
|
||||
|
||||
@@ -73,7 +73,9 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> =
|
||||
|
||||
## Serve the API
|
||||
|
||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
||||
### Helper Functions (Optional)
|
||||
|
||||
For convenience, you can create a helper file to instantiate the bknd instance and retrieve the API. This is optional but recommended as it simplifies usage throughout your app. The examples below assume you've created this helper, but you can adjust the approach according to your needs.
|
||||
|
||||
```ts title="app/bknd.ts"
|
||||
import {
|
||||
@@ -108,7 +110,9 @@ export async function getApi(
|
||||
|
||||
For more information about the connection object, refer to the [Database](/usage/database) guide.
|
||||
|
||||
Create a new api splat route file at `app/routes/api.$.ts`:
|
||||
### API Route
|
||||
|
||||
Create a catch-all route file at `app/routes/api.$.ts` that forwards requests to bknd:
|
||||
|
||||
```ts title="app/routes/api.$.ts"
|
||||
import { getApp } from "~/bknd";
|
||||
@@ -122,9 +126,22 @@ export const loader = handler;
|
||||
export const action = handler;
|
||||
```
|
||||
|
||||
If you're using [`@react-router/fs-routes`](https://reactrouter.com/how-to/file-route-conventions), this file will automatically be picked up as a route.
|
||||
|
||||
If you're manually defining routes in [`app/routes.ts`](https://reactrouter.com/api/framework-conventions/routes.ts), reference this file in your configuration:
|
||||
|
||||
```ts title="app/routes.ts"
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
// your other routes...
|
||||
route("api/*", "./routes/api.$.ts"),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
## Enabling the Admin UI
|
||||
|
||||
Create a new splat route file at `app/routes/admin.$.tsx`:
|
||||
Create a route file at `app/routes/admin.$.tsx` to enable the bknd Admin UI for managing your data, schema, and users:
|
||||
|
||||
```tsx title="app/routes/admin.$.tsx"
|
||||
import { lazy, Suspense, useSyncExternalStore } from "react";
|
||||
@@ -165,6 +182,19 @@ export default function AdminPage() {
|
||||
}
|
||||
```
|
||||
|
||||
If you're using [`@react-router/fs-routes`](https://reactrouter.com/how-to/file-route-conventions), this file will automatically be picked up as a route.
|
||||
|
||||
If you're manually defining routes in `app/routes.ts`, reference this file in your configuration:
|
||||
|
||||
```ts title="app/routes.ts"
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
// your other routes...
|
||||
route("admin/*", "./routes/admin.$.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
## Example usage of the API
|
||||
|
||||
You can use the `getApi` helper function we've already set up to fetch and mutate:
|
||||
@@ -193,3 +223,24 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using React Hooks (Optional)
|
||||
|
||||
If you want to use bknd's client-side React hooks (like `useEntityQuery`, `useAuth`, etc.), wrap your app in the `ClientProvider` component. This is typically done in `app/root.tsx`:
|
||||
|
||||
```tsx title="app/root.tsx"
|
||||
// other imports
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
// ...
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider>
|
||||
<Outlet />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
The `ClientProvider` automatically uses the same origin for API requests, which works perfectly when bknd is served from your React Router app. For more details on using React hooks, see the [React SDK documentation](/usage/react).
|
||||
@@ -4,19 +4,24 @@ description: "Use the bknd SDK for React"
|
||||
icon: React
|
||||
tags: ["documentation"]
|
||||
---
|
||||
import { TypeTable } from 'fumadocs-ui/components/type-table';
|
||||
|
||||
There are 4 useful hooks to work with your backend:
|
||||
There are several useful hooks to work with your backend:
|
||||
|
||||
1. simple hooks which are solely based on the [API](/usage/sdk):
|
||||
- [`useApi`](#useapi)
|
||||
- [`useEntity`](#useentity)
|
||||
2. query hooks that wraps the API in [SWR](https://swr.vercel.app/):
|
||||
- [`useApiQuery`](#useapiquery)
|
||||
- [`useEntityQuery`](#useentityquery)
|
||||
1. **Simple hooks** based on the [API](/usage/sdk):
|
||||
- [`useApi`](#useapi) - Access the API instance
|
||||
- [`useAuth`](#useauth) - Authentication helpers and state
|
||||
- [`useEntity`](#useentity) - CRUD operations without caching
|
||||
2. **Query hooks** that wrap the API in [SWR](https://swr.vercel.app/):
|
||||
- [`useApiQuery`](#useapiquery) - Query any API endpoint with caching
|
||||
- [`useEntityQuery`](#useentityquery) - Entity CRUD with automatic caching
|
||||
3. **Utility hooks** for advanced use cases:
|
||||
- [`useInvalidate`](#useinvalidate) - Manual cache invalidation
|
||||
- [`useEntityMutate`](#useentitymutate) - Mutations without fetching
|
||||
|
||||
## Setup
|
||||
|
||||
In order to use them, make sure you wrap your `<App />` inside `<ClientProvider />`, so that these hooks point to your bknd instance:
|
||||
In order to use the React hooks, make sure you wrap your `<App />` inside `<ClientProvider />`. This provides the bknd API instance to all hooks in your component tree:
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
@@ -26,21 +31,434 @@ export default function App() {
|
||||
}
|
||||
```
|
||||
|
||||
For all other examples below, we'll assume that your app is wrapped inside the `ClientProvider`.
|
||||
### ClientProvider Props
|
||||
|
||||
The `ClientProvider` accepts the following props:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
baseUrl: {
|
||||
description: 'The base URL of your bknd instance (similar to host in the API). If left blank, it points to the same origin, which is useful when bknd is served from your framework (e.g., Next.js, Astro, React Router)',
|
||||
type: 'string',
|
||||
},
|
||||
children: {
|
||||
description: 'React components that will have access to the bknd context',
|
||||
type: 'ReactNode',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
All [Api options](/usage/sdk#setup) are also supported and will be passed to the internal API instance. Common options include:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
token: {
|
||||
description: 'Authentication token for API requests',
|
||||
type: 'string',
|
||||
},
|
||||
storage: {
|
||||
description: 'Custom storage implementation for persisting tokens (defaults to localStorage in browsers)',
|
||||
type: '{ getItem, setItem, removeItem }',
|
||||
},
|
||||
onAuthStateChange: {
|
||||
description: 'Callback function triggered when authentication state changes',
|
||||
type: '(state: AuthState) => void',
|
||||
},
|
||||
fetcher: {
|
||||
description: 'Custom fetch implementation (useful for local/embedded mode)',
|
||||
type: '(input: RequestInfo, init?: RequestInit) => Promise<Response>',
|
||||
},
|
||||
credentials: {
|
||||
description: 'Request credentials mode',
|
||||
type: '"include" | "omit" | "same-origin"',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Using with a remote bknd instance:**
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider baseUrl="https://your-bknd-instance.com">
|
||||
{/* your app */}
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Using with an embedded bknd instance (same origin):**
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
// no baseUrl needed - will use window.location.origin
|
||||
return <ClientProvider>{/* your app */}</ClientProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
**Using with custom authentication:**
|
||||
|
||||
```tsx
|
||||
import { ClientProvider } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider
|
||||
baseUrl="https://your-bknd-instance.com"
|
||||
token="your-auth-token"
|
||||
onAuthStateChange={(state) => {
|
||||
console.log("Auth state changed:", state);
|
||||
}}
|
||||
>
|
||||
{/* your app */}
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For all examples below, we'll assume that your app is wrapped inside the `ClientProvider`.
|
||||
|
||||
## `useApi()`
|
||||
|
||||
To use the simple hook that returns the Api, you can use:
|
||||
Returns the [Api instance](/usage/sdk) from the `ClientProvider` context. This gives you direct access to all API methods for data, auth, media, and system operations.
|
||||
|
||||
```tsx
|
||||
import { useApi } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
export default async function App() {
|
||||
const api = useApi();
|
||||
|
||||
// access data API
|
||||
const posts = await api.data.readMany("posts");
|
||||
|
||||
// access auth API
|
||||
const user = await api.auth.me();
|
||||
|
||||
// access media API
|
||||
const files = await api.media.listFiles();
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
host: {
|
||||
description: 'Optional host URL to create a new Api instance instead of using the one from context',
|
||||
type: 'string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
See the [SDK documentation](/usage/sdk) for all available API methods and options.
|
||||
|
||||
## `useAuth()`
|
||||
|
||||
Provides authentication state and helper functions for login, register, logout, and token management. This hook automatically tracks the authentication state from the `ClientProvider` context.
|
||||
|
||||
```tsx
|
||||
import { useAuth } from "bknd/client";
|
||||
|
||||
export default function AuthComponent() {
|
||||
const { user, verified, login, logout } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await login({
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Welcome, {user.email}!</p>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
baseUrl: {
|
||||
description: 'Optional base URL to use a different bknd instance',
|
||||
type: 'string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Return Values
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
data: {
|
||||
description: 'The complete authentication state object',
|
||||
type: 'Partial<AuthState>',
|
||||
},
|
||||
user: {
|
||||
description: 'The currently authenticated user (or undefined if not authenticated)',
|
||||
type: 'SafeUser | undefined',
|
||||
},
|
||||
token: {
|
||||
description: 'The current authentication token',
|
||||
type: 'string | undefined',
|
||||
},
|
||||
verified: {
|
||||
description: 'Whether the token has been verified with the server',
|
||||
type: 'boolean',
|
||||
},
|
||||
login: {
|
||||
description: 'Login with email and password',
|
||||
type: '(data: { email: string; password: string }) => Promise<AuthResponse>',
|
||||
},
|
||||
register: {
|
||||
description: 'Register a new user with email and password',
|
||||
type: '(data: { email: string; password: string }) => Promise<AuthResponse>',
|
||||
},
|
||||
logout: {
|
||||
description: 'Logout and invalidate all cached data',
|
||||
type: '() => Promise<void>',
|
||||
},
|
||||
verify: {
|
||||
description: 'Verify the current token with the server and invalidate cache',
|
||||
type: '() => Promise<void>',
|
||||
},
|
||||
setToken: {
|
||||
description: 'Manually set the authentication token',
|
||||
type: '(token: string) => void',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Usage Notes
|
||||
|
||||
- The `login` and `register` functions automatically update the authentication state and store the token
|
||||
- The `logout` function clears the token and invalidates all SWR cache entries
|
||||
- The `verify` function checks if the current token is still valid with the server
|
||||
- Authentication state changes are automatically propagated to all components using `useAuth`
|
||||
|
||||
### Authentication Patterns
|
||||
|
||||
Depending on your deployment architecture, there are different ways to handle authentication:
|
||||
|
||||
#### 1. SPA with localStorage (Independent Deployments)
|
||||
|
||||
Use this pattern when your frontend and backend are deployed independently on different domains. The token is stored in the browser's localStorage.
|
||||
|
||||
```tsx
|
||||
import { ClientProvider, useAuth } from "bknd/client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// setup ClientProvider with localStorage
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider
|
||||
baseUrl="https://your-backend.com"
|
||||
storage={window.localStorage}
|
||||
>
|
||||
<AuthComponent />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthComponent() {
|
||||
const auth = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// important: verify auth on mount to check if stored token is still valid
|
||||
useEffect(() => {
|
||||
auth.verify();
|
||||
}, []);
|
||||
|
||||
if (auth.user) {
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as {auth.user.email}</p>
|
||||
<button onClick={() => auth.logout()}>Logout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await auth.login({ email, password });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. SPA with Cookies (Same Domain)
|
||||
|
||||
Use this pattern when your frontend and backend are deployed on the same domain or when using a framework that serves both. Authentication is handled via HTTP-only cookies.
|
||||
|
||||
```tsx
|
||||
import { ClientProvider, useAuth } from "bknd/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// setup ClientProvider with credentials included
|
||||
export default function App() {
|
||||
return (
|
||||
<ClientProvider
|
||||
baseUrl="https://your-app.com"
|
||||
credentials="include"
|
||||
>
|
||||
<InnerApp />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerApp() {
|
||||
const auth = useAuth();
|
||||
|
||||
// important: verify auth on mount since cookies aren't readable from client-side JavaScript
|
||||
// cookies are included automatically in requests
|
||||
useEffect(() => {
|
||||
auth.verify();
|
||||
}, []);
|
||||
|
||||
if (auth.user) {
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as {auth.user.email}</p>
|
||||
{/* logout by navigating to the logout endpoint */}
|
||||
<a href="/api/auth/logout">Logout</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// option 1: programmatic login
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await auth.login({ email: "user@example.com", password: "password" });
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
);
|
||||
|
||||
// option 2: form-based login (traditional)
|
||||
return (
|
||||
<form method="post" action="/api/auth/password/login">
|
||||
<input type="email" name="email" placeholder="Email" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- With `credentials: "include"`, cookies are automatically sent with every request
|
||||
- The logout endpoint (`/api/auth/logout`) clears the cookie and redirects back to the referrer
|
||||
- You can use either programmatic login with `auth.login()` or traditional form submission
|
||||
|
||||
#### 3. Full Stack (Embedded Mode)
|
||||
|
||||
Use this pattern when bknd is embedded in your framework (e.g., Next.js, Astro, React Router). The backend and frontend run in the same process.
|
||||
|
||||
```tsx
|
||||
// this example is not specific to any framework, but you can use it with any framework that supports server-side rendering
|
||||
|
||||
import { ClientProvider, useAuth } from "bknd/client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// setup: extract user from server-side
|
||||
// in your server-side code (e.g., Next.js loader, Astro endpoint):
|
||||
export async function loader({ request }) {
|
||||
// create API instance from your app (may be available in context)
|
||||
const api = app.getApi({ request }); // extracts credentials from request
|
||||
// or: const api = app.getApi({ headers: request.headers });
|
||||
|
||||
const user = api.getUser();
|
||||
// optionally: await api.verifyAuth();
|
||||
|
||||
return { user };
|
||||
}
|
||||
|
||||
// in your component:
|
||||
export default function App({ user }) {
|
||||
return (
|
||||
<ClientProvider user={user}>
|
||||
<InnerApp />
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerApp() {
|
||||
const auth = useAuth();
|
||||
|
||||
// optionally verify if not already verified
|
||||
useEffect(() => {
|
||||
if (!auth.verified) {
|
||||
auth.verify();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (auth.user) {
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as {auth.user.email}</p>
|
||||
{/* logout by navigating to the logout endpoint */}
|
||||
<a href="/api/auth/logout">Logout</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// use form-based authentication for full-stack apps
|
||||
return (
|
||||
<form method="post" action="/api/auth/password/login">
|
||||
<input type="email" name="email" placeholder="Email" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- No `baseUrl` needed in `ClientProvider` - it automatically uses the same origin
|
||||
- Pass the `user` prop from server-side to avoid an initial unauthenticated state
|
||||
- Use `app.getApi({ request })` or `app.getApi({ headers })` on the server to extract credentials
|
||||
- The logout endpoint (`/api/auth/logout`) clears the session and redirects back
|
||||
- Authentication persists via cookies automatically handled by the framework
|
||||
|
||||
## `useApiQuery()`
|
||||
|
||||
This hook wraps the API class in an SWR hook for convenience. You can use any API endpoint
|
||||
@@ -61,24 +479,34 @@ export default function App() {
|
||||
|
||||
### Props
|
||||
|
||||
- `selector: (api: Api) => FetchPromise`
|
||||
<TypeTable
|
||||
type={{
|
||||
selector: {
|
||||
description: 'A selector function that provides an Api instance and expects an endpoint function to be returned',
|
||||
type: '(api: Api) => FetchPromise',
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
description: 'Optional SWR configuration with additional options',
|
||||
type: 'SWRConfiguration & { enabled?: boolean; refine?: (data: Data) => Data | any }',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
The first parameter is a selector function that provides an Api instance and expects an
|
||||
endpoint function to be returned.
|
||||
**Options properties:**
|
||||
|
||||
- `options`: optional object that inherits from `SWRConfiguration`
|
||||
|
||||
```ts
|
||||
type Options<Data> = import("swr").SWRConfiguration & {
|
||||
enabled?: boolean;
|
||||
refine?: (data: Data) => Data | any;
|
||||
};
|
||||
```
|
||||
|
||||
* `enabled`: Determines whether this hook should trigger a fetch of the data or not.
|
||||
* `refine`: Optional refinement that is called after a response from the API has been
|
||||
|
||||
received. Useful to omit irrelevant data from the response (see example below).
|
||||
<TypeTable
|
||||
type={{
|
||||
enabled: {
|
||||
description: 'Determines whether this hook should trigger a fetch of the data or not',
|
||||
type: 'boolean',
|
||||
},
|
||||
refine: {
|
||||
description: 'Optional refinement function called after a response from the API has been received. Useful to omit irrelevant data from the response',
|
||||
type: '(data: Data) => Data | any',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Using mutations
|
||||
|
||||
@@ -163,27 +591,46 @@ of entities instead of a single entry.
|
||||
|
||||
### Props
|
||||
|
||||
Following props are available when using `useEntityQuery([entity], [id?])`:
|
||||
|
||||
- `entity: string`: Specify the table name of the entity
|
||||
- `id?: number | string`: If an id given, it will fetch a single entry, otherwise a list
|
||||
<TypeTable
|
||||
type={{
|
||||
entity: {
|
||||
description: 'The table name of the entity',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
description: 'Optional ID. If provided, operations target a single entry; otherwise a list',
|
||||
type: 'number | string',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Returned actions
|
||||
|
||||
The following actions are returned from this hook:
|
||||
|
||||
- `create: (input: object)`: Create a new entry
|
||||
- `read: (query: Partial<RepoQuery> = {})`: If an id was given,
|
||||
it returns a single item, otherwise a list
|
||||
- `update: (input: object, id?: number | string)`: If an id was given, the id parameter is
|
||||
optional. Updates the given entry partially.
|
||||
- `_delete: (id?: number | string)`: If an id was given, the id parameter is
|
||||
optional. Deletes the given entry.
|
||||
<TypeTable
|
||||
type={{
|
||||
create: {
|
||||
description: 'Create a new entry',
|
||||
type: '(input: object) => Promise<Response>',
|
||||
},
|
||||
read: {
|
||||
description: 'If an id was given, returns a single item; otherwise returns a list',
|
||||
type: '(query?: RepoQueryIn) => Promise<Response>',
|
||||
},
|
||||
update: {
|
||||
description: 'Update an entry partially. If an id was given to the hook, the id parameter is optional',
|
||||
type: '(input: object, id?: number | string) => Promise<Response>',
|
||||
},
|
||||
_delete: {
|
||||
description: 'Delete an entry. If an id was given to the hook, the id parameter is optional',
|
||||
type: '(id?: number | string) => Promise<Response>',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
## `useEntityQuery()`
|
||||
|
||||
This hook wraps the actions from `useEntity` around `SWR`. The previous example would look like
|
||||
this:
|
||||
This hook wraps the actions from `useEntity` around `SWR` for automatic data fetching, caching, and revalidation. It combines the power of SWR with CRUD operations for your entities.
|
||||
|
||||
```tsx
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
@@ -195,10 +642,207 @@ export default function App() {
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The returned CRUD actions are typed differently based on whether you provide an `id`:
|
||||
|
||||
- **With `id`** (single item mode): `update` and `_delete` don't require an `id` parameter since the item is already specified
|
||||
- **Without `id`** (list mode): `update` and `_delete` require an `id` parameter to specify which item to modify
|
||||
|
||||
```tsx
|
||||
// single item mode - id is already specified
|
||||
const { data, update, _delete } = useEntityQuery("comments", 1);
|
||||
await update({ content: "new text" }); // no id needed
|
||||
await _delete(); // no id needed
|
||||
|
||||
// list mode - must specify which item to update/delete
|
||||
const { data, update, _delete } = useEntityQuery("comments");
|
||||
await update({ content: "new text" }, 1); // id required
|
||||
await _delete(1); // id required
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
entity: {
|
||||
description: 'The table name of the entity',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
description: 'Optional ID. If provided, fetches a single entry; otherwise fetches a list',
|
||||
type: 'number | string',
|
||||
},
|
||||
query: {
|
||||
description: 'Optional query parameters for filtering, sorting, pagination, etc.',
|
||||
type: 'RepoQueryIn',
|
||||
},
|
||||
options: {
|
||||
description: 'Optional SWR configuration plus additional options',
|
||||
type: 'SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
The `query` parameter accepts a `RepoQueryIn` object with the following options:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
limit: {
|
||||
description: 'Limit the number of results',
|
||||
type: 'number',
|
||||
default: '10',
|
||||
},
|
||||
offset: {
|
||||
description: 'Skip a number of results for pagination',
|
||||
type: 'number',
|
||||
default: '0',
|
||||
},
|
||||
sort: {
|
||||
description: 'Sort by field(s). Prefix with - for descending order (e.g., "-id" or ["name", "-createdAt"])',
|
||||
type: 'string | string[]',
|
||||
default: 'id',
|
||||
},
|
||||
where: {
|
||||
description: 'Filter conditions using query operators (e.g., { status: "active", views: { $gt: 100 } })',
|
||||
type: 'object',
|
||||
},
|
||||
with: {
|
||||
description: 'Include related entities',
|
||||
type: 'string[]',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
#### Options
|
||||
|
||||
The `options` parameter extends SWR's configuration and adds bknd-specific options:
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
enabled: {
|
||||
description: 'If false, prevents the query from running (useful for conditional fetching)',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
},
|
||||
revalidateOnMutate: {
|
||||
description: "If false, mutations won't automatically trigger revalidation",
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
},
|
||||
keepPreviousData: {
|
||||
description: 'Keeps showing previous data while fetching new data',
|
||||
type: 'boolean',
|
||||
default: 'true',
|
||||
},
|
||||
revalidateOnFocus: {
|
||||
description: 'Controls whether to revalidate when window regains focus',
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
All standard [SWR configuration options](https://swr.vercel.app/docs/api) are also supported.
|
||||
|
||||
### Return Values
|
||||
|
||||
The hook returns an object with the following properties:
|
||||
|
||||
**SWR Properties:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
data: {
|
||||
description: 'The fetched data (single entity or array of entities)',
|
||||
type: 'Entity | Entity[]',
|
||||
},
|
||||
error: {
|
||||
description: 'Error object if the request failed',
|
||||
type: 'Error',
|
||||
},
|
||||
isLoading: {
|
||||
description: 'True when the initial request is in progress',
|
||||
type: 'boolean',
|
||||
},
|
||||
isValidating: {
|
||||
description: 'True when a request or revalidation is in progress',
|
||||
type: 'boolean',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**CRUD Actions (auto-wrapped with cache revalidation):**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
create: {
|
||||
description: 'Create a new entry',
|
||||
type: '(input: object) => Promise<Response>',
|
||||
},
|
||||
update: {
|
||||
description: 'Update an entry. When id is provided to the hook (single item mode), the id parameter is optional and defaults to the hook id. When no id is provided to the hook (list mode), the id parameter is required',
|
||||
type: '(input: object, id?: number | string) => Promise<Response>',
|
||||
},
|
||||
_delete: {
|
||||
description: 'Delete an entry. When id is provided to the hook (single item mode), the id parameter is optional and defaults to the hook id. When no id is provided to the hook (list mode), the id parameter is required',
|
||||
type: '(id?: number | string) => Promise<Response>',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**Cache Management:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
mutate: {
|
||||
description: 'Manually invalidate and revalidate cache for this entity',
|
||||
type: '(id?: number | string) => Promise<void>',
|
||||
},
|
||||
mutateRaw: {
|
||||
description: "Direct access to SWR's mutate function for advanced use cases",
|
||||
type: 'SWRResponse["mutate"]',
|
||||
},
|
||||
key: {
|
||||
description: 'The SWR cache key being used',
|
||||
type: 'string',
|
||||
},
|
||||
api: {
|
||||
description: 'Direct access to the data API instance',
|
||||
type: 'Api["data"]',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### Query Example
|
||||
|
||||
Fetching a limited, sorted list of entities:
|
||||
|
||||
```tsx
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
|
||||
export default function TodoList() {
|
||||
const { data: todos, isLoading } = useEntityQuery("todos", undefined, {
|
||||
limit: 5,
|
||||
sort: "-id", // descending by id
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{todos?.map((todo) => (
|
||||
<li key={todo.id}>{todo.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using mutations
|
||||
|
||||
All actions returned from `useEntityQuery` are conveniently wrapped around the `mutate` function,
|
||||
so you don't have think about this:
|
||||
All actions returned from `useEntityQuery` are conveniently wrapped to automatically revalidate the cache after mutations:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -239,3 +883,195 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complete CRUD Example
|
||||
|
||||
Here's a comprehensive example showing all CRUD operations with query parameters:
|
||||
|
||||
```tsx
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
|
||||
export default function TodoList() {
|
||||
const { data: todos, create, update, _delete, isLoading } = useEntityQuery(
|
||||
"todos",
|
||||
undefined, // no id, so we fetch a list
|
||||
{
|
||||
limit: 10,
|
||||
sort: "-id", // newest first
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form
|
||||
action={async (formData: FormData) => {
|
||||
const title = formData.get("title") as string;
|
||||
await create({ title, done: false });
|
||||
}}
|
||||
>
|
||||
<input type="text" name="title" placeholder="New todo" />
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{todos?.map((todo) => (
|
||||
<li key={todo.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!todo.done}
|
||||
onChange={async () => {
|
||||
await update({ done: !todo.done }, todo.id);
|
||||
}}
|
||||
/>
|
||||
<span>{todo.title}</span>
|
||||
<button onClick={() => _delete(todo.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation Behavior
|
||||
|
||||
**Important notes about mutations:**
|
||||
|
||||
- **Auto-revalidation**: By default, all mutations (`create`, `update`, `_delete`) automatically revalidate all queries for that entity. This ensures your UI stays in sync.
|
||||
|
||||
- **Optimistic updates**: For more advanced scenarios, you can use `mutateRaw` to implement optimistic updates manually.
|
||||
|
||||
- **Disable auto-revalidation**: If you need more control, set `revalidateOnMutate: false`:
|
||||
|
||||
```tsx
|
||||
const { data, update } = useEntityQuery("comments", 1, undefined, {
|
||||
revalidateOnMutate: false,
|
||||
});
|
||||
|
||||
// now update won't trigger automatic revalidation
|
||||
await update({ content: "new text" });
|
||||
```
|
||||
|
||||
- **Manual revalidation**: Use the returned `mutate` function to manually trigger revalidation:
|
||||
|
||||
```tsx
|
||||
const { mutate } = useEntityQuery("comments");
|
||||
|
||||
// revalidate all "comments" queries
|
||||
await mutate();
|
||||
|
||||
// revalidate specific comment
|
||||
await mutate(commentId);
|
||||
```
|
||||
|
||||
## Utility Hooks
|
||||
|
||||
### `useInvalidate()`
|
||||
|
||||
This hook provides a convenient way to invalidate SWR cache entries for manual revalidation.
|
||||
|
||||
```tsx
|
||||
import { useInvalidate } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
const handleRefresh = async () => {
|
||||
// invalidate by string key prefix
|
||||
await invalidate("/data/comments");
|
||||
|
||||
// or invalidate using API selector
|
||||
await invalidate((api) => api.data.readMany("comments"));
|
||||
};
|
||||
|
||||
return <button onClick={handleRefresh}>Refresh Comments</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
options: {
|
||||
description: 'Configuration options',
|
||||
type: '{ exact?: boolean }',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**Options properties:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
exact: {
|
||||
description: 'If true, only invalidates the exact key match instead of keys that start with the given prefix',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
### `useEntityMutate()`
|
||||
|
||||
This hook provides mutation actions without fetching data. Useful when you only need to perform CRUD operations without subscribing to data updates.
|
||||
|
||||
```tsx
|
||||
import { useEntityMutate } from "bknd/client";
|
||||
|
||||
export default function QuickActions() {
|
||||
const { create, update, _delete, mutate } = useEntityMutate("todos");
|
||||
|
||||
const createTodo = async () => {
|
||||
await create({ title: "New todo", done: false });
|
||||
// manually update cache
|
||||
await mutate();
|
||||
};
|
||||
|
||||
return <button onClick={createTodo}>Quick Add Todo</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
entity: {
|
||||
description: 'The table name of the entity',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
id: {
|
||||
description: 'Optional ID for single entity operations',
|
||||
type: 'number | string',
|
||||
},
|
||||
options: {
|
||||
description: 'Optional SWR configuration',
|
||||
type: 'SWRConfiguration',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
**Return Values:**
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
create: {
|
||||
description: 'Create a new entry',
|
||||
type: '(input: object) => Promise<Response>',
|
||||
},
|
||||
update: {
|
||||
description: 'Update an entry',
|
||||
type: '(input: object, id?: number | string) => Promise<Response>',
|
||||
},
|
||||
_delete: {
|
||||
description: 'Delete an entry',
|
||||
type: '(id?: number | string) => Promise<Response>',
|
||||
},
|
||||
mutate: {
|
||||
description: 'Function to update cache with partial data without refetching',
|
||||
type: '(id: number | string, data: Partial<Entity>) => Promise<void>',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@tsconfig/strictest": "^2.0.7",
|
||||
"@types/bun": "^1.3.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20251122.1",
|
||||
"miniflare": "^3.20250718.2",
|
||||
"typescript": "^5.9.3",
|
||||
"verdaccio": "^6.2.1"
|
||||
|
||||
Reference in New Issue
Block a user