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 +9 -1
- package/classes/Connection.ts +10 -2
- package/index.ts +3 -0
- package/middleware/transaction.ts +95 -0
- package/package.json +2 -2
- package/util/transaction.ts +81 -0
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
|
|
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
|
|
package/classes/Connection.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
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
|
+
}
|