Oracle #
Oracle Database is one of the most mature enterprise databases, widely used in large companies, financial institutions, and government agencies. Integrating it with TypeScript using Oracle’s own oracledb library gives access to advanced features like REF CURSORs, PL/SQL anonymous blocks, LOBs (Large Objects), and RETURNING INTO — all with fairly good TypeScript support. There are several Oracle characteristics you need to understand from the start: bind parameters use the : prefix (not ? or @), table and column names are case-insensitive by default and stored in uppercase, and this library requires the Oracle Client or Instant Client installed on the system. Understanding these quirks will save you from the confusion developers often experience when first working with Oracle.
Installation and Setup #
oracledb requires the Oracle Instant Client installed on the operating system, in addition to its npm package. This differs from mysql2 and mssql, which are pure JavaScript.
# Install the npm package
npm install oracledb
# Type definitions — needs a separate installation
npm install --save-dev @types/oracledb
Installing the Oracle Instant Client #
# macOS (via Homebrew)
brew install instantclient-basic
# Ubuntu/Debian
# Download from: https://www.oracle.com/database/technologies/instant-client/downloads.html
# Extract to /opt/oracle/instantclient_21_x
sudo apt-get install libaio1
export LD_LIBRARY_PATH=/opt/oracle/instantclient_21_x:$LD_LIBRARY_PATH
# Windows — add the Instant Client folder to PATH
// src/db/connection.ts — oracledb initialization
import oracledb, {
Connection,
Pool,
BindParameters,
ExecuteOptions,
} from 'oracledb';
// Global configuration — call once at startup
oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT; // results as objects, not arrays
oracledb.autoCommit = false; // always commit explicitly
oracledb.fetchArraySize = 100; // rows fetched per server round-trip
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
The recommended project structure:
src/
├── db/
│ ├── connection.ts -- pool singleton and global config
│ └── types.ts -- custom Oracle types and helpers
├── models/
│ ├── user.model.ts
│ └── produk.model.ts
├── repositories/
│ ├── base.repository.ts
│ └── user.repository.ts
└── index.ts
Connection Pool Configuration #
Oracle uses createPool() to create a connection pool. Oracle pools have a unique feature called sessionCallback that allows session initialization when a connection is first taken from the pool.
import oracledb, { Pool, Connection, PoolAttributes } from 'oracledb';
const poolConfig: PoolAttributes = {
user: process.env.DB_USER ?? 'system',
password: process.env.DB_PASSWORD ?? '',
connectString: process.env.DB_CONNECT_STRING ?? 'localhost:1521/XEPDB1',
// connectString formats:
// - Easy Connect: "host:port/service_name"
// - TNS alias: "MYDB" (requires tnsnames.ora)
// - Full: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=host)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=svc)))"
poolMin: 2, // minimum connections always alive
poolMax: 10, // maximum connections
poolIncrement: 1, // how many connections to add when needed
poolTimeout: 60, // seconds before idle connections are removed from the pool
poolPingInterval: 60, // check idle connections every N seconds
sessionCallback: initSession, // called when a new connection is created
};
// Session initialization — runs once per new pool connection
async function initSession(
connection: Connection,
requestedTag: string,
callback: (error?: Error) => void
): Promise<void> {
try {
// Set timezone, date format, and other session parameters
await connection.execute(
`ALTER SESSION SET
NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'
NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF'
TIME_ZONE = 'Asia/Jakarta'`
);
callback();
} catch (err) {
callback(err instanceof Error ? err : new Error(String(err)));
}
}
let poolInstance: Pool | null = null;
export async function getPool(): Promise<Pool> {
if (!poolInstance) {
poolInstance = await oracledb.createPool(poolConfig);
}
return poolInstance;
}
export async function getConnection(): Promise<Connection> {
const pool = await getPool();
return pool.getConnection();
}
export async function closePool(): Promise<void> {
if (poolInstance) {
await poolInstance.close(10); // wait max 10 seconds for active connections to finish
poolInstance = null;
}
}
process.on('SIGTERM', closePool);
process.on('SIGINT', closePool);
flowchart TD
A[oracledb.createPool] --> B[Pool ready\nmin: 2 connections]
B --> C[Request comes in]
C --> D[pool.getConnection]
D --> E{Idle connection\nin the pool?}
E -- Yes --> F[Take the\nidle connection]
E -- No --> G{Count < poolMax?}
G -- Yes --> H[Create a new connection\nrun sessionCallback]
G -- No --> I[Wait for a\nconnection to free up]
F --> J[Execute the query]
H --> J
I --> F
J --> K[connection.close\nreturn to the pool]
K --> BUnlikemysql2andmssql,oracledbrequiresconnection.close()— notconnection.release()— to return a connection to the pool. Callingclose()on a connection from the pool automatically returns it to the pool, not actually closes it. Forgetting to callclose()permanently consumes a pool slot.
Defining Types for Database Rows #
When using oracledb.OUT_FORMAT_OBJECT, query results are returned as an array of objects. The returned column names are all uppercase following Oracle conventions, unless you use lowercase column aliases in the query.
// Type for the USERS table (Oracle column names are always uppercase)
interface UserRow {
ID: number;
NAMA: string;
EMAIL: string;
PASSWORD_HASH: string;
ROLE: string;
AKTIF: number; // Oracle has no native BOOLEAN — use NUMBER(1)
DIBUAT_PADA: Date;
DIPERBARUI_PADA: Date | null;
}
// A friendlier type for use in the application
interface User {
id: number;
nama: string;
email: string;
passwordHash: string;
role: 'admin' | 'user' | 'moderator';
aktif: boolean;
dibuatPada: Date;
diperbarui_pada: Date | null;
}
// Mapper function from Oracle row to domain type
function mapRowToUser(row: UserRow): User {
return {
id: row.ID,
nama: row.NAMA,
email: row.EMAIL,
passwordHash: row.PASSWORD_HASH,
role: row.ROLE as User['role'],
aktif: row.AKTIF === 1,
dibuatPada: row.DIBUAT_PADA,
diperbarui_pada: row.DIPERBARUI_PADA,
};
}
A more concise alternative: use lowercase column aliases directly in the SQL query.
// With lowercase aliases in SQL — the result is directly camelCase-friendly
interface UserFromQuery {
id: number;
nama: string;
email: string;
aktif: number;
dibuat_pada: Date;
}
// Query with explicit column aliases
const SQL_SELECT_USER = `
SELECT
u.id AS "id",
u.nama AS "nama",
u.email AS "email",
u.aktif AS "aktif",
u.dibuat_pada AS "dibuat_pada"
FROM users u
WHERE u.id = :id
`;
// Columns in double quotes in Oracle become case-sensitive
Oracle data type to TypeScript mapping:
| Oracle Type | TypeScript Type | Notes |
|---|---|---|
NUMBER, INTEGER | number | Be careful with BIGINT |
NUMBER(1) | number (0/1) | Oracle has no native BOOLEAN |
VARCHAR2(n) | string | Maximum 4000 characters |
NVARCHAR2(n) | string | Unicode, maximum 2000 characters |
CLOB | string | Long text, needs special fetching |
DATE | Date | Includes the time component |
TIMESTAMP | Date | Precision up to nanoseconds |
BLOB | Buffer | Binary data |
Basic Queries — SELECT #
Oracle uses :namaParam as the bind parameter placeholder. Unlike MySQL and MSSQL, you can bind by position (:1, :2) or by name (:namaParam).
import oracledb from 'oracledb';
import { getConnection } from './db/connection';
// ── Simple SELECT
async function semuaUser(): Promise<User[]> {
const conn = await getConnection();
try {
const result = await conn.execute<UserRow>(
`SELECT id, nama, email, role, aktif, dibuat_pada
FROM users
WHERE aktif = 1
ORDER BY dibuat_pada DESC`
);
return (result.rows ?? []).map(mapRowToUser);
} finally {
await conn.close(); // REQUIRED: always close in finally
}
}
// ── SELECT with a bind parameter — use :namaParam
async function cariUserById(id: number): Promise<User | null> {
const conn = await getConnection();
try {
const result = await conn.execute<UserRow>(
'SELECT * FROM users WHERE id = :id AND aktif = 1',
{ id } // bind by name: { id: value }
);
const row = result.rows?.[0];
return row ? mapRowToUser(row) : null;
} finally {
await conn.close();
}
}
// ── SELECT with multiple bind parameters
async function produkByKategoriDanHarga(
kategoriId: number,
hargaMin: number,
hargaMax: number
): Promise<Produk[]> {
const conn = await getConnection();
try {
const result = await conn.execute<ProdukRow>(
`SELECT *
FROM produk
WHERE kategori_id = :kategoriId
AND harga BETWEEN :hargaMin AND :hargaMax
AND stok > 0
ORDER BY harga ASC`,
{ kategoriId, hargaMin, hargaMax }
);
return (result.rows ?? []).map(mapRowToProduk);
} finally {
await conn.close();
}
}
// ── SELECT with LIKE — Oracle uses || for concatenation
async function cariProdukByNama(keyword: string): Promise<Produk[]> {
const conn = await getConnection();
try {
// Escape Oracle's special LIKE characters: %, _, \
const safeKeyword = keyword.replace(/[%_\\]/g, '\\$&');
const result = await conn.execute<ProdukRow>(
`SELECT * FROM produk
WHERE LOWER(nama) LIKE LOWER(:keyword) ESCAPE '\'
FETCH FIRST 20 ROWS ONLY`,
{ keyword: `%${safeKeyword}%` }
);
return (result.rows ?? []).map(mapRowToProduk);
} finally {
await conn.close();
}
}
// ── SELECT with IN — Oracle can't bind arrays directly
async function produkByIds(ids: number[]): Promise<Produk[]> {
if (ids.length === 0) return [];
const conn = await getConnection();
try {
// Create dynamic bind variables: :id0, :id1, :id2, ...
const binds: Record<string, number> = {};
const placeholders = ids.map((id, i) => {
binds[`id${i}`] = id;
return `:id${i}`;
});
const result = await conn.execute<ProdukRow>(
`SELECT * FROM produk WHERE id IN (${placeholders.join(', ')})`,
binds
);
return (result.rows ?? []).map(mapRowToProduk);
} finally {
await conn.close();
}
}
// ANTI-PATTERN: string interpolation — vulnerable to SQL injection
async function cariUserTidakAman(email: string): Promise<UserRow[]> {
const conn = await getConnection();
try {
const result = await conn.execute<UserRow>(
`SELECT * FROM users WHERE email = '${email}'` // DON'T!
);
return result.rows ?? [];
} finally {
await conn.close();
}
}
// CORRECT: always use bind parameters
async function cariUserAman(email: string): Promise<User | null> {
const conn = await getConnection();
try {
const result = await conn.execute<UserRow>(
'SELECT * FROM users WHERE email = :email',
{ email } // the value is processed separately by Oracle — safe
);
const row = result.rows?.[0];
return row ? mapRowToUser(row) : null;
} finally {
await conn.close();
}
}
INSERT Operations #
Oracle uses the RETURNING INTO clause to get newly inserted values — the equivalent of OUTPUT INSERTED in SQL Server.
import oracledb from 'oracledb';
import { getConnection } from './db/connection';
interface InputUser {
nama: string;
email: string;
passwordHash: string;
role?: string;
}
// ── INSERT with RETURNING INTO to get the ID
async function buatUser(input: InputUser): Promise<number> {
const conn = await getConnection();
try {
const result = await conn.execute(
`INSERT INTO users (id, nama, email, password_hash, role, aktif, dibuat_pada)
VALUES (users_seq.NEXTVAL, :nama, :email, :passwordHash, :role, 1, SYSDATE)
RETURNING id INTO :newId`,
{
nama: input.nama,
email: input.email,
passwordHash: input.passwordHash,
role: input.role ?? 'user',
newId: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
}
);
await conn.commit();
// RETURNING INTO is returned in outBinds
const outBinds = result.outBinds as { newId: number[] };
return outBinds.newId[0];
} catch (error) {
await conn.rollback();
throw error;
} finally {
await conn.close();
}
}
// ── Batch INSERT with executemany — far more efficient than a loop
async function buatBanyakProduk(
produkList: Array<{ nama: string; harga: number; stok: number; kategoriId: number }>
): Promise<number> {
if (produkList.length === 0) return 0;
const conn = await getConnection();
try {
const binds = produkList.map(p => ({
nama: p.nama,
harga: p.harga,
stok: p.stok,
kategoriId: p.kategoriId,
}));
const result = await conn.executeMany(
`INSERT INTO produk (id, nama, harga, stok, kategori_id, dibuat_pada)
VALUES (produk_seq.NEXTVAL, :nama, :harga, :stok, :kategoriId, SYSDATE)`,
binds,
{ autoCommit: true } // commit automatically after all rows are inserted
);
return result.rowsAffected ?? 0;
} catch (error) {
await conn.rollback();
throw error;
} finally {
await conn.close();
}
}
sequenceDiagram
participant App
participant Oracle
participant Sequence
App->>Oracle: INSERT ... VALUES (seq.NEXTVAL, :params)\nRETURNING id INTO :newId
Oracle->>Sequence: seq.NEXTVAL
Sequence-->>Oracle: 42
Oracle->>Oracle: Save row with id=42
Oracle-->>App: outBinds.newId = [42]
App->>Oracle: conn.commit()
Oracle-->>App: OKOracle has no auto-increment columns like MySQL (exceptGENERATED ALWAYS AS IDENTITYcolumns in Oracle 12c+). The traditional way uses a Sequence (CREATE SEQUENCE nama_seq) called withnama_seq.NEXTVAL. Make sure the sequence has been created in the database before running the INSERT, or useGENERATED ALWAYS AS IDENTITYif Oracle is version 12c or later.
UPDATE and DELETE Operations #
import oracledb from 'oracledb';
import { getConnection } from './db/connection';
// ── UPDATE with RETURNING INTO
async function updateStokProduk(
id: number,
deltaStok: number
): Promise<{ stokBaru: number } | null> {
const conn = await getConnection();
try {
const result = await conn.execute(
`UPDATE produk
SET stok = stok + :delta
WHERE id = :id AND (stok + :delta2) >= 0
RETURNING stok INTO :stokBaru`,
{
id,
delta: deltaStok,
delta2: deltaStok, // Oracle needs a separate bind for the same value
stokBaru: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
}
);
await conn.commit();
const out = result.outBinds as { stokBaru: number[] };
if (!out.stokBaru || out.stokBaru.length === 0) return null;
return { stokBaru: out.stokBaru[0] };
} catch (error) {
await conn.rollback();
throw error;
} finally {
await conn.close();
}
}
// ── UPDATE specific fields
async function updateUser(
id: number,
data: Partial<Pick<User, 'nama' | 'email'>>
): Promise<boolean> {
const entries = Object.entries(data).filter(([, v]) => v !== undefined);
if (entries.length === 0) return false;
// Whitelist — REQUIRED for interpolated column names
const allowedColumns: Record<string, string> = {
nama: 'nama',
email: 'email',
};
const setClauses: string[] = [];
const binds: Record<string, unknown> = { id };
for (const [key, value] of entries) {
const col = allowedColumns[key];
if (!col) continue;
setClauses.push(`${col} = :${key}`);
binds[key] = value;
}
if (setClauses.length === 0) return false;
const conn = await getConnection();
try {
const result = await conn.execute(
`UPDATE users
SET ${setClauses.join(', ')}, diperbarui_pada = SYSDATE
WHERE id = :id`,
binds
);
await conn.commit();
return (result.rowsAffected ?? 0) > 0;
} catch (error) {
await conn.rollback();
throw error;
} finally {
await conn.close();
}
}
// ── DELETE
async function hapusUser(id: number): Promise<boolean> {
const conn = await getConnection();
try {
const result = await conn.execute(
'DELETE FROM users WHERE id = :id',
{ id }
);
await conn.commit();
return (result.rowsAffected ?? 0) > 0;
} catch (error) {
await conn.rollback();
throw error;
} finally {
await conn.close();
}
}
Transactions #
In Oracle, every database session implicitly starts a transaction when the first query executes. Commit or rollback ends the transaction and starts a new one. There’s no explicit BEGIN TRANSACTION command like in MySQL or SQL Server.
import oracledb from 'oracledb';
import { getConnection } from './db/connection';
interface ItemPesanan {
produkId: number;
jumlah: number;
hargaSatuan: number;
}
async function buatPesanan(
userId: number,
items: ItemPesanan[]
): Promise<number> {
const conn = await getConnection();
try {
// Oracle: the transaction starts automatically with the first query
const totalHarga = items.reduce(
(sum, item) => sum + item.hargaSatuan * item.jumlah, 0
);
// 1. Insert the order
const r1 = await conn.execute(
`INSERT INTO pesanan (id, user_id, total_harga, status, dibuat_pada)
VALUES (pesanan_seq.NEXTVAL, :userId, :total, 'pending', SYSDATE)
RETURNING id INTO :newId`,
{
userId,
total: totalHarga,
newId: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
}
);
const out1 = r1.outBinds as { newId: number[] };
const pesananId = out1.newId[0];
// 2. Process each item
for (const item of items) {
await conn.execute(
`INSERT INTO pesanan_item
(id, pesanan_id, produk_id, jumlah, harga_satuan)
VALUES (pesanan_item_seq.NEXTVAL, :pesananId, :produkId, :jumlah, :harga)`,
{
pesananId,
produkId: item.produkId,
jumlah: item.jumlah,
harga: item.hargaSatuan,
}
);
// Decrease stock — fails if stock is insufficient
const rStok = await conn.execute(
`UPDATE produk
SET stok = stok - :jumlah
WHERE id = :produkId AND stok >= :jumlah2`,
{ jumlah: item.jumlah, produkId: item.produkId, jumlah2: item.jumlah }
);
if ((rStok.rowsAffected ?? 0) === 0) {
await conn.rollback();
throw new Error(`Stok tidak cukup untuk produk ID ${item.produkId}`);
}
}
// Commit only if all steps succeeded
await conn.commit();
return pesananId;
} catch (error) {
try { await conn.rollback(); } catch { /* ignore rollback errors */ }
throw error;
} finally {
await conn.close();
}
}
flowchart TD
A[conn = pool.getConnection] --> B[Transaction starts\nautomatically with the first query]
B --> C[INSERT pesanan]
C --> D[Loop items]
D --> E[INSERT pesanan_item]
E --> F[UPDATE stock]
F --> G{rowsAffected > 0?}
G -- No --> H[conn.rollback\nThrow Error]
G -- Yes --> I{More\nitems?}
I -- Yes --> D
I -- No --> J[conn.commit]
H --> K[conn.close]
J --> KStored Procedures and PL/SQL #
Oracle is very closely tied to PL/SQL. oracledb supports calling stored procedures and PL/SQL anonymous blocks natively.
import oracledb from 'oracledb';
import { getConnection } from './db/connection';
// ── Calling a stored procedure
async function spCariUser(email: string): Promise<User | null> {
const conn = await getConnection();
try {
const result = await conn.execute(
'BEGIN sp_cari_user(:email, :cursor); END;',
{
email,
cursor: { dir: oracledb.BIND_OUT, type: oracledb.CURSOR },
}
);
const out = result.outBinds as { cursor: oracledb.ResultSet<UserRow> };
const cursor = out.cursor;
const rows = await cursor.getRows<UserRow>(1);
await cursor.close();
return rows[0] ? mapRowToUser(rows[0]) : null;
} finally {
await conn.close();
}
}
// ── Stored procedure with OUTPUT parameters
async function spDaftarUser(
input: InputUser
): Promise<{ userId: number; pesanError: string | null }> {
const conn = await getConnection();
try {
const result = await conn.execute(
`BEGIN
sp_daftar_user(
p_nama => :nama,
p_email => :email,
p_password => :password,
p_user_id => :userId,
p_pesan_error => :pesanError
);
END;`,
{
nama: input.nama,
email: input.email,
password: input.passwordHash,
userId: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
pesanError: { dir: oracledb.BIND_OUT, type: oracledb.STRING, maxSize: 500 },
}
);
await conn.commit();
const out = result.outBinds as { userId: number; pesanError: string | null };
return { userId: out.userId, pesanError: out.pesanError };
} catch (error) {
await conn.rollback();
throw error;
} finally {
await conn.close();
}
}
// ── PL/SQL anonymous block — for logic that doesn't need to be a permanent SP
async function prosesDataBatch(batchId: number): Promise<{ berhasil: number; gagal: number }> {
const conn = await getConnection();
try {
const result = await conn.execute(
`DECLARE
v_berhasil NUMBER := 0;
v_gagal NUMBER := 0;
BEGIN
FOR rec IN (SELECT id FROM antrian_proses WHERE batch_id = :batchId)
LOOP
BEGIN
UPDATE produk SET stok = stok + 1 WHERE id = rec.id;
v_berhasil := v_berhasil + 1;
EXCEPTION
WHEN OTHERS THEN
v_gagal := v_gagal + 1;
END;
END LOOP;
:berhasil := v_berhasil;
:gagal := v_gagal;
END;`,
{
batchId,
berhasil: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
gagal: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER },
}
);
await conn.commit();
const out = result.outBinds as { berhasil: number; gagal: number };
return { berhasil: out.berhasil, gagal: out.gagal };
} finally {
await conn.close();
}
}
Error Handling #
Oracle uses ORA-XXXXX error codes. Understanding these common codes and mapping them to meaningful domain errors is the key to informative error messages.
import oracledb from 'oracledb';
// The most commonly encountered Oracle error codes
const ORA_ERRORS = {
UNIQUE_VIOLATION: 1, // ORA-00001: unique constraint violated
INTEGRITY_VIOLATION: 2291, // ORA-02291: integrity constraint (FK) violated
CHILD_EXISTS: 2292, // ORA-02292: child record found (delete FK)
NOT_NULL_VIOLATION: 1400, // ORA-01400: cannot insert NULL
VALUE_TOO_LARGE: 12899, // ORA-12899: value too large for column
DEADLOCK: 60, // ORA-00060: deadlock detected
SNAPSHOT_TOO_OLD: 1555, // ORA-01555: snapshot too old
TABLE_NOT_EXIST: 942, // ORA-00942: table or view does not exist
SEQUENCE_NOT_EXIST: 2289, // ORA-02289: sequence does not exist
INVALID_NUMBER: 1722, // ORA-01722: invalid number
} as const;
class DatabaseError extends Error {
constructor(message: string, public readonly code?: number) {
super(message);
this.name = 'DatabaseError';
}
}
class UniqueViolationError extends DatabaseError {
constructor(constraint?: string) {
super(`Duplikat data${constraint ? ` pada constraint '${constraint}'` : ''}`);
this.name = 'UniqueViolationError';
}
}
class ForeignKeyError extends DatabaseError {
constructor(message = 'Referensi data tidak valid') {
super(message);
this.name = 'ForeignKeyError';
}
}
class DeadlockError extends DatabaseError {
constructor() {
super('Deadlock terdeteksi — coba ulangi operasi');
this.name = 'DeadlockError';
}
}
function tanganiOracleError(error: unknown): never {
// oracledb errors have an errorNum property
if (error && typeof error === 'object' && 'errorNum' in error) {
const oraError = error as { errorNum: number; message: string };
switch (oraError.errorNum) {
case ORA_ERRORS.UNIQUE_VIOLATION: {
// Extract the constraint name from the message: ORA-00001: unique constraint (SCHEMA.CONST) violated
const match = oraError.message.match(/\((.+?)\)/);
throw new UniqueViolationError(match?.[1]);
}
case ORA_ERRORS.INTEGRITY_VIOLATION:
throw new ForeignKeyError('Record referensi tidak ditemukan');
case ORA_ERRORS.CHILD_EXISTS:
throw new ForeignKeyError('Tidak bisa menghapus — masih ada data yang mereferensikan');
case ORA_ERRORS.NOT_NULL_VIOLATION:
throw new DatabaseError('Field wajib tidak boleh kosong', oraError.errorNum);
case ORA_ERRORS.VALUE_TOO_LARGE:
throw new DatabaseError('Data melebihi panjang maksimum kolom', oraError.errorNum);
case ORA_ERRORS.DEADLOCK:
throw new DeadlockError();
}
}
throw new DatabaseError(`Error Oracle: ${String(error)}`);
}
// Automatic retry for deadlocks
async function withDeadlockRetry<T>(
fn: () => Promise<T>,
maxRetry = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetry; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof DeadlockError && attempt < maxRetry) {
await new Promise(res => setTimeout(res, 200 * attempt));
continue;
}
throw error;
}
}
throw new Error('Unreachable');
}
Pagination in Oracle #
Oracle uses OFFSET ... FETCH NEXT (available since Oracle 12c). For Oracle 11g and below, use ROWNUM or ROW_NUMBER().
import oracledb from 'oracledb';
import { getConnection } from './db/connection';
type SortField = 'nama' | 'email' | 'dibuat_pada';
const ALLOWED_SORT = new Set<SortField>(['nama', 'email', 'dibuat_pada']);
interface PaginasiResult<T> {
data: T[];
total: number;
halaman: number;
perHalaman: number;
totalHalaman: number;
}
// ── Oracle 12c+ — OFFSET...FETCH
async function getUserPaginasi(
halaman: number,
perHalaman: number,
sortBy: SortField = 'dibuat_pada',
sortOrder: 'ASC' | 'DESC' = 'DESC'
): Promise<PaginasiResult<User>> {
const safeSort = ALLOWED_SORT.has(sortBy) ? sortBy : 'dibuat_pada';
const safeOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';
const offset = (halaman - 1) * perHalaman;
const conn = await getConnection();
try {
const [dataResult, countResult] = await Promise.all([
conn.execute<UserRow>(
`SELECT id, nama, email, role, aktif, dibuat_pada
FROM users
WHERE aktif = 1
ORDER BY ${safeSort} ${safeOrder}
OFFSET :offset ROWS FETCH NEXT :perHalaman ROWS ONLY`,
{ offset, perHalaman }
),
conn.execute<{ TOTAL: number }>(
'SELECT COUNT(*) AS TOTAL FROM users WHERE aktif = 1'
),
]);
const total = countResult.rows?.[0]?.TOTAL ?? 0;
return {
data: (dataResult.rows ?? []).map(mapRowToUser),
total,
halaman,
perHalaman,
totalHalaman: Math.ceil(total / perHalaman),
};
} finally {
await conn.close();
}
}
// ── Oracle 11g and older — ROW_NUMBER()
async function getUserPaginasiLegacy(
halaman: number,
perHalaman: number
): Promise<User[]> {
const conn = await getConnection();
try {
const rn_start = (halaman - 1) * perHalaman + 1;
const rn_end = halaman * perHalaman;
const result = await conn.execute<UserRow>(
`SELECT * FROM (
SELECT u.*, ROW_NUMBER() OVER (ORDER BY dibuat_pada DESC) AS rn
FROM users u
WHERE aktif = 1
)
WHERE rn BETWEEN :rnStart AND :rnEnd`,
{ rnStart: rn_start, rnEnd: rn_end }
);
return (result.rows ?? []).map(mapRowToUser);
} finally {
await conn.close();
}
}
When to Switch to Another Approach #
Keep using oracledb directly if:
✓ Enterprise applications making heavy use of PL/SQL stored procedures
✓ Need Oracle-specific features: REF CURSOR, RETURNING INTO, executeMany
✓ Critical performance with DBA-optimized queries
✓ The team is familiar with PL/SQL and Oracle
✓ Using Oracle-specific features: Partitioning, Advanced Queuing, Flashback
Consider an ORM / Query Builder if:
✗ Many models with relationships — TypeORM supports Oracle via the oracledb driver
✗ The team is less familiar with PL/SQL — ORMs help but can hide performance issues
✗ Need automatic migrations — TypeORM migrations for Oracle
✗ Multi-database — TypeORM or Knex.js (Knex has limited Oracle support)
✗ Rapid prototyping — TypeORM is faster for early development
| Aspect | oracledb | TypeORM + Oracle | Knex.js + Oracle |
|---|---|---|---|
| SQL control | Full | Partial | Good |
| PL/SQL / Stored procs | ✓ Native | Limited | Limited |
| REF CURSOR | ✓ | No | No |
| RETURNING INTO | ✓ | Indirect | No |
| Automatic migrations | Manual | ✓ | Via Knex |
| Learning curve | Medium | Medium | Low |
Summary #
connection.close()returns to the pool, not permanently closes — always call it in thefinallyblock; forgetting to callclose()permanently consumes a pool slot.oracledb.outFormat = OUT_FORMAT_OBJECTmust be set globally at application startup — without it, query results are returned as positional arrays that are hard to use with TypeScript types.- Oracle column names are always uppercase by default (
ID,NAMA, notid,nama) — use double-quoted column aliases in SQL or create a mapper function fromUserRowtoUser.- Bind parameters use
:namaParam— unlike MySQL (?) and MSSQL (@nama); bind by name is safer and more readable than bind by position (:1,:2).- No built-in auto-increment before Oracle 12c — use a SEQUENCE with
seq.NEXTVALin INSERT, orGENERATED ALWAYS AS IDENTITYfor Oracle 12c and later.RETURNING INTOto get values after INSERT/UPDATE — declare output binds with{ dir: oracledb.BIND_OUT, type: oracledb.NUMBER }.executeManyfor bulk inserts — far more efficient than one-by-one loops; hand the bind array to the driver and let Oracle optimize delivery.- Transactions start automatically with the first query in Oracle — there’s no
BEGIN TRANSACTION; always end with an explicitcommit()orrollback()becauseautoCommitshould befalse.sessionCallbackin the pool config for session initialization — the right place forALTER SESSIONlike NLS date formats and timezone so they’re consistent across all connections.- The same bind name needs different aliases — if the same value is used twice in one query, create two binds with different names (
:deltaand:delta2); Oracle can’t reuse one bind in different contexts.