Advanced Patterns
This guide shows how to compose Keryx primitives — middleware, actions, and connection.metadata — into patterns you'll reach for in most production apps. The examples use a simple "team" domain, but the techniques apply to any multi-tenant or role-based application.
Middleware Factories
The middleware guide shows static ActionMiddleware objects. But what if different actions need different permission levels? Instead of writing separate middleware for each role, write a function that takes a role and returns a middleware:
function RequireRole(role: string): ActionMiddleware {
return {
runBefore: async (params, connection) => {
// check the role, throw if insufficient
},
};
}Now you can parameterize per-action: middleware = [SessionMiddleware, RequireRole("admin")]. The most common use of this pattern is role-based access control.
Role-Based Access Control
Roles and Hierarchy
Start by defining your roles and a way to compare them:
// roles.ts
export const Roles = ["viewer", "editor", "admin"] as const;
export type Role = (typeof Roles)[number];
const ROLE_LEVEL: Record<Role, number> = {
viewer: 1,
editor: 2,
admin: 3,
};
/** Returns true if the user's role meets or exceeds the required role. */
export function meetsRole(userRole: Role, required: Role): boolean {
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[required];
}RBAC Middleware Factory
The middleware factory looks up the user's membership from the database, checks their role against the requirement, and stores the membership on connection.metadata for the action to use:
// rbac-middleware.ts
import {
api,
ErrorType,
TypedError,
type ActionMiddleware,
type Connection,
} from "keryx";
import { eq, and } from "drizzle-orm";
import { meetsRole, type Role } from "./roles";
import { teamMembers } from "./schema";
import type { AppConnectionMeta } from "./types";
export function RbacMiddleware(requiredRole: Role): ActionMiddleware {
return {
runBefore: async (
params: Record<string, unknown>,
connection: Connection<any, AppConnectionMeta>,
) => {
const userId = connection.session!.data.userId;
const teamId = params.teamId as number;
if (!teamId) {
throw new TypedError({
message: "teamId is required",
type: ErrorType.ACTION_VALIDATION,
});
}
try {
const [membership] = await api.db.db
.select()
.from(teamMembers)
.where(
and(eq(teamMembers.userId, userId), eq(teamMembers.teamId, teamId)),
);
if (!membership || !meetsRole(membership.role as Role, requiredRole)) {
throw new TypedError({
message: "Insufficient permissions",
type: ErrorType.CONNECTION_CHANNEL_AUTHORIZATION,
});
}
// Make the membership available to the action
connection.metadata.membership = membership;
} catch (e) {
if (e instanceof TypedError) throw e;
throw new TypedError({
message: "An unexpected error occurred",
type: ErrorType.CONNECTION_ACTION_RUN,
});
}
},
};
}Use it on any action:
export class TeamEdit implements Action {
name = "team:edit";
middleware = [SessionMiddleware, RbacMiddleware("admin")];
// ...
async run(
params: ActionParams<TeamEdit>,
connection: Connection<any, AppConnectionMeta>,
) {
const membership = connection.metadata.membership!; // set by RbacMiddleware
// ...
}
}Multiple Role Domains
Some apps have separate role hierarchies — for example, organization-level roles (owner, admin, member) and project-level roles (manager, editor, viewer) with no implicit access between them. In that case, create separate middleware factories for each domain:
middleware = [SessionMiddleware, OrgRbacMiddleware("admin")]; // control plane
middleware = [SessionMiddleware, ProjectRbacMiddleware("editor")]; // data planeIf an action should be accessible from either domain (e.g. a project admin or an org admin can manage project members), build a combined middleware that checks both and authorizes if either passes.
Runtime Introspection
You can attach metadata to middleware objects for runtime inspection — useful for building permissions endpoints or auto-generating documentation. One approach is to use a well-known Symbol:
export const RBAC_DESCRIPTOR = Symbol.for("app.rbac");
export function RbacMiddleware(requiredRole: Role): ActionMiddleware {
const mw: ActionMiddleware & { [RBAC_DESCRIPTOR]?: { role: Role } } = {
runBefore: async (params, connection) => {
/* ... */
},
};
mw[RBAC_DESCRIPTOR] = { role: requiredRole };
return mw;
}An endpoint can then iterate over an action's middleware array, check for the Symbol, and expose the role requirements to the frontend or to AI agents.
Database Transactions
Keryx provides two tools for wrapping database operations in transactions: TransactionMiddleware for action-scoped transactions and withTransaction() for standalone use.
TransactionMiddleware
Add TransactionMiddleware to an action's middleware array. It opens a BEGIN in runBefore, stores the transaction-scoped Drizzle instance on connection.metadata.transaction, and commits or rolls back in runAfter based on whether the action succeeded:
import {
Action,
HTTP_METHOD,
TransactionMiddleware,
type ActionParams,
type Connection,
type Transaction,
} from "keryx";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { accounts } from "../schema";
export class TransferFunds extends Action {
constructor() {
super({
name: "transfer:funds",
middleware: [SessionMiddleware, TransactionMiddleware],
web: { route: "/transfer", method: HTTP_METHOD.POST },
inputs: z.object({
fromId: z.number(),
toId: z.number(),
amount: z.number().positive(),
}),
});
}
async run(params: ActionParams<TransferFunds>, connection?: Connection) {
const tx = connection!.metadata.transaction as Transaction;
// Both updates happen atomically — if either fails, both roll back
const [from] = await tx
.update(accounts)
.set({ balance: sql`balance - ${params.amount}` })
.where(eq(accounts.id, params.fromId))
.returning();
const [to] = await tx
.update(accounts)
.set({ balance: sql`balance + ${params.amount}` })
.where(eq(accounts.id, params.toId))
.returning();
return { from, to };
}
}If the action throws at any point, the transaction is rolled back automatically — no partial writes.
withTransaction()
For one-off transactions outside the action lifecycle (ops functions, scripts, tests), use withTransaction():
import { withTransaction } from "keryx";
import { users, auditLogs } from "../schema";
const user = await withTransaction(async (tx) => {
const [created] = await tx
.insert(users)
.values({ name: "Alice", email: "alice@example.com", password_hash: hash })
.returning();
await tx.insert(auditLogs).values({ action: "user:create", userId: created.id });
return created;
});It acquires a dedicated connection from the pool, runs BEGIN, calls your callback, then COMMIT on success or ROLLBACK on error. TypedError is re-thrown directly; other errors are wrapped.
Passing Transactions to Ops Functions
Use the DbOrTransaction type so helper functions accept either api.db.db (no transaction) or a Transaction:
import { api, type DbOrTransaction } from "keryx";
import { eq } from "drizzle-orm";
import { users } from "../schema";
export async function findUserByEmail(
email: string,
db: DbOrTransaction = api.db.db,
) {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return user;
}Now callers can use it with or without a transaction:
// Without transaction — uses api.db.db
const user = await findUserByEmail("alice@example.com");
// Inside TransactionMiddleware action
const user = await findUserByEmail(
"alice@example.com",
connection.metadata.transaction as Transaction,
);Chaining Actions in a Transaction
When an action calls a sub-action via connection.act(), the transaction propagates automatically. TransactionMiddleware is re-entrant — if a transaction already exists on connection.metadata, the child action reuses it instead of opening a new one. Only the outermost middleware commits or rolls back.
export class CreateUserWithWelcome extends Action {
constructor() {
super({
name: "user:create-with-welcome",
middleware: [TransactionMiddleware],
web: { route: "/user/create-with-welcome", method: HTTP_METHOD.PUT },
inputs: z.object({ name: z.string(), email: z.string() }),
});
}
async run(params: ActionParams<CreateUserWithWelcome>, connection?: Connection) {
const tx = connection!.metadata.transaction as Transaction;
const [user] = await tx
.insert(users)
.values({ name: params.name, email: params.email, password_hash: hash })
.returning();
// Sub-action runs inside the same transaction — if it fails,
// both the user creation and the welcome message roll back.
const { error } = await connection!.act("message:create-welcome", {
userId: user.id,
});
if (error) throw error;
return { user };
}
}connection.metadata is preserved across nested act() calls (it resets only on the outermost call), so the child action sees the parent's transaction, _txClient, and any other metadata set by earlier middleware.
Audit Logging with Base Action Classes
When many actions share the same cross-cutting concern — wrapping database writes in a transaction and inserting an audit log — you can extract that into an abstract base class. This pattern uses TransactionMiddleware to manage the transaction lifecycle and a runAfter hook to insert audit entries.
The Base Class
// audited-action.ts
import {
Action,
TransactionMiddleware,
type ActionMiddleware,
type Connection,
type Transaction,
} from "keryx";
import { z } from "zod";
import { auditLogs } from "./schema";
import type { AppConnectionMeta } from "./types";
/** Middleware that inserts an audit log entry inside the active transaction. */
const AuditLogMiddleware: ActionMiddleware = {
runAfter: async (
params: Record<string, unknown>,
connection: Connection<any, AppConnectionMeta>,
error,
) => {
// Only audit successful operations
if (error) return;
const tx = connection.metadata.transaction as Transaction;
if (!tx) return;
await tx.insert(auditLogs).values({
userId: connection.session?.data.userId ?? null,
action: connection.metadata.auditAction ?? "unknown",
metadata: params,
before: connection.metadata.auditBefore ?? null,
after: connection.metadata.auditAfter ?? null,
});
},
};
export abstract class AuditedAction extends Action {
constructor(args: ConstructorParameters<typeof Action>[0]) {
super({
...args,
// TransactionMiddleware must come before AuditLogMiddleware so
// the transaction is open when the audit insert runs
middleware: [
...(args.middleware ?? []),
TransactionMiddleware,
AuditLogMiddleware,
],
});
}
}The key detail: AuditLogMiddleware.runAfter runs inside the same transaction that TransactionMiddleware manages. If the audit insert fails, the entire transaction rolls back.
Using It
Subclasses set before/after snapshots on connection.metadata and use connection.metadata.transaction for all queries:
export class TeamEdit extends AuditedAction {
constructor() {
super({
name: "team:edit",
description: "Edit a team's settings",
inputs: z.object({
teamId: z.coerce.number(),
name: z.string().min(1).optional(),
}),
middleware: [SessionMiddleware, RbacMiddleware("admin")],
web: { route: "/team", method: HTTP_METHOD.PUT },
});
}
async run(
params: ActionParams<TeamEdit>,
connection?: Connection<any, AppConnectionMeta>,
) {
const tx = connection!.metadata.transaction as Transaction;
connection!.metadata.auditAction = this.name;
// Capture the before state
const [before] = await tx
.select()
.from(teams)
.where(eq(teams.id, params.teamId));
connection!.metadata.auditBefore = before;
// Make the change
const [after] = await tx
.update(teams)
.set({ name: params.name })
.where(eq(teams.id, params.teamId))
.returning();
connection!.metadata.auditAfter = after;
return { team: after };
}
}The action only thinks about business logic. The base class composes TransactionMiddleware and AuditLogMiddleware to handle the transaction wrapper, audit logging, and error handling.
Putting It Together
Here's the full request flow when these patterns are combined:
- SessionMiddleware — verifies the user is authenticated
- RbacMiddleware("admin") — looks up team membership, checks role, stores membership on
connection.metadata - TransactionMiddleware.runBefore — opens a database transaction, stores it on
connection.metadata.transaction - Action.run() — captures before state, makes the change, captures after state
- AuditLogMiddleware.runAfter — inserts the audit entry in the same transaction
- TransactionMiddleware.runAfter — commits the transaction (data change + audit log atomically)
If any step throws, everything after it is skipped — middleware short-circuits the action, and the transaction rolls back both the data change and the audit entry.