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 +9 -1
- package/classes/Connection.ts +10 -2
- package/index.ts +5 -0
- package/middleware/transaction.ts +95 -0
- package/package.json +1 -1
- package/util/pagination.ts +54 -0
- package/util/transaction.ts +81 -0
- package/util/zodMixins.ts +33 -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,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
|
@@ -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
|
|