@accounter/server
Version:
Accounter GraphQL server
394 lines • 16.4 kB
JavaScript
import { __decorate, __metadata, __param } from "tslib";
import { AsyncLocalStorage } from 'node:async_hooks';
import { Mutex } from 'async-mutex';
import { GraphQLError } from 'graphql';
import { CONTEXT, Inject, Injectable, Optional, Scope } from 'graphql-modules';
import { resolveWriteTargetBusinessId } from '../../shared/helpers/auth-scope.js';
import { AuthContextProvider } from '../auth/providers/auth-context.provider.js';
import { DBProvider } from './db.provider.js';
/**
* Statements that may modify data (or session/schema state). Used to decide
* whether a stand-alone query must be committed immediately (durability before
* the response) or may stay in the request-scoped read transaction. Word-bound
* so column names like `updated_at` don't match. False positives only cost an
* extra COMMIT + re-BEGIN; false negatives are still committed at request end.
*/
const DATA_MODIFYING_SQL = /\b(insert|update|delete|merge|truncate|create|alter|drop|grant|revoke|copy|call|do|refresh|lock|setval|set_config|vacuum|cluster|reindex)\b/i;
export function isDataModifyingQuery(text) {
return DATA_MODIFYING_SQL.test(text);
}
/**
* TenantAwareDBClient enforces Row-Level Security (RLS) by setting PostgreSQL
* session variables on a request-scoped transaction.
*
* RLS Enforcement:
* - app.current_business_id: Set to the authenticated user's active business
* - app.current_user_id: Set to the authenticated user's ID (or NULL for API keys)
* - app.auth_type: Set to 'jwt' or 'apiKey'
*
* **Usage:**
* Inject into Operation-scoped providers via constructor DI:
*
* @example
* @Injectable({ scope: Scope.Operation })
* class BusinessesProvider {
* constructor(private db: TenantAwareDBClient) {}
*
* async getBusinesses() {
* return this.db.query('SELECT * FROM businesses')
* }
* }
*
* Session model (request-scoped):
* - The first query checks out one pooled connection and opens a transaction
* with the RLS variables set once. Subsequent read queries reuse it — one
* round trip per query instead of BEGIN/SET/query/COMMIT for each.
* - Data-modifying stand-alone queries and explicit `transaction()` scopes are
* committed immediately on success, so a mutation response always reflects
* durable state. The read session re-opens lazily on the next query.
* - A failed statement aborts the surrounding transaction (Postgres 25P02), so
* errors roll the session back and the next query starts a fresh one. Only
* uncommitted read-only work is discarded — writes were already committed.
* - `dispose()` (invoked by dbCleanupPlugin at request/stream end) commits any
* open read session and releases the connection back to the pool.
*
* Transaction Management:
* - Supports nested transactions via SAVEPOINTs
* - Automatically rolls back on error
* - Automatically releases connection on dispose
*
* **DO NOT** access from Yoga context - use DI injection instead.
*
* @throws {GraphQLError} UNAUTHENTICATED if auth context is null
*/
let TenantAwareDBClient = class TenantAwareDBClient {
dbProvider;
authContextProvider;
mutex = new Mutex();
storage = new AsyncLocalStorage();
activeClient = null;
sessionOpen = false;
transactionDepth = 0;
isDisposed = false;
authContext = null;
authContextInitialized = false;
/**
* Per-operation mode: commit and release the connection after every
* top-level query/transaction (the pre-request-scoped behavior). Defaults to
* true for direct constructions outside the GraphQL request lifecycle (no
* CONTEXT injection — test harnesses, scripts) where nothing calls
* dispose(): a held connection would otherwise leak from the pool, keep
* table locks, and block pool.end().
*/
autoRelease;
constructor(dbProvider, authContextProvider, context) {
this.dbProvider = dbProvider;
this.authContextProvider = authContextProvider;
// Register for end-of-request disposal (commit + release of the
// request-scoped connection). dbCleanupPlugin drains this list once the
// response — including any @defer/@stream tail — is fully sent. Absent
// context (direct construction), fall back to commit-and-release per
// operation since nothing would ever call dispose().
if (context) {
(context.dbClientsToDispose ??= []).push(this);
this.autoRelease = false;
}
else {
this.autoRelease = true;
}
}
/**
* Execute a query with RLS enforcement on the request-scoped session.
* Data-modifying statements are committed immediately.
*/
async query(text, params) {
this.ensureNotDisposed();
await this.ensureAuthContext();
if (!this.authContext) {
throw new GraphQLError('Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } });
}
// Inside an explicit transaction() scope — run on its client directly.
if (this.storage.getStore() && this.activeClient) {
const result = await this.activeClient.query(text, params);
return { ...result, rowCount: result.rowCount ?? 0 };
}
return this.mutex.runExclusive(async () => {
this.ensureNotDisposed();
const client = await this.ensureSession();
try {
const result = await client.query(text, params);
if (this.autoRelease || isDataModifyingQuery(text)) {
await this.endSession('COMMIT');
}
return { ...result, rowCount: result.rowCount ?? 0 };
}
catch (error) {
// The failed statement aborted the transaction; roll back so the next
// query gets a fresh session instead of 25P02 errors.
await this.endSession('ROLLBACK');
throw error;
}
finally {
if (this.autoRelease) {
this.releaseClient();
}
}
});
}
/**
* Execute a function within a transaction block.
* Handles nested transactions using SAVEPOINTs. The outermost scope is
* committed immediately on success.
*/
async transaction(fn) {
this.ensureNotDisposed();
await this.ensureAuthContext();
if (!this.authContext) {
throw new GraphQLError('Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } });
}
if (this.storage.getStore()) {
return this.executeTransactionInternal(fn);
}
return this.mutex.runExclusive(() => {
this.ensureNotDisposed();
return this.storage.run(true, () => {
return this.executeTransactionInternal(fn);
});
});
}
async executeTransactionInternal(fn) {
const client = await this.ensureSession();
this.transactionDepth++;
try {
let result;
// Use a savepoint for all nested scopes (depth > 1) to isolate failures
// and allow partial success/failure within the shared transaction.
if (this.transactionDepth > 1) {
const savepointName = `sp_${this.transactionDepth}`;
try {
await client.query(`SAVEPOINT ${savepointName}`);
result = await fn(client);
await client.query(`RELEASE SAVEPOINT ${savepointName}`);
}
catch (error) {
await client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
throw error;
}
}
else {
result = await fn(client);
// Outermost scope: commit promptly — explicit transactions are used by
// mutations whose success response must reflect durable state.
await this.endSession('COMMIT');
}
return result;
}
catch (error) {
// Nested savepoint rollbacks are handled above; an error reaching the
// outermost scope rolls back the whole session.
if (this.transactionDepth === 1) {
await this.endSession('ROLLBACK');
}
throw error;
}
finally {
this.transactionDepth--;
if (this.transactionDepth === 0 && this.autoRelease) {
this.releaseClient();
}
}
}
/**
* Ensure the request-scoped session is open: one pooled connection for the
* whole request, with an open transaction carrying the RLS variables.
* Always called while holding the mutex.
*/
async ensureSession() {
this.activeClient ||= await this.dbProvider.pool.connect();
if (!this.sessionOpen) {
const client = this.activeClient;
try {
await client.query('BEGIN');
await this.setRLSVariables(client);
this.sessionOpen = true;
}
catch (error) {
// A failed BEGIN/RLS setup leaves the connection in an unknown state —
// discard it entirely rather than returning it to the pool.
try {
await client.query('ROLLBACK');
}
catch {
// Ignore rollback errors (e.g. if connection closed)
}
try {
client.release(true);
}
catch {
// Ignore release errors
}
this.activeClient = null;
throw error;
}
}
return this.activeClient;
}
/**
* Close the open transaction (COMMIT or ROLLBACK). The connection is kept
* for the next session unless the close itself fails, in which case the
* connection state is unknown and it is destroyed.
*/
async endSession(mode) {
if (!this.activeClient || !this.sessionOpen) {
return;
}
this.sessionOpen = false;
try {
await this.activeClient.query(mode);
}
catch (error) {
console.error(`Failed to ${mode} transaction:`, error);
try {
this.activeClient.release(true);
}
catch {
// Ignore release errors
}
this.activeClient = null;
}
}
releaseClient() {
if (this.activeClient) {
try {
this.activeClient.release();
}
catch (e) {
console.error('Error releasing client:', e);
}
this.activeClient = null;
}
}
/**
* Set PostgreSQL session variables for Row-Level Security.
*/
async setRLSVariables(client) {
if (!this.authContext) {
throw new GraphQLError('Unauthenticated', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
const { tenant, user, authType, activeReadScope } = this.authContext ?? {};
// Write-target: the single business this request owns / writes to, derived
// from the primary tenant business and the active scope. The auth context
// already re-points `tenant.businessId` to this value; resolving it here
// again keeps the RLS session correct as defense-in-depth.
const businessIdValue = resolveWriteTargetBusinessId(tenant?.businessId, activeReadScope);
if (!businessIdValue) {
throw new Error('Missing businessId in AuthContext');
}
// API keys use a non-UUID identifier (e.g. "api-key:<id>") for app-level tracing.
// The DB helper get_current_user_id() casts app.current_user_id to UUID and handles
// empty string via NULLIF(..., ''), so we pass '' for API key sessions to avoid a
// runtime cast error while explicitly clearing the setting.
const userIdValue = authType === 'apiKey' ? '' : (user?.userId ?? null);
// Read scope: the businesses this request may read from, serialized as a
// Postgres array literal ('{uuid1,uuid2}') for get_current_business_scope().
// When empty/absent we pass '' so the DB helper falls back to the single
// write-target business. Writes remain pinned to app.current_business_id.
const readScopeValue = activeReadScope && activeReadScope.businessIds.length > 0
? `{${activeReadScope.businessIds.map(id => `"${id.replace(/"/g, '\\"')}"`).join(',')}}`
: '';
await client.query(`
SELECT
set_config('app.current_business_id', $1, true),
set_config('app.current_user_id', $2, true),
set_config('app.auth_type', $3, true),
set_config('app.current_business_scope', $4, true);
`, [businessIdValue, userIdValue, authType, readScopeValue]);
}
/**
* End-of-request cleanup: commit any open read session and release the
* connection. Invoked by dbCleanupPlugin once the response (including any
* deferred stream) is fully sent; safe to call manually for direct
* constructions.
*/
async dispose() {
if (this.isDisposed)
return;
if (!this.activeClient) {
this.isDisposed = true;
return;
}
// Use a timeout to prevent hanging indefinitely if a query is stuck
// holding the mutex — we don't want to block the request handler.
const TIMEOUT_MS = 5000;
let release;
try {
release = await Promise.race([
this.mutex.acquire(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout acquiring mutex')), TIMEOUT_MS)),
]);
}
catch (e) {
console.warn('Timeout acquiring mutex during TenantAwareDBClient disposal. Connection may be in use.', e);
// Mark disposed to prevent further usage, and schedule cleanup for when
// the in-flight operation finishes — otherwise the held connection would
// leak from the pool.
this.isDisposed = true;
void this.mutex
.runExclusive(async () => {
await this.endSession('ROLLBACK');
this.releaseClient();
})
.catch(e2 => {
console.error('Deferred TenantAwareDBClient cleanup failed:', e2);
});
return;
}
try {
if (this.isDisposed)
return;
// Any uncommitted residue is read-only (writes commit promptly), but
// COMMIT keeps a missed write-classification durable as a safety net.
await this.endSession('COMMIT');
this.releaseClient();
this.isDisposed = true;
}
finally {
release();
}
}
ensureNotDisposed() {
if (this.isDisposed) {
throw new Error('TenantAwareDBClient is already disposed');
}
}
/**
* Lazy initialization of auth context on first use.
* This ensures the async provider is called only when needed.
*/
async ensureAuthContext() {
if (this.authContextInitialized) {
return;
}
if (!this.authContextProvider) {
throw new GraphQLError('Auth context not available. TenantAwareDBClient requires active authentication.', { extensions: { code: 'UNAUTHENTICATED' } });
}
this.authContext = await this.authContextProvider.getAuthContext();
this.authContextInitialized = true;
}
};
TenantAwareDBClient = __decorate([
Injectable({
scope: Scope.Operation,
global: true,
}),
__param(2, Optional()),
__param(2, Inject(CONTEXT)),
__metadata("design:paramtypes", [DBProvider,
AuthContextProvider, Object])
], TenantAwareDBClient);
export { TenantAwareDBClient };
//# sourceMappingURL=tenant-db-client.js.map