@fuguejs/oracle 0.3.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 +135 -0
  2. package/package.json +47 -0
  3. package/src/index.ts +658 -0
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @fuguejs/oracle
2
+
3
+ Oracle capability adapter for Fugue workflows. **Read-only** — `query` /
4
+ `queryOne` / `queryRaw` only, no write surface.
5
+
6
+ The driver runs in **thin mode** (pure-JS Oracle Net over TCP — no Instant
7
+ Client, no native addon, musl/alpine-safe) and is lazy-loaded via
8
+ `createRequire`, exactly as `@fuguejs/pg` loads `pg`.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ bun add @fuguejs/oracle oracledb zod
14
+ ```
15
+
16
+ `oracledb` and `zod` are peer dependencies — `oracledb` provides the
17
+ connection pool, `zod` the schemas every `query`/`queryOne` call validates
18
+ against.
19
+
20
+ ## Capability key: `oracle` (not `db`)
21
+
22
+ This package augments `@fuguejs/framework`'s `CapabilityRegistry` with the
23
+ `"oracle"` key — distinct from `@fuguejs/pg`'s `"db"`, so both adapters compose
24
+ in one host. Nodes declare `requires: ["oracle"]` and read `ctx.oracle`.
25
+
26
+ ## Named binds (`:name`)
27
+
28
+ The one API divergence from `@fuguejs/pg`: binds are **named** and passed as a
29
+ `Record<string, unknown>` (`{ subId: "123" }`), bound to `:name` placeholders,
30
+ with `outFormat: OUT_FORMAT_OBJECT`. `@fuguejs/pg` uses positional `$1` /
31
+ `unknown[]`.
32
+
33
+ ## Usage
34
+
35
+ ### Register with the host
36
+
37
+ ```ts
38
+ import { createOracleAdapter } from "@fuguejs/oracle";
39
+
40
+ const oracleHandle = createOracleAdapter({
41
+ connectString: process.env.ORACLE_CONNECT_STRING!, // HOST:PORT/SERVICE
42
+ user: process.env.ORACLE_USER!,
43
+ password: process.env.ORACLE_PASSWORD!,
44
+ poolMax: 8,
45
+ });
46
+
47
+ const sharedInfra = {
48
+ // ... other infra ...
49
+ capabilities: [oracleHandle],
50
+ };
51
+ ```
52
+
53
+ ### Use in a node
54
+
55
+ ```ts
56
+ import { createFetchNode } from "@fuguejs/framework";
57
+ import { z } from "zod";
58
+
59
+ const PackageInfoRowSchema = z.object({
60
+ optionKey: z.string(),
61
+ standardPrice: z.string().nullable(),
62
+ discountPrice: z.string().nullable(),
63
+ packName: z.string().nullable(),
64
+ });
65
+
66
+ const fetchPackage = createFetchNode({
67
+ id: "fetch-package",
68
+ inputSchema: z.object({ subId: z.string() }),
69
+ outputSchema: PackageInfoRowSchema.nullable(),
70
+ requires: ["oracle"] as const,
71
+ fetch: async (input, ctx) =>
72
+ ctx.oracle.queryOne(
73
+ PackageInfoRowSchema,
74
+ "SELECT * FROM TABLE(GET_PACKAGE_INFO(:subId)) pkg",
75
+ { subId: input.subId },
76
+ ),
77
+ });
78
+ ```
79
+
80
+ ### Testing with the fake
81
+
82
+ ```ts
83
+ import { createFakeOracleCapability } from "@fuguejs/oracle";
84
+
85
+ const fakeOracle = createFakeOracleCapability({
86
+ "SELECT * FROM TABLE(GET_PACKAGE_INFO": [
87
+ { optionKey: "X", standardPrice: "199", discountPrice: "99", packName: "X" },
88
+ ],
89
+ });
90
+
91
+ const ctx = makeNodeContext({
92
+ runId: "test-run",
93
+ dagId: "test-dag",
94
+ capabilities: { oracle: fakeOracle.client },
95
+ });
96
+ ```
97
+
98
+ ## API
99
+
100
+ ### `OracleCapability`
101
+
102
+ | Method | Description |
103
+ |--------|-------------|
104
+ | `query<T>(schema, sql, binds?)` | Execute query, validate all rows against Zod schema |
105
+ | `queryOne<T>(schema, sql, binds?)` | Execute query, validate first row (or null) |
106
+ | `queryRaw(sql, binds?)` | Escape hatch: raw `unknown[]` rows, no validation — prefer `query` with a schema |
107
+
108
+ All methods return `Result<T, FrameworkError>` — no exceptions escape.
109
+
110
+ ### `createOracleAdapter(config)`
111
+
112
+ Creates a `CapabilityHandle<"oracle">` with lifecycle management:
113
+ - `connect()`: validates connectivity with `SELECT 1 FROM DUAL`
114
+ - `close()`: closes the pool immediately (`pool.close(0)`, zero drain window)
115
+ - `healthCheck()`: `SELECT 1 FROM DUAL`, racing a 5s timeout (a hung pool reports unhealthy)
116
+
117
+ Config options: `connectString`, `user`, `password`, `poolMin` (default 0),
118
+ `poolMax` (default 4). Each query acquires/releases a pooled connection,
119
+ amortizing connection cost.
120
+
121
+ ### `mapOracleError(error, sql)`
122
+
123
+ Classifies an `oracledb` error. Connection-class ORA- codes
124
+ (`ORA-03113/03114/12541/12170/12514`) → `transient` (retriable); everything
125
+ else → non-retriable `node-crash`. Oracle stacks multiple ORA codes in one
126
+ message, so classification prefers the structured `errorNum` and then scans
127
+ **all** `ORA-NNNNN` tokens — if any is connection-class the error is
128
+ `transient`. **Credentials are stripped** from every message: the DSN
129
+ `user/password@host` form and the `password=` / `pwd=` / `user=` / `uid=`
130
+ key-value forms are redacted to `***` before the message is surfaced or logged.
131
+
132
+ ### `createFakeOracleCapability(routes)`
133
+
134
+ In-memory fake for testing. Routes are matched by exact SQL or longest prefix
135
+ match. Binds are not inspected.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@fuguejs/oracle",
3
+ "version": "0.3.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/peterstorm/fugue.git",
7
+ "directory": "packages/adapter-oracle"
8
+ },
9
+ "type": "module",
10
+ "main": "src/index.ts",
11
+ "exports": {
12
+ ".": "./src/index.ts"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "bun test"
18
+ },
19
+ "dependencies": {
20
+ "@fuguejs/framework": "0.3.0"
21
+ },
22
+ "peerDependencies": {
23
+ "oracledb": "^7.0.0",
24
+ "zod": "^4.3.6"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "oracledb": {
28
+ "optional": false
29
+ },
30
+ "zod": {
31
+ "optional": false
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "@types/oracledb": "^6.5.0",
36
+ "@types/bun": "latest",
37
+ "oracledb": "^7.0.0",
38
+ "zod": "^4.3.6"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "!src/__tests__"
46
+ ]
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,658 @@
1
+ /**
2
+ * @fuguejs/oracle — Oracle capability adapter for Fugue.
3
+ *
4
+ * Provides a typed `OracleCapability` interface that nodes access via
5
+ * `requires: ["oracle"]` and `ctx.oracle`. Wraps an `oracledb` thin-mode
6
+ * connection pool with:
7
+ *
8
+ * - Zod schema validation of query results
9
+ * - Result-based error handling (no exceptions escape)
10
+ * - Connection pool lifecycle management via CapabilityHandle
11
+ * - Health check (SELECT 1 FROM DUAL) for degraded-state detection
12
+ *
13
+ * **Read-only.** The capability exposes `query`/`queryOne`/`queryRaw` only —
14
+ * there is no write/exec surface (FR-032).
15
+ *
16
+ * The driver is loaded in **thin mode** (pure-JS Oracle Net over TCP — no
17
+ * Instant Client, no native addon, musl/alpine-safe) and lazy-loaded via
18
+ * `createRequire(import.meta.url)("oracledb")`, exactly as `@fuguejs/pg`
19
+ * loads `pg`.
20
+ *
21
+ * ## Usage
22
+ *
23
+ * ```ts
24
+ * import { createOracleAdapter } from "@fuguejs/oracle";
25
+ *
26
+ * const oracleHandle = createOracleAdapter({
27
+ * connectString: process.env.ORACLE_CONNECT_STRING!, // HOST:PORT/SERVICE
28
+ * user: process.env.ORACLE_USER!,
29
+ * password: process.env.ORACLE_PASSWORD!,
30
+ * poolMax: 8,
31
+ * });
32
+ *
33
+ * // Register with the host:
34
+ * const sharedInfra = { ..., capabilities: [oracleHandle] };
35
+ *
36
+ * // In a node (named binds — :name):
37
+ * createFetchNode({
38
+ * id: "fetch-package",
39
+ * requires: ["oracle"] as const,
40
+ * fetch: async (input, ctx) => {
41
+ * return ctx.oracle.queryOne(
42
+ * PackageInfoRowSchema,
43
+ * "SELECT * FROM TABLE(GET_PACKAGE_INFO(:subId)) pkg",
44
+ * { subId: input.subId },
45
+ * );
46
+ * },
47
+ * });
48
+ * ```
49
+ *
50
+ * ## Module Augmentation
51
+ *
52
+ * This package augments `@fuguejs/framework`'s `CapabilityRegistry` to add the
53
+ * `"oracle"` capability. After importing this package, `requires: ["oracle"]`
54
+ * becomes valid and `ctx.oracle` is typed as `OracleCapability`.
55
+ *
56
+ * A distinct `"oracle"` key (not `"db"`) is used deliberately: `@fuguejs/pg`
57
+ * already claims `"db"`, and two adapters cannot augment the same registry key.
58
+ *
59
+ * @satisfies ADR-0051 — Extensible capability registry
60
+ */
61
+
62
+ import { createRequire } from "node:module";
63
+ import type { z } from "zod";
64
+ import type { Result, FrameworkError, CapabilityHandle } from "@fuguejs/framework";
65
+ import { ok, err, nodeId } from "@fuguejs/framework";
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Capability Interface
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Sentinel node ID for oracle capability errors */
72
+ const ORACLE_NODE_ID = nodeId("oracle-capability");
73
+
74
+ /**
75
+ * Oracle capability interface — what nodes see on `ctx.oracle`.
76
+ *
77
+ * All methods return `Result` — no exceptions escape. Query results are
78
+ * validated against the provided Zod schema. Binds are **named** (`:name`)
79
+ * and supplied as `Record<string, unknown>` — the one API divergence from
80
+ * `@fuguejs/pg`'s positional `$1` / `unknown[]`.
81
+ *
82
+ * Read-only: there is intentionally no `execute`/write method.
83
+ */
84
+ export interface OracleCapability {
85
+ /**
86
+ * Execute a query and validate all rows against the schema.
87
+ * Returns an empty array if no rows match. Binds `:name` placeholders.
88
+ */
89
+ query<T>(schema: z.ZodType<T>, sql: string, binds?: Record<string, unknown>): Promise<Result<T[], FrameworkError>>;
90
+
91
+ /**
92
+ * Execute a query expecting at most one row. Returns `null` if no rows match.
93
+ * Validates the row against the schema if present. Binds `:name` placeholders.
94
+ */
95
+ queryOne<T>(schema: z.ZodType<T>, sql: string, binds?: Record<string, unknown>): Promise<Result<T | null, FrameworkError>>;
96
+
97
+ /**
98
+ * Escape hatch: execute a query and return raw rows as `unknown[]` with NO
99
+ * schema validation. This bypasses parse-don't-validate — every row must be
100
+ * narrowed manually before use. Reach for `query` with a Zod schema first;
101
+ * use this only for genuinely dynamic schemas or pass-through tooling.
102
+ */
103
+ queryRaw(sql: string, binds?: Record<string, unknown>): Promise<Result<unknown[], FrameworkError>>;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Module Augmentation
108
+ // ---------------------------------------------------------------------------
109
+
110
+ declare module "@fuguejs/framework" {
111
+ interface CapabilityRegistry {
112
+ /** Oracle database capability (read-only). Access via `ctx.oracle` in nodes. */
113
+ oracle: OracleCapability;
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Configuration
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Configuration for the Oracle adapter. Credentials come from env config
123
+ * only — never hardcoded (FR-040) and never logged (FR-041).
124
+ */
125
+ export interface OracleAdapterConfig {
126
+ /** Easy-connect string: `HOST:PORT/SERVICE`. */
127
+ readonly connectString: string;
128
+ /** Oracle schema/user. */
129
+ readonly user: string;
130
+ /** Oracle password. Never logged nor included in any error message. */
131
+ readonly password: string;
132
+ /** Minimum number of connections kept open in the pool. Default: 0. */
133
+ readonly poolMin?: number;
134
+ /** Maximum number of connections in the pool. Default: 4. */
135
+ readonly poolMax?: number;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Implementation
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * ORA- codes treated as transient (retriable) connection-class failures:
144
+ * - ORA-03113 end-of-file on communication channel
145
+ * - ORA-03114 not connected to ORACLE
146
+ * - ORA-12541 no listener
147
+ * - ORA-12170 connect timeout
148
+ * - ORA-12514 listener does not currently know of service
149
+ */
150
+ const TRANSIENT_ORA_CODES: ReadonlySet<string> = new Set([
151
+ "ORA-03113",
152
+ "ORA-03114",
153
+ "ORA-12541",
154
+ "ORA-12170",
155
+ "ORA-12514",
156
+ ]);
157
+
158
+ /** Match every `ORA-NNNNN` code in a driver error message. Oracle stacks codes. */
159
+ const ORA_CODE_GLOBAL = /ORA-\d{5}/g;
160
+
161
+ /**
162
+ * Read the structured numeric `errorNum` an `oracledb` error carries (e.g.
163
+ * `12541`) and render it as a canonical `ORA-NNNNN` token. Returns undefined
164
+ * when the field is absent or not a usable integer.
165
+ */
166
+ const oraCodeFromErrorNum = (error: unknown): string | undefined => {
167
+ if (error == null || typeof error !== "object") return undefined;
168
+ const errorNum = (error as { readonly errorNum?: unknown }).errorNum;
169
+ if (typeof errorNum !== "number" || !Number.isInteger(errorNum) || errorNum < 0) {
170
+ return undefined;
171
+ }
172
+ return `ORA-${String(errorNum).padStart(5, "0")}`;
173
+ };
174
+
175
+ /**
176
+ * Strip anything that could be a credential from a string before it is surfaced
177
+ * or logged. Handles BOTH credential shapes Oracle connect strings / driver
178
+ * errors carry (FR-041, NFR-020, SC-008):
179
+ *
180
+ * 1. easy-connect / DSN `user/password@host` fragments → `***@host`. The
181
+ * password MAY itself contain `@` (e.g. `u/p@ss@host`), so the credential
182
+ * run is consumed GREEDILY up to the LAST `@` of the whitespace-delimited
183
+ * token — the `@` that precedes the host. A non-greedy match would stop at
184
+ * the first `@` inside the password and leak the remainder (the bug this
185
+ * replaces). Over-redacting toward the host boundary is the safe direction.
186
+ * 2. `password=` / `pwd=` / `user=` / `uid=` key-value pairs (TNS/JDBC
187
+ * long-form descriptors, e.g. `(DESCRIPTION=...(PASSWORD=secret)...)`) →
188
+ * `key=***`. Case-insensitive; bounded by `;`, `,`, whitespace, or `)`.
189
+ *
190
+ * Idempotent: `***@` and `key=***` are stable under re-application. Pure: no I/O.
191
+ *
192
+ * Exported so the HOST's `connectStringHost` (which surfaces the non-secret
193
+ * host:port/service of a connect string at boot) can delegate to the SAME tested
194
+ * stripper rather than re-implementing a weaker leading-`@` strip.
195
+ */
196
+ export const stripCredentials = (message: string): string =>
197
+ message
198
+ // easy-connect / DSN style: user/password@... → ***@... (greedy to last @).
199
+ .replace(/\b[\w.$-]+\/[^\s]*@/g, "***@")
200
+ // bare user@host form (no `/password`): user@host:port/svc → ***@host:port/svc.
201
+ // The lookahead requires a host-like token followed by `:` (port) or `/`
202
+ // (service) so this only fires on a connect-string `userinfo@host`, not on
203
+ // ordinary prose. `***@` is left intact (`*` ∉ [\w.$-]), so this stays
204
+ // idempotent and composes with the easy-connect rule above.
205
+ .replace(/\b[\w.$-]+@(?=[\w.$-]+[:/])/g, "***@")
206
+ // key=value password fields (password=, pwd=, user=, uid=).
207
+ .replace(/\b(password|pwd|user|uid)\s*=\s*[^;,\s)]+/gi, "$1=***");
208
+
209
+ /**
210
+ * Map an `oracledb` error to a `FrameworkError`. Connection-class ORA- codes
211
+ * (`ORA-03113/03114/12541/12170/12514`) are `transient` (retriable);
212
+ * everything else (syntax, missing table, etc.) is a non-retriable
213
+ * `node-crash`. Credentials are stripped from every message. Exported for
214
+ * testing — this classification drives retry behavior.
215
+ */
216
+ export const mapOracleError = (error: unknown, sql: string): FrameworkError => {
217
+ const rawMessage = error instanceof Error ? error.message : String(error);
218
+ const message = stripCredentials(rawMessage);
219
+
220
+ // Prefer the driver's structured numeric `errorNum` when present (it is the
221
+ // unambiguous code), then fall back to scanning the message. Oracle commonly
222
+ // STACKS several ORA codes in one message (a generic ORA-06512 / ORA-00604
223
+ // wrapping a transient ORA-12541), so match ALL codes — classify transient
224
+ // if ANY of them is connection-class, regardless of position.
225
+ const structuredCode = oraCodeFromErrorNum(error);
226
+ const messageCodes = message.match(ORA_CODE_GLOBAL) ?? [];
227
+ const allCodes = structuredCode != null ? [structuredCode, ...messageCodes] : messageCodes;
228
+
229
+ const transientCode = allCodes.find((code) => TRANSIENT_ORA_CODES.has(code));
230
+ if (transientCode != null) {
231
+ return { kind: "transient", nodeId: ORACLE_NODE_ID, message: `Oracle transient: ${message} (${transientCode})` };
232
+ }
233
+
234
+ return {
235
+ kind: "node-crash",
236
+ nodeId: ORACLE_NODE_ID,
237
+ message: `Oracle error: ${message} [sql: ${stripCredentials(sql).slice(0, 100)}]`,
238
+ retriability: "non-retriable",
239
+ };
240
+ };
241
+
242
+ /**
243
+ * The slice of a pooled `oracledb` connection (or the pool itself) that the
244
+ * capability client actually uses. Tests inject a fake; production passes a
245
+ * thin shim over `pool.getConnection()` → `conn.execute()` → `conn.close()`
246
+ * (structurally compatible).
247
+ */
248
+ export interface OracleQueryable {
249
+ execute(
250
+ sql: string,
251
+ binds?: Record<string, unknown>,
252
+ opts?: { readonly outFormat?: unknown },
253
+ ): Promise<{ rows?: unknown[] }>;
254
+ }
255
+
256
+ /**
257
+ * Build an `OracleCapability` over an injected queryable seam. Exported for
258
+ * testing — `createOracleAdapter` is the production entry point that owns pool
259
+ * construction and lifecycle.
260
+ *
261
+ * `outFormat` is supplied per execute by the seam itself (the adapter sets
262
+ * `OUT_FORMAT_OBJECT`); the client passes binds through verbatim.
263
+ */
264
+ export const createOracleClient = (queryable: OracleQueryable): OracleCapability => ({
265
+ query: async <T,>(schema: z.ZodType<T>, sql: string, binds?: Record<string, unknown>): Promise<Result<T[], FrameworkError>> => {
266
+ try {
267
+ const result = await queryable.execute(sql, binds ?? {});
268
+ const rows = result.rows ?? [];
269
+ const validated: T[] = [];
270
+ for (const row of rows) {
271
+ const parsed = schema.safeParse(row);
272
+ if (!parsed.success) {
273
+ return err({
274
+ kind: "node-crash",
275
+ nodeId: ORACLE_NODE_ID,
276
+ message: `Row validation failed: ${parsed.error.message}`,
277
+ retriability: "non-retriable",
278
+ });
279
+ }
280
+ validated.push(parsed.data);
281
+ }
282
+ return ok(validated);
283
+ } catch (error) {
284
+ return err(mapOracleError(error, sql));
285
+ }
286
+ },
287
+
288
+ queryOne: async <T,>(schema: z.ZodType<T>, sql: string, binds?: Record<string, unknown>): Promise<Result<T | null, FrameworkError>> => {
289
+ try {
290
+ const result = await queryable.execute(sql, binds ?? {});
291
+ const rows = result.rows ?? [];
292
+ if (rows.length === 0) return ok(null);
293
+ const parsed = schema.safeParse(rows[0]);
294
+ if (!parsed.success) {
295
+ return err({
296
+ kind: "node-crash",
297
+ nodeId: ORACLE_NODE_ID,
298
+ message: `Row validation failed: ${parsed.error.message}`,
299
+ retriability: "non-retriable",
300
+ });
301
+ }
302
+ return ok(parsed.data);
303
+ } catch (error) {
304
+ return err(mapOracleError(error, sql));
305
+ }
306
+ },
307
+
308
+ queryRaw: async (sql: string, binds?: Record<string, unknown>): Promise<Result<unknown[], FrameworkError>> => {
309
+ try {
310
+ const result = await queryable.execute(sql, binds ?? {});
311
+ return ok(result.rows ?? []);
312
+ } catch (error) {
313
+ return err(mapOracleError(error, sql));
314
+ }
315
+ },
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Adapter Factory
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /** Health checks are bounded by this timeout so a hung pool reports unhealthy. */
323
+ const HEALTH_CHECK_TIMEOUT_MS = 5_000;
324
+
325
+ /**
326
+ * Session fix-up SQL run ONCE per freshly-created pooled connection (via the
327
+ * pool's `sessionCallback`). It pins the session's numeric formatting so the
328
+ * driver renders NUMBER columns to strings with a PERIOD decimal separator,
329
+ * INDEPENDENT of the database's default locale.
330
+ *
331
+ * Why this exists: the prod OISTERTS database defaults to a Danish locale
332
+ * (`NLS_NUMERIC_CHARACTERS = ',.'`), so price columns (e.g. `GET_PACKAGE_INFO`'s
333
+ * `PACK_FEE_PRICE`) came back as `"74,25"` — a COMMA decimal that a canonical
334
+ * period-decimal parser rejects, collapsing real figures to "unknown" even when
335
+ * the value is genuinely present. Prices are NUMBERs; the separator is a pure
336
+ * locale artifact, so the deterministic fix belongs HERE at the adapter seam
337
+ * (every Oracle-number-as-string consumer benefits) rather than baked into one
338
+ * caller's parser. The two-char value is decimal + group; the group char is
339
+ * irrelevant for implicit NUMBER→string conversion (no group separators are
340
+ * emitted without an explicit format model), so only the leading period matters.
341
+ */
342
+ export const ORACLE_SESSION_NLS_SQL = "ALTER SESSION SET NLS_NUMERIC_CHARACTERS = '. '";
343
+
344
+ /**
345
+ * Minimal structural view of the parts of the `oracledb` module the adapter
346
+ * touches. Kept here (rather than importing the SDK types into core
347
+ * signatures) so the lazy `require` call has a precise shape without leaking
348
+ * the vendor surface.
349
+ */
350
+ interface OracleDbModule {
351
+ readonly OUT_FORMAT_OBJECT: number;
352
+ createPool(config: {
353
+ readonly connectString: string;
354
+ readonly user: string;
355
+ readonly password: string;
356
+ readonly poolMin: number;
357
+ readonly poolMax: number;
358
+ // Node-callback-form session fix-up: oracledb invokes it on each
359
+ // newly-created physical connection and waits for `callbackFn()` before
360
+ // handing the connection out. The CALLBACK form is mandatory here — the
361
+ // promise/async form HANGS under oracledb thin mode (getConnection never
362
+ // resolves). See `ORACLE_SESSION_NLS_SQL`.
363
+ readonly sessionCallback?: (
364
+ connection: OracleConnection,
365
+ requestedTag: string,
366
+ callbackFn: (error?: unknown) => void,
367
+ ) => void;
368
+ }): Promise<OraclePool>;
369
+ }
370
+
371
+ interface OracleConnection {
372
+ execute(
373
+ sql: string,
374
+ binds?: Record<string, unknown>,
375
+ opts?: { readonly outFormat?: unknown },
376
+ ): Promise<{ rows?: unknown[] }>;
377
+ close(): Promise<void>;
378
+ }
379
+
380
+ interface OraclePool {
381
+ getConnection(): Promise<OracleConnection>;
382
+ close(drainTimeSeconds: number): Promise<void>;
383
+ }
384
+
385
+ /**
386
+ * Create an Oracle capability handle.
387
+ *
388
+ * The handle manages the connection pool lifecycle:
389
+ * - `connect()`: validates connectivity with `SELECT 1 FROM DUAL`
390
+ * - `close()`: closes the pool immediately (`pool.close(0)`, zero drain window)
391
+ * - `healthCheck()`: runs `SELECT 1 FROM DUAL`, racing a 5s timeout
392
+ *
393
+ * The driver is loaded lazily via `createRequire` in **thin mode** (the
394
+ * `oracledb` 7.x default). Each query acquires and releases a pooled
395
+ * connection, amortizing connection cost so a single lookup stays within the
396
+ * p95 budget (NFR-001 / SC-004).
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * const oracle = createOracleAdapter({
401
+ * connectString: process.env.ORACLE_CONNECT_STRING!,
402
+ * user: process.env.ORACLE_USER!,
403
+ * password: process.env.ORACLE_PASSWORD!,
404
+ * poolMax: 8,
405
+ * });
406
+ * const sharedInfra = { ..., capabilities: [oracle] };
407
+ * ```
408
+ */
409
+ export const createOracleAdapter = (config: OracleAdapterConfig): CapabilityHandle<"oracle"> => {
410
+ // `createRequire` keeps the lazy-CJS load working under both Bun and plain
411
+ // Node ESM (a bare `require` is undefined in Node ESM module scope). Thin
412
+ // mode is the oracledb 7.x default — no Instant Client / native addon.
413
+ const requireModule = createRequire(import.meta.url);
414
+ const oracledb = requireModule("oracledb") as OracleDbModule;
415
+
416
+ const poolMin = config.poolMin ?? 0;
417
+ const poolMax = config.poolMax ?? 4;
418
+
419
+ // The pool is created eagerly (returns a Promise); we acquire connections
420
+ // per query. Construction is deferred behind a lazily-awaited promise so the
421
+ // handle is synchronous to build (mirrors pg's synchronous `new Pool`).
422
+ let poolPromise: Promise<OraclePool> | undefined;
423
+ const getPool = (): Promise<OraclePool> => {
424
+ if (poolPromise == null) {
425
+ poolPromise = oracledb
426
+ .createPool({
427
+ connectString: config.connectString,
428
+ user: config.user,
429
+ password: config.password,
430
+ poolMin,
431
+ poolMax,
432
+ // Pin numeric locale once per physical connection so NUMBER→string
433
+ // prices use a period decimal regardless of the DB's default locale
434
+ // (see ORACLE_SESSION_NLS_SQL). Amortized across every query on the
435
+ // connection rather than paid per getConnection(). MUST be the
436
+ // node-callback form — the async/promise form hangs in thin mode.
437
+ sessionCallback: (connection, _requestedTag, callbackFn) => {
438
+ connection
439
+ .execute(ORACLE_SESSION_NLS_SQL)
440
+ .then(() => callbackFn())
441
+ .catch((e: unknown) => callbackFn(e));
442
+ },
443
+ })
444
+ .catch((e) => {
445
+ // Reset so a transient createPool failure (DNS blip, listener not yet
446
+ // up, ORA-12541 during a rolling DB restart) can be retried on the
447
+ // next call rather than wedging the capability with a permanently
448
+ // cached rejection. Mirrors realm-jwt-verifier's jwksPromise reset and
449
+ // honours mapOracleError's transient/retriable classification. Also
450
+ // makes `close()` a clean no-op after a failed open (poolPromise is
451
+ // undefined again rather than a rejected promise it would re-await).
452
+ poolPromise = undefined;
453
+ throw e;
454
+ });
455
+ }
456
+ return poolPromise;
457
+ };
458
+
459
+ // Production queryable: acquire a pooled connection, execute with
460
+ // OUT_FORMAT_OBJECT, always release the connection.
461
+ const queryable: OracleQueryable = {
462
+ execute: async (sql, binds, opts) => {
463
+ const pool = await getPool();
464
+ const conn = await pool.getConnection();
465
+ try {
466
+ return await conn.execute(sql, binds ?? {}, {
467
+ outFormat: opts?.outFormat ?? oracledb.OUT_FORMAT_OBJECT,
468
+ });
469
+ } finally {
470
+ await conn.close();
471
+ }
472
+ },
473
+ };
474
+
475
+ return {
476
+ name: "oracle",
477
+ client: createOracleClient(queryable),
478
+
479
+ connect: async () => {
480
+ // Validate connectivity through the pool. ANY failure here — pool
481
+ // creation (createPool DSN parse), getConnection (ORA-12154/ORA-01017),
482
+ // or the SELECT 1 — must surface CREDENTIAL-STRIPPED: oracledb connect-time
483
+ // errors routinely echo the supplied connectString/DSN, and this error
484
+ // propagates to the boot log and the abort HostError (createHost
485
+ // connectAll). Mirror healthCheckWithTimeout: re-throw with the message run
486
+ // through stripCredentials, releasing the connection in finally (FR-041 /
487
+ // NFR-020 / SC-008).
488
+ let conn: OracleConnection | undefined;
489
+ try {
490
+ const pool = await getPool();
491
+ conn = await pool.getConnection();
492
+ await conn.execute("SELECT 1 FROM DUAL", {}, { outFormat: oracledb.OUT_FORMAT_OBJECT });
493
+ } catch (e) {
494
+ throw new Error(stripCredentials(e instanceof Error ? e.message : String(e)));
495
+ } finally {
496
+ // The connection is only acquired if getConnection() resolved; release it
497
+ // even when SELECT 1 throws. close() failures are swallowed — the
498
+ // original (stripped) error is the one worth surfacing.
499
+ if (conn != null) {
500
+ try {
501
+ await conn.close();
502
+ } catch {
503
+ // ignore: a release failure must not mask the connect error.
504
+ }
505
+ }
506
+ }
507
+ },
508
+
509
+ close: async () => {
510
+ if (poolPromise == null) return;
511
+ const pool = await poolPromise;
512
+ await pool.close(0);
513
+ },
514
+
515
+ healthCheck: () => healthCheckWithTimeout(queryable, HEALTH_CHECK_TIMEOUT_MS),
516
+ };
517
+ };
518
+
519
+ /**
520
+ * Race `SELECT 1 FROM DUAL` against a timeout. A pool that hangs (e.g.
521
+ * exhausted connections, dead network) reports unhealthy instead of stalling
522
+ * the caller. Exported for testing.
523
+ */
524
+ export const healthCheckWithTimeout = async (
525
+ queryable: OracleQueryable,
526
+ timeoutMs: number,
527
+ ): Promise<Result<void, string>> => {
528
+ let timer: ReturnType<typeof setTimeout> | undefined;
529
+ // Hold the execute promise so that, if the timeout wins the race, we can
530
+ // still attach a catch to the (now losing) in-flight execute. Without this a
531
+ // later rejection — a hung connection eventually erroring, or the production
532
+ // seam's `conn.close()` throwing in `finally` — becomes a process-level
533
+ // unhandledRejection AFTER we already returned Err(timeout). Swallow it with
534
+ // intent: the timeout result has already been decided.
535
+ const executePromise = queryable.execute("SELECT 1 FROM DUAL", {});
536
+ executePromise.catch(() => {});
537
+ try {
538
+ await Promise.race([
539
+ executePromise,
540
+ new Promise<never>((_, reject) => {
541
+ timer = setTimeout(
542
+ () => reject(new Error(`health check timed out after ${timeoutMs}ms`)),
543
+ timeoutMs,
544
+ );
545
+ }),
546
+ ]);
547
+ return ok(undefined);
548
+ } catch (e) {
549
+ return err(stripCredentials(e instanceof Error ? e.message : String(e)));
550
+ } finally {
551
+ if (timer != null) clearTimeout(timer);
552
+ }
553
+ };
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Fake for Testing
557
+ // ---------------------------------------------------------------------------
558
+
559
+ /**
560
+ * In-memory fake OracleCapability for unit testing nodes that use
561
+ * `ctx.oracle`.
562
+ *
563
+ * Accepts a response map that returns canned results for SQL. Matching is
564
+ * **exact by default**: a route keyed on a full SQL string matches only that
565
+ * exact SQL the real adapter would run. This keeps a test from passing against
566
+ * a query the production pool would never execute.
567
+ *
568
+ * Prefix matching is **opt-in** per route via `{ rows, prefix: true }`: only
569
+ * routes explicitly flagged participate in the longest-`startsWith` fallback,
570
+ * and only when no exact key matched. Reach for it when you deliberately want a
571
+ * broad match (e.g. a query whose binds are interpolated into the SQL string);
572
+ * keep flagged keys specific enough that one can't accidentally swallow another's
573
+ * query. Binds are not inspected, so this fake cannot catch a wrong-`:name`
574
+ * binding bug regardless of match mode.
575
+ *
576
+ * @example
577
+ * ```ts
578
+ * const fakeOracle = createFakeOracleCapability({
579
+ * // exact match (default) — matches this SQL and nothing else
580
+ * "SELECT * FROM packages": [{ optionKey: "X", standardPrice: "199", discountPrice: "99" }],
581
+ * // opt-in prefix match — matches any SQL starting with this string
582
+ * "SELECT * FROM TABLE(GET_PACKAGE_INFO": { prefix: true, rows: [{ optionKey: "Y", standardPrice: "1", discountPrice: "1" }] },
583
+ * });
584
+ * ```
585
+ */
586
+ export interface FakeOracleRoute {
587
+ readonly rows?: unknown[];
588
+ /**
589
+ * When `true`, this route matches any SQL that `startsWith` its key (longest
590
+ * flagged prefix wins), used only after an exact-key match fails. Default
591
+ * `false`/absent → exact-SQL match only. Opt-in to avoid silently swallowing
592
+ * an unrelated query whose text happens to share a prefix.
593
+ */
594
+ readonly prefix?: boolean;
595
+ }
596
+
597
+ export const createFakeOracleCapability = (
598
+ routes: Readonly<Record<string, unknown[] | FakeOracleRoute>>,
599
+ ): CapabilityHandle<"oracle"> => {
600
+ const matchRoute = (sql: string): FakeOracleRoute | null => {
601
+ // Exact match always wins. Guard with hasOwnProperty so a SQL string equal to
602
+ // a prototype key ("constructor"/"toString"/…) can't resolve an inherited
603
+ // function as a phantom route (consistent with the own-property guards used
604
+ // elsewhere, e.g. host.ts assignedScopes).
605
+ if (Object.prototype.hasOwnProperty.call(routes, sql)) {
606
+ const direct = routes[sql];
607
+ return Array.isArray(direct) ? { rows: direct } : direct;
608
+ }
609
+ // Longest-prefix fallback, restricted to routes that opted in with
610
+ // `prefix: true`. An array-shorthand or a route without the flag never
611
+ // participates, so it can only ever match its exact key.
612
+ let bestMatch: FakeOracleRoute | null = null;
613
+ let bestLength = 0;
614
+ for (const [pattern, value] of Object.entries(routes)) {
615
+ if (Array.isArray(value) || value.prefix !== true) continue;
616
+ if (sql.startsWith(pattern) && pattern.length > bestLength) {
617
+ bestMatch = value;
618
+ bestLength = pattern.length;
619
+ }
620
+ }
621
+ return bestMatch;
622
+ };
623
+
624
+ const client: OracleCapability = {
625
+ query: async <T,>(schema: z.ZodType<T>, sql: string, _binds?: Record<string, unknown>): Promise<Result<T[], FrameworkError>> => {
626
+ const route = matchRoute(sql);
627
+ if (!route || !route.rows) {
628
+ return ok([] as T[]);
629
+ }
630
+ const validated: T[] = [];
631
+ for (const row of route.rows) {
632
+ const parsed = schema.safeParse(row);
633
+ if (!parsed.success) {
634
+ return err({ kind: "node-crash", nodeId: ORACLE_NODE_ID, message: `Fake row validation: ${parsed.error.message}`, retriability: "non-retriable" });
635
+ }
636
+ validated.push(parsed.data);
637
+ }
638
+ return ok(validated);
639
+ },
640
+
641
+ queryOne: async <T,>(schema: z.ZodType<T>, sql: string, _binds?: Record<string, unknown>): Promise<Result<T | null, FrameworkError>> => {
642
+ const route = matchRoute(sql);
643
+ if (!route || !route.rows || route.rows.length === 0) return ok(null);
644
+ const parsed = schema.safeParse(route.rows[0]);
645
+ if (!parsed.success) {
646
+ return err({ kind: "node-crash", nodeId: ORACLE_NODE_ID, message: `Fake row validation: ${parsed.error.message}`, retriability: "non-retriable" });
647
+ }
648
+ return ok(parsed.data);
649
+ },
650
+
651
+ queryRaw: async (sql: string, _binds?: Record<string, unknown>): Promise<Result<unknown[], FrameworkError>> => {
652
+ const route = matchRoute(sql);
653
+ return ok(route?.rows ?? []);
654
+ },
655
+ };
656
+
657
+ return { name: "oracle", client };
658
+ };