diff --git a/.env.sample b/.env.sample index 99040e349..73947c0e8 100644 --- a/.env.sample +++ b/.env.sample @@ -43,6 +43,8 @@ AUTH_ENCRYPTION_KEY=encryptionkey DATABASE_URL=postgresql://postgres:postgres@127.0.0.1/postgres DATABASE_POOL_URL=postgresql://postgres:postgres@127.0.0.1:6453/postgres DATABASE_CONNECTION_TIMEOUT=3000 +# Set to false to route through direct PostgreSQL and skip the Database Watt application. +DATABASE_WATT_APPLICATION_ENABLED=false # Set to true to run healthchecks without the scoped transaction path. DATABASE_HEALTHCHECK_UNSCOPED=false DATABASE_SEARCH_PATH= diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index c7722ad2c..7a295e5d2 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -40,7 +40,7 @@ concurrency: jobs: acceptance_local: - name: Local / ${{ matrix.storage_backend }} / ${{ matrix.database }} / ${{ matrix.tenancy }} + name: Local / ${{ matrix.runtime }} / ${{ matrix.storage_backend }} / ${{ matrix.database }} / ${{ matrix.tenancy }} if: ${{ github.event_name != 'workflow_dispatch' || inputs.acceptance_environment == 'local' }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 45 @@ -57,6 +57,17 @@ jobs: tenancy: - single - multitenant + runtime: + - direct + include: + - storage_backend: s3 + database: pg + tenancy: single + runtime: watt + - storage_backend: s3 + database: pg + tenancy: multitenant + runtime: watt steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Node dependencies @@ -74,6 +85,7 @@ jobs: - name: Run local acceptance profile env: ACCEPTANCE_ADMIN_URL: ${{ matrix.tenancy == 'multitenant' && 'http://127.0.0.1:5001' || '' }} + ACCEPTANCE_COMMAND: ${{ matrix.runtime == 'watt' && 'acceptance:watt' || 'acceptance' }} ACCEPTANCE_ENABLE_ADMIN: ${{ matrix.tenancy == 'multitenant' && 'true' || 'false' }} ACCEPTANCE_ENABLE_VECTOR: "true" ACCEPTANCE_INFRA_RESTART_SCRIPT: ${{ matrix.database == 'oriole' && 'infra:restart:ci:oriole:pgvector' || matrix.database == 'multigres' && 'infra:restart:ci:multigres' || 'infra:restart:ci' }} @@ -90,15 +102,16 @@ jobs: VECTOR_DATABASE_URL: ${{ matrix.tenancy == 'single' && 'postgresql://postgres:postgres@127.0.0.1:5432/postgres' || '' }} VECTOR_ENABLED: "true" VECTOR_STORE_MIGRATIONS_ENABLED: "true" + WORKERS_NUM: ${{ matrix.runtime == 'watt' && '2' || '1' }} run: | mkdir -p data coverage/acceptance chmod -R 777 data - npm run acceptance -- --profile "${ACCEPTANCE_PROFILE}" + npm run "${ACCEPTANCE_COMMAND}" -- --profile "${ACCEPTANCE_PROFILE}" - name: Upload acceptance artifacts if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: acceptance-local-${{ matrix.storage_backend }}-${{ matrix.database }}-${{ matrix.tenancy }} + name: acceptance-local-${{ matrix.runtime }}-${{ matrix.storage_backend }}-${{ matrix.database }}-${{ matrix.tenancy }} path: coverage/acceptance if-no-files-found: ignore diff --git a/Dockerfile b/Dockerfile index dc6b442c8..8bc83e6a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,9 @@ COPY --from=production-deps /app/node_modules node_modules # Copy build artifacts from the build stage COPY --from=build /app/dist dist COPY --from=build /app/watt.json /app +COPY --from=build /app/watt-db.json /app COPY --from=build /app/watt.storage.json /app +COPY --from=build /app/watt.database.json /app EXPOSE 5000 CMD ["node", "dist/start/server.js"] diff --git a/acceptance/README.md b/acceptance/README.md index 1e51d75fc..02fa53bdc 100644 --- a/acceptance/README.md +++ b/acceptance/README.md @@ -49,6 +49,23 @@ This restarts local infra, seeds dummy data, starts the TypeScript server from ` The sample env and local CI default to `full`, so enabled capability-gated tests such as Iceberg run by default. Use `--profile smoke` for a faster sanity run. +### Database Watt Runtime + +Run Storage and the Database application together through `watt-db.json` with: + +```bash +npm run acceptance:watt -- --profile core acceptance/specs/database-watt.test.ts +``` + +The acceptance spec only calls Storage's external HTTP API. The Database application loopback +coverage lives beside its source as +`src/applications/database/application.integration.test.ts`; after starting and seeding local +infrastructure, run it with: + +```bash +npm run test:integration -- src/applications/database/application.integration.test.ts +``` + For local backend variants, put server/runtime changes in `.env` or `.env.test`. Keep `.env.acceptance` limited to acceptance runner inputs such as target URLs, client credentials, capability gates, and resource naming. diff --git a/acceptance/scripts/run-managed-local.ts b/acceptance/scripts/run-managed-local.ts index b01ddff41..d8213215c 100644 --- a/acceptance/scripts/run-managed-local.ts +++ b/acceptance/scripts/run-managed-local.ts @@ -9,9 +9,23 @@ import dotenv from 'dotenv' const inheritedEnv = { ...process.env } loadAcceptanceEnvFile() -const args = process.argv.slice(2) -const profile = readArg('profile') ?? acceptanceEnv('ACCEPTANCE_PROFILE') ?? 'smoke' +const { acceptanceArgs: args, managedRuntime } = parseManagedRuntimeArgs(process.argv.slice(2)) +const usesDatabaseWatt = managedRuntime === 'watt' +const profile = + readArg('profile') ?? acceptanceEnv('ACCEPTANCE_PROFILE') ?? (usesDatabaseWatt ? 'core' : 'smoke') const serverEnv = loadServerEnvFiles(inheritedEnv) + +if (usesDatabaseWatt) { + delete serverEnv.ELECTRON_RUN_AS_NODE + serverEnv.LOG_LEVEL ||= 'info' + serverEnv.NODE_ENV = 'test' + serverEnv.UPLOAD_FILE_SIZE_LIMIT ||= '524288000' + serverEnv.WORKERS_NUM ||= '2' + serverEnv.PLT_MANAGEMENT_API ||= 'true' + serverEnv.WATT_HEALTH_ENABLED ||= 'false' + serverEnv.DATABASE_WATT_APPLICATION_ENABLED ||= 'true' +} + configureManagedLocalQueueEnv(serverEnv) const serverPort = serverEnv.SERVER_PORT || serverEnv.PORT || '5000' const baseUrl = acceptanceEnv('ACCEPTANCE_BASE_URL') ?? `http://127.0.0.1:${serverPort}` @@ -19,11 +33,23 @@ const serverIsMultitenant = isMultitenantServer(serverEnv) const acceptanceRunEnv: NodeJS.ProcessEnv = { ...process.env, ACCEPTANCE_BASE_URL: baseUrl, + ACCEPTANCE_DATABASE_WATT: String(usesDatabaseWatt), ACCEPTANCE_PROFILE: profile, ACCEPTANCE_S3_ENDPOINT: acceptanceEnv('ACCEPTANCE_S3_ENDPOINT') ?? `${baseUrl}/s3`, STORAGE_BACKEND: acceptanceEnv('STORAGE_BACKEND') ?? serverEnv.STORAGE_BACKEND, - ACCEPTANCE_TUS_ENDPOINT: - acceptanceEnv('ACCEPTANCE_TUS_ENDPOINT') ?? `${baseUrl}/upload/resumable`, + ACCEPTANCE_TUS_ENDPOINT: usesDatabaseWatt + ? `${baseUrl}/upload/resumable` + : (acceptanceEnv('ACCEPTANCE_TUS_ENDPOINT') ?? `${baseUrl}/upload/resumable`), +} + +if (usesDatabaseWatt) { + acceptanceRunEnv.ACCEPTANCE_SERVICE_KEY = + acceptanceEnv('ACCEPTANCE_SERVICE_KEY') ?? serverEnv.SERVICE_KEY + acceptanceRunEnv.ACCEPTANCE_S3_ACCESS_KEY_ID = + acceptanceEnv('ACCEPTANCE_S3_ACCESS_KEY_ID') ?? serverEnv.S3_PROTOCOL_ACCESS_KEY_ID + acceptanceRunEnv.ACCEPTANCE_S3_SECRET_ACCESS_KEY = + acceptanceEnv('ACCEPTANCE_S3_SECRET_ACCESS_KEY') ?? serverEnv.S3_PROTOCOL_ACCESS_KEY_SECRET + delete acceptanceRunEnv.ELECTRON_RUN_AS_NODE } let server: ChildProcess | undefined @@ -35,6 +61,45 @@ main().catch((error) => { process.exit(1) }) +async function waitForWattApplication( + runtimePid: number, + applicationId: string, + timeoutMs: number +) { + const { RuntimeApiClient } = await import('@platformatic/control') + const client = new RuntimeApiClient() + const started = Date.now() + let lastError: unknown + + try { + while (Date.now() - started < timeoutMs) { + try { + const application = await client + .getRuntimeApplications(runtimePid) + .then(({ applications }) => { + return applications.find((application) => application.id === applicationId) + }) + + if (application?.status === 'started') { + return + } + + lastError = new Error( + `Application ${applicationId} status is ${application?.status ?? 'unknown'}` + ) + } catch (error) { + lastError = error + } + + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } finally { + await client.close() + } + + throw new Error(`Timed out waiting for Watt application ${applicationId}: ${String(lastError)}`) +} + async function main() { try { if (process.env.ACCEPTANCE_SKIP_INFRA !== 'true') { @@ -48,15 +113,35 @@ async function main() { serverEnv.CDN_PURGE_ENDPOINT_URL = purge.url } - server = spawn(localBin('tsx'), ['src/start/server.ts'], { - detached: process.platform !== 'win32', - env: serverEnv, - stdio: ['ignore', 'pipe', 'pipe'], - }) - prefixOutput(server.stdout, '[storage] ') - prefixOutput(server.stderr, '[storage] ') + if (usesDatabaseWatt) { + await run('npm', ['run', 'build'], serverEnv) + } - await waitForStatus(`${baseUrl}/status`, 60_000) + server = spawn( + localBin(usesDatabaseWatt ? 'wattpm' : 'tsx'), + usesDatabaseWatt ? ['start', '--config', 'watt-db.json'] : ['src/start/server.ts'], + { + detached: process.platform !== 'win32', + env: serverEnv, + stdio: ['ignore', 'pipe', 'pipe'], + } + ) + const outputPrefix = usesDatabaseWatt ? '[watt] ' : '[storage] ' + prefixOutput(server.stdout, outputPrefix) + prefixOutput(server.stderr, outputPrefix) + + if (usesDatabaseWatt) { + const runtimePid = server.pid + if (!runtimePid) { + throw new Error('Managed Watt process did not expose a PID') + } + + await waitForStatus(`${baseUrl}/status`, 60_000) + await waitForWattApplication(runtimePid, 'storage', 60_000) + await waitForWattApplication(runtimePid, 'database', 60_000) + } else { + await waitForStatus(`${baseUrl}/status`, 60_000) + } if (serverIsMultitenant) { provisionedS3Credential = await provisionLocalMultitenantTenant(serverEnv) @@ -70,7 +155,9 @@ async function main() { acceptanceRunEnv.ACCEPTANCE_ADMIN_URL = '' acceptanceRunEnv.ACCEPTANCE_ADMIN_API_KEY = '' process.stderr.write( - '[acceptance] disabled admin acceptance for managed single-tenant server\n' + `[acceptance] disabled admin acceptance for managed single-tenant${ + usesDatabaseWatt ? ' Watt' : '' + } server\n` ) } @@ -85,7 +172,7 @@ async function main() { } if (server) { - await stopServer(server) + await stopServer(server, usesDatabaseWatt ? '[watt]' : '[storage]') } if (cdnPurgeServer) { @@ -393,7 +480,7 @@ async function waitForStatus(url: string, timeoutMs: number) { throw new Error(`Timed out waiting for ${url}: ${String(lastError)}`) } -async function stopServer(child: ChildProcess) { +async function stopServer(child: ChildProcess, logPrefix: string) { if (hasExited(child)) { return } @@ -403,7 +490,7 @@ async function stopServer(child: ChildProcess) { child.kill() if (!(await exitedAfterKill)) { - process.stderr.write('[storage] server did not exit after kill\n') + process.stderr.write(`${logPrefix} server did not exit after kill\n`) } return } @@ -415,13 +502,50 @@ async function stopServer(child: ChildProcess) { return } - process.stderr.write('[storage] server did not exit after SIGTERM; sending SIGKILL\n') + process.stderr.write(`${logPrefix} server did not exit after SIGTERM; sending SIGKILL\n`) const exitedAfterKill = waitForExit(child, 2_000) killProcessTree(child, 'SIGKILL') if (!(await exitedAfterKill)) { - process.stderr.write('[storage] server did not exit after SIGKILL\n') + process.stderr.write(`${logPrefix} server did not exit after SIGKILL\n`) + } +} + +function parseManagedRuntimeArgs(inputArgs: string[]): { + acceptanceArgs: string[] + managedRuntime: 'direct' | 'watt' +} { + const acceptanceArgs: string[] = [] + let runtimeValue: string | undefined + + for (let index = 0; index < inputArgs.length; index++) { + const arg = inputArgs[index] + + if (arg === '--managed-runtime') { + runtimeValue = inputArgs[index + 1] + if (!runtimeValue || runtimeValue.startsWith('--')) { + throw new Error('Missing value for --managed-runtime') + } + index++ + continue + } + + if (arg.startsWith('--managed-runtime=')) { + runtimeValue = arg.slice('--managed-runtime='.length) + continue + } + + acceptanceArgs.push(arg) + } + + if (runtimeValue !== undefined && runtimeValue !== 'direct' && runtimeValue !== 'watt') { + throw new Error(`Unsupported managed acceptance runtime: ${runtimeValue}`) + } + + return { + acceptanceArgs, + managedRuntime: runtimeValue ?? 'direct', } } diff --git a/acceptance/specs/database-watt.test.ts b/acceptance/specs/database-watt.test.ts new file mode 100644 index 000000000..68e524bb5 --- /dev/null +++ b/acceptance/specs/database-watt.test.ts @@ -0,0 +1,68 @@ +import { describeAcceptance, encodePathSegments, getAcceptanceConfig } from '../support/config' +import { createRestClient } from '../support/http' +import { + cleanupRestResources, + createRestBucket, + requireServiceKey, + uniqueBucketName, + uniqueObjectKey, + uploadRestObject, +} from '../support/resources' + +interface ListObjectsV2Response { + objects: Array<{ name: string }> +} + +const describeDatabaseWattAcceptance = + process.env.ACCEPTANCE_DATABASE_WATT === 'true' ? describeAcceptance : describe.skip + +describeDatabaseWattAcceptance( + 'Storage through the Database Watt runtime', + { + destructive: true, + profiles: ['core'], + }, + () => { + it('persists tenant metadata through the external Storage API', async () => { + const config = getAcceptanceConfig() + const client = createRestClient() + const bucketName = uniqueBucketName('dbwatt') + const objectKey = uniqueObjectKey('dbwatt') + const payload = `database-watt-acceptance-${config.runId}` + + try { + await createRestBucket(bucketName) + await uploadRestObject(bucketName, objectKey, payload) + + const listed = await client.request( + 'POST', + `/object/list-v2/${bucketName}`, + { + body: { + limit: 100, + prefix: `${config.resourcePrefix}/`, + with_delimiter: false, + }, + expectedStatus: 200, + token: requireServiceKey(config), + } + ) + + expect(listed.json?.objects.map((object) => object.name)).toContain(objectKey) + + const downloaded = await client.request( + 'GET', + `/object/authenticated/${bucketName}/${encodePathSegments(objectKey)}`, + { + expectedStatus: 200, + token: requireServiceKey(config), + } + ) + + expect(downloaded.body).toBe(payload) + } finally { + await cleanupRestResources(bucketName, [objectKey], client) + } + }) + } +) diff --git a/docs/database-watt-postgresql-scope.md b/docs/database-watt-postgresql-scope.md new file mode 100644 index 000000000..82a7c11bf --- /dev/null +++ b/docs/database-watt-postgresql-scope.md @@ -0,0 +1,18 @@ +# Database Watt PostgreSQL Ownership + +Database Watt owns runtime PostgreSQL access for storage metadata and multitenant master DB queries when the app runs under Watt. + +## Routed Through Database Watt + +- Tenant metadata DB access via `getPostgresConnection()` when Watt messaging is available. +- Multitenant master DB access via `multitenantPgExecutor` when Watt messaging is available. + +## Direct PostgreSQL Access That Remains + +- Direct PostgreSQL fallback for non-Watt/local mode. +- Queue/pg-boss access in `src/internal/queue/database.ts`; queues are intentionally out of this migration for now. +- Migration runner access in `src/internal/database/migrations/migrate.ts`; migrations are intentionally out of scope for Database Watt for now. +- Tests and seeding utilities. +- `pg` type/error imports used to preserve existing public interfaces and error mapping. + +Any new runtime PostgreSQL access should go through Database Watt unless it is explicitly documented here as an exception. diff --git a/package-lock.json b/package-lock.json index a8746f3d9..3e6887a7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,11 +33,11 @@ "@opentelemetry/instrumentation-runtime-node": "^0.25.0", "@opentelemetry/sdk-metrics": "^2.6.1", "@opentelemetry/sdk-node": "^0.220.0", - "@platformatic/control": "^3.62.2", + "@platformatic/control": "^3.63.0", "@platformatic/flame": "^1.7.0", - "@platformatic/globals": "^3.62.2", - "@platformatic/node": "^3.62.2", - "@platformatic/wattpm-pprof-capture": "^3.62.2", + "@platformatic/globals": "^3.63.0", + "@platformatic/node": "^3.63.0", + "@platformatic/wattpm-pprof-capture": "^3.63.0", "@smithy/node-http-handler": "^2.3.1", "@tus/file-store": "2.1.0", "@tus/s3-store": "2.0.3", @@ -64,12 +64,12 @@ "pg-listen": "^1.7.0", "pino": "^10.3.1", "pino-logflare": "^0.5.2", - "platformatic": "^3.62.2", + "platformatic": "^3.63.0", "postgres-migrations": "^5.3.0", "pprof-format": "^2.2.1", "safe-stable-stringify": "^2.3.1", "undici": "^7.24.0", - "wattpm": "^3.62.2" + "wattpm": "^3.63.0" }, "bin": { "supa-storage": "dist/server.js" @@ -77,6 +77,7 @@ "devDependencies": { "@aws-sdk/s3-presigned-post": "^3.1023.0", "@biomejs/biome": "2.5.1", + "@platformatic/runtime": "^3.63.0", "@types/js-yaml": "^4.0.5", "@types/json-bigint": "^1.0.4", "@types/node": "^24.12.0", @@ -4167,9 +4168,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -8338,17 +8339,17 @@ } }, "node_modules/@platformatic/basic": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/basic/-/basic-3.62.2.tgz", - "integrity": "sha512-KdifOst6pmD1JY2G9cZ5R+nFkbflpxQZM44brEPnZi/mPcGUjDTlgNEQ7J284K9e0hbpjM9o4t87AJEWJTh/wA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/basic/-/basic-3.63.0.tgz", + "integrity": "sha512-Y+0aFl32lrBkEtkkdA+q7hYnHCuqBz47sLXNa8kgKR5NkjmHTRFAxaRAwKGhXb7mWkdeBG8Znwg+g/CL3RlFig==", "license": "Apache-2.0", "dependencies": { "@fastify/error": "^4.0.0", - "@platformatic/foundation": "3.62.2", - "@platformatic/globals": "3.62.2", - "@platformatic/itc": "3.62.2", - "@platformatic/metrics": "3.62.2", - "@platformatic/telemetry": "3.62.2", + "@platformatic/foundation": "3.63.0", + "@platformatic/globals": "3.63.0", + "@platformatic/itc": "3.63.0", + "@platformatic/metrics": "3.63.0", + "@platformatic/telemetry": "3.63.0", "execa": "^9.3.1", "fast-json-patch": "^3.1.1", "pino": "^9.9.0", @@ -8403,13 +8404,13 @@ } }, "node_modules/@platformatic/control": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/control/-/control-3.62.2.tgz", - "integrity": "sha512-GLBH0FouoQ6aTzALEcejmGM159CNQp4Gds6ewhx/LefX1HQjTFIW6fLWpOunx4SsFlorQncwNkC6j+a39OI2eA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/control/-/control-3.63.0.tgz", + "integrity": "sha512-vCn5j9tWNXgQjG+DlKxsZM0GaksalVT4ibzKNDC8jZJB4hCHpy8QePqpP53gZViUFzpoW6TKmgI/RS3CQYkHfQ==", "license": "Apache-2.0", "dependencies": { "@fastify/error": "^4.0.0", - "@platformatic/foundation": "3.62.2", + "@platformatic/foundation": "3.63.0", "help-me": "^5.0.0", "pino": "^9.9.0", "pino-pretty": "^13.0.0", @@ -8481,9 +8482,9 @@ } }, "node_modules/@platformatic/foundation": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/foundation/-/foundation-3.62.2.tgz", - "integrity": "sha512-f0HYgLfLM11oyTrvzSnWRsMcOiCdJWWhzW0OTvJ+ancWxEoxnMJ8TAlAo9Y4VjGHcx7Aq2mf4BarGPAGrWSg7Q==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/foundation/-/foundation-3.63.0.tgz", + "integrity": "sha512-QpkgEP43b8A0XGgGmY76UCOWO3u6ROspAZykTAia1tLgy4zGZDg5TwVB0q5ykApaRNOJhKwoNG3ddZdqwsB2yw==", "license": "Apache-2.0", "dependencies": { "@fastify/deepmerge": "^3.0.0", @@ -8548,16 +8549,16 @@ } }, "node_modules/@platformatic/generators": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/generators/-/generators-3.62.2.tgz", - "integrity": "sha512-Vz0PkqggUe4+L16FJMArmA0OXtvomJtmUpxlxxaASJrSB1x0lhb6lXr1+3dzDfT3fCkt7Bn5NFRS9+bXU+2mpA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/generators/-/generators-3.63.0.tgz", + "integrity": "sha512-Ni1IcrJWEhZsc9FzPtYkfmuoB8OqqD+mtipG3UCYxtseDuCE2Swk6puKGBjIXtWHATsVrg1br898eM+dNhHBYQ==", "license": "Apache-2.0", "dependencies": { "@fastify/error": "^4.0.0", - "@platformatic/foundation": "3.62.2", + "@platformatic/foundation": "3.63.0", "change-case-all": "^2.1.0", "execa": "^9.6.0", - "fastify": "^5.7.0", + "fastify": "^5.0.0", "pino": "^9.9.0", "undici": "^7.27.2" }, @@ -8606,9 +8607,9 @@ } }, "node_modules/@platformatic/globals": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/globals/-/globals-3.62.2.tgz", - "integrity": "sha512-Dx7okovdY+BjBOHvgjNx6/3KxrYpgapA9/5xyRGQS8IOe9Nf/UtqzNd16rSJcTPpJKNYVvHu4E9Pu0USatYy3Q==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/globals/-/globals-3.63.0.tgz", + "integrity": "sha512-ojGz5ar+OYrA/tzoWBpRPtkFb2+DvG5rDd3JeAY9Pr8uUlAKugX1G+Pxx4BLUi7t1hUJwHVS6ZaEB4XBtHTvqQ==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -8669,14 +8670,14 @@ } }, "node_modules/@platformatic/itc": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/itc/-/itc-3.62.2.tgz", - "integrity": "sha512-NN3KY2Mkm1jgaIo36nszj3JJQZ4ANfuPZZvL55JmltSc7Cr6+lq2xNM11GcxKe2ivckkhVpJsnN53uY1QEpXpA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/itc/-/itc-3.63.0.tgz", + "integrity": "sha512-p0uhmBlMbowMZpodsv0q2/fGkzuVrui/WM43eKF0B1o6UKkp6AQt2x8VHpy6sdevmKGM2fTTgvCLgTV4URfQ1w==", "license": "Apache-2.0", "dependencies": { "@fastify/error": "^4.0.0", "@opentelemetry/api": "^1.9.0", - "@platformatic/globals": "3.62.2", + "@platformatic/globals": "3.63.0", "@watchable/unpromise": "^1.0.2" }, "engines": { @@ -8684,12 +8685,12 @@ } }, "node_modules/@platformatic/metrics": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/metrics/-/metrics-3.62.2.tgz", - "integrity": "sha512-SxYyMju222lW62CP9YoV3L7T0JJnLIQjo5vQ/SiP/yF864Wipy9tlK1QkbnY7Eelu4vCpVdxbdB7m3/9X7Y7eg==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/metrics/-/metrics-3.63.0.tgz", + "integrity": "sha512-7W0RaEx6rRmPRzvWz5u/I6yEc87XHV3V24hSPvgUicVGyARhRpzazjQCGWTsg3aRwpXhTgEyG068L6rbXnvo8A==", "license": "Apache-2.0", "dependencies": { - "@platformatic/globals": "3.62.2", + "@platformatic/globals": "3.63.0", "@platformatic/http-metrics": "^0.3.0", "@platformatic/prom-client": "^1.0.0", "@platformatic/promotel": "^0.2.0" @@ -8699,15 +8700,15 @@ } }, "node_modules/@platformatic/node": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/node/-/node-3.62.2.tgz", - "integrity": "sha512-9TTd1EEFFXAdSrEqz6rEKL6MiXF7D7mv/LIuNt0y59uI4JlRBdQE7TsI9q/w2D5vuh2j9uCLyZLELMsVnsQCJQ==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/node/-/node-3.63.0.tgz", + "integrity": "sha512-V1YvNxJ5pKjFFriFY82wJhoU1K2T8V1AhHVrTuTHFkCCQ2ehvISyQRQOipJkzyJqtn6pgIFMWlE1MQqf7CUtYw==", "license": "Apache-2.0", "dependencies": { - "@platformatic/basic": "3.62.2", - "@platformatic/foundation": "3.62.2", - "@platformatic/generators": "3.62.2", - "@platformatic/globals": "3.62.2", + "@platformatic/basic": "3.63.0", + "@platformatic/foundation": "3.63.0", + "@platformatic/generators": "3.63.0", + "@platformatic/globals": "3.63.0", "@watchable/unpromise": "^1.0.2", "json5": "^2.2.3", "light-my-request": "^6.0.0" @@ -8759,9 +8760,9 @@ } }, "node_modules/@platformatic/runtime": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/runtime/-/runtime-3.62.2.tgz", - "integrity": "sha512-trZw7d7XS6wN846yJC6LY7FjN1sPav85wJb6CE543emRJNv+QIPjJF/SOxrj2graVPgfexVELm+067GwHwBBxg==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/runtime/-/runtime-3.63.0.tgz", + "integrity": "sha512-+vOtZBX5q9XYRqaSCN35glEQT5QvhQwovF4z7jTXhD0ie4ov8taljZpAy8n2v74bxFbgW8ODAhLX6s299oneqg==", "license": "Apache-2.0", "dependencies": { "@fastify/accepts": "^5.0.0", @@ -8770,14 +8771,14 @@ "@fastify/websocket": "^11.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0", - "@platformatic/basic": "3.62.2", - "@platformatic/foundation": "3.62.2", - "@platformatic/generators": "3.62.2", - "@platformatic/globals": "3.62.2", - "@platformatic/itc": "3.62.2", - "@platformatic/metrics": "3.62.2", + "@platformatic/basic": "3.63.0", + "@platformatic/foundation": "3.63.0", + "@platformatic/generators": "3.63.0", + "@platformatic/globals": "3.63.0", + "@platformatic/itc": "3.63.0", + "@platformatic/metrics": "3.63.0", "@platformatic/prom-client": "^1.0.0", - "@platformatic/telemetry": "3.62.2", + "@platformatic/telemetry": "3.63.0", "@platformatic/undici-cache-memory": "^0.9.0", "@watchable/unpromise": "^1.0.2", "change-case-all": "^2.1.0", @@ -8791,7 +8792,7 @@ "help-me": "^5.0.0", "minimist": "^1.2.8", "pino": "^10.1.0", - "pino-opentelemetry-transport": "^3.0.0", + "pino-opentelemetry-transport": "^4.0.2", "pino-pretty": "^13.0.0", "semgrator": "^0.3.0", "sonic-boom": "^4.2.0", @@ -8804,21 +8805,6 @@ "node": ">=22.19.0" } }, - "node_modules/@platformatic/runtime/node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@platformatic/runtime/node_modules/@opentelemetry/exporter-metrics-otlp-http": { "version": "0.219.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz", @@ -8962,9 +8948,9 @@ } }, "node_modules/@platformatic/telemetry": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/telemetry/-/telemetry-3.62.2.tgz", - "integrity": "sha512-xiD4ofmkZmdk2g9J0GK2us0VlsjyJ6wuetxN55Pg/sOKGDBQV8lAzkXZ41CFK2f6A5jA1X6KrbLxcta73qoV4Q==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/telemetry/-/telemetry-3.63.0.tgz", + "integrity": "sha512-rF5vmsqCNVIarShs5/JTDAh2n62UrDg4832LF4D5gCUZYi7xJE284SfcTbyGar8LOlCK9XhfAGJSeG3xRBAWoQ==", "license": "Apache-2.0", "dependencies": { "@fastify/swagger": "^9.5.1", @@ -8980,9 +8966,9 @@ "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "1.41.1", - "@platformatic/foundation": "3.62.2", - "@platformatic/globals": "3.62.2", - "fast-uri": "^3.0.6", + "@platformatic/foundation": "3.63.0", + "@platformatic/globals": "3.63.0", + "fast-uri": "^4.0.0", "fastify-plugin": "^5.0.1" }, "engines": { @@ -9017,21 +9003,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@platformatic/telemetry/node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@platformatic/telemetry/node_modules/@opentelemetry/exporter-logs-otlp-grpc": { "version": "0.219.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.219.0.tgz", @@ -9491,6 +9462,22 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@platformatic/telemetry/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/@platformatic/undici-cache-memory": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@platformatic/undici-cache-memory/-/undici-cache-memory-0.9.0.tgz", @@ -9498,14 +9485,14 @@ "license": "Apache-2.0" }, "node_modules/@platformatic/wattpm-pprof-capture": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/wattpm-pprof-capture/-/wattpm-pprof-capture-3.62.2.tgz", - "integrity": "sha512-7VNoflZVv3Mxt82uXns1yqtKnGlD9+EFHL+TXViokjJAvIuBWplC8By1P78Cp708Gx7o99TmRHyheiOA3ame/w==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/wattpm-pprof-capture/-/wattpm-pprof-capture-3.63.0.tgz", + "integrity": "sha512-ptjIq3kcoSa/Yif2jaDccUI6M8Qo1+W9uvKVzz5nyWv9kg9Dr9WFoGyTqivCUf9+wkTjGIK7VNPYWtqtiBMmew==", "license": "Apache-2.0", "dependencies": { "@datadog/pprof": "^5.3.0", "@fastify/error": "^4.0.0", - "@platformatic/globals": "3.62.2", + "@platformatic/globals": "3.63.0", "undici": "^7.27.2" }, "engines": { @@ -14915,213 +14902,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/otlp-logger": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/otlp-logger/-/otlp-logger-2.1.1.tgz", - "integrity": "sha512-byktkBr8jER0R+g0ynGv1//ljxrFU2VFPf77Xa5CSjqUjaRUTwfJauFtjpuQLtBQB8CBPT1n7m9izhLCLVSwcw==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api-logs": "^0.218.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", - "@opentelemetry/exporter-logs-otlp-proto": "^0.218.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/sdk-logs": "^0.218.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.218.0.tgz", - "integrity": "sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/sdk-logs": "0.218.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", - "integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/sdk-logs": "0.218.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/exporter-logs-otlp-proto": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.218.0.tgz", - "integrity": "sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-trace-base": "2.7.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", - "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-transformer": "0.218.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.218.0.tgz", - "integrity": "sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", - "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/otlp-logger/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -15407,18 +15187,38 @@ } }, "node_modules/pino-opentelemetry-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-opentelemetry-transport/-/pino-opentelemetry-transport-3.0.0.tgz", - "integrity": "sha512-t/fH23X+/pSSaUTdD7hq8FbT5BtTnUvXDojxKNVGX/auDPpDshG58t2yxFr2cmMgpQetIKBCcsH3KmcJXJJ5cQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pino-opentelemetry-transport/-/pino-opentelemetry-transport-4.0.2.tgz", + "integrity": "sha512-ig0F6+RgAzPOcavGLBGNLRS9YzKTw1VlHiJ8dr263y1ED/rcwKcjqzrF8+3L6N8NU7nH9QF7XdrqnH35GiZ2EQ==", "license": "MIT", "dependencies": { - "otlp-logger": "^2.0.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", + "@opentelemetry/otlp-exporter-base": "^0.220.0", + "@opentelemetry/otlp-grpc-exporter-base": "^0.220.0", + "@opentelemetry/resources": "^2.8.0", + "@opentelemetry/sdk-logs": "^0.220.0", "pino-abstract-transport": "^3.0.0" }, "peerDependencies": { "pino": "^10.0.0" } }, + "node_modules/pino-opentelemetry-transport/node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/pino-opentelemetry-transport/node_modules/pino-abstract-transport": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", @@ -15489,13 +15289,13 @@ } }, "node_modules/platformatic": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/platformatic/-/platformatic-3.62.2.tgz", - "integrity": "sha512-eN3Gz2mfjkeubXjnblNYmaowI7C7yhdkcFZD0vv8RNPDxyE94WwhMNIsfjmzQQTmwGByxy8nMorFfcjzRj3+zA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/platformatic/-/platformatic-3.63.0.tgz", + "integrity": "sha512-DmTsp10r6pGC7jD73pbnSRTonyqAEJK/dlAnNLOJeg24lt5n15EXvSdeuitJXN0Zb5LGWf2m2sGy08kcNcDwcw==", "license": "Apache-2.0", "dependencies": { - "@platformatic/foundation": "3.62.2", - "wattpm": "3.62.2" + "@platformatic/foundation": "3.63.0", + "wattpm": "3.63.0" }, "bin": { "platformatic": "bin/platformatic.js", @@ -17567,16 +17367,16 @@ } }, "node_modules/wattpm": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/wattpm/-/wattpm-3.62.2.tgz", - "integrity": "sha512-YAjsW5y5/t64vjVGqkALrxYBhHCDWor2NO24wVZF/BNyxYxhneK6TLSxnmsf+6jK+Bxyk73atOxCNqP6y1cXyg==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/wattpm/-/wattpm-3.63.0.tgz", + "integrity": "sha512-bkubW624xbuWY7I3DsptpeTjsroeurqUE5NbYukjK16y76EKtPUef4BMV98yqLR3Ane85KWxXvP63+n6duGImA==", "license": "Apache-2.0", "dependencies": { "@fastify/websocket": "^11.0.0", - "@platformatic/control": "3.62.2", - "@platformatic/foundation": "3.62.2", - "@platformatic/globals": "3.62.2", - "@platformatic/runtime": "3.62.2", + "@platformatic/control": "3.63.0", + "@platformatic/foundation": "3.63.0", + "@platformatic/globals": "3.63.0", + "@platformatic/runtime": "3.63.0", "colorette": "^2.0.20", "pino-pretty": "^13.0.0", "split2": "^4.2.0", @@ -20509,9 +20309,9 @@ "requires": {} }, "@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "requires": { "@opentelemetry/semantic-conventions": "^1.29.0" } @@ -23267,16 +23067,16 @@ "optional": true }, "@platformatic/basic": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/basic/-/basic-3.62.2.tgz", - "integrity": "sha512-KdifOst6pmD1JY2G9cZ5R+nFkbflpxQZM44brEPnZi/mPcGUjDTlgNEQ7J284K9e0hbpjM9o4t87AJEWJTh/wA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/basic/-/basic-3.63.0.tgz", + "integrity": "sha512-Y+0aFl32lrBkEtkkdA+q7hYnHCuqBz47sLXNa8kgKR5NkjmHTRFAxaRAwKGhXb7mWkdeBG8Znwg+g/CL3RlFig==", "requires": { "@fastify/error": "^4.0.0", - "@platformatic/foundation": "3.62.2", - "@platformatic/globals": "3.62.2", - "@platformatic/itc": "3.62.2", - "@platformatic/metrics": "3.62.2", - "@platformatic/telemetry": "3.62.2", + "@platformatic/foundation": "3.63.0", + "@platformatic/globals": "3.63.0", + "@platformatic/itc": "3.63.0", + "@platformatic/metrics": "3.63.0", + "@platformatic/telemetry": "3.63.0", "execa": "^9.3.1", "fast-json-patch": "^3.1.1", "pino": "^9.9.0", @@ -23324,12 +23124,12 @@ } }, "@platformatic/control": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/control/-/control-3.62.2.tgz", - "integrity": "sha512-GLBH0FouoQ6aTzALEcejmGM159CNQp4Gds6ewhx/LefX1HQjTFIW6fLWpOunx4SsFlorQncwNkC6j+a39OI2eA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/control/-/control-3.63.0.tgz", + "integrity": "sha512-vCn5j9tWNXgQjG+DlKxsZM0GaksalVT4ibzKNDC8jZJB4hCHpy8QePqpP53gZViUFzpoW6TKmgI/RS3CQYkHfQ==", "requires": { "@fastify/error": "^4.0.0", - "@platformatic/foundation": "3.62.2", + "@platformatic/foundation": "3.63.0", "help-me": "^5.0.0", "pino": "^9.9.0", "pino-pretty": "^13.0.0", @@ -23387,9 +23187,9 @@ } }, "@platformatic/foundation": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/foundation/-/foundation-3.62.2.tgz", - "integrity": "sha512-f0HYgLfLM11oyTrvzSnWRsMcOiCdJWWhzW0OTvJ+ancWxEoxnMJ8TAlAo9Y4VjGHcx7Aq2mf4BarGPAGrWSg7Q==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/foundation/-/foundation-3.63.0.tgz", + "integrity": "sha512-QpkgEP43b8A0XGgGmY76UCOWO3u6ROspAZykTAia1tLgy4zGZDg5TwVB0q5ykApaRNOJhKwoNG3ddZdqwsB2yw==", "requires": { "@fastify/deepmerge": "^3.0.0", "@fastify/error": "^4.0.0", @@ -23446,15 +23246,15 @@ } }, "@platformatic/generators": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/generators/-/generators-3.62.2.tgz", - "integrity": "sha512-Vz0PkqggUe4+L16FJMArmA0OXtvomJtmUpxlxxaASJrSB1x0lhb6lXr1+3dzDfT3fCkt7Bn5NFRS9+bXU+2mpA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/generators/-/generators-3.63.0.tgz", + "integrity": "sha512-Ni1IcrJWEhZsc9FzPtYkfmuoB8OqqD+mtipG3UCYxtseDuCE2Swk6puKGBjIXtWHATsVrg1br898eM+dNhHBYQ==", "requires": { "@fastify/error": "^4.0.0", - "@platformatic/foundation": "3.62.2", + "@platformatic/foundation": "3.63.0", "change-case-all": "^2.1.0", "execa": "^9.6.0", - "fastify": "^5.7.0", + "fastify": "^5.0.0", "pino": "^9.9.0", "undici": "^7.27.2" }, @@ -23496,9 +23296,9 @@ } }, "@platformatic/globals": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/globals/-/globals-3.62.2.tgz", - "integrity": "sha512-Dx7okovdY+BjBOHvgjNx6/3KxrYpgapA9/5xyRGQS8IOe9Nf/UtqzNd16rSJcTPpJKNYVvHu4E9Pu0USatYy3Q==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/globals/-/globals-3.63.0.tgz", + "integrity": "sha512-ojGz5ar+OYrA/tzoWBpRPtkFb2+DvG5rDd3JeAY9Pr8uUlAKugX1G+Pxx4BLUi7t1hUJwHVS6ZaEB4XBtHTvqQ==", "requires": { "@opentelemetry/api": "^1.9.0", "@platformatic/prom-client": "^1.0.0", @@ -23550,36 +23350,36 @@ } }, "@platformatic/itc": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/itc/-/itc-3.62.2.tgz", - "integrity": "sha512-NN3KY2Mkm1jgaIo36nszj3JJQZ4ANfuPZZvL55JmltSc7Cr6+lq2xNM11GcxKe2ivckkhVpJsnN53uY1QEpXpA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/itc/-/itc-3.63.0.tgz", + "integrity": "sha512-p0uhmBlMbowMZpodsv0q2/fGkzuVrui/WM43eKF0B1o6UKkp6AQt2x8VHpy6sdevmKGM2fTTgvCLgTV4URfQ1w==", "requires": { "@fastify/error": "^4.0.0", "@opentelemetry/api": "^1.9.0", - "@platformatic/globals": "3.62.2", + "@platformatic/globals": "3.63.0", "@watchable/unpromise": "^1.0.2" } }, "@platformatic/metrics": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/metrics/-/metrics-3.62.2.tgz", - "integrity": "sha512-SxYyMju222lW62CP9YoV3L7T0JJnLIQjo5vQ/SiP/yF864Wipy9tlK1QkbnY7Eelu4vCpVdxbdB7m3/9X7Y7eg==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/metrics/-/metrics-3.63.0.tgz", + "integrity": "sha512-7W0RaEx6rRmPRzvWz5u/I6yEc87XHV3V24hSPvgUicVGyARhRpzazjQCGWTsg3aRwpXhTgEyG068L6rbXnvo8A==", "requires": { - "@platformatic/globals": "3.62.2", + "@platformatic/globals": "3.63.0", "@platformatic/http-metrics": "^0.3.0", "@platformatic/prom-client": "^1.0.0", "@platformatic/promotel": "^0.2.0" } }, "@platformatic/node": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/node/-/node-3.62.2.tgz", - "integrity": "sha512-9TTd1EEFFXAdSrEqz6rEKL6MiXF7D7mv/LIuNt0y59uI4JlRBdQE7TsI9q/w2D5vuh2j9uCLyZLELMsVnsQCJQ==", - "requires": { - "@platformatic/basic": "3.62.2", - "@platformatic/foundation": "3.62.2", - "@platformatic/generators": "3.62.2", - "@platformatic/globals": "3.62.2", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/node/-/node-3.63.0.tgz", + "integrity": "sha512-V1YvNxJ5pKjFFriFY82wJhoU1K2T8V1AhHVrTuTHFkCCQ2ehvISyQRQOipJkzyJqtn6pgIFMWlE1MQqf7CUtYw==", + "requires": { + "@platformatic/basic": "3.63.0", + "@platformatic/foundation": "3.63.0", + "@platformatic/generators": "3.63.0", + "@platformatic/globals": "3.63.0", "@watchable/unpromise": "^1.0.2", "json5": "^2.2.3", "light-my-request": "^6.0.0" @@ -23615,9 +23415,9 @@ } }, "@platformatic/runtime": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/runtime/-/runtime-3.62.2.tgz", - "integrity": "sha512-trZw7d7XS6wN846yJC6LY7FjN1sPav85wJb6CE543emRJNv+QIPjJF/SOxrj2graVPgfexVELm+067GwHwBBxg==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/runtime/-/runtime-3.63.0.tgz", + "integrity": "sha512-+vOtZBX5q9XYRqaSCN35glEQT5QvhQwovF4z7jTXhD0ie4ov8taljZpAy8n2v74bxFbgW8ODAhLX6s299oneqg==", "requires": { "@fastify/accepts": "^5.0.0", "@fastify/basic-auth": "^6.0.0", @@ -23625,14 +23425,14 @@ "@fastify/websocket": "^11.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0", - "@platformatic/basic": "3.62.2", - "@platformatic/foundation": "3.62.2", - "@platformatic/generators": "3.62.2", - "@platformatic/globals": "3.62.2", - "@platformatic/itc": "3.62.2", - "@platformatic/metrics": "3.62.2", + "@platformatic/basic": "3.63.0", + "@platformatic/foundation": "3.63.0", + "@platformatic/generators": "3.63.0", + "@platformatic/globals": "3.63.0", + "@platformatic/itc": "3.63.0", + "@platformatic/metrics": "3.63.0", "@platformatic/prom-client": "^1.0.0", - "@platformatic/telemetry": "3.62.2", + "@platformatic/telemetry": "3.63.0", "@platformatic/undici-cache-memory": "^0.9.0", "@watchable/unpromise": "^1.0.2", "change-case-all": "^2.1.0", @@ -23646,7 +23446,7 @@ "help-me": "^5.0.0", "minimist": "^1.2.8", "pino": "^10.1.0", - "pino-opentelemetry-transport": "^3.0.0", + "pino-opentelemetry-transport": "^4.0.2", "pino-pretty": "^13.0.0", "semgrator": "^0.3.0", "sonic-boom": "^4.2.0", @@ -23656,14 +23456,6 @@ "ws": "^8.16.0" }, "dependencies": { - "@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", - "requires": { - "@opentelemetry/semantic-conventions": "^1.29.0" - } - }, "@opentelemetry/exporter-metrics-otlp-http": { "version": "0.219.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz", @@ -23753,9 +23545,9 @@ } }, "@platformatic/telemetry": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/telemetry/-/telemetry-3.62.2.tgz", - "integrity": "sha512-xiD4ofmkZmdk2g9J0GK2us0VlsjyJ6wuetxN55Pg/sOKGDBQV8lAzkXZ41CFK2f6A5jA1X6KrbLxcta73qoV4Q==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/telemetry/-/telemetry-3.63.0.tgz", + "integrity": "sha512-rF5vmsqCNVIarShs5/JTDAh2n62UrDg4832LF4D5gCUZYi7xJE284SfcTbyGar8LOlCK9XhfAGJSeG3xRBAWoQ==", "requires": { "@fastify/swagger": "^9.5.1", "@opentelemetry/api": "1.9.1", @@ -23770,9 +23562,9 @@ "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/semantic-conventions": "1.41.1", - "@platformatic/foundation": "3.62.2", - "@platformatic/globals": "3.62.2", - "fast-uri": "^3.0.6", + "@platformatic/foundation": "3.63.0", + "@platformatic/globals": "3.63.0", + "fast-uri": "^4.0.0", "fastify-plugin": "^5.0.1" }, "dependencies": { @@ -23791,14 +23583,6 @@ "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", "requires": {} }, - "@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", - "requires": { - "@opentelemetry/semantic-conventions": "^1.29.0" - } - }, "@opentelemetry/exporter-logs-otlp-grpc": { "version": "0.219.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.219.0.tgz", @@ -24089,6 +23873,11 @@ "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" } + }, + "fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==" } } }, @@ -24098,13 +23887,13 @@ "integrity": "sha512-ub1oMcewDpZD5PLFyfXeZYVe3aQBZXVwZ3wUssjvErdavBex+aptvps9UGDDD0k6L1KNlCCJZE74WrxRNGiewg==" }, "@platformatic/wattpm-pprof-capture": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/@platformatic/wattpm-pprof-capture/-/wattpm-pprof-capture-3.62.2.tgz", - "integrity": "sha512-7VNoflZVv3Mxt82uXns1yqtKnGlD9+EFHL+TXViokjJAvIuBWplC8By1P78Cp708Gx7o99TmRHyheiOA3ame/w==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/@platformatic/wattpm-pprof-capture/-/wattpm-pprof-capture-3.63.0.tgz", + "integrity": "sha512-ptjIq3kcoSa/Yif2jaDccUI6M8Qo1+W9uvKVzz5nyWv9kg9Dr9WFoGyTqivCUf9+wkTjGIK7VNPYWtqtiBMmew==", "requires": { "@datadog/pprof": "^5.3.0", "@fastify/error": "^4.0.0", - "@platformatic/globals": "3.62.2", + "@platformatic/globals": "3.63.0", "undici": "^7.27.2" } }, @@ -27715,140 +27504,6 @@ "oauth4webapi": "^3.8.1" } }, - "otlp-logger": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/otlp-logger/-/otlp-logger-2.1.1.tgz", - "integrity": "sha512-byktkBr8jER0R+g0ynGv1//ljxrFU2VFPf77Xa5CSjqUjaRUTwfJauFtjpuQLtBQB8CBPT1n7m9izhLCLVSwcw==", - "requires": { - "@opentelemetry/api-logs": "^0.218.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", - "@opentelemetry/exporter-logs-otlp-proto": "^0.218.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/sdk-logs": "^0.218.0" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", - "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", - "requires": { - "@opentelemetry/api": "^1.3.0" - } - }, - "@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.218.0.tgz", - "integrity": "sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g==", - "requires": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/sdk-logs": "0.218.0" - } - }, - "@opentelemetry/exporter-logs-otlp-http": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", - "integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==", - "requires": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/sdk-logs": "0.218.0" - } - }, - "@opentelemetry/exporter-logs-otlp-proto": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.218.0.tgz", - "integrity": "sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg==", - "requires": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-trace-base": "2.7.1" - } - }, - "@opentelemetry/otlp-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", - "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", - "requires": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-transformer": "0.218.0" - } - }, - "@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.218.0.tgz", - "integrity": "sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A==", - "requires": { - "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.218.0", - "@opentelemetry/otlp-transformer": "0.218.0" - } - }, - "@opentelemetry/otlp-transformer": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", - "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", - "requires": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.218.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" - } - }, - "@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "requires": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - } - }, - "@opentelemetry/sdk-logs": { - "version": "0.218.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", - "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", - "requires": { - "@opentelemetry/api-logs": "0.218.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - } - }, - "@opentelemetry/sdk-metrics": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", - "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", - "requires": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1" - } - }, - "@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "requires": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - } - } - } - }, "package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -28065,14 +27720,30 @@ } }, "pino-opentelemetry-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-opentelemetry-transport/-/pino-opentelemetry-transport-3.0.0.tgz", - "integrity": "sha512-t/fH23X+/pSSaUTdD7hq8FbT5BtTnUvXDojxKNVGX/auDPpDshG58t2yxFr2cmMgpQetIKBCcsH3KmcJXJJ5cQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pino-opentelemetry-transport/-/pino-opentelemetry-transport-4.0.2.tgz", + "integrity": "sha512-ig0F6+RgAzPOcavGLBGNLRS9YzKTw1VlHiJ8dr263y1ED/rcwKcjqzrF8+3L6N8NU7nH9QF7XdrqnH35GiZ2EQ==", "requires": { - "otlp-logger": "^2.0.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", + "@opentelemetry/otlp-exporter-base": "^0.220.0", + "@opentelemetry/otlp-grpc-exporter-base": "^0.220.0", + "@opentelemetry/resources": "^2.8.0", + "@opentelemetry/sdk-logs": "^0.220.0", "pino-abstract-transport": "^3.0.0" }, "dependencies": { + "@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "requires": { + "@opentelemetry/api": "^1.3.0" + } + }, "pino-abstract-transport": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", @@ -28124,12 +27795,12 @@ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" }, "platformatic": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/platformatic/-/platformatic-3.62.2.tgz", - "integrity": "sha512-eN3Gz2mfjkeubXjnblNYmaowI7C7yhdkcFZD0vv8RNPDxyE94WwhMNIsfjmzQQTmwGByxy8nMorFfcjzRj3+zA==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/platformatic/-/platformatic-3.63.0.tgz", + "integrity": "sha512-DmTsp10r6pGC7jD73pbnSRTonyqAEJK/dlAnNLOJeg24lt5n15EXvSdeuitJXN0Zb5LGWf2m2sGy08kcNcDwcw==", "requires": { - "@platformatic/foundation": "3.62.2", - "wattpm": "3.62.2" + "@platformatic/foundation": "3.63.0", + "wattpm": "3.63.0" } }, "postcss": { @@ -29303,15 +28974,15 @@ } }, "wattpm": { - "version": "3.62.2", - "resolved": "https://registry.npmjs.org/wattpm/-/wattpm-3.62.2.tgz", - "integrity": "sha512-YAjsW5y5/t64vjVGqkALrxYBhHCDWor2NO24wVZF/BNyxYxhneK6TLSxnmsf+6jK+Bxyk73atOxCNqP6y1cXyg==", + "version": "3.63.0", + "resolved": "https://registry.npmjs.org/wattpm/-/wattpm-3.63.0.tgz", + "integrity": "sha512-bkubW624xbuWY7I3DsptpeTjsroeurqUE5NbYukjK16y76EKtPUef4BMV98yqLR3Ane85KWxXvP63+n6duGImA==", "requires": { "@fastify/websocket": "^11.0.0", - "@platformatic/control": "3.62.2", - "@platformatic/foundation": "3.62.2", - "@platformatic/globals": "3.62.2", - "@platformatic/runtime": "3.62.2", + "@platformatic/control": "3.63.0", + "@platformatic/foundation": "3.63.0", + "@platformatic/globals": "3.63.0", + "@platformatic/runtime": "3.63.0", "colorette": "^2.0.20", "pino-pretty": "^13.0.0", "split2": "^4.2.0", diff --git a/package.json b/package.json index 7b4ea3281..fdbed08fe 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:integration:coverage": "vitest run --coverage --config vitest.integration.config.ts", "acceptance": "tsx acceptance/scripts/run-managed-local.ts", + "acceptance:watt": "tsx acceptance/scripts/run-managed-local.ts --managed-runtime=watt", "acceptance:run": "tsx acceptance/scripts/run.ts", "acceptance:typecheck": "tsc -p acceptance/tsconfig.json --noEmit", "test": "npm run test:unit && npm run infra:restart && npm run test:dummy-data && npm run test:integration", @@ -75,11 +76,11 @@ "@opentelemetry/instrumentation-runtime-node": "^0.25.0", "@opentelemetry/sdk-metrics": "^2.6.1", "@opentelemetry/sdk-node": "^0.220.0", - "@platformatic/control": "^3.62.2", + "@platformatic/control": "^3.63.0", "@platformatic/flame": "^1.7.0", - "@platformatic/globals": "^3.62.2", - "@platformatic/node": "^3.62.2", - "@platformatic/wattpm-pprof-capture": "^3.62.2", + "@platformatic/globals": "^3.63.0", + "@platformatic/node": "^3.63.0", + "@platformatic/wattpm-pprof-capture": "^3.63.0", "@smithy/node-http-handler": "^2.3.1", "@tus/file-store": "2.1.0", "@tus/s3-store": "2.0.3", @@ -106,16 +107,17 @@ "pg-listen": "^1.7.0", "pino": "^10.3.1", "pino-logflare": "^0.5.2", - "platformatic": "^3.62.2", + "platformatic": "^3.63.0", "postgres-migrations": "^5.3.0", "pprof-format": "^2.2.1", "safe-stable-stringify": "^2.3.1", "undici": "^7.24.0", - "wattpm": "^3.62.2" + "wattpm": "^3.63.0" }, "devDependencies": { "@aws-sdk/s3-presigned-post": "^3.1023.0", "@biomejs/biome": "2.5.1", + "@platformatic/runtime": "^3.63.0", "@types/js-yaml": "^4.0.5", "@types/json-bigint": "^1.0.4", "@types/node": "^24.12.0", diff --git a/src/admin-app.test.ts b/src/admin-app.test.ts index 572e63ce1..fd3be4615 100644 --- a/src/admin-app.test.ts +++ b/src/admin-app.test.ts @@ -8,9 +8,14 @@ const tenantConfigUpdate = vi.hoisted(() => vi.fn()) const adminApiKey = 'test-admin-api-key' const originalServerAdminApiKeys = process.env.SERVER_ADMIN_API_KEYS -vi.mock('@platformatic/globals', () => ({ - getGlobal, -})) +vi.mock('@platformatic/globals', async () => { + const actual = + await vi.importActual('@platformatic/globals') + return { + ...actual, + getGlobal, + } +}) vi.mock('@internal/database', async () => { const actual = await vi.importActual('@internal/database') diff --git a/src/applications/database/application.integration.test.ts b/src/applications/database/application.integration.test.ts new file mode 100644 index 000000000..2a5b15c60 --- /dev/null +++ b/src/applications/database/application.integration.test.ts @@ -0,0 +1,424 @@ +import { randomUUID } from 'node:crypto' +import { removeGlobals } from '@platformatic/globals' +import { setupLoopbackMessaging } from '@platformatic/runtime' +import dotenv from 'dotenv' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { getConfig } from '../../config.js' +import type { DatabasePoolTarget } from './protocol.js' + +dotenv.config({ path: '.env.test', override: false }) +dotenv.config({ path: '.env', override: false }) + +type ApplicationContext = { + close: () => Promise + isBackgroundApplication: boolean +} + +type DatabaseWattStats = { + acquire: number + beginTransaction: number + cancel: number + commitTransaction: number + lockedQuery: number + query: number + release: number + rollbackTransaction: number +} + +type BucketResponse = { + id: string + name: string + public: boolean +} + +type RollbackResponse = { + bucketName: string +} + +type SavepointResponse = { + innerBucket: string + outerBucket: string +} + +type QueryRowsResponse = { + rows: Array<{ value: number }> +} + +type BucketRowsResponse = { + rows: BucketResponse[] +} + +type LockResponse = { + lockId: string +} + +type ErrorResponse = { + code: string + destination?: string + message: string + sqlState?: string +} + +let app: ApplicationContext | undefined +let messaging: ReturnType | undefined + +async function createDatabaseWattApp(): Promise { + const databaseApp = await import('./index.js') + return databaseApp.create() +} + +function testDestination(): DatabasePoolTarget { + const { databaseMaxConnections, databasePoolURL, databaseURL } = getConfig() + const connectionString = databasePoolURL || databaseURL + + if (!connectionString) { + throw new Error('Database Watt integration requires DATABASE_URL or DATABASE_POOL_URL') + } + + return { + connectionString, + id: process.env.ACCEPTANCE_TENANT_ID || process.env.TENANT_ID || 'default', + isExternalPool: Boolean(databasePoolURL), + maxConnections: databaseMaxConnections, + } +} + +function uniqueBucketName(kind: string): string { + return `dbwatt-${kind}-${randomUUID().replaceAll('-', '').slice(0, 16)}`.slice(0, 63) +} + +function sendDatabaseMessage(message: string, payload: unknown): Promise { + if (!messaging) { + throw new Error('Database Watt loopback messaging is not initialized') + } + + return messaging.send('database', message, payload) as Promise +} + +function isDatabaseError(result: unknown): result is ErrorResponse { + return typeof result === 'object' && result !== null && 'code' in result && 'message' in result +} + +async function checkedResult(resultPromise: Promise): Promise { + const result = await resultPromise + if (isDatabaseError(result)) { + throw new Error(result.message) + } + return result as T +} + +async function beginTransaction(destination = testDestination()): Promise { + return checkedResult( + sendDatabaseMessage('database.beginTransaction', { + destination, + requestId: randomUUID(), + }) + ) +} + +async function queryDatabaseWatt(destination = testDestination()): Promise { + return sendDatabaseMessage('database.query', { + destination, + requestId: randomUUID(), + sql: 'SELECT 1 as value', + }) +} + +async function cleanupBucket(bucketName: string, destination = testDestination()): Promise { + const tx = await beginTransaction(destination) + + try { + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: `SELECT set_config('storage.allow_delete_query', 'true', true)`, + }) + ) + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: 'DELETE FROM storage.buckets WHERE id = $1', + values: [bucketName], + }) + ) + await checkedResult(sendDatabaseMessage('database.commitTransaction', { lockId: tx.lockId })) + } catch (error) { + await sendDatabaseMessage('database.rollbackTransaction', { lockId: tx.lockId }).catch( + () => undefined + ) + throw error + } +} + +async function getBucket( + bucketName: string, + destination = testDestination() +): Promise { + const result = await checkedResult( + sendDatabaseMessage('database.query', { + destination, + requestId: randomUUID(), + sql: 'SELECT id, name, public FROM storage.buckets WHERE id = $1', + values: [bucketName], + }) + ) + + return result.rows[0] +} + +async function insertBucket(bucketName: string, destination = testDestination()): Promise { + return sendDatabaseMessage('database.query', { + destination, + requestId: randomUUID(), + sql: `INSERT INTO storage.buckets (id, name, owner, public) VALUES ($1, $1, $2, false)`, + values: [bucketName, randomUUID()], + }) +} + +async function commitBucketDatabaseWatt(): Promise<{ bucketName: string }> { + const bucketName = uniqueBucketName('dbwatt-commit') + const tx = await beginTransaction() + + try { + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: `INSERT INTO storage.buckets (id, name, owner, public) VALUES ($1, $1, $2, false)`, + values: [bucketName, randomUUID()], + }) + ) + await checkedResult(sendDatabaseMessage('database.commitTransaction', { lockId: tx.lockId })) + return { bucketName } + } catch (error) { + await sendDatabaseMessage('database.rollbackTransaction', { lockId: tx.lockId }).catch( + () => undefined + ) + throw error + } +} + +async function rollbackDatabaseWatt(): Promise { + const bucketName = `db-watt-rollback-${Date.now()}` + const tx = await beginTransaction() + + try { + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: `INSERT INTO storage.buckets (id, name, owner, public) VALUES ($1, $1, $2, false)`, + values: [bucketName, randomUUID()], + }) + ) + await checkedResult(sendDatabaseMessage('database.rollbackTransaction', { lockId: tx.lockId })) + return { bucketName } + } catch (error) { + await sendDatabaseMessage('database.rollbackTransaction', { lockId: tx.lockId }).catch( + () => undefined + ) + throw error + } +} + +async function savepointDatabaseWatt(): Promise { + const innerBucket = `db-watt-savepoint-inner-${Date.now()}` + const outerBucket = `db-watt-savepoint-outer-${Date.now()}` + const tx = await beginTransaction() + + try { + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: `INSERT INTO storage.buckets (id, name, owner, public) VALUES ($1, $1, $2, false)`, + values: [outerBucket, randomUUID()], + }) + ) + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: 'SAVEPOINT database_watt_acceptance', + }) + ) + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: `INSERT INTO storage.buckets (id, name, owner, public) VALUES ($1, $1, $2, false)`, + values: [innerBucket, randomUUID()], + }) + ) + await checkedResult( + sendDatabaseMessage('database.lockedQuery', { + lockId: tx.lockId, + requestId: randomUUID(), + sql: 'ROLLBACK TO SAVEPOINT database_watt_acceptance', + }) + ) + await checkedResult(sendDatabaseMessage('database.commitTransaction', { lockId: tx.lockId })) + return { innerBucket, outerBucket } + } catch (error) { + await sendDatabaseMessage('database.rollbackTransaction', { lockId: tx.lockId }).catch( + () => undefined + ) + throw error + } +} + +async function sleepDatabaseWatt(): Promise { + const requestId = randomUUID() + const query = sendDatabaseMessage('database.query', { + destination: testDestination(), + requestId, + sql: 'SELECT pg_sleep(10)', + }) + + setTimeout(() => { + sendDatabaseMessage('database.cancel', { requestId }).catch(() => undefined) + }, 50).unref() + + return query +} + +async function concurrentQueriesDatabaseWatt(): Promise<{ count: number }> { + const results = await Promise.all( + Array.from({ length: 5 }, () => + sendDatabaseMessage('database.query', { + destination: testDestination(), + requestId: randomUUID(), + sql: 'SELECT 1 as value', + }) + ) + ) + const error = results.find(isDatabaseError) + + if (error) { + throw new Error(error.message) + } + + return { count: results.length } +} + +async function getDatabaseWattStats(): Promise { + return sendDatabaseMessage('database.test.stats', {}) +} + +async function resetDatabaseWattStats(): Promise { + await sendDatabaseMessage('database.test.resetStats', {}) +} + +describe('Database Watt application loopback integration', () => { + beforeAll(async () => { + messaging = setupLoopbackMessaging('database-watt-acceptance') + app = await createDatabaseWattApp() + }) + + beforeEach(async () => { + await resetDatabaseWattStats() + }) + + afterAll(async () => { + await app?.close() + app = undefined + messaging = undefined + removeGlobals(['messaging']) + }) + + it('executes stateless queries in the Database Watt worker', async () => { + const response = await queryDatabaseWatt() + const stats = await getDatabaseWattStats() + + expect(response.rows[0]).toEqual({ value: 1 }) + expect(stats.query).toBeGreaterThanOrEqual(1) + }) + + it('commits bucket changes through Database Watt transactions', async () => { + let bucketName: string | undefined + try { + const committed = await commitBucketDatabaseWatt() + bucketName = committed.bucketName + const bucket = await getBucket(bucketName) + const stats = await getDatabaseWattStats() + + expect(bucket).toMatchObject({ id: bucketName, name: bucketName, public: false }) + expect(stats.beginTransaction).toBeGreaterThanOrEqual(1) + expect(stats.lockedQuery).toBeGreaterThanOrEqual(1) + expect(stats.commitTransaction).toBeGreaterThanOrEqual(1) + } finally { + if (bucketName) { + await cleanupBucket(bucketName) + } + } + }) + + it('rolls back failed transaction work and leaves no partial bucket state', async () => { + const rollback = await rollbackDatabaseWatt() + const bucketName = rollback.bucketName + expect(bucketName).toBeTruthy() + + const bucket = await getBucket(bucketName) + const stats = await getDatabaseWattStats() + + expect(bucket).toBeUndefined() + expect(stats.rollbackTransaction).toBeGreaterThanOrEqual(1) + }) + + it('preserves nested savepoint semantics in Database Watt transactions', async () => { + const savepoint = await savepointDatabaseWatt() + const outerBucket = savepoint.outerBucket + const innerBucket = savepoint.innerBucket + + try { + expect(outerBucket).toBeTruthy() + expect(innerBucket).toBeTruthy() + + const outer = await getBucket(outerBucket) + const inner = await getBucket(innerBucket) + const stats = await getDatabaseWattStats() + + expect(outer?.id).toBe(outerBucket) + expect(inner).toBeUndefined() + expect(stats.lockedQuery).toBeGreaterThanOrEqual(3) + expect(stats.commitTransaction).toBeGreaterThanOrEqual(1) + } finally { + if (outerBucket) { + await cleanupBucket(outerBucket) + } + } + }) + + it('preserves PostgreSQL error mapping when Database Watt returns PostgreSQL errors', async () => { + const bucketName = uniqueBucketName('dbwatt-error') + + try { + await checkedResult(insertBucket(bucketName)) + const duplicate = (await insertBucket(bucketName)) as ErrorResponse + const stats = await getDatabaseWattStats() + + expect(duplicate).toMatchObject({ code: 'POSTGRES_ERROR', sqlState: '23505' }) + expect(stats.query).toBeGreaterThanOrEqual(2) + } finally { + await cleanupBucket(bucketName) + } + }) + + it('translates request aborts into Database Watt cancellation', async () => { + const response = await sleepDatabaseWatt() + const stats = await getDatabaseWattStats() + + expect(response).toMatchObject({ code: expect.any(String) }) + expect(stats.cancel).toBeGreaterThanOrEqual(1) + }) + + it('handles concurrent Database Watt query load', async () => { + const response = await concurrentQueriesDatabaseWatt() + const stats = await getDatabaseWattStats() + + expect(response).toEqual({ count: 5 }) + expect(stats.query).toBeGreaterThanOrEqual(5) + }) +}) diff --git a/src/applications/database/application.test.ts b/src/applications/database/application.test.ts new file mode 100644 index 000000000..0f728685c --- /dev/null +++ b/src/applications/database/application.test.ts @@ -0,0 +1,265 @@ +import { setupLoopbackMessaging } from '@platformatic/runtime' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseErrorResponse } from './errors.js' +import type { create } from './index.js' + +type MockClient = { + queries: Array<{ sql: string; values?: unknown[] }> + query: ReturnType + release: ReturnType +} + +type MockedEnvironment = { + app: ReturnType + messaging: ReturnType + clients: MockClient[] + pools: Array<{ + config: Record + ended: boolean + queries: Array<{ sql: string; values?: unknown[] }> + }> +} + +function createMockClient(rows: unknown[]): MockClient { + const client: MockClient = { + queries: [], + query: vi.fn(async (sql: string, values?: unknown[]) => { + client.queries.push({ sql, values }) + return { rowCount: rows.length, rows } + }), + release: vi.fn(), + } + + return client +} + +async function loadApp( + options: { env?: Record; queryRows?: unknown[] } = {} +): Promise { + vi.resetModules() + + const clients: MockClient[] = [] + const pools: MockedEnvironment['pools'] = [] + const queryRows = options.queryRows || [{ ok: true }] + + process.env = { + ...originalEnv, + AUTH_JWT_SECRET: 'test-secret', + DATABASE_URL: 'postgres://single-tenant', + MULTI_TENANT: 'false', + ...options.env, + } + + vi.doMock('pg', () => { + const types = { + getTypeParser: vi.fn(), + } + + class MockDatabaseError extends Error { + code?: string + } + + class MockPool { + totalCount = 0 + idleCount = 0 + waitingCount = 0 + ended = false + on = vi.fn() + queries: Array<{ sql: string; values?: unknown[] }> = [] + config: Record + + constructor(config: Record) { + this.config = config + pools.push(this) + } + + async connect() { + this.totalCount++ + const client = createMockClient(queryRows) + clients.push(client) + return client + } + + async end() { + this.ended = true + } + } + + return { + DatabaseError: MockDatabaseError, + Pool: MockPool, + types, + default: { types }, + } + }) + + vi.doMock('pg/lib/connection', () => ({ + default: class MockPgConnection {}, + })) + + // We need dynamic import due to the mocking of PostgreSQL modules above + const { create } = await import('./index.js') + + const messaging = setupLoopbackMessaging('db') + const app = create() + return { app, messaging, clients, pools } +} + +const originalEnv = { ...process.env } + +afterEach(async () => { + vi.doUnmock('pg') + vi.doUnmock('pg/lib/connection') + vi.resetModules() + process.env = { ...originalEnv } +}) + +describe('database Watt application messaging handlers', () => { + it('registers prefixed handlers and exposes no server', async () => { + const { app } = await loadApp() + + expect(app.isBackgroundApplication).toBe(true) + }) + + it('executes stateless queries against the single-tenant destination', async () => { + const { messaging, clients } = await loadApp() + + const response = await messaging.send('foo', 'database.query', { + destination: createDestination(), + requestId: 'req-1', + sql: 'SELECT 1', + values: [1], + }) + + expect(response).toEqual({ rowCount: 1, rows: [{ ok: true }] }) + expect(clients[0].query).toHaveBeenCalledWith('SELECT 1', [1]) + expect(clients[0].release).toHaveBeenCalledWith(undefined) + }) + + it('uses caller-resolved external pool settings', async () => { + const { messaging, pools } = await loadApp() + + await messaging.send('database', 'database.query', { + destination: createDestination({ + connectionString: 'postgres://pooler', + isExternalPool: true, + }), + sql: 'SELECT 1', + }) + + expect(pools[0].config).toMatchObject({ connectionString: 'postgres://pooler' }) + }) + + it('returns validation errors from malformed requests', async () => { + const { messaging } = await loadApp() + + const response = (await messaging.send('database', 'database.query', { + destination: createDestination({ id: '' }), + sql: 'SELECT 1', + })) as DatabaseErrorResponse + + expect(response).toMatchObject({ + code: 'PROTOCOL_ERROR', + message: 'destination.id must be a non-empty string', + }) + }) + + it('acquires and releases pinned connections', async () => { + const { clients, messaging } = await loadApp() + + const acquire = (await messaging.send('database', 'database.acquire', { + destination: createDestination(), + })) as { lockId: string } + + expect(acquire.lockId).toBeTruthy() + + const release = await messaging.send('database', 'database.release', { lockId: acquire.lockId }) + + expect(release).toEqual({ released: true }) + expect(clients[0].release).toHaveBeenCalledWith(undefined) + }) + + it('runs transaction lifecycle on one pinned connection', async () => { + const { clients, messaging } = await loadApp() + + const begin = (await messaging.send('database', 'database.beginTransaction', { + destination: createDestination(), + isolationLevel: 'serializable', + readOnly: true, + })) as { lockId: string } + + expect(begin.lockId).toBeTruthy() + + await messaging.send('database', 'database.lockedQuery', { + lockId: begin.lockId, + sql: 'SELECT 1', + }) + const commit = await messaging.send('database', 'database.commitTransaction', { + lockId: begin.lockId, + }) + + expect(commit).toEqual({ committed: true }) + expect(clients[0].queries.map((query) => query.sql)).toEqual([ + 'BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY', + `SELECT set_config('statement_timeout', $1, true)`, + 'SELECT 1', + 'COMMIT', + ]) + expect(clients[0].release).toHaveBeenCalledWith(undefined) + }) + + it('replaces a physical pool when Storage sends changed connection details', async () => { + const { messaging, pools } = await loadApp() + + await messaging.send('database', 'database.query', { + destination: createDestination({ connectionString: 'postgres://tenant-db-a' }), + sql: 'SELECT 1', + }) + await messaging.send('database', 'database.query', { + destination: createDestination({ connectionString: 'postgres://tenant-db-b' }), + sql: 'SELECT 1', + }) + + expect(pools).toHaveLength(2) + expect(pools[0].ended).toBe(true) + expect(pools[1].config).toMatchObject({ connectionString: 'postgres://tenant-db-b' }) + }) + + it('closes pools and rejects new work after shutdown starts', async () => { + const { app, messaging, pools } = await loadApp() + + await messaging.send('database', 'database.query', { + destination: createDestination(), + sql: 'SELECT 1', + }) + await app.close() + + const response = (await messaging.send('database', 'database.query', { + destination: createDestination(), + sql: 'SELECT 1', + })) as DatabaseErrorResponse + + expect(pools[0].ended).toBe(true) + expect(response).toMatchObject({ + code: 'SHUTDOWN', + message: 'Database application is shutting down', + }) + }) +}) + +function createDestination( + overrides: Partial<{ + connectionString: string + id: string + isExternalPool: boolean + maxConnections: number + }> = {} +) { + return { + connectionString: 'postgres://tenant-db', + id: 'tenant-a', + isExternalPool: false, + maxConnections: 10, + ...overrides, + } +} diff --git a/src/applications/database/application.ts b/src/applications/database/application.ts new file mode 100644 index 000000000..0b36d31f4 --- /dev/null +++ b/src/applications/database/application.ts @@ -0,0 +1,332 @@ +import { normalizeIsolationLevel } from '@internal/database/postgres/sql.js' +import { getMessaging } from '@platformatic/globals' +import { getConfig, type StorageConfigType } from '../../config.js' +import { CancellationRegistry } from './cancellation.js' +import { DatabaseWattError, type ErrorContext, toErrorResponse } from './errors.js' +import { LockRegistry } from './locks.js' +import { registerDatabaseWattMetrics } from './metrics.js' +import { PoolRegistry, runQuery } from './pools.js' +import { + type AcquireConnectionRequest, + type BeginTransactionRequest, + type CancelRequest, + type CommitTransactionRequest, + DATABASE_MESSAGES, + type LockedQueryRequest, + type QueryRequest, + type ReleaseConnectionRequest, + type RollbackTransactionRequest, +} from './protocol.js' +import { + validateCancelRequest, + validateLockRequestEnvelope, + validateNonLockRequestEnvelope, + validateQueryEnvelope, +} from './validation.js' + +export class Application { + #config: StorageConfigType + #pools: PoolRegistry + #locks: LockRegistry + #cancellations: CancellationRegistry + #shuttingDown: boolean + #stats: Record + + constructor() { + this.#config = getConfig() + this.#pools = new PoolRegistry(this.#config) + this.#locks = new LockRegistry(this.#config) + this.#cancellations = new CancellationRegistry() + this.#shuttingDown = false + this.#stats = { + acquire: 0, + beginTransaction: 0, + cancel: 0, + commitTransaction: 0, + lockedQuery: 0, + query: 0, + release: 0, + rollbackTransaction: 0, + } + + this.#registerHandlers() + registerDatabaseWattMetrics(this.#pools) + } + + close(): Promise { + this.#shuttingDown = true + + return this.#withShutdownTimeout( + Promise.allSettled([this.#locks.close(), this.#pools.close()]).then(() => undefined), + this.#config.databaseWattShutdownTimeout + ) + } + + #registerHandlers(): void { + const messaging = getMessaging({ throwOnMissing: false }) + + if (!messaging) { + return + } + + messaging.handle(DATABASE_MESSAGES.query, this.#handleQuery.bind(this)) + messaging.handle(DATABASE_MESSAGES.acquire, this.#handleAcquire.bind(this)) + messaging.handle(DATABASE_MESSAGES.lockedQuery, this.#handleLockedQuery.bind(this)) + messaging.handle(DATABASE_MESSAGES.release, this.#handleRelease.bind(this)) + messaging.handle(DATABASE_MESSAGES.beginTransaction, this.#handleBeginTransaction.bind(this)) + messaging.handle(DATABASE_MESSAGES.commitTransaction, this.#handleCommitTransaction.bind(this)) + messaging.handle( + DATABASE_MESSAGES.rollbackTransaction, + this.#handleRollbackTransaction.bind(this) + ) + messaging.handle(DATABASE_MESSAGES.cancel, this.#handleCancel.bind(this)) + messaging.handle('database.test.stats', () => ({ ...this.#stats })) + messaging.handle('database.test.resetStats', this.#handleResetStats.bind(this)) + } + + async #handleQuery(rawRequest: unknown): Promise { + this.#stats.query++ + let request: QueryRequest | undefined + let cancellationRequestId: string | undefined + + try { + validateNonLockRequestEnvelope(rawRequest) + validateQueryEnvelope(rawRequest) + request = rawRequest as QueryRequest + + this.#assertAcceptingWork() + this.#cancellations.start(request.requestId, { cancelled: false }) + cancellationRequestId = request.requestId + + const response = await this.#pools.query( + request.destination, + request.sql, + request.values, + (client) => this.#cancellations.setClient(request?.requestId, client) + ) + return response + } catch (error) { + return toErrorResponse(error, request ? this.#withDestinationContext(request) : undefined) + } finally { + this.#cancellations.finish(cancellationRequestId) + } + } + + async #handleAcquire(rawRequest: unknown): Promise { + this.#stats.acquire++ + let request: AcquireConnectionRequest | undefined + + try { + validateNonLockRequestEnvelope(rawRequest) + request = rawRequest as AcquireConnectionRequest + + this.#assertAcceptingWork() + const client = await this.#pools.acquire(request.destination) + return { lockId: this.#locks.create(request.destination, client) } + } catch (error) { + return toErrorResponse(error, request ? this.#withDestinationContext(request) : undefined) + } + } + + async #handleLockedQuery(rawRequest: unknown): Promise { + this.#stats.lockedQuery++ + let request: LockedQueryRequest | undefined + let context: LockedQueryRequest | (LockedQueryRequest & { destination?: string }) | undefined + let cancellationRequestId: string | undefined + + try { + validateLockRequestEnvelope(rawRequest) + validateQueryEnvelope(rawRequest) + request = rawRequest as LockedQueryRequest + context = request + context = this.#withLockContext(request) + + this.#assertAcceptingWork() + this.#cancellations.start(request.requestId, { cancelled: false, lockId: request.lockId }) + cancellationRequestId = request.requestId + + this.#cancellations.setClient(request.requestId, this.#locks.getClient(request.lockId)) + const response = await this.#locks.query(request.lockId, request.sql, request.values) + return response + } catch (error) { + return toErrorResponse(error, context ?? undefined) + } finally { + this.#cancellations.finish(cancellationRequestId) + } + } + + async #handleRelease(rawRequest: unknown): Promise { + this.#stats.release++ + let request: ReleaseConnectionRequest | undefined + let context: + | ReleaseConnectionRequest + | (ReleaseConnectionRequest & { destination?: string }) + | undefined + + try { + validateLockRequestEnvelope(rawRequest) + request = rawRequest as ReleaseConnectionRequest + context = request + context = this.#withLockContext(request) + + await this.#locks.release(request.lockId) + return { released: true } + } catch (error) { + return toErrorResponse(error, context ?? undefined) + } + } + + async #handleBeginTransaction(rawRequest: unknown): Promise { + this.#stats.beginTransaction++ + let request: BeginTransactionRequest | undefined + + try { + validateNonLockRequestEnvelope(rawRequest) + request = rawRequest as BeginTransactionRequest + + this.#assertAcceptingWork() + const client = await this.#pools.acquire(request.destination) + let lockId: string | undefined + + try { + await runQuery(client, this.#buildBeginStatement(request)) + if (this.#config.databaseStatementTimeout > 0) { + await runQuery(client, `SELECT set_config('statement_timeout', $1, true)`, [ + `${this.#config.databaseStatementTimeout}ms`, + ]) + } + lockId = this.#locks.create(request.destination, client, true) + return { lockId } + } catch (error) { + client.release(error instanceof Error ? error : new Error(String(error))) + throw error + } + } catch (error) { + return toErrorResponse(error, request ? this.#withDestinationContext(request) : undefined) + } + } + + async #handleCommitTransaction(rawRequest: unknown): Promise { + this.#stats.commitTransaction++ + let request: CommitTransactionRequest | undefined + let context: + | CommitTransactionRequest + | (CommitTransactionRequest & { destination?: string }) + | undefined + + try { + validateLockRequestEnvelope(rawRequest) + request = rawRequest as CommitTransactionRequest + context = request + context = this.#withLockContext(request) + + await this.#locks.commit(request.lockId) + return { committed: true } + } catch (error) { + return toErrorResponse(error, context ?? undefined) + } + } + + async #handleRollbackTransaction(rawRequest: unknown): Promise { + this.#stats.rollbackTransaction++ + let request: RollbackTransactionRequest | undefined + let context: + | RollbackTransactionRequest + | (RollbackTransactionRequest & { destination?: string }) + | undefined + + try { + validateLockRequestEnvelope(rawRequest) + request = rawRequest as RollbackTransactionRequest + context = request + context = this.#withLockContext(request) + + await this.#locks.rollback(request.lockId) + return { rolledBack: true } + } catch (error) { + return toErrorResponse(error, context ?? undefined) + } + } + + async #handleCancel(rawRequest: unknown): Promise { + this.#stats.cancel++ + let request: CancelRequest | undefined + + try { + validateCancelRequest(rawRequest) + request = rawRequest as CancelRequest + + return this.#cancellations.cancel(request.requestId, request.lockId) + } catch (error) { + return toErrorResponse(error, request ?? undefined) + } + } + + #handleResetStats(): { reset: boolean } { + for (const key of Object.keys(this.#stats)) { + this.#stats[key] = 0 + } + + return { reset: true } + } + + #withLockContext( + request: T + ): T & { destination?: string } { + return { + ...request, + destination: this.#locks.getDestination(request.lockId), + } + } + + #withDestinationContext(request: QueryRequest | AcquireConnectionRequest): ErrorContext { + return { + destination: request.destination.id, + operationName: request.operationName, + requestId: request.requestId, + } + } + + #assertAcceptingWork(): void { + if (this.#shuttingDown) { + throw new DatabaseWattError('SHUTDOWN', 'Database application is shutting down') + } + } + + #buildBeginStatement(request: BeginTransactionRequest): string { + const modes: string[] = [] + const isolationLevel = normalizeIsolationLevel(request.isolationLevel) + + if (isolationLevel) { + modes.push(`ISOLATION LEVEL ${isolationLevel}`) + } + + if (request.readOnly) { + modes.push('READ ONLY') + } + + if (modes.length === 0) { + return 'BEGIN' + } + + return `BEGIN ${modes.join(', ')}` + } + + async #withShutdownTimeout(promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new DatabaseWattError('SHUTDOWN', 'Database application shutdown timed out')) + }, timeoutMs) + timeout.unref() + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } + } +} diff --git a/src/applications/database/cancellation.test.ts b/src/applications/database/cancellation.test.ts new file mode 100644 index 000000000..64d0223fb --- /dev/null +++ b/src/applications/database/cancellation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationRegistry } from './cancellation.js' + +describe('database cancellation registry', () => { + it('tracks missing and completed requests', async () => { + const registry = new CancellationRegistry() + + expect(await registry.cancel('missing')).toEqual({ cancelled: false }) + + registry.start('req-1', { cancelled: false }) + registry.finish('req-1') + + expect(await registry.cancel('req-1')).toEqual({ cancelled: false }) + }) + + it('marks an in-flight operation as cancelled', async () => { + const registry = new CancellationRegistry() + const operation = { cancelled: false, lockId: 'lock-a' } + + registry.start('req-1', operation) + + expect(await registry.cancel('req-1', 'lock-a')).toEqual({ cancelled: true }) + expect(operation.cancelled).toBe(true) + }) + + it('does not cancel operations for a different lock', async () => { + const registry = new CancellationRegistry() + const operation = { cancelled: false, lockId: 'lock-a' } + + registry.start('req-1', operation) + + expect(await registry.cancel('req-1', 'lock-b')).toEqual({ cancelled: false }) + expect(operation.cancelled).toBe(false) + }) + + it('sends PostgreSQL cancel when client backend identifiers are present', async () => { + vi.resetModules() + + const cancel = vi.fn() + const end = vi.fn() + const unref = vi.fn() + vi.doMock('pg/lib/connection', () => ({ + default: class MockPgConnection { + private callbacks = new Map void>() + cancel = cancel + end = end + unref = unref + on(event: string, callback: () => void) { + this.callbacks.set(event, callback) + } + connect() { + setImmediate(() => { + this.callbacks.get('connect')?.() + this.callbacks.get('end')?.() + }) + } + }, + })) + + const { CancellationRegistry: MockedCancellationRegistry } = await import('./cancellation.js') + const registry = new MockedCancellationRegistry() + registry.start('req-1', { cancelled: false }) + registry.setClient('req-1', { + connectionParameters: { host: 'localhost', port: 5432 }, + processID: 1, + secretKey: 2, + } as never) + + expect(await registry.cancel('req-1')).toEqual({ cancelled: true }) + expect(cancel).toHaveBeenCalledWith(1, 2) + expect(end).toHaveBeenCalled() + vi.doUnmock('pg/lib/connection') + }) +}) diff --git a/src/applications/database/cancellation.ts b/src/applications/database/cancellation.ts new file mode 100644 index 000000000..69b517578 --- /dev/null +++ b/src/applications/database/cancellation.ts @@ -0,0 +1,52 @@ +import { CancellableClient, cancelQuery } from '@internal/database/postgres/cancellation' + +export type InFlightOperation = { + client?: CancellableClient + lockId?: string + cancelled: boolean +} + +export class CancellationRegistry { + private readonly operations = new Map() + + start(requestId: string | undefined, operation: InFlightOperation): void { + if (requestId) { + this.operations.set(requestId, operation) + } + } + + setClient(requestId: string | undefined, client: CancellableClient): void { + if (!requestId) { + return + } + + const operation = this.operations.get(requestId) + if (operation) { + operation.client = client + } + } + + finish(requestId: string | undefined): void { + if (requestId) { + this.operations.delete(requestId) + } + } + + async cancel(requestId: string, lockId?: string): Promise<{ cancelled: boolean }> { + const operation = this.operations.get(requestId) + if (!operation) { + return { cancelled: false } + } + + if (lockId && operation.lockId && operation.lockId !== lockId) { + return { cancelled: false } + } + + operation.cancelled = true + if (operation.client) { + await cancelQuery(operation.client) + } + + return { cancelled: true } + } +} diff --git a/src/applications/database/errors.test.ts b/src/applications/database/errors.test.ts new file mode 100644 index 000000000..2007dd200 --- /dev/null +++ b/src/applications/database/errors.test.ts @@ -0,0 +1,45 @@ +import { DatabaseError } from 'pg' +import { describe, expect, it } from 'vitest' +import { DatabaseWattError, isErrorResponse, toErrorResponse } from './errors.js' + +describe('database error contract', () => { + it('serializes DatabaseWattError with safe context', () => { + const response = toErrorResponse(new DatabaseWattError('BUSY', 'queue full'), { + destination: 'tenant-a', + operationName: 'op', + requestId: 'req-1', + }) + + expect(response).toMatchObject({ + code: 'BUSY', + destination: 'tenant-a', + message: 'queue full', + operationName: 'op', + requestId: 'req-1', + }) + }) + + it('maps PostgreSQL errors to POSTGRES_ERROR and SQLSTATE', () => { + const error = new DatabaseError('duplicate key', 1, 'error') + error.code = '23505' + + const response = toErrorResponse(error, { lockId: 'lock-a' }) + + expect(response).toMatchObject({ + code: 'POSTGRES_ERROR', + lockId: 'lock-a', + message: 'duplicate key', + sqlState: '23505', + }) + }) + + it('maps unknown errors to MESSAGING_ERROR', () => { + const response = toErrorResponse(new Error('transport failed')) + + expect(response).toMatchObject({ + code: 'MESSAGING_ERROR', + message: 'transport failed', + }) + expect(isErrorResponse(response)).toBe(true) + }) +}) diff --git a/src/applications/database/errors.ts b/src/applications/database/errors.ts new file mode 100644 index 000000000..6580c6de6 --- /dev/null +++ b/src/applications/database/errors.ts @@ -0,0 +1,87 @@ +import { isConnectionTimeoutError } from '@internal/database/postgres/pool-errors' +import { DatabaseError } from 'pg' +import type { DatabaseErrorCode, DatabaseErrorResponse } from './protocol.js' + +export type { DatabaseErrorCode, DatabaseErrorResponse } from './protocol.js' + +export type ErrorContext = { + requestId?: string + operationName?: string + destination?: string + lockId?: string +} + +export class DatabaseWattError extends Error { + readonly code: DatabaseErrorCode + readonly connectionDiscarded?: boolean + readonly sqlState?: string + + constructor( + code: DatabaseErrorCode, + message: string, + options: { connectionDiscarded?: boolean; cause?: unknown; sqlState?: string } = {} + ) { + super(message, { cause: options.cause }) + this.name = 'DatabaseWattError' + this.code = code + this.connectionDiscarded = options.connectionDiscarded + this.sqlState = options.sqlState + } +} + +export function toErrorResponse(error: unknown, context: ErrorContext = {}): DatabaseErrorResponse { + if (error instanceof DatabaseWattError) { + return { + code: error.code, + message: error.message, + requestId: context.requestId, + operationName: context.operationName, + destination: context.destination, + lockId: context.lockId, + sqlState: error.sqlState, + stack: error.stack, + connectionDiscarded: error.connectionDiscarded, + } + } + + if (error instanceof DatabaseError) { + return { + code: 'POSTGRES_ERROR', + message: error.message, + requestId: context.requestId, + operationName: context.operationName, + destination: context.destination, + lockId: context.lockId, + sqlState: error.code, + stack: error.stack, + } + } + + if (isConnectionTimeoutError(error)) { + const connectionError = error as Error + return { + code: 'CONNECTION_TIMEOUT', + message: connectionError.message, + requestId: context.requestId, + operationName: context.operationName, + destination: context.destination, + lockId: context.lockId, + stack: connectionError.stack, + } + } + + const fallback = error instanceof Error ? error : new Error(String(error)) + return { + code: 'MESSAGING_ERROR', + message: fallback.message, + requestId: context.requestId, + operationName: context.operationName, + destination: context.destination, + lockId: context.lockId, + stack: fallback.stack, + } +} + +export function isErrorResponse(value: unknown): boolean { + return Boolean(value && typeof value === 'object' && 'code' in value && 'message' in value) +} diff --git a/src/applications/database/index.ts b/src/applications/database/index.ts new file mode 100644 index 000000000..4b8556b60 --- /dev/null +++ b/src/applications/database/index.ts @@ -0,0 +1,11 @@ +import { Application } from './application' + +export function create() { + const app = new Application() + + return { + app, + isBackgroundApplication: true, + close: app.close.bind(app), + } +} diff --git a/src/applications/database/locks.test.ts b/src/applications/database/locks.test.ts new file mode 100644 index 000000000..c5d331526 --- /dev/null +++ b/src/applications/database/locks.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { getConfig } from '../../config.js' +import { DatabaseWattError } from './errors.js' +import { LockRegistry } from './locks.js' +import type { DatabasePoolTarget } from './protocol.js' + +type FakeClient = { + query: ReturnType + release: ReturnType +} + +function createDestination(): DatabasePoolTarget { + return { + connectionString: 'postgres://example', + id: 'tenant-a', + isExternalPool: false, + maxConnections: 10, + } +} + +function createClient(): FakeClient { + return { + query: vi.fn().mockResolvedValue({ rowCount: 1, rows: [{ ok: true }] }), + release: vi.fn(), + } +} + +describe('database lock registry', () => { + it('runs locked queries on the pinned client', async () => { + const locks = new LockRegistry(getConfig()) + const client = createClient() + const lockId = locks.create(createDestination(), client as never) + + const result = await locks.query(lockId, 'SELECT 1', [1]) + + expect(result).toEqual({ rowCount: 1, rows: [{ ok: true }] }) + expect(client.query).toHaveBeenCalledWith('SELECT 1', [1]) + expect(client.release).not.toHaveBeenCalled() + + await locks.release(lockId) + expect(client.release).toHaveBeenCalledTimes(1) + await locks.close() + }) + + it('rejects reuse after release', async () => { + const locks = new LockRegistry(getConfig()) + const client = createClient() + const lockId = locks.create(createDestination(), client as never) + + await locks.release(lockId) + + await expect(locks.query(lockId, 'SELECT 1')).rejects.toMatchObject({ + code: 'PROTOCOL_ERROR', + message: 'Unknown lock ID', + }) + await locks.close() + }) + + it('does not release transaction locks through release()', async () => { + const locks = new LockRegistry(getConfig()) + const client = createClient() + const lockId = locks.create(createDestination(), client as never, true) + + await expect(locks.release(lockId)).rejects.toBeInstanceOf(DatabaseWattError) + expect(client.release).not.toHaveBeenCalled() + + await locks.rollback(lockId) + expect(client.query).toHaveBeenCalledWith('ROLLBACK', undefined) + expect(client.release).toHaveBeenCalledTimes(1) + await locks.close() + }) + + it('commits transactions and removes terminal locks', async () => { + const locks = new LockRegistry(getConfig()) + const client = createClient() + const lockId = locks.create(createDestination(), client as never, true) + + await locks.commit(lockId) + + expect(client.query).toHaveBeenCalledWith('COMMIT', undefined) + expect(client.release).toHaveBeenCalledTimes(1) + await expect(locks.rollback(lockId)).rejects.toMatchObject({ code: 'PROTOCOL_ERROR' }) + await locks.close() + }) + + it('serializes lock-bound work for the same lock', async () => { + const locks = new LockRegistry(getConfig()) + const client = createClient() + const order: string[] = [] + let releaseFirstQuery!: () => void + + client.query.mockImplementationOnce( + () => + new Promise((resolve) => { + order.push('first-start') + releaseFirstQuery = () => { + order.push('first-end') + resolve({ rowCount: 1, rows: [] }) + } + }) + ) + client.query.mockImplementationOnce(async () => { + order.push('second-start') + return { rowCount: 1, rows: [] } + }) + + const lockId = locks.create(createDestination(), client as never) + const first = locks.query(lockId, 'SELECT 1') + const second = locks.query(lockId, 'SELECT 2') + + await new Promise((resolve) => setImmediate(resolve)) + expect(order).toEqual(['first-start']) + + releaseFirstQuery() + await Promise.all([first, second]) + + expect(order).toEqual(['first-start', 'first-end', 'second-start']) + await locks.release(lockId) + await locks.close() + }) +}) diff --git a/src/applications/database/locks.ts b/src/applications/database/locks.ts new file mode 100644 index 000000000..fdb806cb3 --- /dev/null +++ b/src/applications/database/locks.ts @@ -0,0 +1,220 @@ +import { randomBytes } from 'node:crypto' +import { isConnectionStateError } from '@internal/database/postgres/pool-errors' +import type { PoolClient, QueryResultRow } from 'pg' +import type { StorageConfigType } from '../../config.js' +import { DatabaseWattError } from './errors.js' +import { runQuery, toQueryResponse } from './pools.js' +import type { DatabasePoolTarget, QueryResponse } from './protocol.js' + +type LockRecord = { + busy: Promise + client: PoolClient + createdAt: number + destination: DatabasePoolTarget + inTransaction: boolean + lastUsedAt: number + lockId: string + terminal: boolean +} + +export class LockRegistry { + private readonly config: StorageConfigType + private readonly locks = new Map() + private readonly cleanupInterval: NodeJS.Timeout + + constructor(config: StorageConfigType) { + this.config = config + this.cleanupInterval = setInterval( + () => { + void this.expireLocks() + }, + Math.min(config.databaseWattLockIdleTimeout, config.databaseWattLockMaxLifetime, 10_000) + ) + this.cleanupInterval.unref() + } + + create(destination: DatabasePoolTarget, client: PoolClient, inTransaction = false): string { + const lockId = randomBytes(32).toString('base64url') + const now = Date.now() + this.locks.set(lockId, { + busy: Promise.resolve(), + client, + createdAt: now, + destination, + inTransaction, + lastUsedAt: now, + lockId, + terminal: false, + }) + return lockId + } + + getDestination(lockId: string): string | undefined { + return this.locks.get(lockId)?.destination.id + } + + getClient(lockId: string): PoolClient { + return this.getLock(lockId).client + } + + async query( + lockId: string, + sql: string, + values?: unknown[] + ): Promise> { + return this.withLock(lockId, async (lock) => { + const result = await runQuery(lock.client, sql, values) + return toQueryResponse(result) + }) + } + + async release(lockId: string): Promise { + await this.withLock(lockId, async (lock) => { + if (lock.inTransaction) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'Cannot release a transaction lock') + } + + this.remove(lock, undefined) + }) + } + + async markTransaction(lockId: string): Promise { + await this.withLock(lockId, async (lock) => { + lock.inTransaction = true + }) + } + + async commit(lockId: string): Promise { + await this.withLock(lockId, async (lock) => { + if (!lock.inTransaction) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'Lock is not in a transaction') + } + + let releaseError: Error | undefined + try { + await runQuery(lock.client, 'COMMIT') + } catch (error) { + releaseError = error instanceof Error ? error : new Error(String(error)) + throw error + } finally { + this.remove(lock, releaseError) + } + }) + } + + async rollback(lockId: string): Promise { + await this.withLock(lockId, async (lock) => { + if (!lock.inTransaction) { + this.remove(lock, undefined) + return + } + + let releaseError: Error | undefined + try { + await runQuery(lock.client, 'ROLLBACK') + } catch (error) { + releaseError = error instanceof Error ? error : new Error(String(error)) + throw error + } finally { + this.remove(lock, releaseError) + } + }) + } + + async purge(lockId: string, releaseError?: Error): Promise { + const lock = this.locks.get(lockId) + if (!lock) { + return + } + + await lock.busy.catch(() => undefined) + this.remove(lock, releaseError) + } + + async close(): Promise { + clearInterval(this.cleanupInterval) + const locks = [...this.locks.values()] + this.locks.clear() + + await Promise.allSettled( + locks.map(async (lock) => { + await lock.busy.catch(() => undefined) + if (lock.inTransaction) { + await runQuery(lock.client, 'ROLLBACK').catch(() => undefined) + } + lock.client.release() + }) + ) + } + + private async withLock(lockId: string, fn: (lock: LockRecord) => Promise): Promise { + const lock = this.getLock(lockId) + const work = lock.busy.then(async () => { + this.assertUsable(lock) + lock.lastUsedAt = Date.now() + + try { + return await fn(lock) + } catch (error) { + if (isConnectionStateError(error)) { + this.remove(lock, error instanceof Error ? error : new Error(String(error))) + } + throw error + } + }) + + lock.busy = work.catch(() => undefined) + return work + } + + private getLock(lockId: string): LockRecord { + const lock = this.locks.get(lockId) + if (!lock || lock.terminal) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'Unknown lock ID') + } + return lock + } + + private assertUsable(lock: LockRecord): void { + const now = Date.now() + if (now - lock.createdAt > this.config.databaseWattLockMaxLifetime) { + this.remove(lock, new Error('Lock maximum lifetime exceeded')) + throw new DatabaseWattError('PROTOCOL_ERROR', 'Unknown lock ID') + } + + if (now - lock.lastUsedAt > this.config.databaseWattLockIdleTimeout) { + this.remove(lock, new Error('Lock idle timeout exceeded')) + throw new DatabaseWattError('PROTOCOL_ERROR', 'Unknown lock ID') + } + } + + private remove(lock: LockRecord, releaseError: Error | undefined): void { + if (lock.terminal) { + return + } + + lock.terminal = true + this.locks.delete(lock.lockId) + lock.client.release(releaseError) + } + + private async expireLocks(): Promise { + const now = Date.now() + const expired = [...this.locks.values()].filter((lock) => { + return ( + now - lock.createdAt > this.config.databaseWattLockMaxLifetime || + now - lock.lastUsedAt > this.config.databaseWattLockIdleTimeout + ) + }) + + await Promise.allSettled( + expired.map(async (lock) => { + await lock.busy.catch(() => undefined) + if (lock.inTransaction) { + await runQuery(lock.client, 'ROLLBACK').catch(() => undefined) + } + this.remove(lock, undefined) + }) + ) + } +} diff --git a/src/applications/database/metrics.ts b/src/applications/database/metrics.ts new file mode 100644 index 000000000..d7e33c8f9 --- /dev/null +++ b/src/applications/database/metrics.ts @@ -0,0 +1,36 @@ +import { metrics } from '@opentelemetry/api' +import type { PoolRegistry } from './pools.js' + +let registered = false + +export function registerDatabaseWattMetrics(pools: PoolRegistry): void { + if (registered) { + return + } + + registered = true + const meter = metrics.getMeter('storage-database-watt') + const activePools = meter.createObservableGauge('database_watt_active_pools', { + description: 'Current number of active Database Watt destination pools', + }) + const totalConnections = meter.createObservableGauge('database_watt_connections', { + description: 'Current number of Database Watt PostgreSQL connections', + }) + const inUseConnections = meter.createObservableGauge('database_watt_connections_in_use', { + description: 'Current number of Database Watt PostgreSQL connections in use', + }) + const waitingRequests = meter.createObservableGauge('database_watt_waiting_requests', { + description: 'Current number of Database Watt requests waiting for PostgreSQL connections', + }) + + meter.addBatchObservableCallback( + (observer) => { + const stats = pools.getStats() + observer.observe(activePools, stats.pools) + observer.observe(totalConnections, stats.totalConnections) + observer.observe(inUseConnections, stats.inUseConnections) + observer.observe(waitingRequests, stats.waitingRequests) + }, + [activePools, totalConnections, inUseConnections, waitingRequests] + ) +} diff --git a/src/applications/database/pools.ts b/src/applications/database/pools.ts new file mode 100644 index 000000000..150aab064 --- /dev/null +++ b/src/applications/database/pools.ts @@ -0,0 +1,279 @@ +import { + attachPoolErrorHandler, + isConnectionStateError, +} from '@internal/database/postgres/pool-errors' +import { getSslSettings } from '@internal/database/postgres/ssl' +import { createPostgresTypeParsers } from '@internal/database/postgres/type-parsers' +import { getLogger } from '@platformatic/globals' +import { Pool, type PoolClient, type QueryResult, type QueryResultRow } from 'pg' +import type { StorageConfigType } from '../../config.js' +import { DatabaseWattError } from './errors.js' +import type { DatabasePoolTarget, QueryResponse } from './protocol.js' + +type PoolEntry = { + config: DatabasePoolTarget + lastUsedAt: number + pool: Pool +} + +export type PoolRegistryStats = { + pools: number + totalConnections: number + inUseConnections: number + waitingRequests: number +} + +export class PoolRegistry { + private readonly config: StorageConfigType + private readonly pools = new Map() + private readonly retiringPools = new Set>() + private pendingGlobalAcquisitions = 0 + private cachedStats?: PoolRegistryStats + private readonly evictionInterval: NodeJS.Timeout + + constructor(config: StorageConfigType) { + this.config = config + this.evictionInterval = setInterval( + () => { + void this.evictIdlePools() + }, + Math.max(config.databaseWattPoolIdleTimeout, 1_000) + ) + this.evictionInterval.unref() + } + + async query( + destination: DatabasePoolTarget, + sql: string, + values: unknown[] | undefined, + onClient?: (client: PoolClient) => void + ): Promise> { + const client = await this.acquire(destination) + let releaseError: Error | undefined + + try { + onClient?.(client) + const result = await runQuery(client, sql, values) + return toQueryResponse(result) + } catch (error) { + if (isConnectionStateError(error)) { + releaseError = error instanceof Error ? error : new Error(String(error)) + } + throw error + } finally { + client.release(releaseError) + } + } + + async acquire(destination: DatabasePoolTarget): Promise { + const entry = this.getOrCreatePool(destination) + this.assertCanAcquire(entry) + this.pendingGlobalAcquisitions++ + + const timeout = createTimeout(this.config.databaseWattAcquireTimeout) + try { + return await Promise.race([ + entry.pool.connect(), + timeout.promise.then(() => { + throw new DatabaseWattError('ACQUIRE_TIMEOUT', 'Timed out acquiring database connection') + }), + ]) + } finally { + timeout.clear() + this.pendingGlobalAcquisitions-- + entry.lastUsedAt = Date.now() + } + } + + getStats(): PoolRegistryStats { + if (this.cachedStats) { + return this.cachedStats + } + + let inUseConnections = 0 + let totalConnections = 0 + let waitingRequests = 0 + + for (const entry of this.pools.values()) { + inUseConnections += Math.max(entry.pool.totalCount - entry.pool.idleCount, 0) + totalConnections += entry.pool.totalCount + waitingRequests += entry.pool.waitingCount + } + + const stats = { + inUseConnections, + pools: this.pools.size, + totalConnections, + waitingRequests, + } + this.cachedStats = stats + queueMicrotask(() => { + this.cachedStats = undefined + }) + + return stats + } + + async close(): Promise { + clearInterval(this.evictionInterval) + const pools = [...this.pools.values()].map((entry) => entry.pool) + this.pools.clear() + this.cachedStats = undefined + await Promise.allSettled([...pools.map((pool) => pool.end()), ...this.retiringPools]) + } + + private getOrCreatePool(destination: DatabasePoolTarget): PoolEntry { + const existing = this.pools.get(destination.id) + if (existing && hasSamePoolConfig(existing.config, destination)) { + existing.lastUsedAt = Date.now() + return existing + } + + if (!existing && this.pools.size >= this.config.databaseWattMaxActivePools) { + throw new DatabaseWattError('BUSY', 'Maximum active destination pools reached') + } + + const maxConnections = Math.max( + Math.min(destination.maxConnections, this.config.databaseWattDestinationMaxConnections), + 1 + ) + const pool = attachPoolErrorHandler( + new Pool({ + application_name: this.config.databaseApplicationName, + connectionString: destination.connectionString, + connectionTimeoutMillis: this.config.databaseConnectionTimeout, + idleTimeoutMillis: this.config.databaseWattPoolIdleTimeout, + max: maxConnections, + min: 0, + ssl: getSslSettings({ + connectionString: destination.connectionString, + databaseSSLRootCert: this.config.databaseSSLRootCert, + }), + types: createPostgresTypeParsers(), + }), + (error) => { + getLogger({ throwOnMissing: false })?.warn( + { + err: error, + type: 'db', + tenantId: destination.id, + project: destination.id, + }, + '[DatabaseWatt] Idle destination pg client error' + ) + } + ) + + const entry = { + config: destination, + lastUsedAt: Date.now(), + pool, + } + + if (existing) { + this.retirePool(existing.pool) + } + + this.pools.set(destination.id, entry) + this.cachedStats = undefined + return entry + } + + private assertCanAcquire(entry: PoolEntry): void { + const stats = this.getStats() + + if (this.pendingGlobalAcquisitions >= this.config.databaseWattGlobalAcquireQueueLimit) { + throw new DatabaseWattError('BUSY', 'Global acquisition queue is full') + } + + if (entry.pool.waitingCount >= this.config.databaseWattDestinationAcquireQueueLimit) { + throw new DatabaseWattError('BUSY', 'Destination acquisition queue is full') + } + + const targetHasIdleConnection = entry.pool.idleCount > 0 + if ( + !targetHasIdleConnection && + stats.totalConnections >= this.config.databaseWattGlobalMaxConnections + ) { + throw new DatabaseWattError('BUSY', 'Global connection budget is exhausted') + } + } + + private async evictIdlePools(): Promise { + const now = Date.now() + const toEvict: Pool[] = [] + + for (const [destination, entry] of this.pools) { + if (now - entry.lastUsedAt < this.config.databaseWattPoolIdleTimeout) { + continue + } + + if (entry.pool.totalCount !== entry.pool.idleCount || entry.pool.waitingCount > 0) { + continue + } + + this.pools.delete(destination) + this.cachedStats = undefined + toEvict.push(entry.pool) + } + + await Promise.allSettled(toEvict.map((pool) => pool.end())) + } + + private retirePool(pool: Pool): void { + const retirement = pool + .end() + .catch((error) => { + getLogger({ throwOnMissing: false })?.warn( + { err: error, type: 'db' }, + '[DatabaseWatt] Failed to retire replaced destination pool' + ) + }) + .finally(() => { + this.retiringPools.delete(retirement) + }) + this.retiringPools.add(retirement) + } +} + +function hasSamePoolConfig(left: DatabasePoolTarget, right: DatabasePoolTarget): boolean { + return ( + left.connectionString === right.connectionString && + left.isExternalPool === right.isExternalPool && + left.maxConnections === right.maxConnections + ) +} + +export async function runQuery( + client: PoolClient, + sql: string, + values?: unknown[] +): Promise> { + return client.query(sql, values) +} + +export function toQueryResponse( + result: QueryResult +): QueryResponse { + return { + rows: result.rows, + rowCount: result.rowCount || 0, + } +} + +function createTimeout(timeoutMs: number): { clear: () => void; promise: Promise } { + let timeout: NodeJS.Timeout | undefined + const promise = new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs) + timeout.unref() + }) + + return { + clear: () => { + if (timeout) { + clearTimeout(timeout) + } + }, + promise, + } +} diff --git a/src/applications/database/protocol.ts b/src/applications/database/protocol.ts new file mode 100644 index 000000000..a82754fbe --- /dev/null +++ b/src/applications/database/protocol.ts @@ -0,0 +1,141 @@ +export const DATABASE_APPLICATION_ID = 'database' + +export const DATABASE_MESSAGES = { + acquire: 'database.acquire', + beginTransaction: 'database.beginTransaction', + cancel: 'database.cancel', + commitTransaction: 'database.commitTransaction', + lockedQuery: 'database.lockedQuery', + query: 'database.query', + release: 'database.release', + rollbackTransaction: 'database.rollbackTransaction', +} as const + +export type DatabaseErrorCode = + | 'POSTGRES_ERROR' + | 'DESTINATION_UNKNOWN' + | 'CLIENT_TIMEOUT' + | 'SERVER_TIMEOUT' + | 'CONNECTION_TIMEOUT' + | 'ACQUIRE_TIMEOUT' + | 'MESSAGING_TIMEOUT' + | 'MESSAGING_ERROR' + | 'BUSY' + | 'RESULT_TOO_LARGE' + | 'PROTOCOL_ERROR' + | 'SHUTDOWN' + +export type DatabaseErrorResponse = { + code: DatabaseErrorCode + message: string + requestId?: string + operationName?: string + destination?: string + lockId?: string + sqlState?: string + stack?: string + connectionDiscarded?: boolean +} + +export type WireRequestMeta = { + requestId?: string + operationName?: string +} + +export type DatabasePoolTarget = { + connectionString: string + id: string + isExternalPool: boolean + maxConnections: number +} + +export type QueryRequest = WireRequestMeta & { + destination: DatabasePoolTarget + sql: string + values?: unknown[] +} + +export type AcquireConnectionRequest = WireRequestMeta & { + destination: DatabasePoolTarget +} + +export type AcquireConnectionResponse = { + lockId: string +} + +export type LockedQueryRequest = WireRequestMeta & { + lockId: string + sql: string + values?: unknown[] +} + +export type ReleaseConnectionRequest = WireRequestMeta & { + lockId: string +} + +export type BeginTransactionRequest = WireRequestMeta & { + destination: DatabasePoolTarget + isolationLevel?: string + readOnly?: boolean +} + +export type CommitTransactionRequest = WireRequestMeta & { + lockId: string +} + +export type RollbackTransactionRequest = WireRequestMeta & { + lockId: string +} + +export type CancelRequest = { + requestId: string + lockId?: string +} + +export type QueryResponse = { + rows: T[] + rowCount: number +} + +export type DatabaseProtocol = { + [DATABASE_MESSAGES.acquire]: { + request: AcquireConnectionRequest + response: AcquireConnectionResponse + } + [DATABASE_MESSAGES.beginTransaction]: { + request: BeginTransactionRequest + response: AcquireConnectionResponse + } + [DATABASE_MESSAGES.cancel]: { + request: CancelRequest + response: { cancelled: boolean } + } + [DATABASE_MESSAGES.commitTransaction]: { + request: CommitTransactionRequest + response: { committed: boolean } + } + [DATABASE_MESSAGES.lockedQuery]: { + request: LockedQueryRequest + response: QueryResponse + } + [DATABASE_MESSAGES.query]: { + request: QueryRequest + response: QueryResponse + } + [DATABASE_MESSAGES.release]: { + request: ReleaseConnectionRequest + response: { released: boolean } + } + [DATABASE_MESSAGES.rollbackTransaction]: { + request: RollbackTransactionRequest + response: { rolledBack: boolean } + } +} + +export type DatabaseMessageName = keyof DatabaseProtocol + +export type DatabaseMessageRequest = + DatabaseProtocol[Message]['request'] + +export type DatabaseMessageResponse = + DatabaseProtocol[Message]['response'] diff --git a/src/applications/database/validation.test.ts b/src/applications/database/validation.test.ts new file mode 100644 index 000000000..85c0efc99 --- /dev/null +++ b/src/applications/database/validation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { DatabaseWattError } from './errors.js' +import { + validateCancelRequest, + validateLockRequestEnvelope, + validateNonLockRequestEnvelope, + validateQueryEnvelope, +} from './validation.js' + +describe('database request validation', () => { + it('accepts valid stateless query envelopes', () => { + const request = { + destination: createDestination(), + operationName: 'select', + requestId: 'req-1', + sql: 'SELECT 1', + values: [1], + } + + expect(() => validateNonLockRequestEnvelope(request)).not.toThrow() + expect(() => validateQueryEnvelope(request)).not.toThrow() + }) + + it('rejects missing destinations before query execution', () => { + expect(() => validateNonLockRequestEnvelope({ sql: 'SELECT 1' })).toThrow(DatabaseWattError) + }) + + it('rejects incomplete physical pool destinations', () => { + expect(() => + validateNonLockRequestEnvelope({ + destination: { id: 'tenant-a' }, + sql: 'SELECT 1', + }) + ).toThrow(/connectionString/) + }) + + it('rejects missing lock identifiers for lock-bound requests', () => { + expect(() => validateLockRequestEnvelope({ sql: 'SELECT 1' })).toThrow(/lockId/) + }) + + it('rejects invalid SQL and parameter payloads', () => { + expect(() => validateQueryEnvelope({ sql: 1 })).toThrow(/sql/) + expect(() => validateQueryEnvelope({ sql: 'SELECT 1', values: 'bad' })).toThrow(/values/) + }) + + it('validates cancellation requests', () => { + expect(() => validateCancelRequest({ requestId: 'req-1' })).not.toThrow() + expect(() => validateCancelRequest({})).toThrow(/requestId/) + }) +}) + +function createDestination() { + return { + connectionString: 'postgres://tenant-db', + id: 'tenant-a', + isExternalPool: false, + maxConnections: 10, + } +} diff --git a/src/applications/database/validation.ts b/src/applications/database/validation.ts new file mode 100644 index 000000000..949eabccc --- /dev/null +++ b/src/applications/database/validation.ts @@ -0,0 +1,74 @@ +import { DatabaseWattError } from './errors.js' + +export function validateNonLockRequestEnvelope(request: unknown): void { + validateBaseEnvelope(request) + const destination = (request as { destination?: unknown }).destination + if (!destination || typeof destination !== 'object') { + throw new DatabaseWattError('PROTOCOL_ERROR', 'destination must be an object') + } + + const { connectionString, id, isExternalPool, maxConnections } = destination as Record< + string, + unknown + > + + if (typeof id !== 'string' || id.length === 0) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'destination.id must be a non-empty string') + } + + if (typeof connectionString !== 'string' || connectionString.length === 0) { + throw new DatabaseWattError( + 'PROTOCOL_ERROR', + 'destination.connectionString must be a non-empty string' + ) + } + + if (typeof isExternalPool !== 'boolean') { + throw new DatabaseWattError('PROTOCOL_ERROR', 'destination.isExternalPool must be a boolean') + } + + if (!Number.isInteger(maxConnections) || (maxConnections as number) < 0) { + throw new DatabaseWattError( + 'PROTOCOL_ERROR', + 'destination.maxConnections must be a non-negative integer' + ) + } +} + +export function validateLockRequestEnvelope(request: unknown): void { + validateBaseEnvelope(request) + const lockId = (request as { lockId?: unknown }).lockId + if (typeof lockId !== 'string' || lockId.length === 0) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'lockId must be a non-empty string') + } +} + +export function validateQueryEnvelope(request: unknown): void { + const sql = (request as { sql?: unknown }).sql + const values = (request as { values?: unknown }).values + + if (typeof sql !== 'string') { + throw new DatabaseWattError('PROTOCOL_ERROR', 'sql must be a string') + } + + if (values !== undefined && !Array.isArray(values)) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'values must be an array when present') + } +} + +export function validateCancelRequest(request: unknown): void { + if (!request || typeof request !== 'object') { + throw new DatabaseWattError('PROTOCOL_ERROR', 'request must be an object') + } + + const requestId = (request as { requestId?: unknown }).requestId + if (typeof requestId !== 'string' || requestId.length === 0) { + throw new DatabaseWattError('PROTOCOL_ERROR', 'requestId must be a non-empty string') + } +} + +function validateBaseEnvelope(request: unknown): void { + if (!request || typeof request !== 'object') { + throw new DatabaseWattError('PROTOCOL_ERROR', 'request must be an object') + } +} diff --git a/src/config.test.ts b/src/config.test.ts index 68f8df2cb..1ff0855c8 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -7,7 +7,20 @@ const CONFIG_ENV_KEYS = [ 'TENANT_POOL_CACHE_HIT_LOG_SAMPLE_RATE', 'TENANT_POOL_CACHE_MISS_LOG_SAMPLE_RATE', 'DATABASE_POOL_DRAIN_TIMEOUT', + 'DATABASE_FREE_POOL_AFTER_INACTIVITY', 'DATABASE_HEALTHCHECK_UNSCOPED', + 'DATABASE_MAX_CONNECTIONS', + 'DATABASE_WATT_APPLICATION_ENABLED', + 'DATABASE_WATT_ACQUIRE_TIMEOUT', + 'DATABASE_WATT_DESTINATION_ACQUIRE_QUEUE_LIMIT', + 'DATABASE_WATT_DESTINATION_MAX_CONNECTIONS', + 'DATABASE_WATT_GLOBAL_ACQUIRE_QUEUE_LIMIT', + 'DATABASE_WATT_GLOBAL_MAX_CONNECTIONS', + 'DATABASE_WATT_LOCK_IDLE_TIMEOUT', + 'DATABASE_WATT_LOCK_MAX_LIFETIME', + 'DATABASE_WATT_MAX_ACTIVE_POOLS', + 'DATABASE_WATT_POOL_IDLE_TIMEOUT', + 'DATABASE_WATT_SHUTDOWN_TIMEOUT', 'REQUEST_HARD_LIMITS_ENABLED', 'STORAGE_S3_REQUEST_CHECKSUM_CALCULATION', 'STORAGE_S3_RESPONSE_CHECKSUM_VALIDATION', @@ -23,6 +36,8 @@ function setConfigEnv(env: Partial>) { } process.env.MULTI_TENANT = 'true' + process.env.DATABASE_FREE_POOL_AFTER_INACTIVITY = '60000' + process.env.DATABASE_MAX_CONNECTIONS = '20' for (const [key, value] of Object.entries(env)) { process.env[key] = value @@ -135,6 +150,75 @@ describe('tenant pool cache config parsing', () => { expect(config.databaseHealthcheckUnscoped).toBe(true) }) + test('disables the Database Watt application by default', async () => { + setConfigEnv({}) + + const { getConfig } = await import('./config') + const config = getConfig({ reload: true }) + + expect(config.databaseWattApplicationEnabled).toBe(false) + }) + + test('enables the Database Watt application from env', async () => { + setConfigEnv({ DATABASE_WATT_APPLICATION_ENABLED: 'true' }) + + const { getConfig } = await import('./config') + const config = getConfig({ reload: true }) + + expect(config.databaseWattApplicationEnabled).toBe(true) + }) + + test('defaults Database Watt physical pool management settings', async () => { + setConfigEnv({}) + + const { getConfig } = await import('./config') + const config = getConfig({ reload: true }) + + expect(config).toMatchObject({ + databaseWattAcquireTimeout: 3_000, + databaseWattDestinationAcquireQueueLimit: 100, + databaseWattDestinationMaxConnections: 20, + databaseWattGlobalAcquireQueueLimit: 500, + databaseWattGlobalMaxConnections: 20, + databaseWattLockIdleTimeout: 30_000, + databaseWattLockMaxLifetime: 120_000, + databaseWattMaxActivePools: 1_000, + databaseWattPoolIdleTimeout: 60_000, + databaseWattShutdownTimeout: 10_000, + }) + }) + + test('parses Database Watt physical pool management settings', async () => { + setConfigEnv({ + DATABASE_WATT_ACQUIRE_TIMEOUT: '1001', + DATABASE_WATT_DESTINATION_ACQUIRE_QUEUE_LIMIT: '101', + DATABASE_WATT_DESTINATION_MAX_CONNECTIONS: '11', + DATABASE_WATT_GLOBAL_ACQUIRE_QUEUE_LIMIT: '501', + DATABASE_WATT_GLOBAL_MAX_CONNECTIONS: '51', + DATABASE_WATT_LOCK_IDLE_TIMEOUT: '30001', + DATABASE_WATT_LOCK_MAX_LIFETIME: '120001', + DATABASE_WATT_MAX_ACTIVE_POOLS: '1001', + DATABASE_WATT_POOL_IDLE_TIMEOUT: '60001', + DATABASE_WATT_SHUTDOWN_TIMEOUT: '10001', + }) + + const { getConfig } = await import('./config') + const config = getConfig({ reload: true }) + + expect(config).toMatchObject({ + databaseWattAcquireTimeout: 1_001, + databaseWattDestinationAcquireQueueLimit: 101, + databaseWattDestinationMaxConnections: 11, + databaseWattGlobalAcquireQueueLimit: 501, + databaseWattGlobalMaxConnections: 51, + databaseWattLockIdleTimeout: 30_001, + databaseWattLockMaxLifetime: 120_001, + databaseWattMaxActivePools: 1_001, + databaseWattPoolIdleTimeout: 60_001, + databaseWattShutdownTimeout: 10_001, + }) + }) + test.each([ '0', '-1', diff --git a/src/config.ts b/src/config.ts index 31709e11e..01c55bf2d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,7 +55,7 @@ export interface JwksConfig { urlSigningKey?: JwksConfigKeyOCT } -type StorageConfigType = { +export type StorageConfigType = { serviceName: string isProduction: boolean version: string @@ -113,6 +113,17 @@ type StorageConfigType = { databaseConnectionTimeout: number databaseEnableQueryCancellation: boolean databaseHealthcheckUnscoped: boolean + databaseWattApplicationEnabled: boolean + databaseWattAcquireTimeout: number + databaseWattDestinationAcquireQueueLimit: number + databaseWattDestinationMaxConnections: number + databaseWattGlobalAcquireQueueLimit: number + databaseWattGlobalMaxConnections: number + databaseWattLockIdleTimeout: number + databaseWattLockMaxLifetime: number + databaseWattMaxActivePools: number + databaseWattPoolIdleTimeout: number + databaseWattShutdownTimeout: number databaseStatementTimeout: number databaseApplicationName: string region: string @@ -507,6 +518,51 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType { getOptionalConfigFromEnv('DATABASE_ENABLE_QUERY_CANCELLATION') === 'true', databaseHealthcheckUnscoped: getOptionalConfigFromEnv('DATABASE_HEALTHCHECK_UNSCOPED') === 'true', + databaseWattApplicationEnabled: + getOptionalConfigFromEnv('DATABASE_WATT_APPLICATION_ENABLED') === 'true', + databaseWattAcquireTimeout: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_ACQUIRE_TIMEOUT'), + 3_000 + ), + databaseWattDestinationAcquireQueueLimit: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_DESTINATION_ACQUIRE_QUEUE_LIMIT'), + 100 + ), + databaseWattDestinationMaxConnections: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_DESTINATION_MAX_CONNECTIONS') || + getOptionalConfigFromEnv('DATABASE_MAX_CONNECTIONS'), + 20 + ), + databaseWattGlobalAcquireQueueLimit: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_GLOBAL_ACQUIRE_QUEUE_LIMIT'), + 500 + ), + databaseWattGlobalMaxConnections: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_GLOBAL_MAX_CONNECTIONS') || + getOptionalConfigFromEnv('DATABASE_MAX_CONNECTIONS'), + 20 + ), + databaseWattLockIdleTimeout: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_LOCK_IDLE_TIMEOUT'), + 30_000 + ), + databaseWattLockMaxLifetime: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_LOCK_MAX_LIFETIME'), + 120_000 + ), + databaseWattMaxActivePools: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_MAX_ACTIVE_POOLS'), + 1_000 + ), + databaseWattPoolIdleTimeout: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_POOL_IDLE_TIMEOUT') || + getOptionalConfigFromEnv('DATABASE_FREE_POOL_AFTER_INACTIVITY'), + 60_000 + ), + databaseWattShutdownTimeout: envNonNegativeInteger( + getOptionalConfigFromEnv('DATABASE_WATT_SHUTDOWN_TIMEOUT'), + 10_000 + ), databaseStatementTimeout: parseInt( getOptionalConfigFromEnv('DATABASE_STATEMENT_TIMEOUT') || '30000', 10 @@ -741,6 +797,15 @@ function envPositiveInteger(value: string | undefined, defaultValue: number): nu return parsed && parsed > 0 ? parsed : defaultValue } +function envNonNegativeInteger(value: string | undefined, defaultValue: number): number { + if (value === undefined || value === '') { + return defaultValue + } + + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? Math.max(parsed, 0) : defaultValue +} + function envSampleRate(value: string | undefined, defaultValue: number): number { if (!value) { return defaultValue diff --git a/src/http/plugins/vector.test.ts b/src/http/plugins/vector.test.ts index 4c341a52f..83f418806 100644 --- a/src/http/plugins/vector.test.ts +++ b/src/http/plugins/vector.test.ts @@ -51,7 +51,7 @@ function mockPgModule(): void { vi.doMock('pg', () => { const types = { - setTypeParser: vi.fn(), + getTypeParser: vi.fn(), } class DatabaseError extends Error {} diff --git a/src/http/plugins/vector.ts b/src/http/plugins/vector.ts index 06e2ec486..fcfb50e37 100644 --- a/src/http/plugins/vector.ts +++ b/src/http/plugins/vector.ts @@ -1,11 +1,13 @@ import { - attachPgPoolErrorHandler, + attachPoolErrorHandler, getTenantConfig, multitenantPgExecutor, PgPoolExecutor, } from '@internal/database' +import { createPostgresTypeParsers } from '@internal/database/postgres/type-parsers' import { deriveVectorDatabaseUrl } from '@internal/database/vector-store-url' import { ERRORS } from '@internal/errors' +import { logger, logSchema } from '@internal/monitoring' import { BucketScopedSingleShard, PgShardStoreFactory, @@ -60,17 +62,22 @@ export const s3vector = fastifyPlugin(async function (fastify: FastifyInstance) const connectionString = vectorDatabaseCreate ? deriveVectorDatabaseUrl(vectorDatabaseURL) : vectorDatabaseURL - stPgVectorPool = attachPgPoolErrorHandler( + stPgVectorPool = attachPoolErrorHandler( new PgPool({ connectionString, application_name: databaseApplicationName, min: 0, max: 10, + types: createPostgresTypeParsers(), }), - { - message: '[Vector] Idle pgvector client error', + (error) => { + logSchema.warning(logger, '[Vector] Idle pgvector client error', { + type: 'db', + error, + }) } ) + // TODO: watt stPgVectorAdapter = new PgVectorStore(new PgPoolExecutor(stPgVectorPool)) fastify.addHook('onClose', async () => { await stPgVectorPool?.end() diff --git a/src/internal/database/client.test.ts b/src/internal/database/client.test.ts new file mode 100644 index 000000000..4fa779037 --- /dev/null +++ b/src/internal/database/client.test.ts @@ -0,0 +1,180 @@ +import { removeGlobals, updateGlobals } from '@platformatic/globals' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type LoadClientOptions = { + isMultitenant?: boolean + hasWattMessaging?: boolean + databaseWattApplicationEnabled?: boolean + disableHostCheck?: boolean +} + +afterEach(() => { + vi.doUnmock('@internal/cluster') + vi.doUnmock('@internal/errors') + vi.doUnmock('../../config') + vi.doUnmock('./pg-connection') + vi.doUnmock('./tenant') + vi.doUnmock('./watt/connection') + vi.resetModules() + removeGlobals(['messaging']) +}) + +describe('database connection client', () => { + it('uses Database Watt when messaging is available', async () => { + const { client, getTenantConfig, getWattPostgresConnection, wattConnection } = await loadClient( + { + hasWattMessaging: true, + } + ) + + const connection = await client.getPostgresConnection(createConnectionOptions()) + + expect(connection).toBe(wattConnection) + expect(getTenantConfig).toHaveBeenCalledOnce() + expect(getTenantConfig).toHaveBeenCalledWith('tenant-a') + expect(getWattPostgresConnection).toHaveBeenCalledWith( + expect.objectContaining({ + dbUrl: 'postgres://tenant-db', + isExternalPool: false, + maxConnections: 7, + tenantId: 'tenant-a', + }) + ) + }) + + it('falls back to direct PostgreSQL when Watt messaging is unavailable', async () => { + const { client, getTenantConfig, pgConnection, pgCreate } = await loadClient({ + hasWattMessaging: false, + }) + + const connection = await client.getPostgresConnection(createConnectionOptions()) + + expect(connection).toBe(pgConnection) + expect(getTenantConfig).toHaveBeenCalledOnce() + expect(getTenantConfig).toHaveBeenCalledWith('tenant-a') + expect(pgCreate).toHaveBeenCalledWith( + expect.objectContaining({ + clusterSize: 3, + dbUrl: 'postgres://tenant-db', + maxConnections: 7, + }) + ) + }) + + it('falls back to direct PostgreSQL when the Database Watt application is disabled', async () => { + const { client, getTenantConfig, getWattPostgresConnection, pgConnection, pgCreate } = + await loadClient({ + databaseWattApplicationEnabled: false, + hasWattMessaging: true, + }) + + const connection = await client.getPostgresConnection(createConnectionOptions()) + + expect(connection).toBe(pgConnection) + expect(getWattPostgresConnection).not.toHaveBeenCalled() + expect(getTenantConfig).toHaveBeenCalledOnce() + expect(getTenantConfig).toHaveBeenCalledWith('tenant-a') + expect(pgCreate).toHaveBeenCalledWith( + expect.objectContaining({ + clusterSize: 3, + dbUrl: 'postgres://tenant-db', + maxConnections: 7, + }) + ) + }) + + it('validates multitenant forwarded host before routing to Database Watt', async () => { + const { client, getWattPostgresConnection } = await loadClient({ + hasWattMessaging: true, + isMultitenant: true, + }) + + await expect( + client.getPostgresConnection( + createConnectionOptions({ host: 'evil.example.test', disableHostCheck: false }) + ) + ).rejects.toThrow('X-Forwarded-Host header does not match regular expression') + + expect(getWattPostgresConnection).not.toHaveBeenCalled() + }) +}) + +async function loadClient(options: LoadClientOptions = {}) { + vi.resetModules() + + const getTenantConfig = vi.fn(async () => ({ + databasePoolUrl: undefined, + databaseUrl: 'postgres://tenant-db', + maxConnections: 7, + })) + const pgConnection = { kind: 'pg-connection' } + const wattConnection = { kind: 'watt-connection' } + const pgCreate = vi.fn(async () => pgConnection) + const getWattPostgresConnection = vi.fn(async () => wattConnection) + + vi.doMock('@internal/cluster', () => ({ Cluster: { size: 3 } })) + vi.doMock('@internal/errors', () => ({ + ERRORS: { + InvalidTenantId: () => new Error('Invalid tenant id'), + InvalidXForwardedHeader: (message: string) => new Error(message), + }, + })) + vi.doMock('../../config', () => ({ + getConfig: () => ({ + databaseMaxConnections: 20, + databasePoolURL: undefined, + databaseURL: 'postgres://default-db', + databaseWattApplicationEnabled: options.databaseWattApplicationEnabled ?? true, + isMultitenant: options.isMultitenant ?? true, + requestXForwardedHostRegExp: '^tenant-[a-z]+\\.example\\.test$', + }), + })) + vi.doMock('./pg-connection', () => ({ + PgTenantConnection: { create: pgCreate }, + })) + vi.doMock('./tenant', () => ({ getTenantConfig })) + vi.doMock('./watt/connection', () => ({ + getWattPostgresConnection, + })) + + if (options.hasWattMessaging) { + updateGlobals({ + messaging: { + handle: vi.fn(), + notify: vi.fn(), + send: vi.fn(), + }, + }) + } else { + removeGlobals(['messaging']) + } + + const client = await import('./client') + return { + client, + getTenantConfig, + getWattPostgresConnection, + pgConnection, + pgCreate, + wattConnection, + } +} + +function createConnectionOptions( + overrides: Partial[0]> = {} +) { + return { + disableHostCheck: true, + host: 'tenant-a.example.test', + superUser: { + jwt: 'service-jwt', + payload: { role: 'service_role' }, + }, + tenantId: 'tenant-a', + user: { + jwt: 'user-jwt', + payload: { role: 'authenticated' }, + }, + ...overrides, + } +} diff --git a/src/internal/database/client.ts b/src/internal/database/client.ts index 5e21412aa..c2e647a2a 100644 --- a/src/internal/database/client.ts +++ b/src/internal/database/client.ts @@ -1,10 +1,13 @@ import { Cluster } from '@internal/cluster' import { ERRORS } from '@internal/errors' import { getXForwardedHostRegExp } from '@internal/http/x-forwarded-host' +import { hasField } from '@platformatic/globals' import { getConfig } from '../../config' +import type { TenantConnection } from './connection' import { PgTenantConnection } from './pg-connection' -import { User } from './pool' +import type { User } from './pool' import { getTenantConfig } from './tenant' +import { getWattPostgresConnection } from './watt/connection' const xForwardedHostRegExp = getXForwardedHostRegExp() @@ -18,24 +21,38 @@ interface ConnectionOptions { superUser: User disableHostCheck?: boolean operation?: () => string | undefined + maxConnections?: number } export async function getPgPostgresConnection( options: ConnectionOptions ): Promise { + return PgTenantConnection.create(await resolveConnectionOptions(options)) +} + +export async function getPostgresConnection(options: ConnectionOptions): Promise { + const connectionOptions = await resolveConnectionOptions(options) + const { databaseWattApplicationEnabled } = getConfig() + + if (!databaseWattApplicationEnabled || !hasField('messaging')) { + return PgTenantConnection.create(connectionOptions) + } + + return getWattPostgresConnection(connectionOptions) +} + +async function resolveConnectionOptions(options: ConnectionOptions) { const dbCredentials = await getDbSettings(options.tenantId, options.host, { disableHostCheck: options.disableHostCheck, }) - return PgTenantConnection.create({ + return { ...dbCredentials, ...options, clusterSize: Cluster.size, - }) + } } -export const getPostgresConnection = getPgPostgresConnection - async function getDbSettings( tenantId: string, host: string | undefined, diff --git a/src/internal/database/index.ts b/src/internal/database/index.ts index 7af5b0031..1ff69afe6 100644 --- a/src/internal/database/index.ts +++ b/src/internal/database/index.ts @@ -3,7 +3,8 @@ export * from './connection' export * from './migration-admin-store-pg' export * from './multitenant-pg' export * from './pg-connection' +export * from './postgres/pool-errors' +export * from './postgres/sql' export * from './pubsub' -export * from './sql' export * from './tenant' export * from './tenant-store-pg' diff --git a/src/internal/database/migration-admin-store-pg.ts b/src/internal/database/migration-admin-store-pg.ts index c3ce30357..98139803c 100644 --- a/src/internal/database/migration-admin-store-pg.ts +++ b/src/internal/database/migration-admin-store-pg.ts @@ -1,6 +1,6 @@ import { QueryResultRow } from 'pg' import type { DatabaseExecutor } from './connection' -import { quoteIdentifier } from './sql' +import { quoteIdentifier } from './postgres/sql' import { TenantCursorRow } from './tenant-store-pg' export const MIGRATION_ADMIN_JOB_LIMIT = 2000 diff --git a/src/internal/database/migrations/migrate.test.ts b/src/internal/database/migrations/migrate.test.ts index 85bc494d2..4ade15426 100644 --- a/src/internal/database/migrations/migrate.test.ts +++ b/src/internal/database/migrations/migrate.test.ts @@ -80,6 +80,9 @@ vi.mock('pg', () => ({ return mockPgClientConstructor(...args) } }, + types: { + getTypeParser: vi.fn(), + }, })) vi.mock('../multitenant-pg', () => ({ diff --git a/src/internal/database/migrations/migrate.ts b/src/internal/database/migrations/migrate.ts index f17fe3474..3f458f9ed 100644 --- a/src/internal/database/migrations/migrate.ts +++ b/src/internal/database/migrations/migrate.ts @@ -1,5 +1,6 @@ import { ERRORS } from '@internal/errors' import { ResetMigrationsOnTenant, RunMigrationsOnTenants } from '@storage/events' +// Database migrations intentionally keep direct pg access outside Database Watt for now. import { Client, ClientConfig } from 'pg' import { MigrationError } from 'postgres-migrations' import { runMigration } from 'postgres-migrations/dist/run-migration' @@ -11,7 +12,8 @@ import { logger, logSchema } from '../../monitoring' import type { DatabaseExecutor, DatabaseTransaction } from '../connection' import { multitenantPgExecutor } from '../multitenant-pg' import { searchPath } from '../pool' -import { getSslSettings } from '../ssl' +import { getSslSettings } from '../postgres/ssl' +import { createPostgresTypeParsers } from '../postgres/type-parsers' import { getTenantConfig, TenantMigrationStatus } from '../tenant' import { TenantConfigStorePg } from '../tenant-store-pg' import { deriveVectorDatabaseUrl, VECTOR_DATABASE_NAME } from '../vector-store-url' @@ -662,6 +664,7 @@ async function connect(options: { connectionTimeoutMillis: 60_000, options: `-c search_path=${searchPath}`, ssl, + types: createPostgresTypeParsers(), } const client = new Client(dbConfig) diff --git a/src/internal/database/migrations/vector-store-migrations.test.ts b/src/internal/database/migrations/vector-store-migrations.test.ts index 67ff99372..6657969fd 100644 --- a/src/internal/database/migrations/vector-store-migrations.test.ts +++ b/src/internal/database/migrations/vector-store-migrations.test.ts @@ -52,6 +52,9 @@ vi.mock('pg', () => ({ mockClientConfigs.push(config) return client }), + types: { + getTypeParser: vi.fn(), + }, })) vi.mock('postgres-migrations/dist/run-migration', () => ({ diff --git a/src/internal/database/multitenant-pg.test.ts b/src/internal/database/multitenant-pg.test.ts index 838932a18..07a1f3432 100644 --- a/src/internal/database/multitenant-pg.test.ts +++ b/src/internal/database/multitenant-pg.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'events' -import { vi } from 'vitest' +import { afterEach, describe, it, vi } from 'vitest' type MultitenantPgModule = typeof import('./multitenant-pg') type MockPgPoolOptions = { @@ -19,11 +19,13 @@ type MockPgPool = { } let createdPools: MockPgPool[] = [] +const originalEnv = { ...process.env } async function loadMultitenantPgModule( configOverrides: Record = {} ): Promise { vi.resetModules() + process.env = { ...originalEnv, MULTI_TENANT: 'true' } mockPgModule() const configModule = await import('../../config') @@ -46,7 +48,9 @@ describe('multitenant pg pool', () => { await loadedModule?.closeMultitenantPg() loadedModule = undefined vi.doUnmock('pg') + vi.doUnmock('./watt-connection') vi.resetModules() + process.env = { ...originalEnv } }) it('does not pass DATABASE_SSL_ROOT_CERT into the shared multitenant pool config', async () => { @@ -195,6 +199,16 @@ describe('multitenant pg pool', () => { expect('getMultitenantPgPool' in loadedModule).toBe(false) expect('getMultitenantPgPoolConfig' in loadedModule).toBe(false) }) + + it('uses direct PostgreSQL independently of the Database Watt application flag', async () => { + loadedModule = await loadMultitenantPgModule({ + databaseWattApplicationEnabled: true, + }) + + await runQuery(loadedModule) + + expect(createdPools).toHaveLength(1) + }) }) async function runQuery(module: MultitenantPgModule): Promise { @@ -216,7 +230,7 @@ function mockPgModule(): void { vi.doMock('pg', () => { const types = { - setTypeParser: vi.fn(), + getTypeParser: vi.fn(), } class DatabaseError extends Error {} diff --git a/src/internal/database/multitenant-pg.ts b/src/internal/database/multitenant-pg.ts index d088a9a47..81a6e6308 100644 --- a/src/internal/database/multitenant-pg.ts +++ b/src/internal/database/multitenant-pg.ts @@ -2,7 +2,9 @@ import { logger, logSchema } from '@internal/monitoring' import { Pool, PoolConfig } from 'pg' import { getConfig } from '../../config' import type { DatabaseTransactionalExecutor } from './connection' -import { attachPgPoolErrorHandler, PgPoolExecutor } from './pg-connection' +import { PgPoolExecutor } from './pg-connection' +import { attachPoolErrorHandler } from './postgres/pool-errors' +import { createPostgresTypeParsers } from './postgres/type-parsers' function buildMultitenantPgPoolConfig(config: ReturnType): PoolConfig { const { @@ -24,6 +26,7 @@ function buildMultitenantPgPoolConfig(config: ReturnType): Poo min: 0, max: poolSize, idleTimeoutMillis: 5000, + types: createPostgresTypeParsers(), } } @@ -73,8 +76,11 @@ class MultitenantPgPoolOwner { } const oldState = this.state - const pool = attachPgPoolErrorHandler(new Pool(poolConfig), { - message: '[MultitenantPg] Idle pg client error', + const pool = attachPoolErrorHandler(new Pool(poolConfig), (error) => { + logSchema.warning(logger, '[MultitenantPg] Idle pg client error', { + type: 'db', + error, + }) }) this.state = { pool, diff --git a/src/internal/database/pg-connection.test.ts b/src/internal/database/pg-connection.test.ts index b3544eaa2..d8cd4065f 100644 --- a/src/internal/database/pg-connection.test.ts +++ b/src/internal/database/pg-connection.test.ts @@ -7,7 +7,6 @@ import PgConnection from 'pg/lib/connection' import { vi } from 'vitest' import type { DatabaseExecutor } from './connection' import { - getPgCancelConnectionTarget, PgPoolExecutor, PgPoolManager, PgPoolStrategy, @@ -249,87 +248,6 @@ async function loadPgConnectionModuleWithConfig(configOverrides: Record { - it('uses direct client host and port for TCP cancel connections', () => { - expect( - getPgCancelConnectionTarget({ - host: 'db.example.test', - port: 6432, - }) - ).toEqual({ - type: 'tcp', - host: 'db.example.test', - port: 6432, - }) - }) - - it('falls back to connection parameters for TCP cancel connections', () => { - expect( - getPgCancelConnectionTarget({ - connectionParameters: { - host: 'pool.example.test', - port: 5433, - }, - }) - ).toEqual({ - type: 'tcp', - host: 'pool.example.test', - port: 5433, - }) - }) - - it('uses the first connection-parameter host for multi-host TCP cancel connections', () => { - expect( - getPgCancelConnectionTarget({ - connectionParameters: { - host: ['primary.example.test', 'standby.example.test'], - port: 5433, - }, - }) - ).toEqual({ - type: 'tcp', - host: 'primary.example.test', - port: 5433, - }) - }) - - it('uses localhost and the default postgres port when the client does not expose a target', () => { - expect(getPgCancelConnectionTarget({})).toEqual({ - type: 'tcp', - host: 'localhost', - port: 5432, - }) - }) - - it('builds a Unix socket path from direct client connection fields', () => { - expect( - getPgCancelConnectionTarget({ - host: '/var/run/postgresql', - port: 6432, - }) - ).toEqual({ - type: 'socket', - path: '/var/run/postgresql/.s.PGSQL.6432', - }) - }) - - it('prefers direct client fields over connection parameter fallbacks', () => { - expect( - getPgCancelConnectionTarget({ - host: '/tmp/pg', - port: 6543, - connectionParameters: { - host: 'pool.example.test', - port: 5433, - }, - }) - ).toEqual({ - type: 'socket', - path: '/tmp/pg/.s.PGSQL.6543', - }) - }) -}) - describe('PgPoolExecutor', () => { it('tracks checked-out client errors during direct queries', async () => { const socketError = new Error('socket reset') diff --git a/src/internal/database/pg-connection.ts b/src/internal/database/pg-connection.ts index bafca578e..15d4ae87a 100644 --- a/src/internal/database/pg-connection.ts +++ b/src/internal/database/pg-connection.ts @@ -1,7 +1,6 @@ import { ERRORS } from '@internal/errors' import { logger, logSchema } from '@internal/monitoring' -import pg, { DatabaseError, Pool, PoolClient, QueryResult, QueryResultRow } from 'pg' -import PgConnection from 'pg/lib/connection' +import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg' import { getConfig } from '../../config' import type { DatabaseExecutor, @@ -20,7 +19,21 @@ import { searchPath, TenantConnectionOptions, } from './pool' -import { getSslSettings } from './ssl' +import { assertValidSignal } from './postgres/asserts' +import { cancelQuery } from './postgres/cancellation' +import { + ABORT_ERROR, + attachPoolErrorHandler, + isConnectionStateError, + isConnectionTimeoutError, + isRetryableTransactionSetupError, + markClientDisposable, + shouldDisposeClient, +} from './postgres/pool-errors' +import { buildScopeStatement } from './postgres/scope' +import { normalizeIsolationLevel, normalizeStatement } from './postgres/sql' +import { getSslSettings } from './postgres/ssl' +import { createPostgresTypeParsers } from './postgres/type-parsers' import { createTlsSessionSlot, installTlsSessionResumption, @@ -39,8 +52,6 @@ const { databaseTlsSessionResumption, } = getConfig() -pg.types.setTypeParser(20, 'text', parseInt) - interface PgTransactionOptions { searchPath?: string statementTimeoutMs?: number @@ -61,81 +72,18 @@ const defaultExternalPoolBeginTransactionOptions: PgBeginTransactionOptions = Ob searchPath: externalPoolSearchPath, }) -const scopeConfigSetters = `set_config('role', $1, true), - set_config('request.jwt.claim.role', $2, true), - set_config('request.jwt', $3, true), - set_config('request.jwt.claim.sub', $4, true), - set_config('request.jwt.claims', $5, true), - set_config('request.headers', $6, true), - set_config('request.method', $7, true), - set_config('request.path', $8, true), - set_config('storage.operation', $9, true), - set_config('storage.allow_delete_query', 'true', true)` - -const scopeConfigSql = ` - SELECT - ${scopeConfigSetters}; - ` - -const scopeConfigSqlWithStatementTimeout = ` - SELECT - ${scopeConfigSetters}, - set_config('statement_timeout', $10, true); - ` - -const scopeConfigSqlWithSearchPath = ` - SELECT - ${scopeConfigSetters}, - set_config('search_path', $10, true); - ` - -const scopeConfigSqlWithStatementTimeoutAndSearchPath = ` - SELECT - ${scopeConfigSetters}, - set_config('statement_timeout', $10, true), - set_config('search_path', $11, true); - ` - interface PgPoolErrorContext { message: string tenantId?: string project?: string } -type PgClientWithCancel = PoolClient & { - processID?: number - secretKey?: number - host?: string | string[] - port?: number - connectionParameters?: { - host?: string | string[] - port?: number - } -} - -const disposeClientOnRelease = Symbol('disposeClientOnRelease') - -type DisposableQueryError = Error & { - [disposeClientOnRelease]?: true -} - const poolDrainCheckIntervalMs = 200 const transactionSetupMaxAttempts = 11 const transactionSetupTotalBudgetMs = 3000 const transactionSetupRetryMinDelayMs = 50 const transactionSetupRetryMaxDelayMs = 200 -export type PgCancelConnectionTarget = - | { - type: 'socket' - path: string - } - | { - type: 'tcp' - host: string - port: number - } - export class PgPoolStrategy { protected pool?: Pool protected tlsSession?: TlsSessionSlot @@ -246,7 +194,7 @@ export class PgPoolStrategy { installTlsSessionResumption(ssl, this.tlsSession) } - return attachPgPoolErrorHandler( + return attachPoolErrorHandler( new Pool({ min: 0, max: settings.maxConnections, @@ -254,17 +202,18 @@ export class PgPoolStrategy { connectionTimeoutMillis: databaseConnectionTimeout, idleTimeoutMillis: settings.idleTimeoutMillis, ssl, + types: createPostgresTypeParsers(), Client: databaseTlsSessionResumption && ssl ? TlsSessionResumptionClient : undefined, application_name: databaseApplicationName, options: settings.searchPath ? `-c search_path=${settings.searchPath.join(',')}` : undefined, }), - { + createPgPoolErrorHandler({ message: '[PgPoolStrategy] Idle pg client error', tenantId: settings.tenantId, project: settings.tenantId, - } + }) ) } @@ -312,17 +261,15 @@ export class PgPoolStrategy { } } -export function attachPgPoolErrorHandler(pool: Pool, context: PgPoolErrorContext): Pool { - pool.on('error', (error) => { +function createPgPoolErrorHandler(context: PgPoolErrorContext): (error: Error) => void { + return (error) => { logSchema.warning(logger, context.message, { type: 'db', tenantId: context.tenantId, project: context.project, error, }) - }) - - return pool + } } function getPoolWorkStats(pool: Pool): { @@ -770,7 +717,7 @@ export class PgTenantConnection implements TenantConnection { private beginTransactionForRequest(opts?: PgBeginTransactionOptions): Promise { this.assertNotDisposed() if (this.abortSignal?.aborted) { - throw createAbortError() + throw ABORT_ERROR } // PgPoolExecutor derives the deferred statement_timeout from options.timeout. return this.pool.acquire().beginTransaction(opts) @@ -790,29 +737,20 @@ export class PgTenantConnection implements TenantConnection { pendingSearchPath = tnx.takePendingSearchPath() } - const values: unknown[] = [ - this.role, - this.role, - this.options.user.jwt || '', - this.options.user.payload.sub || '', - this.getUserPayload(), - this.headersPayload, - this.options.method || '', - this.options.path || '', - this.options.operation?.() || '', - ] - - if (statementTimeoutMs) { - values.push(`${statementTimeoutMs}ms`) - } - if (pendingSearchPath) { - values.push(pendingSearchPath) - } - - await tnx.query({ - text: getScopeConfigSql(statementTimeoutMs, pendingSearchPath), - values, - }) + await tnx.query( + buildScopeStatement({ + role: this.role, + jwt: this.options.user.jwt || '', + subject: this.options.user.payload.sub || '', + claims: this.getUserPayload(), + headers: this.headersPayload, + method: this.options.method || '', + path: this.options.path || '', + operation: this.options.operation?.() || '', + statementTimeoutMs, + searchPath: pendingSearchPath, + }) + ) } private getUserPayload(): string { @@ -836,25 +774,6 @@ function getPendingSettingsSql( return "SELECT set_config('search_path', $1, true)" } -function getScopeConfigSql( - statementTimeoutMs: number | undefined, - pendingSearchPath: string | undefined -): string { - if (statementTimeoutMs && pendingSearchPath) { - return scopeConfigSqlWithStatementTimeoutAndSearchPath - } - - if (statementTimeoutMs) { - return scopeConfigSqlWithStatementTimeout - } - - if (pendingSearchPath) { - return scopeConfigSqlWithSearchPath - } - - return scopeConfigSql -} - function withDefaultTransactionSettings( options: TransactionOptions | undefined, isExternalPool: boolean | undefined @@ -906,13 +825,6 @@ function ensureError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } -export function createAbortError(): Error & { code: string } { - const error = new Error('Query was aborted') as Error & { code: string } - error.name = 'AbortError' - error.code = 'ABORT_ERR' - return error -} - async function runPgQuery( client: PoolClient, statement: string | DatabaseStatement, @@ -943,8 +855,8 @@ async function runPgQuery( const onAbort = () => { aborted = true - cancelPromise = cancelPgQuery(client).catch(() => undefined) - rejectAbort?.(createAbortError()) + cancelPromise = cancelQuery(client).catch(() => undefined) + rejectAbort?.(ABORT_ERROR) rejectAbort = undefined } @@ -955,14 +867,14 @@ async function runPgQuery( const result = await Promise.race([queryPromise, abortPromise]) if (aborted) { - throw createAbortError() + throw ABORT_ERROR } return result } catch (e) { if (aborted) { void cancelPromise - throw createAbortError() + throw ABORT_ERROR } if (isConnectionStateError(e)) { @@ -974,31 +886,6 @@ async function runPgQuery( } } -function normalizeStatement( - statement: string | DatabaseStatement, - values?: unknown[] -): DatabaseStatement { - if (typeof statement === 'string') { - return { text: statement, values } - } - - return statement -} - -function assertValidSignal(signal?: AbortSignal): void { - if (!signal) { - return - } - - if (!(signal instanceof AbortSignal)) { - throw new Error('Expected signal to be an instance of AbortSignal') - } - - if (signal.aborted) { - throw createAbortError() - } -} - function getQuerySignal(options?: DatabaseQueryArgument): AbortSignal | undefined { return Array.isArray(options) ? undefined : options?.signal } @@ -1021,171 +908,3 @@ function buildBeginStatement(options?: TransactionOptions): string { return `BEGIN ${modes.join(', ')}` } - -function normalizeIsolationLevel(isolation?: string): string | undefined { - switch (isolation?.toLowerCase()) { - case 'read committed': - return 'READ COMMITTED' - case 'repeatable read': - return 'REPEATABLE READ' - case 'serializable': - return 'SERIALIZABLE' - default: - return undefined - } -} - -function isConnectionLimitError(error: unknown): boolean { - // PgBouncer can report connection limits as 08P01 protocol_violation. That - // intentionally overlaps isConnectionStateError so these failed clients are - // retried and disposed instead of being returned to the pool. - return ( - error instanceof DatabaseError && - ((error.code === '08P01' && error.message.includes('no more connections allowed')) || - error.message.includes('Max client connections reached')) - ) -} - -function isRetryableTransactionSetupError(error: unknown): boolean { - return ( - isConnectionStateError(error) || isConnectionLimitError(error) || isBrokenClientError(error) - ) -} - -// Socket-level of a dead pooled connection can surface as a plain Error. -// No setup is run yet, so a fresh client can safely retry. -// Connection-establishment failures (ECONNREFUSED, connect timeouts) aren't retried. -function isBrokenClientError(error: unknown): boolean { - if (!(error instanceof Error) || error.name === 'AbortError') { - return false - } - - if ((error as DisposableQueryError)[disposeClientOnRelease] === true) { - return true - } - - const code = (error as NodeJS.ErrnoException).code - return ( - code === 'ECONNRESET' || - code === 'EPIPE' || - error.message === 'Connection terminated unexpectedly' || - error.message === 'Client has encountered a connection error and is not queryable' - ) -} - -function isConnectionTimeoutError(error: unknown): error is Error { - return ( - error instanceof Error && - (error.message === 'timeout expired' || - error.message === 'timeout exceeded when trying to connect' || - error.message === 'Connection terminated due to connection timeout') - ) -} - -function shouldDisposeClient(error: unknown): boolean { - return ( - error instanceof Error && - (error.name === 'AbortError' || - (error as DisposableQueryError)[disposeClientOnRelease] === true) - ) -} - -function isConnectionStateError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - - if (error instanceof DatabaseError) { - return error.code ? error.code.startsWith('08') : isPgProtocolError(error) - } - - return isPgProtocolError(error) -} - -function isPgProtocolError(error: Error): boolean { - return ( - error.message.startsWith('received invalid response:') || - error.message.startsWith('Received unexpected ') || - error.message.startsWith('Unknown authenticationOk message type') - ) -} - -function markClientDisposable(error: unknown): void { - if (error instanceof Error) { - // PgPoolExecutor.shouldDisposeClient reads this marker from the exact Error instance - // thrown by runPgQuery. Do not wrap or replace the error before the pool release path. - const disposableError = error as DisposableQueryError - disposableError[disposeClientOnRelease] = true - } -} - -async function cancelPgQuery(client: PgClientWithCancel): Promise { - // PostgreSQL cancel requests are best effort. node-postgres sends them over a - // fresh raw protocol connection, so SSL-required proxies can close the socket - // before the backend sees the cancel request. - const processID = client.processID - const secretKey = client.secretKey - - if (!processID || !secretKey) { - return - } - - const cancelConnection = new PgConnection() - cancelConnection.unref() - const target = getPgCancelConnectionTarget(client) - - return new Promise((resolve) => { - let resolved = false - - const done = () => { - if (resolved) { - return - } - - resolved = true - clearTimeout(timeout) - cancelConnection.end() - resolve() - } - - const timeout = setTimeout(done, 5000) - timeout.unref() - - cancelConnection.on('error', done) - cancelConnection.on('end', done) - cancelConnection.on('connect', () => { - try { - cancelConnection.cancel(processID, secretKey) - } catch { - done() - } - }) - - if (target.type === 'socket') { - cancelConnection.connect(target.path) - } else { - cancelConnection.connect(target.port, target.host) - } - }) -} - -export function getPgCancelConnectionTarget( - client: Pick -): PgCancelConnectionTarget { - const rawHost = client.host || client.connectionParameters?.host || 'localhost' - const host = Array.isArray(rawHost) ? rawHost[0] || 'localhost' : rawHost - const port = client.port || client.connectionParameters?.port || 5432 - - if (host.startsWith('/')) { - return { - type: 'socket', - path: `${host}/.s.PGSQL.${port}`, - } - } - - return { - type: 'tcp', - host, - port, - } -} diff --git a/src/internal/database/postgres/asserts.ts b/src/internal/database/postgres/asserts.ts new file mode 100644 index 000000000..35057e436 --- /dev/null +++ b/src/internal/database/postgres/asserts.ts @@ -0,0 +1,15 @@ +import { ABORT_ERROR } from './pool-errors' + +export function assertValidSignal(signal?: AbortSignal): void { + if (!signal) { + return + } + + if (!(signal instanceof AbortSignal)) { + throw new Error('Expected signal to be an instance of AbortSignal') + } + + if (signal.aborted) { + throw ABORT_ERROR + } +} diff --git a/src/internal/database/postgres/cancellation.test.ts b/src/internal/database/postgres/cancellation.test.ts new file mode 100644 index 000000000..f7e6bbbba --- /dev/null +++ b/src/internal/database/postgres/cancellation.test.ts @@ -0,0 +1,82 @@ +import { getCancelTarget } from './cancellation' + +describe('getCancelTarget', () => { + it('uses direct client host and port for TCP cancel connections', () => { + expect( + getCancelTarget({ + host: 'db.example.test', + port: 6432, + }) + ).toEqual({ + type: 'tcp', + host: 'db.example.test', + port: 6432, + }) + }) + + it('falls back to connection parameters for TCP cancel connections', () => { + expect( + getCancelTarget({ + connectionParameters: { + host: 'pool.example.test', + port: 5433, + }, + }) + ).toEqual({ + type: 'tcp', + host: 'pool.example.test', + port: 5433, + }) + }) + + it('uses the first connection-parameter host for multi-host TCP cancel connections', () => { + expect( + getCancelTarget({ + connectionParameters: { + host: ['primary.example.test', 'standby.example.test'], + port: 5433, + }, + }) + ).toEqual({ + type: 'tcp', + host: 'primary.example.test', + port: 5433, + }) + }) + + it('uses localhost and the default postgres port when the client does not expose a target', () => { + expect(getCancelTarget({})).toEqual({ + type: 'tcp', + host: 'localhost', + port: 5432, + }) + }) + + it('builds a Unix socket path from direct client connection fields', () => { + expect( + getCancelTarget({ + host: '/var/run/postgresql', + port: 6432, + }) + ).toEqual({ + type: 'socket', + path: '/var/run/postgresql/.s.PGSQL.6432', + }) + }) + + it('prefers direct client fields over connection parameter fallbacks', () => { + expect( + getCancelTarget({ + host: '/tmp/pg', + port: 6543, + connectionParameters: { + host: 'pool.example.test', + port: 5433, + }, + }) + ).toEqual({ + type: 'socket', + path: '/tmp/pg/.s.PGSQL.6543', + }) + }) +}) diff --git a/src/internal/database/postgres/cancellation.ts b/src/internal/database/postgres/cancellation.ts new file mode 100644 index 000000000..30c16fe38 --- /dev/null +++ b/src/internal/database/postgres/cancellation.ts @@ -0,0 +1,95 @@ +import { type PoolClient } from 'pg' +import PgConnection from 'pg/lib/connection' + +export type CancellableClient = PoolClient & { + processID?: number + secretKey?: number + host?: string | string[] + port?: number + connectionParameters?: { + host?: string | string[] + port?: number + } +} + +export type CancelTarget = + | { + type: 'socket' + path: string + } + | { + type: 'tcp' + host: string + port: number + } + +export async function cancelQuery(client: CancellableClient): Promise { + // PostgreSQL cancel requests are best effort. node-postgres sends them over a + // fresh raw protocol connection, so SSL-required proxies can close the socket + // before the backend sees the cancel request. + const processID = client.processID + const secretKey = client.secretKey + + if (!processID || !secretKey) { + return + } + + const cancelConnection = new PgConnection() + cancelConnection.unref() + const target = getCancelTarget(client) + + return new Promise((resolve) => { + let resolved = false + + const done = () => { + if (resolved) { + return + } + + resolved = true + clearTimeout(timeout) + cancelConnection.end() + resolve() + } + + const timeout = setTimeout(done, 5000) + timeout.unref() + + cancelConnection.on('error', done) + cancelConnection.on('end', done) + cancelConnection.on('connect', () => { + try { + cancelConnection.cancel(processID, secretKey) + } catch { + done() + } + }) + + if (target.type === 'socket') { + cancelConnection.connect(target.path) + } else { + cancelConnection.connect(target.port, target.host) + } + }) +} + +export function getCancelTarget( + client: Pick +): CancelTarget { + const rawHost = client.host || client.connectionParameters?.host || 'localhost' + const host = Array.isArray(rawHost) ? rawHost[0] || 'localhost' : rawHost + const port = client.port || client.connectionParameters?.port || 5432 + + if (host.startsWith('/')) { + return { + type: 'socket', + path: `${host}/.s.PGSQL.${port}`, + } + } + + return { + type: 'tcp', + host, + port, + } +} diff --git a/src/internal/database/postgres/pool-errors.test.ts b/src/internal/database/postgres/pool-errors.test.ts new file mode 100644 index 000000000..3ca7423d8 --- /dev/null +++ b/src/internal/database/postgres/pool-errors.test.ts @@ -0,0 +1,41 @@ +import { EventEmitter } from 'node:events' +import type { Pool } from 'pg' +import { describe, expect, it, vi } from 'vitest' +import { attachPoolErrorHandler, isConnectionStateError } from './pool-errors' + +describe('PostgreSQL pool errors', () => { + it('attaches caller-owned idle client error handling', () => { + const pool = new EventEmitter() as Pool + const onError = vi.fn() + const error = new Error('idle client failed') + + expect(attachPoolErrorHandler(pool, onError)).toBe(pool) + + pool.emit('error', error) + expect(onError).toHaveBeenCalledWith(error) + }) + + it.each([ + '08000', + '08003', + '08006', + '08P01', + ])('recognizes SQLSTATE %s as a connection-state error', (code) => { + const error = Object.assign(new Error('connection failed'), { code }) + + expect(isConnectionStateError(error)).toBe(true) + }) + + it.each([ + 'received invalid response: 58', + 'Received unexpected authentication request', + 'Unknown authenticationOk message type', + ])('recognizes PostgreSQL protocol failure %s', (message) => { + expect(isConnectionStateError(new Error(message))).toBe(true) + }) + + it('rejects unrelated and non-error values', () => { + expect(isConnectionStateError(new Error('duplicate key'))).toBe(false) + expect(isConnectionStateError({ code: '08006' })).toBe(false) + }) +}) diff --git a/src/internal/database/postgres/pool-errors.ts b/src/internal/database/postgres/pool-errors.ts new file mode 100644 index 000000000..f3aaa6ae4 --- /dev/null +++ b/src/internal/database/postgres/pool-errors.ts @@ -0,0 +1,102 @@ +import { DatabaseError, type Pool } from 'pg' + +const disposeClientOnRelease = Symbol('disposeClientOnRelease') + +type DisposableQueryError = Error & { + [disposeClientOnRelease]?: true +} + +export function shouldDisposeClient(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'AbortError' || + (error as DisposableQueryError)[disposeClientOnRelease] === true) + ) +} + +export function markClientDisposable(error: unknown): void { + if (error instanceof Error) { + // PgPoolExecutor.shouldDisposeClient reads this marker from the exact Error instance + // thrown by runPgQuery. Do not wrap or replace the error before the pool release path. + const disposableError = error as DisposableQueryError + disposableError[disposeClientOnRelease] = true + } +} + +export function isConnectionStateError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + const code = (error as NodeJS.ErrnoException).code + return ( + (typeof code === 'string' && code.startsWith('08')) || + error.message.startsWith('received invalid response:') || + error.message.startsWith('Received unexpected ') || + error.message.startsWith('Unknown authenticationOk message type') + ) +} + +// Socket-level of a dead pooled connection can surface as a plain Error. +// No setup is run yet, so a fresh client can safely retry. +// Connection-establishment failures (ECONNREFUSED, connect timeouts) aren't retried. +export function isBrokenClientError(error: unknown): boolean { + if (!(error instanceof Error) || error.name === 'AbortError') { + return false + } + + if ((error as DisposableQueryError)[disposeClientOnRelease] === true) { + return true + } + + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'ECONNRESET' || + code === 'EPIPE' || + error.message === 'Connection terminated unexpectedly' || + error.message === 'Client has encountered a connection error and is not queryable' + ) +} + +export function isConnectionTimeoutError(error: unknown): error is Error { + return ( + error instanceof Error && + (error.message === 'timeout expired' || + error.message === 'timeout exceeded when trying to connect' || + error.message === 'Connection terminated due to connection timeout') + ) +} + +export function isConnectionLimitError(error: unknown): boolean { + // PgBouncer can report connection limits as 08P01 protocol_violation. That + // intentionally overlaps isConnectionStateError so these failed clients are + // retried and disposed instead of being returned to the pool. + return ( + error instanceof DatabaseError && + ((error.code === '08P01' && error.message.includes('no more connections allowed')) || + error.message.includes('Max client connections reached')) + ) +} + +export function isRetryableTransactionSetupError(error: unknown): boolean { + return ( + isConnectionStateError(error) || isConnectionLimitError(error) || isBrokenClientError(error) + ) +} + +export type PoolErrorHandler = (error: Error) => void + +export function attachPoolErrorHandler(pool: T, onError: PoolErrorHandler): T { + pool.on('error', onError) + return pool +} + +class AbortError extends Error { + readonly code = 'ABORT_ERR' + + constructor() { + super('Query was aborted') + this.name = 'AbortError' + } +} +export const ABORT_ERROR = new AbortError() diff --git a/src/internal/database/postgres/scope.test.ts b/src/internal/database/postgres/scope.test.ts new file mode 100644 index 000000000..88b43dccc --- /dev/null +++ b/src/internal/database/postgres/scope.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { buildScopeStatement, type Scope } from './scope' + +const baseScope: Scope = { + role: 'authenticated', + jwt: 'jwt', + subject: 'user-id', + claims: '{"role":"authenticated"}', + headers: '{"x-client-info":"test"}', + method: 'POST', + path: '/object/bucket/name', + operation: 'object.create', +} + +describe('PostgreSQL scope statement', () => { + it('builds the common request scope without optional transaction settings', () => { + const statement = buildScopeStatement(baseScope) + + expect(statement.text).toContain("set_config('role', $1, true)") + expect(statement.text).not.toContain("set_config('statement_timeout'") + expect(statement.text).not.toContain("set_config('search_path'") + expect(statement.values).toEqual([ + 'authenticated', + 'authenticated', + 'jwt', + 'user-id', + '{"role":"authenticated"}', + '{"x-client-info":"test"}', + 'POST', + '/object/bucket/name', + 'object.create', + ]) + }) + + it('keeps timeout and search path placeholder ordering stable', () => { + const statement = buildScopeStatement({ + ...baseScope, + statementTimeoutMs: 4321, + searchPath: 'storage,public,extensions', + }) + + expect(statement.text).toContain("set_config('statement_timeout', $10, true)") + expect(statement.text).toContain("set_config('search_path', $11, true)") + expect(statement.values.slice(9)).toEqual(['4321ms', 'storage,public,extensions']) + }) + + it('uses placeholder ten when only search path is present', () => { + const statement = buildScopeStatement({ + ...baseScope, + searchPath: 'storage,public,extensions', + }) + + expect(statement.text).toContain("set_config('search_path', $10, true)") + expect(statement.values[9]).toBe('storage,public,extensions') + }) +}) diff --git a/src/internal/database/postgres/scope.ts b/src/internal/database/postgres/scope.ts new file mode 100644 index 000000000..3d0b4121d --- /dev/null +++ b/src/internal/database/postgres/scope.ts @@ -0,0 +1,61 @@ +export interface Scope { + role: string + jwt: string + subject: string + claims: string + headers: string + method: string + path: string + operation: string + statementTimeoutMs?: number + searchPath?: string +} + +export interface ScopeStatement { + text: string + values: unknown[] +} + +export function buildScopeStatement(scope: Scope): ScopeStatement { + const setters = [ + "set_config('role', $1, true)", + "set_config('request.jwt.claim.role', $2, true)", + "set_config('request.jwt', $3, true)", + "set_config('request.jwt.claim.sub', $4, true)", + "set_config('request.jwt.claims', $5, true)", + "set_config('request.headers', $6, true)", + "set_config('request.method', $7, true)", + "set_config('request.path', $8, true)", + "set_config('storage.operation', $9, true)", + "set_config('storage.allow_delete_query', 'true', true)", + ] + const values: unknown[] = [ + scope.role, + scope.role, + scope.jwt, + scope.subject, + scope.claims, + scope.headers, + scope.method, + scope.path, + scope.operation, + ] + + if (scope.statementTimeoutMs) { + values.push(`${scope.statementTimeoutMs}ms`) + setters.push(`set_config('statement_timeout', $${values.length}, true)`) + } + + if (scope.searchPath) { + values.push(scope.searchPath) + setters.push(`set_config('search_path', $${values.length}, true)`) + } + + return { + text: ` + SELECT + ${setters.join(',\n ')}; + `, + values, + } +} diff --git a/src/internal/database/sql.test.ts b/src/internal/database/postgres/sql.test.ts similarity index 100% rename from src/internal/database/sql.test.ts rename to src/internal/database/postgres/sql.test.ts diff --git a/src/internal/database/postgres/sql.ts b/src/internal/database/postgres/sql.ts new file mode 100644 index 000000000..29f488a3b --- /dev/null +++ b/src/internal/database/postgres/sql.ts @@ -0,0 +1,45 @@ +import { DatabaseStatement } from '../connection' + +const POSTGRES_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function quoteIdentifier(identifier: string): string { + if (!POSTGRES_IDENTIFIER_PATTERN.test(identifier)) { + throw new Error(`Invalid PostgreSQL identifier: ${identifier}`) + } + + return `"${identifier}"` +} + +export function quoteQualifiedIdentifier(tableName: string): string { + const [schema, name, ...rest] = tableName.split('.') + + if (!schema || !name || rest.length > 0) { + throw new Error(`Invalid PostgreSQL table name: ${tableName}`) + } + + return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}` +} + +export function normalizeStatement( + statement: string | DatabaseStatement, + values?: unknown[] +): DatabaseStatement { + if (typeof statement === 'string') { + return { text: statement, values } + } + + return statement +} + +export function normalizeIsolationLevel(isolation?: string): string | undefined { + switch (isolation?.toLowerCase()) { + case 'read committed': + return 'READ COMMITTED' + case 'repeatable read': + return 'REPEATABLE READ' + case 'serializable': + return 'SERIALIZABLE' + default: + return undefined + } +} diff --git a/src/internal/database/ssl.test.ts b/src/internal/database/postgres/ssl.test.ts similarity index 99% rename from src/internal/database/ssl.test.ts rename to src/internal/database/postgres/ssl.test.ts index 39d60aa77..bc6a599c9 100644 --- a/src/internal/database/ssl.test.ts +++ b/src/internal/database/postgres/ssl.test.ts @@ -1,5 +1,5 @@ import type { PeerCertificate } from 'node:tls' -import { getSslSettings, isIpAddress } from '@internal/database/ssl' +import { getSslSettings, isIpAddress } from '@internal/database/postgres/ssl' describe('database utils', () => { test.each([ diff --git a/src/internal/database/ssl.ts b/src/internal/database/postgres/ssl.ts similarity index 100% rename from src/internal/database/ssl.ts rename to src/internal/database/postgres/ssl.ts diff --git a/src/internal/database/postgres/type-parsers.test.ts b/src/internal/database/postgres/type-parsers.test.ts new file mode 100644 index 000000000..bd8c87405 --- /dev/null +++ b/src/internal/database/postgres/type-parsers.test.ts @@ -0,0 +1,31 @@ +import type { CustomTypesConfig } from 'pg' +import { describe, expect, it, vi } from 'vitest' +import { createPostgresTypeParsers } from './type-parsers' + +describe('PostgreSQL type parsers', () => { + it('parses textual int8 values as numbers', () => { + const fallback = vi.fn() + const typeParsers = createPostgresTypeParsers({ + getTypeParser: fallback, + } as CustomTypesConfig) + + const parseInt8 = typeParsers.getTypeParser(20, 'text') + + expect(parseInt8('42')).toBe(42) + expect(parseInt8('-42')).toBe(-42) + expect(fallback).not.toHaveBeenCalled() + }) + + it('delegates binary int8 and unrelated types to the base parsers', () => { + const parseFallback = vi.fn((value: unknown) => value) + const getTypeParser = vi.fn(() => parseFallback) + const typeParsers = createPostgresTypeParsers({ + getTypeParser, + } as CustomTypesConfig) + + expect(typeParsers.getTypeParser(20, 'binary')).toBe(parseFallback) + expect(typeParsers.getTypeParser(25, 'text')).toBe(parseFallback) + expect(getTypeParser).toHaveBeenNthCalledWith(1, 20, 'binary') + expect(getTypeParser).toHaveBeenNthCalledWith(2, 25, 'text') + }) +}) diff --git a/src/internal/database/postgres/type-parsers.ts b/src/internal/database/postgres/type-parsers.ts new file mode 100644 index 000000000..5edd4873a --- /dev/null +++ b/src/internal/database/postgres/type-parsers.ts @@ -0,0 +1,17 @@ +import { type CustomTypesConfig, types as defaultTypes } from 'pg' + +const INT8_OID = 20 + +export function createPostgresTypeParsers( + baseTypes: CustomTypesConfig = defaultTypes +): CustomTypesConfig { + return { + getTypeParser(oid, format) { + if (oid === INT8_OID && (format === undefined || format === 'text')) { + return (value: string) => Number.parseInt(value, 10) + } + + return baseTypes.getTypeParser(oid, format) + }, + } +} diff --git a/src/internal/database/sql.ts b/src/internal/database/sql.ts deleted file mode 100644 index c30fe4967..000000000 --- a/src/internal/database/sql.ts +++ /dev/null @@ -1,19 +0,0 @@ -const POSTGRES_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ - -export function quoteIdentifier(identifier: string): string { - if (!POSTGRES_IDENTIFIER_PATTERN.test(identifier)) { - throw new Error(`Invalid PostgreSQL identifier: ${identifier}`) - } - - return `"${identifier}"` -} - -export function quoteQualifiedIdentifier(tableName: string): string { - const [schema, name, ...rest] = tableName.split('.') - - if (!schema || !name || rest.length > 0) { - throw new Error(`Invalid PostgreSQL table name: ${tableName}`) - } - - return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}` -} diff --git a/src/internal/database/tenant-store-pg.ts b/src/internal/database/tenant-store-pg.ts index c970f888b..76a54d68f 100644 --- a/src/internal/database/tenant-store-pg.ts +++ b/src/internal/database/tenant-store-pg.ts @@ -1,7 +1,7 @@ import { QueryResultRow } from 'pg' import { getConfig, JwksConfigKey } from '../../config' import type { DatabaseExecutor } from './connection' -import { quoteIdentifier } from './sql' +import { quoteIdentifier } from './postgres/sql' const { multitenantDatabaseQueryTimeout } = getConfig() const QUOTED_ID_COLUMN = quoteIdentifier('id') diff --git a/src/internal/database/watt/client.test.ts b/src/internal/database/watt/client.test.ts new file mode 100644 index 000000000..402d11f4b --- /dev/null +++ b/src/internal/database/watt/client.test.ts @@ -0,0 +1,140 @@ +import { removeGlobals, updateGlobals } from '@platformatic/globals' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DatabaseWattClient, DatabaseWattProtocolError, DatabaseWattResponseError } from './client' + +type SentWattMessage = { + application: string + data: Record + message: string +} + +afterEach(() => { + removeGlobals(['messaging']) +}) + +describe('Database Watt client transport', () => { + it('sends typed query requests and validates their responses', async () => { + const { sent } = installWattMessagingMock({ + 'database.query': { rowCount: 1, rows: [{ id: 1 }] }, + }) + const client = new DatabaseWattClient() + + const response = await client.query<{ id: number }>({ + destination: createDestination(), + operationName: 'select-object', + sql: 'SELECT $1::int AS id', + values: [1], + }) + + expect(response).toEqual({ rowCount: 1, rows: [{ id: 1 }] }) + expect(sent[0]).toMatchObject({ + application: 'database', + message: 'database.query', + data: { + destination: createDestination(), + operationName: 'select-object', + requestId: expect.any(String), + sql: 'SELECT $1::int AS id', + values: [1], + }, + }) + }) + + it('surfaces Database Watt error envelopes without PostgreSQL adaptation', async () => { + installWattMessagingMock({ + 'database.query': { + code: 'POSTGRES_ERROR', + message: 'duplicate key value violates unique constraint', + sqlState: '23505', + }, + }) + const client = new DatabaseWattClient() + + const error = await client + .query({ destination: createDestination(), sql: 'INSERT' }) + .catch((error: unknown) => error) + + expect(error).toBeInstanceOf(DatabaseWattResponseError) + expect(error).toMatchObject({ + code: 'POSTGRES_ERROR', + response: { + code: 'POSTGRES_ERROR', + message: 'duplicate key value violates unique constraint', + sqlState: '23505', + }, + }) + }) + + it('rejects malformed success responses at the transport boundary', async () => { + installWattMessagingMock({ + 'database.query': { rowCount: '1', rows: [] }, + }) + const client = new DatabaseWattClient() + + const error = await client + .query({ destination: createDestination(), sql: 'SELECT 1' }) + .catch((error: unknown) => error) + + expect(error).toBeInstanceOf(DatabaseWattProtocolError) + expect(error).toMatchObject({ + code: 'PROTOCOL_ERROR', + name: 'DatabaseWattProtocolError', + }) + }) + + it('cancels a lock-bound request with the same request and lock ids', async () => { + let resolveQuery!: (value: unknown) => void + const { sent } = installWattMessagingMock({ + 'database.lockedQuery': new Promise((resolve) => { + resolveQuery = resolve + }), + 'database.cancel': { cancelled: true }, + }) + const client = new DatabaseWattClient() + const controller = new AbortController() + + const query = client.lockedQuery( + { lockId: 'lock-a', sql: 'SELECT pg_sleep(10)' }, + { signal: controller.signal } + ) + controller.abort() + + await expect(query).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + resolveQuery({ rowCount: 0, rows: [] }) + + expect(sent.map(({ message }) => message)).toEqual(['database.lockedQuery', 'database.cancel']) + expect(sent[1].data).toMatchObject({ + lockId: 'lock-a', + requestId: sent[0].data.requestId, + }) + }) +}) + +function createDestination() { + return { + connectionString: 'postgres://tenant-db', + id: 'tenant-a', + isExternalPool: false, + maxConnections: 10, + } +} + +function installWattMessagingMock(responses: Record): { + sent: SentWattMessage[] +} { + const sent: SentWattMessage[] = [] + + updateGlobals({ + messaging: { + handle: vi.fn(), + notify: vi.fn(), + send: vi.fn(async (application: string, message: string, data: Record) => { + sent.push({ application, data, message }) + const response = responses[message] + return response instanceof Promise ? response : response + }), + }, + }) + + return { sent } +} diff --git a/src/internal/database/watt/client.ts b/src/internal/database/watt/client.ts new file mode 100644 index 000000000..66a065757 --- /dev/null +++ b/src/internal/database/watt/client.ts @@ -0,0 +1,215 @@ +import { randomUUID } from 'node:crypto' +import { getMessaging } from '@platformatic/globals' +import { + type AcquireConnectionRequest, + type AcquireConnectionResponse, + type BeginTransactionRequest, + type CommitTransactionRequest, + DATABASE_APPLICATION_ID, + DATABASE_MESSAGES, + type DatabaseErrorResponse, + type DatabaseMessageName, + type DatabaseMessageRequest, + type LockedQueryRequest, + type QueryRequest, + type QueryResponse, + type ReleaseConnectionRequest, + type RollbackTransactionRequest, +} from '../../../applications/database/protocol' +import { assertValidSignal } from '../postgres/asserts' +import { ABORT_ERROR } from '../postgres/pool-errors' + +export interface DatabaseWattRequestOptions { + signal?: AbortSignal +} + +interface DatabaseWattSendOptions extends DatabaseWattRequestOptions { + lockId?: string +} + +export interface DatabaseWattTransport { + query( + request: QueryRequest, + options?: DatabaseWattRequestOptions + ): Promise> + acquire(request: AcquireConnectionRequest): Promise + lockedQuery( + request: LockedQueryRequest, + options?: DatabaseWattRequestOptions + ): Promise> + release(request: ReleaseConnectionRequest): Promise + beginTransaction(request: BeginTransactionRequest): Promise + commitTransaction(request: CommitTransactionRequest): Promise + rollbackTransaction(request: RollbackTransactionRequest): Promise +} + +export class DatabaseWattClient implements DatabaseWattTransport { + query( + request: QueryRequest, + options?: DatabaseWattRequestOptions + ): Promise> { + return this.send(DATABASE_MESSAGES.query, request, parseQueryResponse, options) + } + + acquire(request: AcquireConnectionRequest): Promise { + return this.send(DATABASE_MESSAGES.acquire, request, parseAcquireResponse) + } + + lockedQuery( + request: LockedQueryRequest, + options?: DatabaseWattRequestOptions + ): Promise> { + return this.send(DATABASE_MESSAGES.lockedQuery, request, parseQueryResponse, { + ...options, + lockId: request.lockId, + }) + } + + async release(request: ReleaseConnectionRequest): Promise { + await this.send(DATABASE_MESSAGES.release, request, parseReleasedResponse) + } + + beginTransaction(request: BeginTransactionRequest): Promise { + return this.send(DATABASE_MESSAGES.beginTransaction, request, parseAcquireResponse) + } + + async commitTransaction(request: CommitTransactionRequest): Promise { + await this.send(DATABASE_MESSAGES.commitTransaction, request, parseCommittedResponse) + } + + async rollbackTransaction(request: RollbackTransactionRequest): Promise { + await this.send(DATABASE_MESSAGES.rollbackTransaction, request, parseRolledBackResponse) + } + + private async send( + message: Message, + data: DatabaseMessageRequest, + parseResponse: (response: unknown) => Response, + options: DatabaseWattSendOptions = {} + ): Promise { + const { signal, lockId } = options + assertValidSignal(signal) + + const requestId = typeof data.requestId === 'string' ? data.requestId : randomUUID() + const payload = { ...data, requestId } + const request = getMessaging().send(DATABASE_APPLICATION_ID, message, payload) + + if (!signal) { + return parseResponseOrThrow(await request, parseResponse) + } + + let abortListener: (() => void) | undefined + let settled = false + + const abortPromise = new Promise((_, reject) => { + abortListener = () => { + if (settled) { + return + } + + void getMessaging() + .send(DATABASE_APPLICATION_ID, DATABASE_MESSAGES.cancel, { requestId, lockId }) + .catch(() => undefined) + reject(ABORT_ERROR) + } + + signal.addEventListener('abort', abortListener, { once: true }) + }) + + try { + const response = await Promise.race([request, abortPromise]) + return parseResponseOrThrow(response, parseResponse) + } finally { + settled = true + if (abortListener) { + signal.removeEventListener('abort', abortListener) + } + } + } +} + +export class DatabaseWattResponseError extends Error { + readonly code: string + + constructor(readonly response: DatabaseErrorResponse) { + super(response.message) + this.name = 'DatabaseWattResponseError' + this.code = response.code + this.stack = response.stack ?? this.stack + } +} + +export class DatabaseWattProtocolError extends Error { + readonly code = 'PROTOCOL_ERROR' + + constructor(message: string) { + super(message) + this.name = 'DatabaseWattProtocolError' + } +} + +export const databaseWattClient: DatabaseWattTransport = new DatabaseWattClient() + +function parseResponseOrThrow( + response: unknown, + parseResponse: (response: unknown) => Response +): Response { + if (isDatabaseErrorResponse(response)) { + throw new DatabaseWattResponseError(response) + } + + return parseResponse(response) +} + +function parseQueryResponse(response: unknown): QueryResponse { + if ( + !isRecord(response) || + typeof response.rowCount !== 'number' || + !Array.isArray(response.rows) + ) { + throw new DatabaseWattProtocolError('Invalid Database Watt query response') + } + + return { + rowCount: response.rowCount, + rows: response.rows, + } +} + +function parseAcquireResponse(response: unknown): AcquireConnectionResponse { + if (!isRecord(response) || typeof response.lockId !== 'string') { + throw new DatabaseWattProtocolError('Invalid Database Watt acquire response') + } + + return { lockId: response.lockId } +} + +function parseReleasedResponse(response: unknown): void { + assertBooleanResponse(response, 'released') +} + +function parseCommittedResponse(response: unknown): void { + assertBooleanResponse(response, 'committed') +} + +function parseRolledBackResponse(response: unknown): void { + assertBooleanResponse(response, 'rolledBack') +} + +function assertBooleanResponse(response: unknown, property: string): void { + if (!isRecord(response) || response[property] !== true) { + throw new DatabaseWattProtocolError( + `Invalid Database Watt response: expected ${property} to be true` + ) + } +} + +function isDatabaseErrorResponse(response: unknown): response is DatabaseErrorResponse { + return ( + isRecord(response) && typeof response.code === 'string' && typeof response.message === 'string' + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/src/internal/database/watt/connection.test.ts b/src/internal/database/watt/connection.test.ts new file mode 100644 index 000000000..a65eaa55e --- /dev/null +++ b/src/internal/database/watt/connection.test.ts @@ -0,0 +1,269 @@ +import { removeGlobals, updateGlobals } from '@platformatic/globals' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseExecutor } from '../connection' +import { searchPath, type TenantConnectionOptions } from '../pool' +import type { DatabaseWattTransport } from './client' +import { getWattPostgresConnection, WattPgExecutor } from './connection' + +type SentWattMessage = { + application: string + data: Record + message: string +} + +function installWattMessagingMock(responses: Record = {}): { + sent: SentWattMessage[] +} { + const sent: SentWattMessage[] = [] + + updateGlobals({ + messaging: { + handle: vi.fn(), + notify: vi.fn(), + send: vi.fn(async (application: string, message: string, data: Record) => { + sent.push({ application, data, message }) + const response = responses[message] + return response instanceof Promise ? response : response + }), + }, + }) + + return { sent } +} + +afterEach(() => { + removeGlobals(['messaging']) +}) + +describe('Watt PostgreSQL connection adapter', () => { + it('adapts an injected Watt transport without Platformatic messaging', async () => { + const transport: DatabaseWattTransport = { + query: vi.fn().mockResolvedValue({ rowCount: 1, rows: [{ id: 1 }] }), + acquire: vi.fn(), + lockedQuery: vi.fn(), + release: vi.fn(), + beginTransaction: vi.fn(), + commitTransaction: vi.fn(), + rollbackTransaction: vi.fn(), + } + const executor = new WattPgExecutor(createPoolTarget(), () => 'operation-a', transport) + + const result = await executor.query<{ id: number }>('SELECT $1::int AS id', [1]) + + expect(result).toMatchObject({ rowCount: 1, rows: [{ id: 1 }] }) + expect(transport.query).toHaveBeenCalledWith( + { + destination: createPoolTarget(), + operationName: 'operation-a', + sql: 'SELECT $1::int AS id', + values: [1], + }, + { signal: undefined } + ) + }) + + it('sends stateless queries through Database Watt messaging', async () => { + const { sent } = installWattMessagingMock({ + 'database.query': { rowCount: 1, rows: [{ id: 1 }] }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + const result = await connection.query<{ id: number }>({ + text: 'SELECT $1::int as id', + values: [1], + }) + + expect(result.rows).toEqual([{ id: 1 }]) + expect(result.rowCount).toBe(1) + expect(sent[0]).toMatchObject({ + application: 'database', + message: 'database.query', + data: { + destination: createPoolTarget(), + operationName: 'operation-a', + sql: 'SELECT $1::int as id', + values: [1], + }, + }) + expect(sent[0].data.requestId).toEqual(expect.any(String)) + }) + + it('runs transaction lifecycle through lock-bound messages', async () => { + const { sent } = installWattMessagingMock({ + 'database.beginTransaction': { lockId: 'lock-a' }, + 'database.lockedQuery': { rowCount: 1, rows: [{ ok: true }] }, + 'database.commitTransaction': { committed: true }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + const tx = await connection.transaction({ isolation: 'serializable', readOnly: true }) + const result = await tx.query('SELECT 1') + await tx.commit() + + expect(result.rows).toEqual([{ ok: true }]) + expect(sent.map((message) => message.message)).toEqual([ + 'database.beginTransaction', + 'database.lockedQuery', + 'database.commitTransaction', + ]) + expect(sent[0].data).toMatchObject({ + destination: createPoolTarget(), + isolationLevel: 'serializable', + readOnly: true, + }) + expect(sent[1].data).toMatchObject({ lockId: 'lock-a', sql: 'SELECT 1' }) + expect(tx.isCompleted()).toBe(true) + }) + + it('rolls back transactions through Database Watt messaging', async () => { + const { sent } = installWattMessagingMock({ + 'database.beginTransaction': { lockId: 'lock-a' }, + 'database.rollbackTransaction': { rolledBack: true }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + const tx = await connection.transaction() + await tx.rollback() + + expect(sent.map((message) => message.message)).toEqual([ + 'database.beginTransaction', + 'database.rollbackTransaction', + ]) + expect(tx.isCompleted()).toBe(true) + }) + + it('maps PostgreSQL error responses back to pg DatabaseError', async () => { + installWattMessagingMock({ + 'database.query': { + code: 'POSTGRES_ERROR', + message: 'duplicate key value violates unique constraint', + sqlState: '23505', + }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + await expect(connection.query('INSERT')).rejects.toMatchObject({ + code: '23505', + message: 'duplicate key value violates unique constraint', + }) + }) + + it('preserves timeout response codes on pg DatabaseError', async () => { + installWattMessagingMock({ + 'database.query': { + code: 'ACQUIRE_TIMEOUT', + message: 'Timed out acquiring database connection', + }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + await expect(connection.query('SELECT 1')).rejects.toMatchObject({ + code: 'ACQUIRE_TIMEOUT', + message: 'Timed out acquiring database connection', + }) + }) + + it('maps server timeouts to PostgreSQL query-canceled SQLSTATE', async () => { + installWattMessagingMock({ + 'database.query': { + code: 'SERVER_TIMEOUT', + message: 'canceling statement due to statement timeout', + }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + await expect(connection.query('SELECT pg_sleep(10)')).rejects.toMatchObject({ + code: '57014', + message: 'canceling statement due to statement timeout', + }) + }) + + it('sends explicit cancellation when an AbortSignal fires', async () => { + let resolveQuery!: (value: unknown) => void + const { sent } = installWattMessagingMock({ + 'database.query': new Promise((resolve) => { + resolveQuery = resolve + }), + 'database.cancel': { cancelled: true }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + const controller = new AbortController() + + const query = connection.query('SELECT pg_sleep(10)', { signal: controller.signal }) + controller.abort() + + await expect(query).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + resolveQuery({ rowCount: 0, rows: [] }) + + expect(sent.map((message) => message.message)).toEqual(['database.query', 'database.cancel']) + expect(sent[1].data.requestId).toBe(sent[0].data.requestId) + }) + + it('creates superuser connections with service role credentials', async () => { + const { sent } = installWattMessagingMock({ + 'database.query': { rowCount: 1, rows: [] }, + }) + const connection = await getWattPostgresConnection(createConnectionOptions()) + + await connection.asSuperUser().query('SELECT 1') + + expect(sent[0].data.destination).toEqual(createPoolTarget()) + expect(connection.asSuperUser().role).toBe('service_role') + }) + + it('uses the shared PostgreSQL scope statement', async () => { + const connection = await getWattPostgresConnection({ + ...createConnectionOptions(), + headers: { 'x-client-info': 'test' }, + method: 'POST', + path: '/object/bucket/name', + }) + const query = vi.fn().mockResolvedValue({ rowCount: 0, rows: [] }) + + await connection.setScope({ query } as unknown as DatabaseExecutor) + + expect(query).toHaveBeenCalledTimes(1) + const statement = query.mock.calls[0][0] + expect(statement.text).toContain("set_config('role', $1, true)") + expect(statement.text).toContain("set_config('search_path', $10, true)") + expect(statement.values).toEqual([ + 'authenticated', + 'authenticated', + 'user-jwt', + '', + '{"role":"authenticated"}', + '{"x-client-info":"test"}', + 'POST', + '/object/bucket/name', + 'operation-a', + searchPath.join(','), + ]) + }) +}) + +function createConnectionOptions(): TenantConnectionOptions { + return { + dbUrl: 'postgres://tenant-db', + isExternalPool: false, + maxConnections: 10, + operation: () => 'operation-a', + superUser: { + jwt: 'service-jwt', + payload: { role: 'service_role' }, + }, + tenantId: 'tenant-a', + user: { + jwt: 'user-jwt', + payload: { role: 'authenticated' }, + }, + } +} + +function createPoolTarget() { + return { + connectionString: 'postgres://tenant-db', + id: 'tenant-a', + isExternalPool: false, + maxConnections: 10, + } +} diff --git a/src/internal/database/watt/connection.ts b/src/internal/database/watt/connection.ts new file mode 100644 index 000000000..c45dcf7f5 --- /dev/null +++ b/src/internal/database/watt/connection.ts @@ -0,0 +1,298 @@ +import { DatabaseError, QueryResult, QueryResultRow } from 'pg' +import { + type DatabaseErrorResponse, + type DatabasePoolTarget, + type QueryResponse, +} from '../../../applications/database/protocol' +import { + DatabaseExecutor, + DatabaseQueryArgument, + DatabaseStatement, + DatabaseTransaction, + DatabaseTransactionalExecutor, + TenantConnection, + TransactionOptions, +} from '../connection' +import { searchPath, TenantConnectionOptions } from '../pool' +import { buildScopeStatement } from '../postgres/scope' +import { normalizeStatement } from '../postgres/sql' +import { DatabaseWattResponseError, DatabaseWattTransport, databaseWattClient } from './client' + +class WattPgTransaction implements DatabaseTransaction { + private wattCompleted = false + private readonly lockId: string + private readonly operation?: () => string | undefined + + constructor( + lockId: string, + operation: (() => string | undefined) | undefined, + private readonly transport: DatabaseWattTransport + ) { + this.lockId = lockId + this.operation = operation + } + + isCompleted(): boolean { + return this.wattCompleted + } + + async query( + statement: string | DatabaseStatement, + options?: DatabaseQueryArgument + ): Promise> { + if (this.wattCompleted) { + throw new Error('Cannot query a completed transaction') + } + + const query = normalizeStatement(statement, Array.isArray(options) ? options : undefined) + const signal = Array.isArray(options) ? undefined : options?.signal + const response = await withPgErrors( + this.transport.lockedQuery( + { + lockId: this.lockId, + operationName: this.operation?.(), + sql: query.text, + values: query.values, + }, + { signal } + ) + ) + + return toPgQueryResult(response) + } + + async commit(): Promise { + if (this.wattCompleted) { + return + } + + try { + await withPgErrors(this.transport.commitTransaction({ lockId: this.lockId })) + } finally { + this.wattCompleted = true + } + } + + async rollback(): Promise { + if (this.wattCompleted) { + return + } + + try { + await withPgErrors(this.transport.rollbackTransaction({ lockId: this.lockId })) + } finally { + this.wattCompleted = true + } + } +} + +export class WattPgExecutor implements DatabaseTransactionalExecutor { + private readonly destination: DatabasePoolTarget + private readonly operation?: () => string | undefined + + constructor( + destination: DatabasePoolTarget, + operation?: () => string | undefined, + private readonly transport: DatabaseWattTransport = databaseWattClient + ) { + this.destination = destination + this.operation = operation + } + + async query( + statement: string | DatabaseStatement, + options?: DatabaseQueryArgument + ): Promise> { + const query = normalizeStatement(statement, Array.isArray(options) ? options : undefined) + const signal = Array.isArray(options) ? undefined : options?.signal + const response = await withPgErrors( + this.transport.query( + { + destination: this.destination, + operationName: this.operation?.(), + sql: query.text, + values: query.values, + }, + { signal } + ) + ) + + return toPgQueryResult(response) + } + + async beginTransaction(options?: TransactionOptions): Promise { + const response = await withPgErrors( + this.transport.beginTransaction({ + destination: this.destination, + isolationLevel: options?.isolation, + operationName: this.operation?.(), + readOnly: options?.readOnly, + }) + ) + + return new WattPgTransaction(response.lockId, this.operation, this.transport) + } +} + +export class WattPgTenantConnection implements TenantConnection { + readonly role: string + private readonly executor: WattPgExecutor + private wattAbortSignal?: AbortSignal + + constructor( + private readonly options: TenantConnectionOptions, + private readonly transport: DatabaseWattTransport + ) { + this.role = options.user.payload.role || 'anon' + this.executor = new WattPgExecutor( + { + connectionString: options.dbUrl, + id: options.tenantId, + isExternalPool: Boolean(options.isExternalPool), + maxConnections: options.maxConnections, + }, + options.operation, + transport + ) + } + + dispose(): void {} + + setAbortSignal(signal: AbortSignal): void { + this.wattAbortSignal = signal + } + + getAbortSignal(): AbortSignal | undefined { + return this.wattAbortSignal + } + + async query( + statement: string | DatabaseStatement, + options?: DatabaseQueryArgument + ): Promise> { + return this.executor.query(statement, mergeSignalOptions(options, this.wattAbortSignal)) + } + + async beginTransaction(options?: TransactionOptions): Promise { + return this.executor.beginTransaction(options) + } + + asSuperUser(): TenantConnection { + const connection = new WattPgTenantConnection( + { + ...this.options, + user: this.options.superUser, + }, + this.transport + ) + + if (this.wattAbortSignal) { + connection.setAbortSignal(this.wattAbortSignal) + } + + return connection + } + + async transaction(options?: TransactionOptions): Promise { + return this.beginTransaction(options) + } + + async setScope(tnx: DatabaseExecutor): Promise { + const headers = JSON.stringify(this.options.headers || {}) + await tnx.query( + buildScopeStatement({ + role: this.role, + jwt: this.options.user.jwt || '', + subject: this.options.user.payload.sub || '', + claims: JSON.stringify(this.options.user.payload), + headers, + method: this.options.method || '', + path: this.options.path || '', + operation: this.options.operation?.() || '', + searchPath: searchPath.join(','), + }) + ) + } +} + +export async function getWattPostgresConnection( + options: TenantConnectionOptions, + transport: DatabaseWattTransport = databaseWattClient +): Promise { + return new WattPgTenantConnection(options, transport) +} + +function mergeSignalOptions( + options: DatabaseQueryArgument | undefined, + signal: AbortSignal | undefined +): DatabaseQueryArgument | undefined { + if (!signal || Array.isArray(options)) { + return options + } + + return { + ...options, + signal: options?.signal || signal, + } +} + +function toDatabaseError(response: DatabaseErrorResponse): Error { + if (response.code === 'POSTGRES_ERROR') { + const error = new DatabaseError(response.message, 0, 'error') + error.code = response.sqlState + error.stack = response.stack + return error + } + + if ( + response.code === 'CLIENT_TIMEOUT' || + response.code === 'SERVER_TIMEOUT' || + response.code === 'CONNECTION_TIMEOUT' || + response.code === 'ACQUIRE_TIMEOUT' || + response.code === 'MESSAGING_TIMEOUT' + ) { + const error = new DatabaseError(response.message, 0, 'error') + error.code = response.code === 'SERVER_TIMEOUT' ? '57014' : response.code + error.stack = response.stack + return error + } + + return new DatabaseWattRemoteError(response) +} + +class DatabaseWattRemoteError extends Error { + readonly code: string + readonly databaseCode: string + + constructor(readonly databaseResponse: DatabaseErrorResponse) { + super(databaseResponse.message) + this.name = 'DatabaseWattRemoteError' + this.code = databaseResponse.code + this.databaseCode = databaseResponse.code + this.stack = databaseResponse.stack ?? this.stack + } +} + +function toPgQueryResult( + response: QueryResponse +): QueryResult { + return { + command: '', + fields: [], + oid: 0, + rowCount: response.rowCount, + rows: response.rows, + } +} + +async function withPgErrors(request: Promise): Promise { + try { + return await request + } catch (error) { + if (error instanceof DatabaseWattResponseError) { + throw toDatabaseError(error.response) + } + + throw error + } +} diff --git a/src/internal/queue/database.ts b/src/internal/queue/database.ts index e81695da3..53012e78d 100644 --- a/src/internal/queue/database.ts +++ b/src/internal/queue/database.ts @@ -4,7 +4,7 @@ import { ERRORS } from '@internal/errors' import pg from 'pg' import { Db } from 'pg-boss' -export { quoteIdentifier } from '../database/sql' +export { quoteIdentifier } from '../database/postgres/sql' export class QueueDB extends EventEmitter implements Db { opened = false diff --git a/src/internal/testing/seeder/pg-persistence.ts b/src/internal/testing/seeder/pg-persistence.ts index e0b56c8d1..66b4345a0 100644 --- a/src/internal/testing/seeder/pg-persistence.ts +++ b/src/internal/testing/seeder/pg-persistence.ts @@ -1,5 +1,5 @@ import { Pool, PoolClient, PoolConfig, QueryResult } from 'pg' -import { quoteIdentifier } from '../../database/sql' +import { quoteIdentifier } from '../../database/postgres/sql' import { Persistence } from './persistence' export class PgPersistence implements Persistence { diff --git a/src/storage/database/adapter.ts b/src/storage/database/adapter.ts index 1702f73be..2c9c17468 100644 --- a/src/storage/database/adapter.ts +++ b/src/storage/database/adapter.ts @@ -3,6 +3,8 @@ import { DBMigration } from '@internal/database/migrations' import { ObjectMetadata } from '../backend' import { Bucket, IcebergCatalog, Obj, S3MultipartUpload, S3PartUpload } from '../schemas' +export type { TransactionOptions } from '@internal/database' + export interface SearchObjectOption { search?: string sortBy?: { diff --git a/src/types/pg-lib-connection.d.ts b/src/types/pg-lib-connection.d.ts index 53ab8d90b..4779466cb 100644 --- a/src/types/pg-lib-connection.d.ts +++ b/src/types/pg-lib-connection.d.ts @@ -1,12 +1,10 @@ declare module 'pg/lib/connection' { - class PgConnection { - on(event: 'connect' | 'end' | 'error', listener: (...args: unknown[]) => void): void + import { EventEmitter } from 'node:events' + export default class PgConnection extends EventEmitter { end(): void unref(): void cancel(processID: number, secretKey: number): void connect(port: number, host: string): void connect(path: string): void } - - export default PgConnection } diff --git a/tsconfig.json b/tsconfig.json index 7b2e88d1d..daf811e2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "paths": { "@internal/*": ["./src/internal/*"], - "@storage/*": ["./src/storage/*"] + "@storage/*": ["./src/storage/*"], + "@applications/*": ["./src/applications/*"] }, "types": ["node", "vitest/globals"], "downlevelIteration": true, diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index c2e7bb359..869f56ea5 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ alias: { '@internal': path.resolve(rootDir, 'src/internal'), '@storage': path.resolve(rootDir, 'src/storage'), + '@applications': path.resolve(rootDir, 'src/applications'), }, }, test: { @@ -21,7 +22,7 @@ export default defineConfig({ fileParallelism: false, globals: true, hookTimeout: 30_000, - include: ['src/test/**/*.test.ts'], + include: ['src/test/**/*.test.ts', 'src/**/*.integration.test.ts'], sequence: { sequencer: IntegrationSequencer, }, diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 58d421a25..7af136d1b 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ alias: { '@internal': path.resolve(rootDir, 'src/internal'), '@storage': path.resolve(rootDir, 'src/storage'), + '@applications': path.resolve(rootDir, 'src/applications'), }, }, test: { @@ -19,6 +20,6 @@ export default defineConfig({ environment: 'node', globals: true, include: ['src/**/*.test.ts'], - exclude: ['src/test/**/*.test.ts'], + exclude: ['src/test/**/*.test.ts', 'src/**/*.integration.test.ts'], }, }) diff --git a/watt-db.json b/watt-db.json new file mode 100644 index 000000000..eaa0afd79 --- /dev/null +++ b/watt-db.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://schemas.platformatic.dev/@platformatic/runtime/3.63.0.json", + "entrypoint": "storage", + "exitOnUnhandledErrors": false, + "workers": "{WORKERS_NUM}", + "logger": { + "level": "{LOG_LEVEL}" + }, + "health": { + "enabled": "{WATT_HEALTH_ENABLED}", + "maxELU": "{WATT_HEALTH_MAX_ELU}", + "gracePeriod": "{WATT_HEALTH_GRACE_PERIOD}", + "maxUnhealthyChecks": "{WATT_HEALTH_MAX_UNHEALTHY_CHECKS}", + "interval": "{WATT_HEALTH_INTERVAL}" + }, + "managementApi": "{PLT_MANAGEMENT_API}", + "management": { + "operations": ["getWorkers"] + }, + "applications": [ + { + "id": "storage", + "path": ".", + "config": "watt.storage.json", + "dependencies": ["database"] + }, + { + "id": "database", + "path": ".", + "config": "watt.database.json", + "workers": 1 + } + ] +} diff --git a/watt.database.json b/watt.database.json new file mode 100644 index 000000000..400b758a5 --- /dev/null +++ b/watt.database.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://schemas.platformatic.dev/@platformatic/node/3.63.0.json", + "node": { + "main": "./dist/applications/database/index.js", + "hasServer": false + } +} diff --git a/watt.json b/watt.json index 47fa03da2..39558d8f2 100644 --- a/watt.json +++ b/watt.json @@ -1,5 +1,5 @@ { - "$schema": "https://schemas.platformatic.dev/@platformatic/runtime/3.59.0.json", + "$schema": "https://schemas.platformatic.dev/@platformatic/runtime/3.63.0.json", "entrypoint": "storage", "exitOnUnhandledErrors": false, "workers": "{WORKERS_NUM}", diff --git a/watt.storage.json b/watt.storage.json index 0323add60..c9bebabb8 100644 --- a/watt.storage.json +++ b/watt.storage.json @@ -1,5 +1,5 @@ { - "$schema": "https://schemas.platformatic.dev/@platformatic/node/3.62.2.json", + "$schema": "https://schemas.platformatic.dev/@platformatic/node/3.63.0.json", "node": { "main": "./dist/start/server.js", "disableBuildInDevelopment": true