keryx 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/classes/Action.ts CHANGED
@@ -116,11 +116,19 @@ export type ActionMiddleware = {
116
116
  connection: Connection,
117
117
  ) => Promise<ActionMiddlewareResponse | void>;
118
118
  /**
119
- * Runs after the action's `run()` method. Can replace the response by returning `{ updatedResponse }`.
119
+ * Runs after the action's `run()` method (in a `finally` block, so it always runs).
120
+ * Can replace the response by returning `{ updatedResponse }`.
121
+ *
122
+ * @param params - The validated action inputs (same object passed to `run()`).
123
+ * @param connection - The connection that initiated this action.
124
+ * @param error - The `TypedError` from the action's `run()`, or `undefined` on success.
125
+ * Useful for middleware that needs to react to success/failure (e.g., committing or
126
+ * rolling back a database transaction).
120
127
  */
121
128
  runAfter?: (
122
129
  params: ActionParams<Action>,
123
130
  connection: Connection,
131
+ error?: TypedError,
124
132
  ) => Promise<ActionMiddlewareResponse | void>;
125
133
  };
126
134
 
@@ -43,8 +43,10 @@ export class Connection<
43
43
  rateLimitInfo?: RateLimitInfo;
44
44
  /** Request correlation ID for distributed tracing. Propagated from the incoming `X-Request-Id` header when `config.server.web.correlationId.trustProxy` is enabled. */
45
45
  correlationId?: string;
46
- /** App-defined request-scoped metadata. Reset to `{}` at the start of each `act()` call so that long-lived connections (e.g., WebSockets) don't leak state between actions. */
46
+ /** App-defined request-scoped metadata. Reset to `{}` at the start of each top-level `act()` call so that long-lived connections (e.g., WebSockets) don't leak state between actions. Preserved across nested `act()` calls so that middleware state (e.g., an open transaction) propagates to sub-actions. */
47
47
  metadata: Partial<TMeta>;
48
+ /** @internal Tracks nested `act()` depth so metadata is only reset on the outermost call. */
49
+ private _actDepth = 0;
48
50
 
49
51
  /**
50
52
  * Create a new connection and register it in `api.connections`.
@@ -94,7 +96,11 @@ export class Connection<
94
96
  method: Request["method"] = "",
95
97
  url: string = "",
96
98
  ): Promise<{ response: Object; error?: TypedError }> {
97
- this.metadata = {};
99
+ // Only reset metadata on the outermost act() call. Nested calls (action
100
+ // chaining) preserve the parent's metadata so middleware state like an
101
+ // open database transaction propagates to sub-actions.
102
+ if (this._actDepth === 0) this.metadata = {};
103
+ this._actDepth++;
98
104
  const reqStartTime = new Date().getTime();
99
105
  let loggerResponsePrefix: "OK" | "ERROR" = "OK";
100
106
  let response: Object = {};
@@ -164,6 +170,7 @@ export class Connection<
164
170
  const middlewareResponse = await middleware.runAfter(
165
171
  formattedParams,
166
172
  this,
173
+ error,
167
174
  );
168
175
  if (middlewareResponse && middlewareResponse?.updatedResponse) {
169
176
  if (response instanceof StreamingResponse) {
@@ -177,6 +184,7 @@ export class Connection<
177
184
  }
178
185
  }
179
186
  }
187
+ this._actDepth--;
180
188
  }
181
189
 
182
190
  const duration = new Date().getTime() - reqStartTime;
package/index.ts CHANGED
@@ -29,13 +29,18 @@ export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
29
29
  export type { KeryxConfig } from "./config";
30
30
  export type { SessionData } from "./initializers/session";
31
31
  export { checkRateLimit, RateLimitMiddleware } from "./middleware/rateLimit";
32
+ export { TransactionMiddleware } from "./middleware/transaction";
32
33
  export type { WebServer } from "./servers/web";
33
34
  export { buildProgram } from "./util/cli";
34
35
  export { deepMerge, loadFromEnvIfSet } from "./util/config";
35
36
  export { globLoader } from "./util/glob";
37
+ export { type PaginatedResult, paginate } from "./util/pagination";
36
38
  export { toMarkdown } from "./util/toMarkdown";
39
+ export type { DbOrTransaction, Transaction } from "./util/transaction";
40
+ export { withTransaction } from "./util/transaction";
37
41
  export {
38
42
  isSecret,
43
+ paginationInputs,
39
44
  secret,
40
45
  zBooleanFromString,
41
46
  zIdOrModel,
@@ -0,0 +1,95 @@
1
+ import { drizzle } from "drizzle-orm/node-postgres";
2
+ import type { PoolClient } from "pg";
3
+ import { api, logger } from "../api";
4
+ import type { ActionMiddleware } from "../classes/Action";
5
+ import type { Connection } from "../classes/Connection";
6
+ import type { TypedError } from "../classes/TypedError";
7
+ import type { Transaction } from "../util/transaction";
8
+
9
+ /**
10
+ * Action middleware that wraps the entire action execution in a database transaction.
11
+ *
12
+ * In `runBefore`, a dedicated `PoolClient` is acquired from `api.db.pool`, a `BEGIN`
13
+ * is issued, and a transaction-scoped Drizzle instance is stored on
14
+ * `connection.metadata.transaction`. Actions and ops functions should use this
15
+ * instance (instead of `api.db.db`) for all queries that must be atomic.
16
+ *
17
+ * In `runAfter`, the transaction is committed on success or rolled back if the
18
+ * action threw an error. The pool client is always released.
19
+ *
20
+ * **Re-entrant**: When a parent action already opened a transaction (e.g., via
21
+ * `connection.act()` chaining), the middleware reuses the existing transaction
22
+ * instead of opening a new one. Only the outermost middleware instance commits
23
+ * or rolls back.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { Action, HTTP_METHOD, TransactionMiddleware, type ActionParams, type Connection } from "keryx";
28
+ *
29
+ * export class TransferFunds extends Action {
30
+ * constructor() {
31
+ * super({
32
+ * name: "transfer:funds",
33
+ * middleware: [SessionMiddleware, TransactionMiddleware],
34
+ * web: { route: "/transfer", method: HTTP_METHOD.POST },
35
+ * inputs: z.object({ fromId: z.number(), toId: z.number(), amount: z.number() }),
36
+ * });
37
+ * }
38
+ *
39
+ * async run(params: ActionParams<TransferFunds>, connection: Connection) {
40
+ * const tx = connection.metadata.transaction;
41
+ * await tx.update(accounts).set(...).where(eq(accounts.id, params.fromId));
42
+ * await tx.update(accounts).set(...).where(eq(accounts.id, params.toId));
43
+ * return { success: true };
44
+ * }
45
+ * }
46
+ * ```
47
+ */
48
+ export const TransactionMiddleware: ActionMiddleware = {
49
+ runBefore: async (
50
+ _params: Record<string, unknown>,
51
+ connection: Connection,
52
+ ) => {
53
+ // If a parent action already opened a transaction, reuse it.
54
+ // Track depth so only the outermost middleware commits/rolls back.
55
+ const depth = (connection.metadata._txDepth as number | undefined) ?? 0;
56
+ connection.metadata._txDepth = depth + 1;
57
+
58
+ if (depth > 0) return; // already inside a transaction — nothing to do
59
+
60
+ const client: PoolClient = await api.db.pool.connect();
61
+ await client.query("BEGIN");
62
+ const tx = drizzle(client) as Transaction;
63
+ connection.metadata.transaction = tx;
64
+ connection.metadata._txClient = client;
65
+ },
66
+
67
+ runAfter: async (
68
+ _params: Record<string, unknown>,
69
+ connection: Connection,
70
+ error?: TypedError,
71
+ ) => {
72
+ const depth = (connection.metadata._txDepth as number | undefined) ?? 0;
73
+ connection.metadata._txDepth = Math.max(0, depth - 1);
74
+
75
+ // Only the outermost middleware manages the transaction lifecycle
76
+ if (depth > 1) return;
77
+
78
+ const client = connection.metadata._txClient as PoolClient | undefined;
79
+ if (!client) return;
80
+
81
+ try {
82
+ if (error) {
83
+ await client.query("ROLLBACK");
84
+ logger.debug("transaction rolled back");
85
+ } else {
86
+ await client.query("COMMIT");
87
+ logger.debug("transaction committed");
88
+ }
89
+ } finally {
90
+ client.release();
91
+ connection.metadata.transaction = undefined;
92
+ connection.metadata._txClient = undefined;
93
+ }
94
+ },
95
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Standardized pagination response envelope.
3
+ */
4
+ export interface PaginatedResult<T> {
5
+ data: T[];
6
+ pagination: {
7
+ page: number;
8
+ limit: number;
9
+ total: number;
10
+ pages: number;
11
+ };
12
+ }
13
+
14
+ /**
15
+ * Applies pagination to a Drizzle select query and returns a standardized envelope.
16
+ *
17
+ * Runs the data query (with LIMIT/OFFSET) and a COUNT query in parallel for efficiency.
18
+ * The caller provides both queries separately so they can use different joins or WHERE
19
+ * clauses for the count (e.g., skipping expensive JOINs that don't affect the total).
20
+ *
21
+ * @param query - A Drizzle select query builder that has not yet had `.limit()` or
22
+ * `.offset()` applied. Must support chaining `.limit(n).offset(n)`.
23
+ * @param countQuery - A promise resolving to `[{ count: number }]`. Typically built with
24
+ * `db.select({ count: count() }).from(table).where(...)`.
25
+ * @param params - Object with `page` (1-indexed) and `limit`, matching the output of
26
+ * `paginationInputs()`.
27
+ * @returns A `PaginatedResult<T>` containing the `data` array and `pagination` metadata.
28
+ */
29
+ export async function paginate<T>(
30
+ query: {
31
+ limit: (n: number) => { offset: (n: number) => PromiseLike<T[]> };
32
+ },
33
+ countQuery: PromiseLike<{ count: number }[]>,
34
+ params: { page: number; limit: number },
35
+ ): Promise<PaginatedResult<T>> {
36
+ const offset = (params.page - 1) * params.limit;
37
+
38
+ const [data, countResult] = await Promise.all([
39
+ query.limit(params.limit).offset(offset),
40
+ countQuery,
41
+ ]);
42
+
43
+ const total = Number(countResult[0]?.count ?? 0);
44
+
45
+ return {
46
+ data,
47
+ pagination: {
48
+ page: params.page,
49
+ limit: params.limit,
50
+ total,
51
+ pages: Math.ceil(total / params.limit),
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,81 @@
1
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { drizzle } from "drizzle-orm/node-postgres";
3
+ import { api, logger } from "../api";
4
+ import { ErrorType, TypedError } from "../classes/TypedError";
5
+
6
+ /**
7
+ * A Drizzle database instance scoped to a single PostgreSQL connection.
8
+ * Returned by {@link withTransaction} and stored on `connection.metadata.transaction`
9
+ * by `TransactionMiddleware`. Shares the same query-builder interface as `api.db.db`,
10
+ * so ops functions can accept either without changing their query code.
11
+ */
12
+ export type Transaction = NodePgDatabase<Record<string, never>>;
13
+
14
+ /**
15
+ * Union of the top-level Drizzle database and a transaction-scoped instance.
16
+ * Use this as the `db` parameter type in ops/helper functions so callers can
17
+ * pass either `api.db.db` (no transaction) or a `Transaction` from middleware.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { api, type DbOrTransaction } from "keryx";
22
+ * import { users } from "../schema/users";
23
+ *
24
+ * export async function findUserByEmail(email: string, db: DbOrTransaction = api.db.db) {
25
+ * return db.select().from(users).where(eq(users.email, email)).limit(1);
26
+ * }
27
+ * ```
28
+ */
29
+ export type DbOrTransaction = NodePgDatabase<Record<string, never>>;
30
+
31
+ /**
32
+ * Run a callback inside a database transaction with automatic commit/rollback.
33
+ *
34
+ * Acquires a dedicated `PoolClient` from `api.db.pool`, issues `BEGIN`, and
35
+ * creates a Drizzle instance scoped to that client. If `fn` resolves, the
36
+ * transaction is committed; if it throws, the transaction is rolled back and
37
+ * the error is re-thrown. The pool client is always released.
38
+ *
39
+ * For request-scoped transactions that span middleware + action execution,
40
+ * use `TransactionMiddleware` instead — it manages the same lifecycle
41
+ * automatically via `connection.metadata.transaction`.
42
+ *
43
+ * @param fn - Async callback that receives a transaction-scoped Drizzle instance.
44
+ * All queries executed through `tx` participate in the same transaction.
45
+ * @returns The value returned by `fn`.
46
+ * @throws {TypedError} Re-throws `TypedError` directly. Wraps other errors in
47
+ * a `TypedError` with `ErrorType.CONNECTION_ACTION_RUN`.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * const user = await withTransaction(async (tx) => {
52
+ * const [created] = await tx.insert(users).values({ name: "Alice" }).returning();
53
+ * await tx.insert(auditLogs).values({ action: "user:create", userId: created.id });
54
+ * return created;
55
+ * });
56
+ * ```
57
+ */
58
+ export async function withTransaction<T>(
59
+ fn: (tx: Transaction) => Promise<T>,
60
+ ): Promise<T> {
61
+ const client = await api.db.pool.connect();
62
+ try {
63
+ await client.query("BEGIN");
64
+ const tx = drizzle(client) as Transaction;
65
+ const result = await fn(tx);
66
+ await client.query("COMMIT");
67
+ logger.debug("transaction committed");
68
+ return result;
69
+ } catch (e) {
70
+ await client.query("ROLLBACK");
71
+ logger.debug("transaction rolled back");
72
+ if (e instanceof TypedError) throw e;
73
+ throw new TypedError({
74
+ message: `${e}`,
75
+ type: ErrorType.CONNECTION_ACTION_RUN,
76
+ originalError: e,
77
+ });
78
+ } finally {
79
+ client.release();
80
+ }
81
+ }
package/util/zodMixins.ts CHANGED
@@ -46,6 +46,39 @@ export function zBooleanFromString() {
46
46
  });
47
47
  }
48
48
 
49
+ /**
50
+ * Creates a Zod schema for pagination inputs with sensible defaults.
51
+ * Returns `{ page, limit }` where `page` is 1-indexed.
52
+ *
53
+ * @param options - Optional overrides for default values and bounds.
54
+ * @param options.defaultLimit - Default number of items per page (default: 25).
55
+ * @param options.maxLimit - Maximum allowed items per page (default: 100).
56
+ * @returns A Zod object schema with `page` and `limit` fields.
57
+ */
58
+ export function paginationInputs(options?: {
59
+ defaultLimit?: number;
60
+ maxLimit?: number;
61
+ }) {
62
+ const defaultLimit = options?.defaultLimit ?? 25;
63
+ const maxLimit = options?.maxLimit ?? 100;
64
+
65
+ return z.object({
66
+ page: z.coerce
67
+ .number()
68
+ .int()
69
+ .min(1)
70
+ .default(1)
71
+ .describe("Page number (1-indexed)"),
72
+ limit: z.coerce
73
+ .number()
74
+ .int()
75
+ .min(1)
76
+ .max(maxLimit)
77
+ .default(defaultLimit)
78
+ .describe("Number of items per page"),
79
+ });
80
+ }
81
+
49
82
  // Type for Drizzle tables with an id column
50
83
  type TableWithId = { id: any; $inferSelect: any };
51
84