From a2f7b9b5a6c01e0982728dfae565be2e94e820fb Mon Sep 17 00:00:00 2001 From: Ryan Rasti Date: Sat, 15 Aug 2026 14:44:11 -0700 Subject: [PATCH] feat: add Oracle driver (thin oracledb) and :n placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of Oracle support: DialectName grows "oracle", compile emits :n binds, and OracleDriver talks thin-mode node-oracledb with fetchAsString so rows stay raw text. Connection construction no longer treats a non-pg dialect as sqlite — StatementExecutor runs statements and live() throws. Live FROM DUAL tests skip unless ORACLE_URL is set. bin/startora boots gvenzl/oracle-free:23-slim-faststart for local work. --- bin/startora | 56 +++ bin/stopora | 15 + package-lock.json | 698 ++----------------------------------- package.json | 12 +- src/builder/sql.test.ts | 7 + src/builder/sql.ts | 3 +- src/database.ts | 34 +- src/drivers/oracle.test.ts | 89 +++++ src/drivers/oracle.ts | 71 ++++ src/executor.ts | 21 +- src/index.ts | 1 + src/live/canonical.ts | 18 +- tsconfig.json | 1 + tsdown.config.ts | 6 +- vitest.config.ts | 1 + 15 files changed, 343 insertions(+), 690 deletions(-) create mode 100755 bin/startora create mode 100755 bin/stopora create mode 100644 src/drivers/oracle.test.ts create mode 100644 src/drivers/oracle.ts diff --git a/bin/startora b/bin/startora new file mode 100755 index 00000000..bc51b2bd --- /dev/null +++ b/bin/startora @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Boot gvenzl/oracle-free:23-slim-faststart for local oracle tests. +# Honors a pre-existing ORACLE_URL (CI / a long-lived instance). Idempotent: +# a running container of the same name is reused; a stopped one is started. +set -euo pipefail + +NAME="${TYPEGRES_ORA_NAME:-typegres-ora}" +IMAGE="${TYPEGRES_ORA_IMAGE:-gvenzl/oracle-free:23-slim-faststart}" +PORT="${TYPEGRES_ORA_PORT:-1521}" +APP_USER="${TYPEGRES_ORA_USER:-typegres}" +APP_PASSWORD="${TYPEGRES_ORA_PASSWORD:-typegres}" +SYS_PASSWORD="${TYPEGRES_ORA_SYS_PASSWORD:-oracle}" +URL="${ORACLE_URL:-${APP_USER}/${APP_PASSWORD}@localhost:${PORT}/FREEPDB1}" + +if [ -z "${ORACLE_URL:-}" ]; then + export ORACLE_URL="$URL" +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required to start Oracle. Install Docker or set ORACLE_URL at an existing instance." >&2 + exit 1 +fi + +if docker inspect -f '{{.State.Running}}' "$NAME" >/dev/null 2>&1; then + running="$(docker inspect -f '{{.State.Running}}' "$NAME")" + if [ "$running" = "true" ]; then + echo "Oracle container '$NAME' already running." + else + echo "Starting existing Oracle container '$NAME'..." + docker start "$NAME" >/dev/null + fi +else + echo "Starting $IMAGE as '$NAME' (port $PORT)..." + docker run -d --name "$NAME" \ + -p "${PORT}:1521" \ + -e ORACLE_PASSWORD="$SYS_PASSWORD" \ + -e APP_USER="$APP_USER" \ + -e APP_USER_PASSWORD="$APP_PASSWORD" \ + "$IMAGE" >/dev/null +fi + +echo "Waiting for Oracle to accept connections..." +# healthcheck.sh is the image's own ready probe (DATABASE IS READY TO USE). +for i in $(seq 1 60); do + if docker exec "$NAME" healthcheck.sh >/dev/null 2>&1; then + echo "Oracle is ready." + echo " ORACLE_URL=$ORACLE_URL" + echo "Export it in this shell: export ORACLE_URL='$ORACLE_URL'" + exit 0 + fi + sleep 5 +done + +echo "Oracle did not become ready in time. Last logs:" >&2 +docker logs --tail 40 "$NAME" >&2 +exit 1 diff --git a/bin/stopora b/bin/stopora new file mode 100755 index 00000000..6f5f2dbf --- /dev/null +++ b/bin/stopora @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Stop the local Oracle container started by bin/startora. Leaves the +# container (and its data volume) in place so the next startora is fast. +set -euo pipefail + +NAME="${TYPEGRES_ORA_NAME:-typegres-ora}" + +if ! docker inspect "$NAME" >/dev/null 2>&1; then + echo "No Oracle container named '$NAME'." + exit 0 +fi + +echo "Stopping Oracle container '$NAME'" +docker stop "$NAME" >/dev/null +echo "Stopped. Re-run bin/startora to start it again; docker rm $NAME to discard data." diff --git a/package-lock.json b/package-lock.json index e2fef7ee..b14005a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/acorn": "^6.0.4", "@types/better-sqlite3": "^7.6.13", "@types/node": "^26.1.1", + "@types/oracledb": "^7.0.2", "@types/pg": "^8.20.0", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.64.0", @@ -31,6 +32,7 @@ "capnweb": "file:packages/capnweb", "eslint": "^10.7.0", "fast-check": "^4.9.0", + "oracledb": "^6.10.0", "pg": "^8.22.0", "prettier": "^3.9.5", "secure-json-parse": "^4.1.0", @@ -44,8 +46,9 @@ "node": ">=22" }, "peerDependencies": { - "@electric-sql/pglite": "^0.4.4", - "better-sqlite3": "^12.11.1", + "@electric-sql/pglite": "^0.4.4 || ^0.5.0", + "better-sqlite3": "^12.11.1 || ^13.0.0", + "oracledb": "^6.8.0", "pg": "^8.20.0" }, "peerDependenciesMeta": { @@ -55,6 +58,9 @@ "better-sqlite3": { "optional": true }, + "oracledb": { + "optional": true + }, "pg": { "optional": true } @@ -587,15 +593,6 @@ "node": ">=16" } }, - "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workers-types": { - "version": "5.20260719.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260719.1.tgz", - "integrity": "sha512-DcGasbfUuczQilc80vhL2MPPdWcaxWkx0hN5IW9UdNDBdrRvWcal3akSJ6Ccm7e8+/OGeR+8tYrqkykA3YGSZw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -3022,18 +3019,6 @@ "@octokit/openapi-types": "^20.0.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3083,287 +3068,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -4155,6 +3859,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/oracledb": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-7.0.2.tgz", + "integrity": "sha512-yAMn1N+yTyKpJpJ4EDf5Mfgas81dF3pchxZXlK0A4FG6+Jhvv5jaXdl0KmHbdg1ookCqPEYjzLo+7UBmsz9QWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -6512,280 +6226,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7173,6 +6613,17 @@ "node": ">= 0.8.0" } }, + "node_modules/oracledb": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.10.0.tgz", + "integrity": "sha512-kGUumXmrEWbSpBuKJyb9Ip3rXcNgKK6grunI3/cLPzrRvboZ6ZoLi9JQ+z6M/RIG924tY8BLflihL4CKKQAYMA==", + "dev": true, + "hasInstallScript": true, + "license": "(Apache-2.0 OR UPL-1.0)", + "engines": { + "node": ">=14.17" + } + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -7994,42 +7445,6 @@ "node": ">=0.10.0" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -9258,7 +8673,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -9276,7 +8690,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -9294,7 +8707,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -9312,7 +8724,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -9330,7 +8741,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -9348,7 +8758,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -9366,7 +8775,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -9384,7 +8792,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -9402,7 +8809,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9420,7 +8826,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9438,7 +8843,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9456,7 +8860,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9474,7 +8877,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9492,7 +8894,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9510,7 +8911,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9528,7 +8928,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9546,7 +8945,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -9564,7 +8962,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -9582,7 +8979,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -9600,7 +8996,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -9618,7 +9013,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -9636,7 +9030,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -9654,7 +9047,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -9672,7 +9064,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -9690,7 +9081,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -9708,7 +9098,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -9914,35 +9303,6 @@ "@swc/core": "^1.2.108" } }, - "node_modules/unrun": { - "version": "0.2.37", - "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.37.tgz", - "integrity": "sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "rolldown": "1.0.0-rc.17" - }, - "bin": { - "unrun": "dist/cli.mjs" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/sponsors/Gugustinette" - }, - "peerDependencies": { - "synckit": "^0.11.11" - }, - "peerDependenciesMeta": { - "synckit": { - "optional": true - } - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index d074abc6..cb3dc9da 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,10 @@ "./drivers/sqlite": { "import": "./dist/drivers/sqlite.mjs", "types": "./dist/drivers/sqlite.d.mts" + }, + "./drivers/oracle": { + "import": "./dist/drivers/oracle.mjs", + "types": "./dist/drivers/oracle.d.mts" } }, "bin": { @@ -66,6 +70,7 @@ "peerDependencies": { "@electric-sql/pglite": "^0.4.4 || ^0.5.0", "better-sqlite3": "^12.11.1 || ^13.0.0", + "oracledb": "^6.8.0", "pg": "^8.20.0" }, "peerDependenciesMeta": { @@ -77,6 +82,9 @@ }, "better-sqlite3": { "optional": true + }, + "oracledb": { + "optional": true } }, "engines": { @@ -103,20 +111,22 @@ "@types/acorn": "^6.0.4", "@types/better-sqlite3": "^7.6.13", "@types/node": "^26.1.1", + "@types/oracledb": "^7.0.2", "@types/pg": "^8.20.0", "@typescript-eslint/eslint-plugin": "^8.64.0", "@typescript-eslint/parser": "^8.64.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", "acorn": "^8.17.0", "better-sqlite3": "^12.11.1", + "capnweb": "file:packages/capnweb", "eslint": "^10.7.0", "fast-check": "^4.9.0", + "oracledb": "^6.10.0", "pg": "^8.22.0", "prettier": "^3.9.5", "secure-json-parse": "^4.1.0", "tsdown": "^0.22.7", "typescript": "^6.0.3", - "capnweb": "file:packages/capnweb", "unplugin-swc": "^1.5.9", "vitest": "^4.1.10", "zod": "^4.4.3" diff --git a/src/builder/sql.test.ts b/src/builder/sql.test.ts index d8194192..ea8eec7b 100644 --- a/src/builder/sql.test.ts +++ b/src/builder/sql.test.ts @@ -7,8 +7,10 @@ import { compileOnlyDb } from "../test-helpers"; const $ident = (name: string) => new Ident(name); const pgDb = compileOnlyDb("postgres"); const sqliteDb = compileOnlyDb("sqlite"); +const oracleDb = compileOnlyDb("oracle"); const pgCtx = { database: pgDb }; const sqliteCtx = { database: sqliteDb }; +const oracleCtx = { database: oracleDb }; test("param compiles with pg style", () => { const q = sql`SELECT ${1}, ${2}`; @@ -20,6 +22,11 @@ test("param compiles with sqlite style", () => { expect(compile(q, sqliteCtx)).toEqual({ text: "SELECT ?, ?", values: [1, 2] }); }); +test("param compiles with oracle style", () => { + const q = sql`SELECT ${1}, ${2}`; + expect(compile(q, oracleCtx)).toEqual({ text: "SELECT :1, :2", values: [1, 2] }); +}); + test("ident in tagged template", () => { const col = $ident("user_name"); const q = sql`SELECT ${col} FROM ${$ident("users")}`; diff --git a/src/builder/sql.ts b/src/builder/sql.ts index 01c77dc4..0bc7f7f5 100644 --- a/src/builder/sql.ts +++ b/src/builder/sql.ts @@ -13,7 +13,7 @@ import { SqlValue } from "../types/sql-value"; // dialect tag. Extend this union to register a new dialect — every // switch on `DialectName` becomes an exhaustiveness error until the // new case is handled. -export type DialectName = "postgres" | "sqlite"; +export type DialectName = "postgres" | "sqlite" | "oracle"; export type CompiledSql = { text: string; values: readonly unknown[] }; @@ -343,6 +343,7 @@ const paramForDialect = (dialect: DialectName, position: number): string => { switch (dialect) { case "postgres": return `$${position}`; case "sqlite": return "?"; + case "oracle": return `:${position}`; default: { const _exhaustive: never = dialect; throw new Error(`Unknown dialect: ${String(_exhaustive)}`); diff --git a/src/database.ts b/src/database.ts index cbefef9b..30f86b39 100644 --- a/src/database.ts +++ b/src/database.ts @@ -12,7 +12,7 @@ import { DeleteBuilder } from "./builder/delete"; import { Bus, type Subscription, type BusOptions } from "./live/bus"; import { SqliteLiveExecutor } from "./live/sqlite/executor"; import { PgExecutor } from "./live/pg/executor"; -import type { Executor } from "./executor"; +import { StatementExecutor, type Executor } from "./executor"; import type { DialectName } from "./builder/sql"; export type TransactionIsolation = "read committed" | "repeatable read" | "serializable"; @@ -203,7 +203,7 @@ export class Connection { } else if (database.dialect === "postgres") { this.#bus = new Bus(this, liveOpts); this.#executor = new PgExecutor(database, (compiled) => driver.execute(compiled)); - } else { + } else if (database.dialect === "sqlite") { if (!isSyncDriver(driver)) { throw new Error( "sqlite requires a SyncDriver (SqliteDriver, DoSqliteDriver) — " + @@ -213,6 +213,10 @@ export class Connection { const bus = new Bus(this, liveOpts); this.#bus = bus; this.#executor = new SqliteLiveExecutor(database, driver, bus); + } else { + // Oracle (and any future dialect without a live engine): statements + // only. live() throws from StatementExecutor. + this.#executor = new StatementExecutor(database, (compiled) => driver.execute(compiled)); } if (isolation) { this.#isolation = isolation; } } @@ -291,10 +295,11 @@ export class Connection { throw new Error("transaction() requires a callback"); } if (opts?.isolation && this.database.dialect !== "postgres") { - // Sqlite rejects pg's BEGIN ISOLATION LEVEL syntax, and its - // transactions are serializable by nature — no level to pick. + // Only pg has BEGIN ISOLATION LEVEL. Sqlite transactions are + // serializable by nature; Oracle isolation is a session/txn + // attribute we don't model yet. throw new Error( - `transaction({ isolation }) is pg-only — sqlite transactions are always serializable; omit the option`, + `transaction({ isolation }) is pg-only — the '${this.database.dialect}' dialect does not take an isolation option; omit it`, ); } if (this.#executor.bound) { @@ -318,13 +323,16 @@ export class Connection { } return fn(this); } - const bus = this.#bus!; + const bus = this.#bus; return this.driver.runInSingleConnection(async (execute) => { const driver = this.driver; let txExecutor: Executor; if (this.database.dialect === "postgres") { txExecutor = new PgExecutor(this.database, execute, true); - } else if (isSyncDriver(driver)) { + } else if (this.database.dialect === "sqlite") { + if (!isSyncDriver(driver)) { + throw new Error("unreachable: sqlite Connection without a SyncDriver"); + } // Bound and pooled are the same channel on sqlite's one handle — // checked, not assumed; the bound executor differs only in event // timing (commit-deferred flush). @@ -333,11 +341,12 @@ export class Connection { "sync driver must pass its executeSync to runInSingleConnection — one handle, one channel", ); } + if (!bus) { + throw new Error("sqlite Connection is missing its live bus"); + } txExecutor = new SqliteLiveExecutor(this.database, driver, bus, true); } else { - // The constructor rejects async sqlite drivers at attach — this - // branch exists so TS narrows `driver` above. - throw new Error("unreachable: sqlite Connection without a SyncDriver"); + txExecutor = new StatementExecutor(this.database, execute, true); } const tx = new Connection(this.database, this.driver, txExecutor, opts?.isolation); // Drivers with a native transaction protocol (Durable Objects) own @@ -399,7 +408,10 @@ export class Connection { if (this.#executor.bound) { throw new Error("live() can't be called inside a transaction"); } - const bus = this.#bus!; + const bus = this.#bus; + if (!bus) { + throw new Error(`live() is not supported on the '${this.database.dialect}' dialect`); + } // Lazy engine start: a no-op on sqlite (capture feeds the bus // synchronously from attach); on pg this seeds the snapshot watermark // and spins the poll loop on first use, so connections that never diff --git a/src/drivers/oracle.test.ts b/src/drivers/oracle.test.ts new file mode 100644 index 00000000..6eb5b406 --- /dev/null +++ b/src/drivers/oracle.test.ts @@ -0,0 +1,89 @@ +// Driver-level smoke for Oracle. Skips unless ORACLE_URL is set so the +// default `npm test` / CI check job stays Docker-free. `bin/startora` +// boots gvenzl/oracle-free:23-slim-faststart and prints the URL. +// +// Exercises: connect, compile :n placeholders, execute a FROM DUAL +// select, bind a param, normalize a NUMBER to a string. No typed +// surface, no query builder — those land in later steps. +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { OracleDriver } from "./oracle"; +import { Database } from "../database"; +import { compile, sql } from "../builder/sql"; +import type { Connection } from "../database"; + +const url = process.env["ORACLE_URL"]; +const parseUrl = (s: string) => { + const m = /^([^/]+)\/([^@]+)@(.+)$/.exec(s); + if (!m) { + throw new Error(`ORACLE_URL must be user/password@host:port/service, got ${JSON.stringify(s)}`); + } + return { user: m[1]!, password: m[2]!, connectString: m[3]! }; +}; + +// Always-on: Connection construction + compile + statement execute, no Oracle process. +test("oracle Connection constructs without a live engine", async () => { + const db = new Database(); + const conn = db.connect({ + dialect: "oracle", + execute: async () => ({ rows: [{ v: "1" }] }), + runInSingleConnection: async () => { + throw new Error("unused"); + }, + close: async () => {}, + }); + expect(conn.dialect).toBe("oracle"); + expect(compile(sql`SELECT ${1} FROM DUAL`, { database: db })).toEqual({ + text: "SELECT :1 FROM DUAL", + values: [1], + }); + expect(await conn.execute(sql`SELECT 1 FROM DUAL`)).toEqual({ rows: [{ v: "1" }] }); + await expect(conn.live(null as never)[Symbol.asyncIterator]().next()).rejects.toThrow(/not supported/); + await conn.close(); +}); + +describe.skipIf(!url)("oracle driver", () => { + let driver: OracleDriver; + let db: Database; + let conn: Connection; + + beforeAll(async () => { + driver = await OracleDriver.create(parseUrl(url!)); + db = new Database(); + conn = db.connect(driver); + }); + + afterAll(async () => { + await conn.close(); + }); + + test("dialect is oracle", () => { + expect(conn.dialect).toBe("oracle"); + expect(db.dialect).toBe("oracle"); + }); + + test("compile emits :n placeholders", () => { + const compiled = compile(sql`SELECT ${1}, ${"x"} FROM DUAL`, { database: db }); + expect(compiled).toEqual({ text: "SELECT :1, :2 FROM DUAL", values: [1, "x"] }); + }); + + test("SELECT 1 FROM DUAL returns a string", async () => { + const result = await conn.execute( + sql`SELECT 1 AS ${db.scopedIdent("v")} FROM DUAL`, + ); + expect(result.rows).toEqual([{ v: "1" }]); + }); + + test("bound param round-trips as a string", async () => { + const result = await conn.execute( + sql`SELECT ${7} AS ${db.scopedIdent("v")} FROM DUAL`, + ); + expect(result.rows).toEqual([{ v: "7" }]); + }); + + test("NULL comes back as null", async () => { + const result = await conn.execute( + sql`SELECT NULL AS ${db.scopedIdent("v")} FROM DUAL`, + ); + expect(result.rows).toEqual([{ v: null }]); + }); +}); diff --git a/src/drivers/oracle.ts b/src/drivers/oracle.ts new file mode 100644 index 00000000..bab42a9b --- /dev/null +++ b/src/drivers/oracle.ts @@ -0,0 +1,71 @@ +import type { CompiledSql } from "../builder/sql"; +import type { DialectName } from "../builder/sql"; +import oracledb from "oracledb"; +import type { Driver, ExecuteFn, QueryResult } from "./types"; + +// node-oracledb adapter (thin mode — no Instant Client). Optional peer, +// imported statically because this module only loads when the caller +// imports `typegres/drivers/oracle`. +// +// fetchAsString is the full set node-oracledb accepts (NUMBER/DATE/ +// BUFFER/CLOB/NCLOB). VARCHAR2 is already a string. Same contract as +// PgDriver: the driver returns raw text; typed coercion is downstream. + +let fetchConfigured = false; +const configureFetch = (): void => { + if (fetchConfigured) { return; } + // Process-wide: node-oracledb has no per-pool fetch override. Fine + // inside typegres (this is the only oracledb consumer); rude if a + // host app also uses oracledb in the same process and wants Dates. + oracledb.fetchAsString = [ + oracledb.NUMBER, + oracledb.DATE, + oracledb.BUFFER, + oracledb.CLOB, + oracledb.NCLOB, + ]; + fetchConfigured = true; +}; + +export class OracleDriver implements Driver { + readonly dialect: DialectName = "oracle"; + + static async create(attrs: oracledb.PoolAttributes): Promise { + configureFetch(); + return new OracleDriver(await oracledb.createPool(attrs)); + } + + private constructor(private pool: oracledb.Pool) {} + + async execute({ text, values }: CompiledSql): Promise { + const conn = await this.pool.getConnection(); + try { + const result = await conn.execute(text, values as oracledb.BindParameters, { + outFormat: oracledb.OUT_FORMAT_OBJECT, + autoCommit: true, + }); + return { rows: (result.rows ?? []) as QueryResult["rows"] }; + } finally { + await conn.close(); + } + } + + async runInSingleConnection(cb: (execute: ExecuteFn) => Promise): Promise { + const conn = await this.pool.getConnection(); + try { + return await cb(async ({ text, values }) => { + const result = await conn.execute(text, values as oracledb.BindParameters, { + outFormat: oracledb.OUT_FORMAT_OBJECT, + autoCommit: false, + }); + return { rows: (result.rows ?? []) as QueryResult["rows"] }; + }); + } finally { + await conn.close(); + } + } + + async close(): Promise { + await this.pool.close(0); + } +} diff --git a/src/executor.ts b/src/executor.ts index 821f8bdc..5d6d528d 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -1,6 +1,6 @@ import { compile, type CompiledSql, type Sql } from "./builder/sql"; import type { Connection, Database } from "./database"; -import type { QueryResult } from "./drivers/types"; +import type { AnyExecuteFn, QueryResult } from "./drivers/types"; import type { QueryBuilder, RowType, RowTypeToTsType } from "./builder/query"; import { InsertBuilder } from "./builder/insert"; import { UpdateBuilder } from "./builder/update"; @@ -81,3 +81,22 @@ export abstract class Executor { onCommit(): void {} onRollback(): void {} } + +// Statement-only executor — no live capture. Oracle (and any future +// dialect that isn't wired into the live engine) uses this so +// Connection construction doesn't fall through to the sqlite branch. +export class StatementExecutor extends Executor { + constructor( + database: Database, + private execFn: AnyExecuteFn, + bound = false, + ) { + super(database, bound); + } + protected exec(compiled: CompiledSql): Promise { + return Promise.resolve(this.execFn(compiled)); + } + override runLiveIteration(): never { + throw new Error(`live() is not supported on the '${this.database.dialect}' dialect`); + } +} diff --git a/src/index.ts b/src/index.ts index 6a383532..778f40b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ // import { Database, sql, expose } from "typegres"; // import { DoSqliteDriver } from "typegres/drivers/do"; // import { PgDriver } from "typegres/drivers/pg"; +// import { OracleDriver } from "typegres/drivers/oracle"; export { Database, Connection } from "./database"; export type { TransactionIsolation, TransactionOptions } from "./database"; diff --git a/src/live/canonical.ts b/src/live/canonical.ts index 406bdaff..d117e437 100644 --- a/src/live/canonical.ts +++ b/src/live/canonical.ts @@ -14,7 +14,17 @@ import { Cast, type DialectName, sql, type Sql } from "../builder/sql"; // but the stored value as '1'. Collapse integral reals to INTEGER before // the text cast; non-integral reals ('1.5') and other storage classes // pass through. (CAST(x AS numeric) can't do this — it's a no-op on REAL.) -export const canonicalText = (expr: Sql, dialect: DialectName): Sql => - dialect === "sqlite" - ? sql`CASE WHEN typeof(${expr}) = 'real' AND ${expr} = CAST(${expr} AS integer) THEN CAST(CAST(${expr} AS integer) AS text) ELSE CAST(${expr} AS text) END` - : new Cast(expr, sql`text`, dialect); +export const canonicalText = (expr: Sql, dialect: DialectName): Sql => { + switch (dialect) { + case "sqlite": + return sql`CASE WHEN typeof(${expr}) = 'real' AND ${expr} = CAST(${expr} AS integer) THEN CAST(CAST(${expr} AS integer) AS text) ELSE CAST(${expr} AS text) END`; + case "postgres": + return new Cast(expr, sql`text`, dialect); + case "oracle": + throw new Error("canonicalText is live-only; oracle has no live engine"); + default: { + const _exhaustive: never = dialect; + throw new Error(`Unknown dialect: ${String(_exhaustive)}`); + } + } +}; diff --git a/tsconfig.json b/tsconfig.json index 166b0975..5034aa5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "typegres/drivers/pg": ["./src/drivers/pg.ts"], "typegres/drivers/pglite": ["./src/drivers/pglite.ts"], "typegres/drivers/sqlite": ["./src/drivers/sqlite.ts"], + "typegres/drivers/oracle": ["./src/drivers/oracle.ts"], "typegres/drivers/do": ["./src/drivers/do.ts"] } }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 0a4d13e6..5f645854 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -20,14 +20,14 @@ export default defineConfig([ // loaders — those use `require` / `__filename` and would trip Node's // CJS/ESM mixed-mode check when a consumer runs the CLI. { - entry: ["src/index.ts", "src/config.ts", "src/builder/sql.ts", "src/types/postgres/index.ts", "src/types/sqlite/index.ts", "src/cli.ts", "src/exoeval/index.ts", "src/capnweb/shim.ts", "src/drivers/do.ts", "src/drivers/pg.ts", "src/drivers/pglite.ts", "src/drivers/sqlite.ts"], + entry: ["src/index.ts", "src/config.ts", "src/builder/sql.ts", "src/types/postgres/index.ts", "src/types/sqlite/index.ts", "src/cli.ts", "src/exoeval/index.ts", "src/capnweb/shim.ts", "src/drivers/do.ts", "src/drivers/pg.ts", "src/drivers/pglite.ts", "src/drivers/sqlite.ts", "src/drivers/oracle.ts"], format: ["esm"], clean: true, // capnweb is force-bundled: typegres needs a fork that isn't on npm, so it // ships inlined and the shim re-exports the surface consumers need (see // src/capnweb/shim.ts). deps: { - neverBundle: ["pg", "@electric-sql/pglite", "better-sqlite3"], + neverBundle: ["pg", "@electric-sql/pglite", "better-sqlite3", "oracledb"], alwaysBundle: ["capnweb"], }, plugins: [swcPlugin()], @@ -45,7 +45,7 @@ export default defineConfig([ clean: false, inputOptions: { resolve: { conditionNames: ["workerd", "import", "default"] } }, deps: { - neverBundle: ["pg", "@electric-sql/pglite", "better-sqlite3", "cloudflare:workers"], + neverBundle: ["pg", "@electric-sql/pglite", "better-sqlite3", "oracledb", "cloudflare:workers"], alwaysBundle: ["capnweb"], }, plugins: [swcPlugin()], diff --git a/vitest.config.ts b/vitest.config.ts index 5063eda0..a8a7436e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,7 @@ const shared = { "typegres/drivers/pglite": `${src}/drivers/pglite.ts`, "typegres/drivers/sqlite": `${src}/drivers/sqlite.ts`, "typegres/drivers/pg": `${src}/drivers/pg.ts`, + "typegres/drivers/oracle": `${src}/drivers/oracle.ts`, "typegres/drivers/do": `${src}/drivers/do.ts`, "typegres/postgres": `${src}/types/postgres/index.ts`, "typegres/sqlite": `${src}/types/sqlite/index.ts`,