@deckbuilderkit/db 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@deckbuilderkit/db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/adrianogpena/deckbuilder.git",
8
+ "directory": "packages/db"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@deckbuilderkit/server-core": "0.1.0",
23
+ "@deckbuilderkit/server-mods": "0.1.0",
24
+ "oracledb": "6.10.0"
25
+ }
26
+ }
@@ -0,0 +1,19 @@
1
+ import type { Routes } from '@deckbuilderkit/server-core';
2
+ import { recordRoutes, type RecordStore } from '@deckbuilderkit/server-mods';
3
+ import type { DbSessions } from './sessions.ts';
4
+
5
+ const BASE = '/api/db/connections';
6
+
7
+ /** `/api/db/connections` CRUD over the app's connection store; a deleted record's open session is rolled back and closed. */
8
+ export function connectionRoutes(store: RecordStore<object>, sessions: Pick<DbSessions, 'close'>): Routes {
9
+ const routes = recordRoutes(store, BASE);
10
+ return {
11
+ ...routes,
12
+ [`DELETE ${BASE}`]: async (rc) => {
13
+ const id = rc.query.get('id');
14
+ const result = await routes[`DELETE ${BASE}`]!(rc);
15
+ if (id) await sessions.close(id);
16
+ return result;
17
+ },
18
+ };
19
+ }
package/src/history.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { SqlHistoryEntry } from '@deckbuilderkit/server-core/protocol';
4
+
5
+ export type { SqlHistoryEntry };
6
+
7
+ export interface SqlHistoryOptions {
8
+ /** Folder of `sql-history.json`. */
9
+ dir: string;
10
+ /** Most entries kept; 0 turns recording off. A function is read on every append. */
11
+ max: number | (() => number);
12
+ }
13
+
14
+ export interface SqlHistory {
15
+ /** Adds an entry at the top (no-op while max is 0); gives the stored entry, or null when off. */
16
+ append(entry: Omit<SqlHistoryEntry, 'id'>): Promise<SqlHistoryEntry | null>;
17
+ /** Sets the row count of an entry, if it still exists. */
18
+ setRows(id: string, rows: number): Promise<void>;
19
+ /** Newest first; `q` matches the SQL ignoring case, `connection` the connection id. */
20
+ list(filter?: { q?: string; connection?: string }): Promise<SqlHistoryEntry[]>;
21
+ delete(ids: string[]): Promise<void>;
22
+ clear(): Promise<void>;
23
+ setMax(max: number): void;
24
+ }
25
+
26
+ const isEntry = (e: unknown): e is SqlHistoryEntry => {
27
+ const r = e as Partial<SqlHistoryEntry> | null;
28
+ return (
29
+ typeof r === 'object' && r !== null && typeof r.id === 'string' && typeof r.connectionId === 'string' &&
30
+ typeof r.sql === 'string' && typeof r.at === 'number' && typeof r.ms === 'number'
31
+ );
32
+ };
33
+
34
+ /** The SQL history in `<dir>/sql-history.json`: newest first, capped at `max`, written atomically. */
35
+ export function sqlHistory({ dir, max }: SqlHistoryOptions): SqlHistory {
36
+ const file = join(dir, 'sql-history.json');
37
+ let limit = max;
38
+ const maxNow = () => Math.max(0, Math.floor(typeof limit === 'function' ? limit() : limit) || 0);
39
+ let entries: Promise<SqlHistoryEntry[]> | null = null;
40
+ let queue: Promise<unknown> = Promise.resolve();
41
+
42
+ function load(): Promise<SqlHistoryEntry[]> {
43
+ entries ??= readFile(file, 'utf8')
44
+ .then((text) => {
45
+ const raw: unknown = JSON.parse(text);
46
+ return Array.isArray(raw) ? raw.filter(isEntry) : [];
47
+ })
48
+ .catch(() => []);
49
+ return entries;
50
+ }
51
+
52
+ /** Runs `change` on the list, one at a time, then writes it via a temp file and a rename. */
53
+ function mutate<T>(change: (list: SqlHistoryEntry[]) => { list: SqlHistoryEntry[]; result: T; dirty: boolean }): Promise<T> {
54
+ const run = queue.then(async () => {
55
+ const { list, result, dirty } = change(await load());
56
+ entries = Promise.resolve(list);
57
+ if (dirty) {
58
+ await mkdir(dir, { recursive: true });
59
+ const tmp = `${file}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
60
+ try {
61
+ await writeFile(tmp, JSON.stringify(list));
62
+ await rename(tmp, file);
63
+ } catch (err) {
64
+ await rm(tmp, { force: true });
65
+ throw err;
66
+ }
67
+ }
68
+ return result;
69
+ });
70
+ queue = run.catch(() => {});
71
+ return run;
72
+ }
73
+
74
+ return {
75
+ append(entry) {
76
+ if (maxNow() === 0) return Promise.resolve(null);
77
+ return mutate((list) => {
78
+ const cap = maxNow();
79
+ if (cap === 0) return { list, result: null, dirty: false };
80
+ const stored: SqlHistoryEntry = { id: crypto.randomUUID(), ...entry };
81
+ return { list: [stored, ...list].slice(0, cap), result: stored, dirty: true };
82
+ });
83
+ },
84
+ setRows: (id, rows) =>
85
+ mutate((list) => {
86
+ const i = list.findIndex((e) => e.id === id);
87
+ if (i < 0) return { list, result: undefined, dirty: false };
88
+ const next = list.slice();
89
+ next[i] = { ...next[i]!, rows };
90
+ return { list: next, result: undefined, dirty: true };
91
+ }),
92
+ async list({ q, connection } = {}) {
93
+ await queue;
94
+ const needle = q?.toLowerCase();
95
+ return (await load()).filter((e) => (!connection || e.connectionId === connection) && (!needle || e.sql.toLowerCase().includes(needle)));
96
+ },
97
+ delete(ids) {
98
+ const drop = new Set(ids);
99
+ return mutate((list) => {
100
+ const next = list.filter((e) => !drop.has(e.id));
101
+ return { list: next, result: undefined, dirty: next.length !== list.length };
102
+ });
103
+ },
104
+ clear: () => mutate((list) => ({ list: [], result: undefined, dirty: list.length > 0 })),
105
+ setMax(n) {
106
+ limit = n;
107
+ },
108
+ };
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './connection-routes.ts';
2
+ export * from './history.ts';
3
+ export * from './oracle-url.ts';
4
+ export * from './routes.ts';
5
+ export * from './sessions.ts';
@@ -0,0 +1,101 @@
1
+ import { isIPv6 } from 'node:net';
2
+ import { invalid, isRecord } from '@deckbuilderkit/server-core';
3
+
4
+ export interface OracleUrl {
5
+ /** What `oracledb.getConnection` takes: `host:port/service`, or a descriptor for a SID. */
6
+ connectString: string;
7
+ host: string;
8
+ port: number;
9
+ service?: string;
10
+ sid?: string;
11
+ }
12
+
13
+ /** What the app's connection records hold (the password is the store's secret field). */
14
+ export interface DbConnectionFields {
15
+ name: string;
16
+ url: string;
17
+ user: string;
18
+ }
19
+
20
+ const MAX_URL = 300;
21
+ const DEFAULT_PORT = 1521;
22
+ const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
23
+ const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
24
+ const IPV4ISH = /^[\d.]+$/;
25
+ const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
26
+ const NAME = /^[A-Za-z0-9_.$#-]{1,128}$/;
27
+ const THIN = /^(?:jdbc:)?oracle:thin:@/i;
28
+ // Host (bracketed IPv6 or anything up to : or /), optional :port, then /service or :SID.
29
+ const PARTS = /^(\[[^\]]*\]|[^:/[\]]+)(?::(\d{1,5}))?(?:([/:])(.*))?$/;
30
+
31
+ const badUrl = () => invalid('badDbUrl', 'Use oracle:thin:@host:port/service.');
32
+
33
+ /**
34
+ * Parses the Oracle URL forms people paste: `jdbc:oracle:thin:@host:port/service`,
35
+ * `oracle:thin:@host:port/service`, `@//host:port/service`, `host:port/service` and the SID form
36
+ * `…@host:port:SID`. The port defaults to 1521 in the `//` and plain forms. Descriptors, TNS
37
+ * aliases and query parameters are refused.
38
+ */
39
+ export function parseOracleUrl(raw: unknown): OracleUrl {
40
+ if (typeof raw !== 'string') throw badUrl();
41
+ let rest = raw.trim();
42
+ if (!rest || rest.length > MAX_URL) throw badUrl();
43
+ let prefixed = false;
44
+ const thin = THIN.exec(rest);
45
+ if (thin) {
46
+ rest = rest.slice(thin[0].length);
47
+ prefixed = true;
48
+ } else if (rest.startsWith('@')) {
49
+ rest = rest.slice(1);
50
+ prefixed = true;
51
+ }
52
+ const slashes = rest.startsWith('//');
53
+ if (slashes) rest = rest.slice(2);
54
+
55
+ const m = PARTS.exec(rest);
56
+ if (!m) throw badUrl();
57
+ const [, hostPart, portText, sep, name] = m;
58
+ if (!sep || !name || !NAME.test(name)) throw badUrl();
59
+ const host = validHost(hostPart!);
60
+ if (host === null) throw badUrl();
61
+ const port = portText === undefined ? null : Number(portText);
62
+ if (port !== null && (port < 1 || port > 65535)) throw badUrl();
63
+
64
+ if (sep === ':') {
65
+ // SID: needs the port, and EZConnect's `//` form has no SID.
66
+ if (port === null || slashes) throw badUrl();
67
+ const connectString = `(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=${host})(PORT=${port}))(CONNECT_DATA=(SID=${name})))`;
68
+ return { connectString, host, port, sid: name };
69
+ }
70
+ if (port === null && prefixed && !slashes) throw badUrl();
71
+ const p = port ?? DEFAULT_PORT;
72
+ const hostText = host.includes(':') ? `[${host}]` : host;
73
+ return { connectString: `${hostText}:${p}/${name}`, host, port: p, service: name };
74
+ }
75
+
76
+ /** The host without brackets, or null. */
77
+ function validHost(text: string): string | null {
78
+ if (text.startsWith('[')) {
79
+ const inner = text.slice(1, -1);
80
+ return isIPv6(inner) ? inner : null;
81
+ }
82
+ if (IPV4ISH.test(text)) return IPV4.test(text) ? text : null;
83
+ return text.length <= 253 && HOSTNAME.test(text) ? text : null;
84
+ }
85
+
86
+ /** `validate` for the app's connection `recordStore`: builds the record fields from raw client input. */
87
+ export function validConnection(input: unknown): DbConnectionFields {
88
+ if (!isRecord(input)) throw invalid('badRequest', 'Invalid request.');
89
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
90
+ if (name.length < 1 || name.length > 80) throw invalid('dbBadName', 'The name must have 1 to 80 characters.');
91
+ const url = typeof input.url === 'string' ? input.url.trim() : '';
92
+ parseOracleUrl(url);
93
+ return { name, url, user: validUser(input.user) };
94
+ }
95
+
96
+ /** The trimmed user name of a connection. */
97
+ export function validUser(input: unknown): string {
98
+ const user = typeof input === 'string' ? input.trim() : '';
99
+ if (!/^\S{1,128}$/.test(user)) throw invalid('dbBadUser', 'The user must have 1 to 128 characters, without spaces.');
100
+ return user;
101
+ }
@@ -0,0 +1,24 @@
1
+ // The part of oracledb 6.10 (thin mode) this package uses; the driver ships no types.
2
+ declare module 'oracledb' {
3
+ interface DbType {
4
+ readonly num: number;
5
+ readonly name: string;
6
+ }
7
+ interface FetchMetadata {
8
+ name: string;
9
+ dbType: DbType;
10
+ }
11
+ interface FetchTypeResult {
12
+ type?: DbType;
13
+ converter?: (value: any) => unknown;
14
+ }
15
+ interface OracleDb {
16
+ OUT_FORMAT_ARRAY: number;
17
+ STRING: DbType;
18
+ BUFFER: DbType;
19
+ getConnection(attrs: { connectString: string; user: string; password: string; connectTimeout?: number }): Promise<any>;
20
+ }
21
+ const oracledb: OracleDb;
22
+ export default oracledb;
23
+ export type { DbType, FetchMetadata, FetchTypeResult };
24
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { invalid, isRecord, ndjson, notFound, type Routes } from '@deckbuilderkit/server-core';
2
+ import { parseOracleUrl, validUser } from './oracle-url.ts';
3
+ import type { SqlHistory } from './history.ts';
4
+ import type { DbCredentials, DbSessions } from './sessions.ts';
5
+
6
+ export interface DbRoutesOptions {
7
+ sessions: DbSessions;
8
+ /** Server-side lookup of a connection record: null when `id` is unknown. Never expose the result. */
9
+ credentials: (id: string) => Promise<DbCredentials | null>;
10
+ /** The Workbench's default script of a connection id: `read` gives '' when there is none yet. */
11
+ scripts: { read: (id: string) => Promise<string>; write: (id: string, text: string) => Promise<void> };
12
+ /** Records every `/api/db/run`; also serves `/api/db/history*`. */
13
+ history?: SqlHistory;
14
+ }
15
+
16
+ const MAX_SQL = 1_000_000;
17
+ const MAX_PASSWORD = 1024;
18
+ const MAX_ROWS = 100_000;
19
+ const ID = /^[\w-]{1,64}$/;
20
+ const OWNER = /^[A-Za-z0-9_$#]{1,128}$/;
21
+ /** A table name as the dictionary holds it (quoted names may hold almost anything). */
22
+ const TABLE = /^[^\x00-\x1f"]{1,128}$/;
23
+ const MAX_IDS = 10_000;
24
+ /** Query runs whose row count is still unknown, by run id: the history entry the streamed rows go to. */
25
+ const MAX_PENDING = 200;
26
+
27
+ const badRequest = () => invalid('badRequest', 'Invalid request.');
28
+
29
+ /**
30
+ * The Workbench's database API: `POST /api/db/test|connect|run|explain|commit|rollback|disconnect`,
31
+ * `GET /api/db/rows?run=` (NDJSON of row arrays), `GET /api/db/schema?id=&owner=`, `GET /api/db/table?id=&owner=&name=` and `GET /api/db/status?id=`;
32
+ * with `history`, `GET /api/db/history?q=&connection=` and `POST /api/db/history/delete|clear`.
33
+ */
34
+ export function dbRoutes({ sessions, credentials, scripts, history }: DbRoutesOptions): Routes {
35
+ const pending = new Map<string, string>();
36
+ function idOf(value: unknown): string {
37
+ if (typeof value !== 'string' || !ID.test(value)) throw badRequest();
38
+ return value;
39
+ }
40
+
41
+ async function lookup(id: string): Promise<DbCredentials> {
42
+ const creds = await credentials(id);
43
+ if (!creds) throw notFound('dbConnection', 'This database connection no longer exists.');
44
+ return creds;
45
+ }
46
+
47
+ async function body(json: () => Promise<unknown>): Promise<Record<string, unknown>> {
48
+ const b = await json();
49
+ if (!isRecord(b)) throw badRequest();
50
+ return b;
51
+ }
52
+
53
+ return {
54
+ // Tests a connection form's values (`url`, `user`, `password`) without keeping a session. An absent
55
+ // password means the saved one of record `id`, as the form leaves out a password that wasn't touched.
56
+ 'POST /api/db/test': async ({ json }) => {
57
+ const b = await body(json);
58
+ const { connectString } = parseOracleUrl(b.url);
59
+ const user = validUser(b.user);
60
+ let password = '';
61
+ if (typeof b.password === 'string') password = b.password;
62
+ else if (b.password !== undefined) throw badRequest();
63
+ else if (b.id !== undefined) {
64
+ const saved = await lookup(idOf(b.id));
65
+ if (saved.connectString !== connectString || saved.user !== user) throw badRequest();
66
+ password = saved.password;
67
+ }
68
+ if (password.length > MAX_PASSWORD) throw badRequest();
69
+ await sessions.test({ connectString, user, password });
70
+ return { ok: true };
71
+ },
72
+ 'POST /api/db/connect': async ({ json }) => {
73
+ const id = idOf((await body(json)).id);
74
+ return sessions.open(id, await lookup(id));
75
+ },
76
+ 'POST /api/db/run': async ({ json }) => {
77
+ const b = await body(json);
78
+ const id = idOf(b.id);
79
+ if (typeof b.sql !== 'string' || b.sql.length > MAX_SQL) throw badRequest();
80
+ const maxRows = b.maxRows;
81
+ if (maxRows !== undefined && !(Number.isInteger(maxRows) && (maxRows as number) >= 1 && (maxRows as number) <= MAX_ROWS)) throw badRequest();
82
+ const creds = await lookup(id);
83
+ const at = Date.now();
84
+ const start = performance.now();
85
+ const record = (extra: { rows?: number; error?: string }) =>
86
+ history?.append({ connectionId: id, sql: b.sql as string, at, ms: Math.round(performance.now() - start), ...extra }).catch(() => null) ?? Promise.resolve(null);
87
+ let result;
88
+ try {
89
+ result = await sessions.run(id, creds, b.sql, { maxRows: maxRows as number | undefined });
90
+ } catch (err) {
91
+ await record({ error: err instanceof Error ? err.message : String(err) });
92
+ throw err;
93
+ }
94
+ const entry = await record(result.kind === 'done' && result.rowsAffected !== undefined ? { rows: result.rowsAffected } : {});
95
+ if (entry && result.kind === 'rows') {
96
+ pending.set(result.run, entry.id);
97
+ if (pending.size > MAX_PENDING) pending.delete(pending.keys().next().value!);
98
+ }
99
+ return result;
100
+ },
101
+ // Not recorded in the history.
102
+ 'POST /api/db/explain': async ({ json }) => {
103
+ const b = await body(json);
104
+ const id = idOf(b.id);
105
+ if (typeof b.sql !== 'string' || b.sql.length > MAX_SQL) throw badRequest();
106
+ return { rows: await sessions.explain(id, await lookup(id), b.sql) };
107
+ },
108
+ // Not recorded in the history.
109
+ 'GET /api/db/table': async ({ query }) => {
110
+ const id = idOf(query.get('id'));
111
+ const owner = query.get('owner') ?? '';
112
+ const name = query.get('name') ?? '';
113
+ if ((owner && !OWNER.test(owner)) || !TABLE.test(name)) throw badRequest();
114
+ return sessions.table(id, await lookup(id), owner, name);
115
+ },
116
+ 'GET /api/db/rows': ({ req, query }) => {
117
+ const run = query.get('run') ?? '';
118
+ if (!/^[\w-]{1,64}$/.test(run)) throw badRequest();
119
+ const entryId = pending.get(run);
120
+ pending.delete(run);
121
+ let count = 0;
122
+ return ndjson(req, async (emit, signal) => {
123
+ try {
124
+ await sessions.rows(run, (item) => {
125
+ if (Array.isArray(item)) count++;
126
+ return emit(item);
127
+ }, signal);
128
+ } finally {
129
+ if (entryId && history) await history.setRows(entryId, count).catch(() => {});
130
+ }
131
+ });
132
+ },
133
+ 'GET /api/db/history': async ({ query }) => {
134
+ if (!history) throw notFound('history', 'SQL history is not available.');
135
+ const q = query.get('q') || undefined;
136
+ const connection = query.get('connection') || undefined;
137
+ if ((q !== undefined && q.length > 1000) || (connection !== undefined && !ID.test(connection))) throw badRequest();
138
+ return history.list({ q, connection });
139
+ },
140
+ 'POST /api/db/history/delete': async ({ json }) => {
141
+ if (!history) throw notFound('history', 'SQL history is not available.');
142
+ const { ids } = await body(json);
143
+ if (!Array.isArray(ids) || ids.length > MAX_IDS || !ids.every((x) => typeof x === 'string' && x.length <= 64)) throw badRequest();
144
+ await history.delete(ids as string[]);
145
+ return { ok: true };
146
+ },
147
+ 'POST /api/db/history/clear': async () => {
148
+ if (!history) throw notFound('history', 'SQL history is not available.');
149
+ await history.clear();
150
+ return { ok: true };
151
+ },
152
+ 'POST /api/db/commit': async ({ json }) => {
153
+ const id = idOf((await body(json)).id);
154
+ await lookup(id);
155
+ return sessions.commit(id);
156
+ },
157
+ 'POST /api/db/rollback': async ({ json }) => {
158
+ const id = idOf((await body(json)).id);
159
+ await lookup(id);
160
+ return sessions.rollback(id);
161
+ },
162
+ // Works for a deleted record too, so its open session can still be closed.
163
+ 'POST /api/db/disconnect': async ({ json }) => sessions.close(idOf((await body(json)).id)),
164
+ 'GET /api/db/schema': async ({ query }) => {
165
+ const id = idOf(query.get('id'));
166
+ const owner = query.get('owner') || undefined;
167
+ if (owner !== undefined && !OWNER.test(owner)) throw badRequest();
168
+ return sessions.schema(id, await lookup(id), owner);
169
+ },
170
+ 'GET /api/db/script': async ({ query }) => ({ text: await scripts.read(idOf(query.get('id'))) }),
171
+ 'PUT /api/db/script': async ({ query, json }) => {
172
+ const id = idOf(query.get('id'));
173
+ const { text } = await body(json);
174
+ if (typeof text !== 'string' || text.length > MAX_SQL) throw badRequest();
175
+ await scripts.write(id, text);
176
+ return { ok: true };
177
+ },
178
+ 'GET /api/db/status': ({ query }) => sessions.status(idOf(query.get('id'))),
179
+ };
180
+ }
@@ -0,0 +1,745 @@
1
+ /// <reference path="./oracledb.d.ts" />
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import oracledb, { type FetchMetadata, type FetchTypeResult } from 'oracledb';
4
+ import { AppError, type Emit, invalid, notFound } from '@deckbuilderkit/server-core';
5
+
6
+ export interface DbCredentials {
7
+ connectString: string;
8
+ user: string;
9
+ password: string;
10
+ }
11
+
12
+ export interface DbColumnMeta {
13
+ name: string;
14
+ dbType?: { name: string };
15
+ }
16
+
17
+ export interface DbResultSet {
18
+ getRows(n: number): Promise<unknown[][]>;
19
+ close(): Promise<void>;
20
+ }
21
+
22
+ /** The part of an oracledb connection the sessions use; tests pass a fake. */
23
+ export interface DbConnection {
24
+ execute(sql: string, binds: unknown[], options: Record<string, unknown>): Promise<{
25
+ metaData?: DbColumnMeta[];
26
+ resultSet?: DbResultSet;
27
+ rows?: unknown[][];
28
+ rowsAffected?: number;
29
+ }>;
30
+ commit(): Promise<void>;
31
+ rollback(): Promise<void>;
32
+ break(): Promise<void>;
33
+ close(): Promise<void>;
34
+ }
35
+
36
+ export type DbColumnType = 'number' | 'text' | 'date' | 'binary';
37
+ export interface DbColumn {
38
+ name: string;
39
+ type: DbColumnType;
40
+ /** A TIMESTAMP column (also reported as type `date`). */
41
+ timestamp?: boolean;
42
+ }
43
+
44
+ export type DbRunResult =
45
+ | { kind: 'rows'; run: string; columns: DbColumn[] }
46
+ | { kind: 'done'; rowsAffected?: number };
47
+
48
+ export interface DbRunOptions {
49
+ /** Most rows this query streams (1 to the sessions' `maxRows`); then `{ $truncated: true }` ends it. */
50
+ maxRows?: number;
51
+ }
52
+
53
+ export interface DbSchemaColumn {
54
+ name: string;
55
+ type: string;
56
+ nullable: boolean;
57
+ }
58
+
59
+ export interface DbSchemaTable {
60
+ name: string;
61
+ kind: 'table' | 'view';
62
+ columns: DbSchemaColumn[];
63
+ }
64
+
65
+ export interface DbSchema {
66
+ owner: string;
67
+ owners: string[];
68
+ tables: DbSchemaTable[];
69
+ /** The column limit cut the list short. */
70
+ truncated: boolean;
71
+ }
72
+
73
+ /** One step of an EXPLAIN PLAN, as PLAN_TABLE holds it. */
74
+ export interface DbPlanRow {
75
+ id: number;
76
+ parentId: number | null;
77
+ depth: number;
78
+ operation: string;
79
+ options: string | null;
80
+ objectOwner: string | null;
81
+ objectName: string | null;
82
+ cost: number | null;
83
+ cardinality: number | null;
84
+ bytes: number | null;
85
+ accessPredicates: string | null;
86
+ filterPredicates: string | null;
87
+ }
88
+
89
+ export interface DbTableIndex {
90
+ name: string;
91
+ unique: boolean;
92
+ /** In index order; an expression index shows its system column name. */
93
+ columns: string[];
94
+ }
95
+
96
+ export interface DbTableConstraint {
97
+ name: string;
98
+ /** P primary key, U unique, R foreign key, C check. */
99
+ type: 'P' | 'U' | 'R' | 'C';
100
+ columns: string[];
101
+ /** The condition of a check constraint. */
102
+ condition?: string;
103
+ /** For a foreign key: the constraint it references. */
104
+ references?: { owner: string; table: string; constraint: string; columns: string[] };
105
+ }
106
+
107
+ export interface DbTableDetails {
108
+ owner: string;
109
+ name: string;
110
+ indexes: DbTableIndex[];
111
+ /** Ordered P, U, R, C, then by name; system NOT NULL checks left out. */
112
+ constraints: DbTableConstraint[];
113
+ /** DBMS_METADATA.GET_DDL output; null when the user may not read it. */
114
+ ddl: string | null;
115
+ }
116
+
117
+ export interface DbStatus {
118
+ connected: boolean;
119
+ /** Uncommitted changes may exist. */
120
+ pending: boolean;
121
+ busy: boolean;
122
+ }
123
+
124
+ export interface DbSessionsOptions {
125
+ connect?: (c: DbCredentials) => Promise<DbConnection>;
126
+ /** A session without a call for this long is rolled back and closed. */
127
+ idleMs?: number;
128
+ /** Most rows one query streams; then `{ $truncated: true }` ends it. */
129
+ maxRows?: number;
130
+ /** Rows per round trip (and per `getRows`) when streaming a query's rows. */
131
+ fetchBatch?: number;
132
+ }
133
+
134
+ export interface DbSessions {
135
+ /** Connects, runs `select 1 from dual` and disconnects; no session is kept. */
136
+ test(creds: DbCredentials): Promise<void>;
137
+ /** Opens the session of connection record `id` (or reuses it) and reports its status. */
138
+ open(id: string, creds: DbCredentials): Promise<DbStatus>;
139
+ run(id: string, creds: DbCredentials, sql: string, opts?: DbRunOptions): Promise<DbRunResult>;
140
+ /** Tables, views and columns of `owner` (default: the session's schema) and the owners to choose from. */
141
+ schema(id: string, creds: DbCredentials, owner?: string): Promise<DbSchema>;
142
+ /**
143
+ * The execution plan of `sql` (not run), ordered by id. Its PLAN_TABLE rows are deleted again in
144
+ * the same transaction; nothing is committed or rolled back and `pending` is left as it was.
145
+ */
146
+ explain(id: string, creds: DbCredentials, sql: string): Promise<DbPlanRow[]>;
147
+ /** Indexes, constraints and DDL of table `owner.name` (owner default: the session's schema); dictionary reads only. */
148
+ table(id: string, creds: DbCredentials, owner: string, name: string): Promise<DbTableDetails>;
149
+ /** Streams the rows of a `run` result, then closes its result set. */
150
+ rows(run: string, emit: Emit, signal: AbortSignal): Promise<void>;
151
+ commit(id: string): Promise<DbStatus>;
152
+ rollback(id: string): Promise<DbStatus>;
153
+ /** Rolls back and closes. */
154
+ close(id: string): Promise<DbStatus>;
155
+ status(id: string): DbStatus;
156
+ /** Rolls back and closes every session, e.g. on shutdown. */
157
+ closeAll(): Promise<void>;
158
+ }
159
+
160
+ interface OpenResult {
161
+ run: string;
162
+ rs: DbResultSet;
163
+ streaming: boolean;
164
+ maxRows: number;
165
+ }
166
+
167
+ interface Session {
168
+ fingerprint: string;
169
+ creds: DbCredentials;
170
+ conn: Promise<DbConnection>;
171
+ connected: boolean;
172
+ pending: boolean;
173
+ executing: boolean;
174
+ /** Settles when the statement now executing ends. */
175
+ idle: Promise<void>;
176
+ result?: OpenResult;
177
+ timer?: ReturnType<typeof setTimeout>;
178
+ }
179
+
180
+ const SCHEMA_MAX_COLUMNS = 200_000;
181
+ const SCHEMA_MAX_OWNERS = 5000;
182
+ /**
183
+ * Rows per round trip for the schema's columns. The driver's default of 100 made a 29k-column
184
+ * schema take 12 s over a 40 ms link (290 round trips); 10 000 takes it under 1 s.
185
+ */
186
+ const SCHEMA_FETCH_SIZE = 10_000;
187
+
188
+ const DISCONNECTED: DbStatus = { connected: false, pending: false, busy: false };
189
+
190
+ /** Statements on one session wait for the one still executing; an open result set makes `run` fail with this. */
191
+ const busyError = () => invalid('dbBusy', 'A statement is still running on this connection.');
192
+
193
+ /**
194
+ * One Oracle session per connection record, with an explicit transaction: statements run with
195
+ * `autoCommit: false`, and DML stays pending until `commit` or `rollback`. Closing (also on idle)
196
+ * rolls back. Driver errors become AppErrors whose texts never contain the password.
197
+ */
198
+ export function dbSessions(opts: DbSessionsOptions = {}): DbSessions {
199
+ const connect = opts.connect ?? defaultConnect;
200
+ const idleMs = opts.idleMs ?? 900_000;
201
+ const maxRows = opts.maxRows ?? 100_000;
202
+ const fetchBatch = opts.fetchBatch ?? 2000;
203
+ const sessions = new Map<string, Session>();
204
+ const runs = new Map<string, string>();
205
+
206
+ const isBusy = (s: Session) => s.executing || s.result !== undefined;
207
+ const statusOf = (s: Session | undefined): DbStatus =>
208
+ s ? { connected: s.connected, pending: s.pending, busy: isBusy(s) } : DISCONNECTED;
209
+
210
+ function touch(id: string, s: Session) {
211
+ clearTimeout(s.timer);
212
+ s.timer = setTimeout(() => {
213
+ if (sessions.get(id) !== s) return;
214
+ if (s.executing || s.result?.streaming) touch(id, s);
215
+ else void closeSession(id, s);
216
+ }, idleMs);
217
+ (s.timer as { unref?: () => void }).unref?.();
218
+ }
219
+
220
+ /** Marks `s` executing until the returned function is called. */
221
+ function startExecuting(s: Session): () => void {
222
+ let end!: () => void;
223
+ s.idle = new Promise((resolve) => { end = resolve; });
224
+ s.executing = true;
225
+ return () => {
226
+ s.executing = false;
227
+ end();
228
+ };
229
+ }
230
+
231
+ async function closeSession(id: string, s: Session): Promise<void> {
232
+ clearTimeout(s.timer);
233
+ if (sessions.get(id) === s) sessions.delete(id);
234
+ const result = s.result;
235
+ s.result = undefined;
236
+ if (result) runs.delete(result.run);
237
+ const conn = await s.conn.catch(() => null);
238
+ if (!conn) return;
239
+ if (result) {
240
+ if (result.streaming) await conn.break().catch(() => {});
241
+ await result.rs.close().catch(() => {});
242
+ }
243
+ await conn.rollback().catch(() => {});
244
+ await conn.close().catch(() => {});
245
+ }
246
+
247
+ async function session(id: string, creds: DbCredentials): Promise<Session> {
248
+ const fingerprint = fingerprintOf(creds);
249
+ let s = sessions.get(id);
250
+ if (s && s.fingerprint !== fingerprint) {
251
+ if (s.pending) throw invalid('dbPendingChanges', 'Commit or roll back the pending changes first.');
252
+ if (isBusy(s)) throw busyError();
253
+ await closeSession(id, s);
254
+ s = sessions.get(id);
255
+ }
256
+ if (!s || s.fingerprint !== fingerprint) {
257
+ const created: Session = {
258
+ fingerprint,
259
+ creds,
260
+ conn: connect(creds),
261
+ connected: false,
262
+ pending: false,
263
+ executing: false,
264
+ idle: Promise.resolve(),
265
+ };
266
+ sessions.set(id, created);
267
+ s = created;
268
+ }
269
+ touch(id, s);
270
+ try {
271
+ await s.conn;
272
+ s.connected = true;
273
+ } catch (err) {
274
+ if (sessions.get(id) === s) {
275
+ sessions.delete(id);
276
+ clearTimeout(s.timer);
277
+ }
278
+ throw mapError(err, s.creds);
279
+ }
280
+ return s;
281
+ }
282
+
283
+ async function run(id: string, creds: DbCredentials, sql: string, runOpts: DbRunOptions = {}): Promise<DbRunResult> {
284
+ const statement = prepareSql(sql);
285
+ if (!statement) throw invalid('badRequest', 'Invalid request.');
286
+ const s = await session(id, creds);
287
+ while (s.executing) await s.idle;
288
+ if (s.result) throw busyError();
289
+ const done = startExecuting(s);
290
+ try {
291
+ const conn = await s.conn;
292
+ const kind = statementKind(statement);
293
+ if (kind === 'query') {
294
+ const r = await conn.execute(statement, [], {
295
+ autoCommit: false,
296
+ resultSet: true,
297
+ // The driver's default is 100 rows per round trip, slow over a VPN (see SCHEMA_FETCH_SIZE).
298
+ fetchArraySize: fetchBatch,
299
+ outFormat: oracledb.OUT_FORMAT_ARRAY,
300
+ fetchTypeHandler,
301
+ });
302
+ const columns = (r.metaData ?? []).map((m) => ({ name: m.name, type: columnType(m.dbType?.name), ...(m.dbType?.name?.startsWith('DB_TYPE_TIMESTAMP') ? { timestamp: true } : {}) }));
303
+ if (!r.resultSet) return { kind: 'done' };
304
+ const result = { run: randomUUID(), rs: r.resultSet, streaming: false, maxRows: Math.min(Math.max(runOpts.maxRows ?? maxRows, 1), maxRows) };
305
+ s.result = result;
306
+ runs.set(result.run, id);
307
+ return { kind: 'rows', run: result.run, columns };
308
+ }
309
+ const r = await conn.execute(statement, [], { autoCommit: false });
310
+ if (kind === 'ddl' || kind === 'end') s.pending = false;
311
+ else s.pending = true;
312
+ return r.rowsAffected === undefined ? { kind: 'done' } : { kind: 'done', rowsAffected: r.rowsAffected };
313
+ } catch (err) {
314
+ throw mapError(err, s.creds);
315
+ } finally {
316
+ done();
317
+ touch(id, s);
318
+ }
319
+ }
320
+
321
+ async function schema(id: string, creds: DbCredentials, owner?: string): Promise<DbSchema> {
322
+ const s = await session(id, creds);
323
+ // An open result set doesn't block reading the dictionary.
324
+ while (s.executing) await s.idle;
325
+ const done = startExecuting(s);
326
+ try {
327
+ const conn = await s.conn;
328
+ const options = { autoCommit: false, outFormat: oracledb.OUT_FORMAT_ARRAY, maxRows: SCHEMA_MAX_COLUMNS + 1, fetchArraySize: SCHEMA_FETCH_SIZE };
329
+ let name = owner;
330
+ if (!name) {
331
+ const current = await conn.execute("select sys_context('USERENV', 'CURRENT_SCHEMA') from dual", [], options);
332
+ name = String(current.rows?.[0]?.[0] ?? creds.user.toUpperCase());
333
+ }
334
+ const owners = await conn.execute('select username from all_users order by username', [], { ...options, maxRows: SCHEMA_MAX_OWNERS });
335
+ const found = await conn.execute(
336
+ `select c.table_name, o.object_type, c.column_name, c.data_type, c.nullable
337
+ from all_tab_columns c
338
+ join all_objects o on o.owner = c.owner and o.object_name = c.table_name and o.object_type in ('TABLE', 'VIEW')
339
+ where c.owner = :owner and c.table_name not like 'BIN$%'
340
+ order by c.table_name, c.column_id`,
341
+ [name],
342
+ options,
343
+ );
344
+ const rows = found.rows ?? [];
345
+ const tables: DbSchemaTable[] = [];
346
+ for (const [table, kind, column, type, nullable] of rows.slice(0, SCHEMA_MAX_COLUMNS) as string[][]) {
347
+ let t = tables[tables.length - 1];
348
+ if (t?.name !== table) {
349
+ t = { name: table!, kind: kind === 'VIEW' ? 'view' : 'table', columns: [] };
350
+ tables.push(t);
351
+ }
352
+ t.columns.push({ name: column!, type: type!, nullable: nullable === 'Y' });
353
+ }
354
+ return {
355
+ owner: name,
356
+ owners: (owners.rows ?? []).map((r) => String(r[0])),
357
+ tables,
358
+ truncated: rows.length > SCHEMA_MAX_COLUMNS,
359
+ };
360
+ } catch (err) {
361
+ throw mapError(err, s.creds);
362
+ } finally {
363
+ done();
364
+ touch(id, s);
365
+ }
366
+ }
367
+
368
+ // EXPLAIN PLAN is DML on PLAN_TABLE (a session-private temporary table), not DDL: it commits
369
+ // nothing, and deleting its rows in the same transaction leaves the user's pending work as it was.
370
+ async function explain(id: string, creds: DbCredentials, sql: string): Promise<DbPlanRow[]> {
371
+ const statement = prepareSql(sql);
372
+ if (!statement) throw invalid('badRequest', 'Invalid request.');
373
+ const s = await session(id, creds);
374
+ // Like `schema`, an open result set doesn't block it.
375
+ while (s.executing) await s.idle;
376
+ const done = startExecuting(s);
377
+ try {
378
+ const conn = await s.conn;
379
+ // STATEMENT_ID is VARCHAR2(30) and can't be bound; this one is hex only.
380
+ const sid = `db${randomUUID().replace(/-/g, '').slice(0, 28)}`;
381
+ await conn.execute(`EXPLAIN PLAN SET STATEMENT_ID = '${sid}' FOR ${statement}`, [], { autoCommit: false });
382
+ try {
383
+ const r = await conn.execute(
384
+ `select id, parent_id, depth, operation, options, object_owner, object_name, cost, cardinality, bytes,
385
+ access_predicates, filter_predicates
386
+ from plan_table where statement_id = :sid order by id`,
387
+ [sid],
388
+ { autoCommit: false, outFormat: oracledb.OUT_FORMAT_ARRAY },
389
+ );
390
+ return (r.rows ?? []).map((row) => {
391
+ const [id, parentId, depth, operation, options, objectOwner, objectName, cost, cardinality, bytes, access, filter] = row;
392
+ const num = (v: unknown) => (v === null || v === undefined ? null : Number(v));
393
+ const str = (v: unknown) => (v === null || v === undefined ? null : String(v));
394
+ return {
395
+ id: Number(id),
396
+ parentId: num(parentId),
397
+ depth: Number(depth ?? 0),
398
+ operation: String(operation ?? ''),
399
+ options: str(options),
400
+ objectOwner: str(objectOwner),
401
+ objectName: str(objectName),
402
+ cost: num(cost),
403
+ cardinality: num(cardinality),
404
+ bytes: num(bytes),
405
+ accessPredicates: str(access),
406
+ filterPredicates: str(filter),
407
+ };
408
+ });
409
+ } finally {
410
+ await conn.execute('delete from plan_table where statement_id = :sid', [sid], { autoCommit: false }).catch(() => {});
411
+ }
412
+ } catch (err) {
413
+ throw mapError(err, s.creds);
414
+ } finally {
415
+ done();
416
+ touch(id, s);
417
+ }
418
+ }
419
+
420
+ // Only dictionary selects and DBMS_METADATA (a function in a select): no DML, so the transaction is untouched.
421
+ async function table(id: string, creds: DbCredentials, owner: string, name: string): Promise<DbTableDetails> {
422
+ if (!name) throw invalid('badRequest', 'Invalid request.');
423
+ const s = await session(id, creds);
424
+ while (s.executing) await s.idle;
425
+ const done = startExecuting(s);
426
+ try {
427
+ const conn = await s.conn;
428
+ const options = { autoCommit: false, outFormat: oracledb.OUT_FORMAT_ARRAY };
429
+ let tableOwner = owner;
430
+ if (!tableOwner) {
431
+ const current = await conn.execute("select sys_context('USERENV', 'CURRENT_SCHEMA') from dual", [], options);
432
+ tableOwner = String(current.rows?.[0]?.[0] ?? creds.user.toUpperCase());
433
+ }
434
+ const binds = [tableOwner, name];
435
+ const exists = await conn.execute('select 1 from all_tables where owner = :owner and table_name = :name', binds, options);
436
+ if (!exists.rows?.length) throw notFound('dbTable', 'This table does not exist or is not visible.');
437
+
438
+ const str = (v: unknown) => (v === null || v === undefined ? '' : String(v));
439
+ const ix = await conn.execute(
440
+ `select i.index_name, i.uniqueness, c.column_name
441
+ from all_indexes i
442
+ join all_ind_columns c on c.index_owner = i.owner and c.index_name = i.index_name
443
+ where i.table_owner = :owner and i.table_name = :name
444
+ order by i.index_name, c.column_position`,
445
+ binds,
446
+ options,
447
+ );
448
+ const indexes: DbTableIndex[] = [];
449
+ for (const [index, uniqueness, column] of ix.rows ?? []) {
450
+ let last = indexes[indexes.length - 1];
451
+ if (last?.name !== str(index)) {
452
+ last = { name: str(index), unique: uniqueness === 'UNIQUE', columns: [] };
453
+ indexes.push(last);
454
+ }
455
+ last.columns.push(str(column));
456
+ }
457
+
458
+ const cons = await conn.execute(
459
+ `select c.constraint_name, c.constraint_type, c.generated, c.search_condition, c.r_owner, c.r_constraint_name,
460
+ (select r.table_name from all_constraints r where r.owner = c.r_owner and r.constraint_name = c.r_constraint_name)
461
+ from all_constraints c
462
+ where c.owner = :owner and c.table_name = :name and c.constraint_type in ('P', 'U', 'R', 'C')`,
463
+ binds,
464
+ options,
465
+ );
466
+ const cols = await conn.execute(
467
+ `select constraint_name, column_name from all_cons_columns
468
+ where owner = :owner and table_name = :name order by constraint_name, position`,
469
+ binds,
470
+ options,
471
+ );
472
+ const refCols = await conn.execute(
473
+ `select cc.owner, cc.constraint_name, cc.column_name
474
+ from all_constraints c
475
+ join all_cons_columns cc on cc.owner = c.r_owner and cc.constraint_name = c.r_constraint_name
476
+ where c.owner = :owner and c.table_name = :name and c.constraint_type = 'R'
477
+ order by cc.owner, cc.constraint_name, cc.position`,
478
+ binds,
479
+ options,
480
+ );
481
+ const group = (rows: unknown[][] | undefined, key: (r: unknown[]) => string, value: (r: unknown[]) => string) => {
482
+ const m = new Map<string, string[]>();
483
+ for (const r of rows ?? []) {
484
+ const k = key(r);
485
+ const list = m.get(k) ?? [];
486
+ if (!m.has(k)) m.set(k, list);
487
+ list.push(value(r));
488
+ }
489
+ return m;
490
+ };
491
+ const columnsOf = group(cols.rows, (r) => str(r[0]), (r) => str(r[1]));
492
+ const refColumnsOf = group(refCols.rows, (r) => `${str(r[0])}.${str(r[1])}`, (r) => str(r[2]));
493
+ const constraints: DbTableConstraint[] = [];
494
+ for (const [cname, type, generated, condition, rOwner, rName, rTable] of cons.rows ?? []) {
495
+ const c: DbTableConstraint = { name: str(cname), type: str(type) as DbTableConstraint['type'], columns: columnsOf.get(str(cname)) ?? [] };
496
+ if (c.type === 'C') {
497
+ c.condition = str(condition);
498
+ if (generated === 'GENERATED NAME' && /^\s*"[^"]+"\s+IS\s+NOT\s+NULL\s*$/i.test(c.condition)) continue;
499
+ }
500
+ if (c.type === 'R') {
501
+ c.references = { owner: str(rOwner), table: str(rTable), constraint: str(rName), columns: refColumnsOf.get(`${str(rOwner)}.${str(rName)}`) ?? [] };
502
+ }
503
+ constraints.push(c);
504
+ }
505
+ const order = 'PURC';
506
+ constraints.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type) || a.name.localeCompare(b.name));
507
+
508
+ let ddl: string | null = null;
509
+ try {
510
+ const r = await conn.execute("select dbms_metadata.get_ddl('TABLE', :name, :owner) as ddl from dual", [name, tableOwner], {
511
+ ...options,
512
+ fetchInfo: { DDL: { type: oracledb.STRING } },
513
+ });
514
+ const v = r.rows?.[0]?.[0];
515
+ ddl = v === null || v === undefined ? null : String(v).trim();
516
+ } catch {
517
+ // No privilege to read another schema's metadata: show the rest.
518
+ }
519
+ return { owner: tableOwner, name, indexes, constraints, ddl };
520
+ } catch (err) {
521
+ throw err instanceof AppError ? err : mapError(err, s.creds);
522
+ } finally {
523
+ done();
524
+ touch(id, s);
525
+ }
526
+ }
527
+
528
+ async function rows(run: string, emit: Emit, signal: AbortSignal): Promise<void> {
529
+ const id = runs.get(run);
530
+ const s = id === undefined ? undefined : sessions.get(id);
531
+ const result = s?.result;
532
+ if (id === undefined || !s || result?.run !== run || result.streaming) {
533
+ throw notFound('dbResultGone', 'This result is no longer available; run the statement again.');
534
+ }
535
+ result.streaming = true;
536
+ const limit = result.maxRows;
537
+ const conn = await s.conn;
538
+ const stop = () => { void conn.break().catch(() => {}); };
539
+ signal.addEventListener('abort', stop, { once: true });
540
+ try {
541
+ let count = 0;
542
+ while (!signal.aborted) {
543
+ if (count >= limit) {
544
+ const more = await result.rs.getRows(1);
545
+ if (more.length) await emit({ $truncated: true });
546
+ break;
547
+ }
548
+ const batch = await result.rs.getRows(Math.min(fetchBatch, limit - count));
549
+ touch(id, s);
550
+ for (const row of batch) {
551
+ if (signal.aborted) break;
552
+ await emit(row);
553
+ }
554
+ count += batch.length;
555
+ if (batch.length === 0) break;
556
+ }
557
+ } catch (err) {
558
+ if (!signal.aborted) throw mapError(err, s.creds);
559
+ } finally {
560
+ signal.removeEventListener('abort', stop);
561
+ runs.delete(run);
562
+ if (s.result === result) s.result = undefined;
563
+ await result.rs.close().catch(() => {});
564
+ if (sessions.get(id) === s) touch(id, s);
565
+ }
566
+ }
567
+
568
+ async function finish(id: string, op: 'commit' | 'rollback'): Promise<DbStatus> {
569
+ const s = sessions.get(id);
570
+ if (!s) return DISCONNECTED;
571
+ while (s.executing) await s.idle;
572
+ const conn = await s.conn;
573
+ try {
574
+ await conn[op]();
575
+ } catch (err) {
576
+ throw mapError(err, s.creds);
577
+ }
578
+ s.pending = false;
579
+ touch(id, s);
580
+ return statusOf(s);
581
+ }
582
+
583
+ return {
584
+ async test(creds) {
585
+ let conn: DbConnection | undefined;
586
+ try {
587
+ conn = await connect(creds);
588
+ await conn.execute('select 1 from dual', [], { autoCommit: false });
589
+ } catch (err) {
590
+ throw mapError(err, creds);
591
+ } finally {
592
+ await conn?.close().catch(() => {});
593
+ }
594
+ },
595
+ async open(id, creds) {
596
+ return statusOf(await session(id, creds));
597
+ },
598
+ run,
599
+ schema,
600
+ explain,
601
+ table,
602
+ rows,
603
+ commit: (id) => finish(id, 'commit'),
604
+ rollback: (id) => finish(id, 'rollback'),
605
+ async close(id) {
606
+ const s = sessions.get(id);
607
+ if (s) await closeSession(id, s);
608
+ return DISCONNECTED;
609
+ },
610
+ status: (id) => statusOf(sessions.get(id)),
611
+ async closeAll() {
612
+ await Promise.all([...sessions].map(([id, s]) => closeSession(id, s)));
613
+ },
614
+ };
615
+ }
616
+
617
+ async function defaultConnect(c: DbCredentials): Promise<DbConnection> {
618
+ return oracledb.getConnection({ connectString: c.connectString, user: c.user, password: c.password, connectTimeout: 15 });
619
+ }
620
+
621
+ function fingerprintOf(c: DbCredentials): string {
622
+ return createHash('sha256').update(`${c.connectString}|${c.user}|${c.password}`).digest('hex');
623
+ }
624
+
625
+ const PLSQL = /^(?:BEGIN|DECLARE|CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?(?:PROCEDURE|FUNCTION|PACKAGE|TRIGGER|TYPE)\b)/i;
626
+
627
+ /** Leading whitespace and comments removed, for keyword checks. */
628
+ function head(sql: string): string {
629
+ let s = sql.trimStart();
630
+ for (;;) {
631
+ if (s.startsWith('--')) {
632
+ const nl = s.indexOf('\n');
633
+ s = nl < 0 ? '' : s.slice(nl + 1).trimStart();
634
+ } else if (s.startsWith('/*')) {
635
+ const end = s.indexOf('*/');
636
+ s = end < 0 ? '' : s.slice(end + 2).trimStart();
637
+ } else return s;
638
+ }
639
+ }
640
+
641
+ /**
642
+ * The statement as Oracle takes it: without a trailing `/` line (SQL*Plus' run marker) and,
643
+ * unless it is PL/SQL, without one trailing `;`. Empty when nothing is left.
644
+ */
645
+ export function prepareSql(sql: string): string {
646
+ let s = sql.trim().replace(/(?:^|\n)[ \t]*\/[ \t]*$/, '').trimEnd();
647
+ if (!PLSQL.test(head(s)) && s.endsWith(';')) s = s.slice(0, -1).trimEnd();
648
+ return head(s) ? s : '';
649
+ }
650
+
651
+ export type StatementKind = 'query' | 'dml' | 'ddl' | 'end' | 'other';
652
+
653
+ /**
654
+ * `ddl` statements (and `COMMIT`/`ROLLBACK`) end the transaction; `dml`, PL/SQL blocks and
655
+ * anything unknown may leave changes pending. Creating a procedure is DDL: Oracle commits it.
656
+ */
657
+ export function statementKind(sql: string): StatementKind {
658
+ const word = /^[A-Za-z]+/.exec(head(sql))?.[0]?.toUpperCase() ?? '';
659
+ if (word === 'SELECT' || word === 'WITH') return 'query';
660
+ if (['INSERT', 'UPDATE', 'DELETE', 'MERGE'].includes(word)) return 'dml';
661
+ if (['CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'GRANT', 'REVOKE', 'RENAME', 'COMMENT', 'AUDIT', 'NOAUDIT', 'PURGE', 'FLASHBACK', 'ANALYZE'].includes(word)) return 'ddl';
662
+ if (word === 'COMMIT' || (word === 'ROLLBACK' && !/^ROLLBACK\s+(?:WORK\s+)?TO\b/i.test(head(sql)))) return 'end';
663
+ return 'other';
664
+ }
665
+
666
+ export function columnType(dbTypeName: string | undefined): DbColumnType {
667
+ switch (dbTypeName) {
668
+ case 'DB_TYPE_NUMBER':
669
+ case 'DB_TYPE_BINARY_FLOAT':
670
+ case 'DB_TYPE_BINARY_DOUBLE':
671
+ case 'DB_TYPE_BINARY_INTEGER':
672
+ return 'number';
673
+ case 'DB_TYPE_DATE':
674
+ case 'DB_TYPE_TIMESTAMP':
675
+ case 'DB_TYPE_TIMESTAMP_TZ':
676
+ case 'DB_TYPE_TIMESTAMP_LTZ':
677
+ return 'date';
678
+ case 'DB_TYPE_RAW':
679
+ case 'DB_TYPE_LONG_RAW':
680
+ case 'DB_TYPE_BLOB':
681
+ return 'binary';
682
+ default:
683
+ return 'text';
684
+ }
685
+ }
686
+
687
+ const pad = (n: number, len = 2) => String(n).padStart(len, '0');
688
+
689
+ /** A DATE/TIMESTAMP without zone, as written in the database (the driver reads it as local time). */
690
+ function localIso(d: Date): string {
691
+ const ms = d.getMilliseconds();
692
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? `.${pad(ms, 3)}` : ''}`;
693
+ }
694
+
695
+ const binary = (v: Uint8Array | null) => (v == null ? null : `<binary ${v.length} bytes>`);
696
+
697
+ /** NUMBER as string (keeps precision), CLOB as string, dates as ISO text, binary as a size note. */
698
+ export function fetchTypeHandler(meta: FetchMetadata): FetchTypeResult | undefined {
699
+ switch (meta.dbType.name) {
700
+ case 'DB_TYPE_NUMBER':
701
+ case 'DB_TYPE_CLOB':
702
+ case 'DB_TYPE_NCLOB':
703
+ return { type: oracledb.STRING };
704
+ case 'DB_TYPE_DATE':
705
+ case 'DB_TYPE_TIMESTAMP':
706
+ return { converter: (v: Date | null) => (v == null ? null : localIso(v)) };
707
+ case 'DB_TYPE_TIMESTAMP_TZ':
708
+ case 'DB_TYPE_TIMESTAMP_LTZ':
709
+ return { converter: (v: Date | null) => (v == null ? null : v.toISOString()) };
710
+ case 'DB_TYPE_BLOB':
711
+ return { type: oracledb.BUFFER, converter: binary };
712
+ case 'DB_TYPE_RAW':
713
+ case 'DB_TYPE_LONG_RAW':
714
+ return { converter: binary };
715
+ case 'DB_TYPE_JSON':
716
+ return { converter: (v: unknown) => (v == null ? null : JSON.stringify(v)) };
717
+ default:
718
+ return undefined;
719
+ }
720
+ }
721
+
722
+ const LOGIN = /ORA-01017/;
723
+ const UNREACHABLE = /ORA-12514|ORA-12505|ORA-12541|ORA-12170|NJS-503|NJS-500|NJS-530|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/;
724
+
725
+ /** The host of a connect string (`host:port/service`, `[v6]:port/service` or a descriptor). */
726
+ export function hostOf(connectString: string): string {
727
+ return /\(HOST=([^)]+)\)/i.exec(connectString)?.[1] ?? /^\[([^\]]+)\]/.exec(connectString)?.[1]
728
+ ?? /^[^:/]*/.exec(connectString)![0];
729
+ }
730
+
731
+ /** Driver error → AppError; the password is replaced by `***` in every text. */
732
+ export function mapError(err: unknown, creds: DbCredentials): AppError {
733
+ if (err instanceof AppError) return err;
734
+ const scrub = (text: string) => (creds.password ? text.split(creds.password).join('***') : text);
735
+ const e = err as { message?: unknown; code?: unknown };
736
+ const raw = scrub(`${typeof e?.code === 'string' ? `${e.code} ` : ''}${String(e?.message ?? err)}`);
737
+ const message = scrub(String(e?.message ?? err));
738
+ if (LOGIN.test(raw)) return new AppError('invalid', 'dbLogin', 'Login failed: wrong user or password.', { detail: message });
739
+ if (UNREACHABLE.test(raw)) {
740
+ const host = scrub(hostOf(creds.connectString));
741
+ return new AppError('invalid', 'dbUnreachable', `The database at ${host} can't be reached.`, { detail: message, params: { host } });
742
+ }
743
+ const first = message.split('\n')[0]!.trim() || 'Database error.';
744
+ return new AppError('invalid', 'dbError', first, { detail: message, params: { message: first } });
745
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.