@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.
- package/README.md +135 -0
- package/package.json +47 -0
- 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
|
+
};
|