@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.
Files changed (3) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -0
  3. 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
+ };