keryx 0.17.0 → 0.18.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,11 +29,14 @@ 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";
36
37
  export { toMarkdown } from "./util/toMarkdown";
38
+ export type { DbOrTransaction, Transaction } from "./util/transaction";
39
+ export { withTransaction } from "./util/transaction";
37
40
  export {
38
41
  isSecret,
39
42
  secret,
@@ -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.0",
3
+ "version": "0.18.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -99,7 +99,7 @@
99
99
  "zod": "^4.3.6"
100
100
  },
101
101
  "devDependencies": {
102
- "@types/bun": "latest",
102
+ "@types/bun": "^1.1.8",
103
103
  "@types/formidable": "^3.4.6",
104
104
  "drizzle-kit": "^0.31.9",
105
105
  "drizzle-zod": "^0.8.3"
@@ -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
+ }