@fuguejs/pg 0.1.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/README.md +106 -0
- package/package.json +42 -0
- package/src/index.ts +408 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @fuguejs/pg
|
|
2
|
+
|
|
3
|
+
PostgreSQL capability adapter for Fugue workflows.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @fuguejs/pg pg zod
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`pg` and `zod` are peer dependencies — `pg` provides the connection pool,
|
|
12
|
+
`zod` the schemas every `query`/`queryOne` call validates against.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
### Register with the host
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { createPgAdapter } from "@fuguejs/pg";
|
|
20
|
+
|
|
21
|
+
const pgHandle = createPgAdapter({
|
|
22
|
+
connectionString: process.env.DATABASE_URL!,
|
|
23
|
+
poolSize: 20,
|
|
24
|
+
statementTimeoutMs: 15_000,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Pass to SharedInfra capabilities:
|
|
28
|
+
const sharedInfra = {
|
|
29
|
+
// ... other infra ...
|
|
30
|
+
capabilities: [pgHandle],
|
|
31
|
+
};
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Use in a node
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { createFetchNode } from "@fuguejs/framework";
|
|
38
|
+
import { z } from "zod";
|
|
39
|
+
|
|
40
|
+
const UserSchema = z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
name: z.string(),
|
|
43
|
+
email: z.string().email(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const fetchUser = createFetchNode({
|
|
47
|
+
id: "fetch-user",
|
|
48
|
+
inputSchema: z.object({ userId: z.string() }),
|
|
49
|
+
outputSchema: UserSchema,
|
|
50
|
+
requires: ["db"] as const,
|
|
51
|
+
fetch: async (input, ctx) => {
|
|
52
|
+
// ctx.db is typed as PgCapability — non-null, schema-validated
|
|
53
|
+
return ctx.db.queryOne(
|
|
54
|
+
UserSchema,
|
|
55
|
+
"SELECT id, name, email FROM users WHERE id = $1",
|
|
56
|
+
[input.userId],
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Testing with the fake
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { createFakePgCapability } from "@fuguejs/pg";
|
|
66
|
+
|
|
67
|
+
const fakeDb = createFakePgCapability({
|
|
68
|
+
"SELECT * FROM users WHERE id": [
|
|
69
|
+
{ id: "1", name: "Alice", email: "alice@example.com" },
|
|
70
|
+
],
|
|
71
|
+
"INSERT INTO orders": { rowCount: 1 },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Use in tests via makeNodeContext:
|
|
75
|
+
const ctx = makeNodeContext({
|
|
76
|
+
runId: "test-run",
|
|
77
|
+
dagId: "test-dag",
|
|
78
|
+
capabilities: { db: fakeDb.client },
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
### `PgCapability`
|
|
85
|
+
|
|
86
|
+
| Method | Description |
|
|
87
|
+
|--------|-------------|
|
|
88
|
+
| `query<T>(schema, sql, params?)` | Execute query, validate all rows against Zod schema |
|
|
89
|
+
| `queryOne<T>(schema, sql, params?)` | Execute query, validate first row (or null) |
|
|
90
|
+
| `execute(sql, params?)` | Execute write, return `{ rowCount }` |
|
|
91
|
+
| `queryRaw(sql, params?)` | Escape hatch: raw `unknown[]` rows, no validation — prefer `query` with a schema |
|
|
92
|
+
|
|
93
|
+
All methods return `Result<T, FrameworkError>` — no exceptions escape.
|
|
94
|
+
|
|
95
|
+
### `createPgAdapter(config)`
|
|
96
|
+
|
|
97
|
+
Creates a `CapabilityHandle<"db">` with lifecycle management:
|
|
98
|
+
- `connect()`: validates connectivity with SELECT 1
|
|
99
|
+
- `close()`: drains the connection pool
|
|
100
|
+
- `healthCheck()`: SELECT 1, racing a 5s timeout (a hung pool reports unhealthy)
|
|
101
|
+
|
|
102
|
+
Config options: `poolSize` (default 10), `statementTimeoutMs` (default 30000, applied as the pool's `statement_timeout`), `connectionTimeoutMs` (default 5000), `idleTimeoutMs` (default 10000).
|
|
103
|
+
|
|
104
|
+
### `createFakePgCapability(routes)`
|
|
105
|
+
|
|
106
|
+
In-memory fake for testing. Routes are matched by exact SQL or longest prefix match.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fuguejs/pg",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "bun test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@fuguejs/framework": "0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"pg": "^8.0.0",
|
|
19
|
+
"zod": "^4.3.6"
|
|
20
|
+
},
|
|
21
|
+
"peerDependenciesMeta": {
|
|
22
|
+
"pg": {
|
|
23
|
+
"optional": false
|
|
24
|
+
},
|
|
25
|
+
"zod": {
|
|
26
|
+
"optional": false
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/pg": "^8.0.0",
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"pg": "^8.13.0",
|
|
33
|
+
"zod": "^4.3.6"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src",
|
|
40
|
+
"!src/__tests__"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fuguejs/pg — PostgreSQL capability adapter for Fugue.
|
|
3
|
+
*
|
|
4
|
+
* Provides a typed `PgCapability` interface that nodes access via
|
|
5
|
+
* `requires: ["db"]` and `ctx.db`. Wraps the `pg` Pool with:
|
|
6
|
+
*
|
|
7
|
+
* - Zod schema validation of query results
|
|
8
|
+
* - Result-based error handling (no exceptions escape)
|
|
9
|
+
* - Connection pool lifecycle management via CapabilityHandle
|
|
10
|
+
* - Health check (SELECT 1) for degraded-state detection
|
|
11
|
+
*
|
|
12
|
+
* ## Usage
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createPgAdapter } from "@fuguejs/pg";
|
|
16
|
+
*
|
|
17
|
+
* const pgHandle = createPgAdapter({
|
|
18
|
+
* connectionString: process.env.DATABASE_URL,
|
|
19
|
+
* poolSize: 10,
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* // Register with the host:
|
|
23
|
+
* const sharedInfra = { ..., capabilities: [pgHandle] };
|
|
24
|
+
*
|
|
25
|
+
* // In a node:
|
|
26
|
+
* createFetchNode({
|
|
27
|
+
* id: "fetch-users",
|
|
28
|
+
* requires: ["db"] as const,
|
|
29
|
+
* fetch: async (input, ctx) => {
|
|
30
|
+
* return ctx.db.query(UserSchema, "SELECT * FROM users WHERE active = $1", [true]);
|
|
31
|
+
* },
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* ## Module Augmentation
|
|
36
|
+
*
|
|
37
|
+
* This package augments `@fuguejs/framework`'s `CapabilityRegistry` to add the
|
|
38
|
+
* `"db"` capability. After importing this package, `requires: ["db"]` becomes
|
|
39
|
+
* valid and `ctx.db` is typed as `PgCapability`.
|
|
40
|
+
*
|
|
41
|
+
* @satisfies ADR-0051 — Extensible capability registry
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { createRequire } from "node:module";
|
|
45
|
+
import type { z } from "zod";
|
|
46
|
+
import type { Result, FrameworkError, CapabilityHandle } from "@fuguejs/framework";
|
|
47
|
+
import { ok, err, nodeId } from "@fuguejs/framework";
|
|
48
|
+
import type { Pool as PgPool, PoolConfig } from "pg";
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Capability Interface
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/** Sentinel node ID for pg capability errors */
|
|
55
|
+
const PG_NODE_ID = nodeId("pg-capability");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* PostgreSQL capability interface — what nodes see on `ctx.db`.
|
|
59
|
+
*
|
|
60
|
+
* All methods return `Result` — no exceptions escape. Query results are
|
|
61
|
+
* validated against the provided Zod schema.
|
|
62
|
+
*/
|
|
63
|
+
export interface PgCapability {
|
|
64
|
+
/**
|
|
65
|
+
* Execute a query and validate all rows against the schema.
|
|
66
|
+
* Returns an empty array if no rows match.
|
|
67
|
+
*/
|
|
68
|
+
query<T>(schema: z.ZodType<T>, sql: string, params?: unknown[]): Promise<Result<T[], FrameworkError>>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Execute a query expecting at most one row. Returns `null` if no rows match.
|
|
72
|
+
* Validates the row against the schema if present.
|
|
73
|
+
*/
|
|
74
|
+
queryOne<T>(schema: z.ZodType<T>, sql: string, params?: unknown[]): Promise<Result<T | null, FrameworkError>>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Execute a write statement (INSERT, UPDATE, DELETE).
|
|
78
|
+
* Returns the number of affected rows.
|
|
79
|
+
*/
|
|
80
|
+
execute(sql: string, params?: unknown[]): Promise<Result<{ rowCount: number }, FrameworkError>>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Escape hatch: execute a query and return raw rows as `unknown[]` with NO
|
|
84
|
+
* schema validation. This bypasses parse-don't-validate — every row must be
|
|
85
|
+
* narrowed manually before use. Reach for `query` with a Zod schema first;
|
|
86
|
+
* use this only for genuinely dynamic schemas (e.g. `information_schema`
|
|
87
|
+
* introspection) or pass-through tooling.
|
|
88
|
+
*/
|
|
89
|
+
queryRaw(sql: string, params?: unknown[]): Promise<Result<unknown[], FrameworkError>>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Module Augmentation
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
declare module "@fuguejs/framework" {
|
|
97
|
+
interface CapabilityRegistry {
|
|
98
|
+
/** PostgreSQL database capability. Access via `ctx.db` in nodes. */
|
|
99
|
+
db: PgCapability;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Configuration
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Configuration for the PostgreSQL adapter.
|
|
109
|
+
*/
|
|
110
|
+
export interface PgAdapterConfig {
|
|
111
|
+
/** PostgreSQL connection string (e.g., "postgresql://user:pass@host:5432/db") */
|
|
112
|
+
readonly connectionString: string;
|
|
113
|
+
/** Maximum number of connections in the pool. Default: 10. */
|
|
114
|
+
readonly poolSize?: number;
|
|
115
|
+
/** Statement timeout in milliseconds. Default: 30000 (30s). */
|
|
116
|
+
readonly statementTimeoutMs?: number;
|
|
117
|
+
/** Connection timeout in milliseconds. Default: 5000 (5s). */
|
|
118
|
+
readonly connectionTimeoutMs?: number;
|
|
119
|
+
/** Idle timeout in milliseconds before a connection is closed. Default: 10000 (10s). */
|
|
120
|
+
readonly idleTimeoutMs?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Implementation
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Map a `pg` error to a `FrameworkError`. Connection-class SQLSTATEs
|
|
129
|
+
* (08xxx), insufficient-resources (53xxx), and admin shutdown (57P01) are
|
|
130
|
+
* `transient` (retriable); everything else (syntax, constraint, etc.) is a
|
|
131
|
+
* non-retriable `node-crash`. Exported for testing — this classification
|
|
132
|
+
* drives retry behavior.
|
|
133
|
+
*/
|
|
134
|
+
export const mapPgError = (error: unknown, sql: string): FrameworkError => {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
// Determine if the error is transient (connection issues) or permanent (syntax, constraint).
|
|
137
|
+
// Guard the SQLSTATE on its runtime type — a non-string `code` (a driver that
|
|
138
|
+
// sets it numeric, or an unrelated object carrying a `code` field) must not
|
|
139
|
+
// make `.startsWith` throw out of this function and escape the client's catch.
|
|
140
|
+
if (error instanceof Error && "code" in error) {
|
|
141
|
+
const pgCode = (error as { code?: unknown }).code;
|
|
142
|
+
// Connection-class errors (08xxx) and insufficient resources (53xxx) are transient
|
|
143
|
+
if (
|
|
144
|
+
typeof pgCode === "string" &&
|
|
145
|
+
(pgCode.startsWith("08") || pgCode.startsWith("53") || pgCode === "57P01")
|
|
146
|
+
) {
|
|
147
|
+
return { kind: "transient", nodeId: PG_NODE_ID, message: `PG transient: ${message} (${pgCode})` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
kind: "node-crash",
|
|
152
|
+
nodeId: PG_NODE_ID,
|
|
153
|
+
message: `PG error: ${message} [sql: ${sql.slice(0, 100)}]`,
|
|
154
|
+
retriability: "non-retriable",
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The slice of `pg.Pool` the capability client actually uses. Tests inject a
|
|
160
|
+
* fake; production passes the real pool (structurally compatible).
|
|
161
|
+
*/
|
|
162
|
+
export interface PgQueryable {
|
|
163
|
+
query(sql: string, params?: unknown[]): Promise<{ rows: unknown[]; rowCount: number | null }>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build a `PgCapability` over an injected pool. Exported for testing —
|
|
168
|
+
* `createPgAdapter` is the production entry point that owns pool
|
|
169
|
+
* construction and lifecycle.
|
|
170
|
+
*/
|
|
171
|
+
export const createPgClient = (pool: PgQueryable): PgCapability => ({
|
|
172
|
+
query: async <T,>(schema: z.ZodType<T>, sql: string, params?: unknown[]): Promise<Result<T[], FrameworkError>> => {
|
|
173
|
+
try {
|
|
174
|
+
const result = await pool.query(sql, params);
|
|
175
|
+
const validated: T[] = [];
|
|
176
|
+
for (const row of result.rows) {
|
|
177
|
+
const parsed = schema.safeParse(row);
|
|
178
|
+
if (!parsed.success) {
|
|
179
|
+
return err({
|
|
180
|
+
kind: "node-crash",
|
|
181
|
+
nodeId: PG_NODE_ID,
|
|
182
|
+
message: `Row validation failed: ${parsed.error.message}`,
|
|
183
|
+
retriability: "non-retriable",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
validated.push(parsed.data);
|
|
187
|
+
}
|
|
188
|
+
return ok(validated);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return err(mapPgError(error, sql));
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
queryOne: async <T,>(schema: z.ZodType<T>, sql: string, params?: unknown[]): Promise<Result<T | null, FrameworkError>> => {
|
|
195
|
+
try {
|
|
196
|
+
const result = await pool.query(sql, params);
|
|
197
|
+
if (result.rows.length === 0) return ok(null);
|
|
198
|
+
const parsed = schema.safeParse(result.rows[0]);
|
|
199
|
+
if (!parsed.success) {
|
|
200
|
+
return err({
|
|
201
|
+
kind: "node-crash",
|
|
202
|
+
nodeId: PG_NODE_ID,
|
|
203
|
+
message: `Row validation failed: ${parsed.error.message}`,
|
|
204
|
+
retriability: "non-retriable",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return ok(parsed.data);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return err(mapPgError(error, sql));
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
execute: async (sql: string, params?: unknown[]): Promise<Result<{ rowCount: number }, FrameworkError>> => {
|
|
214
|
+
try {
|
|
215
|
+
const result = await pool.query(sql, params);
|
|
216
|
+
return ok({ rowCount: result.rowCount ?? 0 });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return err(mapPgError(error, sql));
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
queryRaw: async (sql: string, params?: unknown[]): Promise<Result<unknown[], FrameworkError>> => {
|
|
223
|
+
try {
|
|
224
|
+
const result = await pool.query(sql, params);
|
|
225
|
+
return ok(result.rows);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return err(mapPgError(error, sql));
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Adapter Factory
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/** Health checks are bounded by this timeout so a hung pool reports unhealthy. */
|
|
237
|
+
const HEALTH_CHECK_TIMEOUT_MS = 5_000;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a PostgreSQL capability handle.
|
|
241
|
+
*
|
|
242
|
+
* The handle manages the connection pool lifecycle:
|
|
243
|
+
* - `connect()`: validates connectivity with a SELECT 1 query
|
|
244
|
+
* - `close()`: drains the pool
|
|
245
|
+
* - `healthCheck()`: runs SELECT 1, racing a 5s timeout
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* const pg = createPgAdapter({
|
|
250
|
+
* connectionString: process.env.DATABASE_URL!,
|
|
251
|
+
* poolSize: 20,
|
|
252
|
+
* statementTimeoutMs: 15_000,
|
|
253
|
+
* });
|
|
254
|
+
*
|
|
255
|
+
* // Register with host
|
|
256
|
+
* const sharedInfra = { ..., capabilities: [pg] };
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
export const createPgAdapter = (config: PgAdapterConfig): CapabilityHandle<"db"> => {
|
|
260
|
+
// Lazy pool creation — constructed at handle creation, connected at boot.
|
|
261
|
+
// `createRequire` keeps the lazy-CJS load working under both Bun and plain
|
|
262
|
+
// Node ESM (a bare `require` is undefined in Node ESM module scope).
|
|
263
|
+
const requireModule = createRequire(import.meta.url);
|
|
264
|
+
const { Pool } = requireModule("pg") as typeof import("pg");
|
|
265
|
+
|
|
266
|
+
const poolConfig: PoolConfig = {
|
|
267
|
+
connectionString: config.connectionString,
|
|
268
|
+
max: config.poolSize ?? 10,
|
|
269
|
+
connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
|
|
270
|
+
idleTimeoutMillis: config.idleTimeoutMs ?? 10_000,
|
|
271
|
+
statement_timeout: config.statementTimeoutMs ?? 30_000,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const pool = new Pool(poolConfig);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
name: "db",
|
|
278
|
+
client: createPgClient(pool),
|
|
279
|
+
|
|
280
|
+
connect: async () => {
|
|
281
|
+
// Validate connectivity
|
|
282
|
+
const client = await pool.connect();
|
|
283
|
+
try {
|
|
284
|
+
await client.query("SELECT 1");
|
|
285
|
+
} finally {
|
|
286
|
+
client.release();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
close: async () => {
|
|
291
|
+
await pool.end();
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
healthCheck: () => healthCheckWithTimeout(pool, HEALTH_CHECK_TIMEOUT_MS),
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Race SELECT 1 against a timeout. A pool that hangs (e.g. exhausted
|
|
300
|
+
* connections, dead network) reports unhealthy instead of stalling the
|
|
301
|
+
* caller. Exported for testing.
|
|
302
|
+
*/
|
|
303
|
+
export const healthCheckWithTimeout = async (
|
|
304
|
+
pool: PgQueryable,
|
|
305
|
+
timeoutMs: number,
|
|
306
|
+
): Promise<Result<void, string>> => {
|
|
307
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
308
|
+
try {
|
|
309
|
+
await Promise.race([
|
|
310
|
+
pool.query("SELECT 1"),
|
|
311
|
+
new Promise<never>((_, reject) => {
|
|
312
|
+
timer = setTimeout(
|
|
313
|
+
() => reject(new Error(`health check timed out after ${timeoutMs}ms`)),
|
|
314
|
+
timeoutMs,
|
|
315
|
+
);
|
|
316
|
+
}),
|
|
317
|
+
]);
|
|
318
|
+
return ok(undefined);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
321
|
+
} finally {
|
|
322
|
+
if (timer != null) clearTimeout(timer);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Fake for Testing
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* In-memory fake PgCapability for unit testing nodes that use `ctx.db`.
|
|
332
|
+
*
|
|
333
|
+
* Accepts a response map that returns canned results for SQL patterns.
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```ts
|
|
337
|
+
* const fakeDb = createFakePgCapability({
|
|
338
|
+
* "SELECT * FROM users": [{ id: "1", name: "Alice" }],
|
|
339
|
+
* "INSERT INTO orders": { rowCount: 1 },
|
|
340
|
+
* });
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
343
|
+
export interface FakePgRoute {
|
|
344
|
+
readonly rows?: unknown[];
|
|
345
|
+
readonly rowCount?: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export const createFakePgCapability = (
|
|
349
|
+
routes: Readonly<Record<string, unknown[] | FakePgRoute>>,
|
|
350
|
+
): CapabilityHandle<"db"> => {
|
|
351
|
+
const matchRoute = (sql: string): FakePgRoute | null => {
|
|
352
|
+
// Try exact match first, then longest prefix match
|
|
353
|
+
const direct = routes[sql];
|
|
354
|
+
if (direct) {
|
|
355
|
+
return Array.isArray(direct) ? { rows: direct } : direct;
|
|
356
|
+
}
|
|
357
|
+
// Find the longest prefix match
|
|
358
|
+
let bestMatch: FakePgRoute | null = null;
|
|
359
|
+
let bestLength = 0;
|
|
360
|
+
for (const [pattern, value] of Object.entries(routes)) {
|
|
361
|
+
if (sql.startsWith(pattern) && pattern.length > bestLength) {
|
|
362
|
+
bestMatch = Array.isArray(value) ? { rows: value } : value;
|
|
363
|
+
bestLength = pattern.length;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return bestMatch;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const client: PgCapability = {
|
|
370
|
+
query: async <T,>(schema: z.ZodType<T>, sql: string, _params?: unknown[]): Promise<Result<T[], FrameworkError>> => {
|
|
371
|
+
const route = matchRoute(sql);
|
|
372
|
+
if (!route || !route.rows) {
|
|
373
|
+
return ok([] as T[]);
|
|
374
|
+
}
|
|
375
|
+
const validated: T[] = [];
|
|
376
|
+
for (const row of route.rows) {
|
|
377
|
+
const parsed = schema.safeParse(row);
|
|
378
|
+
if (!parsed.success) {
|
|
379
|
+
return err({ kind: "node-crash", nodeId: PG_NODE_ID, message: `Fake row validation: ${parsed.error.message}`, retriability: "non-retriable" });
|
|
380
|
+
}
|
|
381
|
+
validated.push(parsed.data);
|
|
382
|
+
}
|
|
383
|
+
return ok(validated);
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
queryOne: async <T,>(schema: z.ZodType<T>, sql: string, _params?: unknown[]): Promise<Result<T | null, FrameworkError>> => {
|
|
387
|
+
const route = matchRoute(sql);
|
|
388
|
+
if (!route || !route.rows || route.rows.length === 0) return ok(null);
|
|
389
|
+
const parsed = schema.safeParse(route.rows[0]);
|
|
390
|
+
if (!parsed.success) {
|
|
391
|
+
return err({ kind: "node-crash", nodeId: PG_NODE_ID, message: `Fake row validation: ${parsed.error.message}`, retriability: "non-retriable" });
|
|
392
|
+
}
|
|
393
|
+
return ok(parsed.data);
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
execute: async (sql: string, _params?: unknown[]): Promise<Result<{ rowCount: number }, FrameworkError>> => {
|
|
397
|
+
const route = matchRoute(sql);
|
|
398
|
+
return ok({ rowCount: route?.rowCount ?? 0 });
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
queryRaw: async (sql: string, _params?: unknown[]): Promise<Result<unknown[], FrameworkError>> => {
|
|
402
|
+
const route = matchRoute(sql);
|
|
403
|
+
return ok(route?.rows ?? []);
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
return { name: "db", client };
|
|
408
|
+
};
|