@accounter/server
Version:
Accounter GraphQL server
547 lines (493 loc) • 20 kB
text/typescript
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 type { PoolClient, QueryResult, QueryResultRow } from 'pg';
import { resolveWriteTargetBusinessId } from '../../shared/helpers/auth-scope.js';
import type { AuthContext } from '../../shared/types/auth.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: string): boolean {
return DATA_MODIFYING_SQL.test(text);
}
/**
* Clients currently holding a checked-out pooled connection, so a leaked one
* can be found and reclaimed. A client that is never disposed holds that
* connection *and* an open transaction forever: Postgres reports it as `idle
* in transaction` with `wait_event = ClientRead`, and the pool loses the slot
* permanently. Once the leak count reaches the pool's `max`, every request
* hangs in `pool.connect()`.
*
* Membership is tied to holding a connection rather than to the client's
* lifetime, so the set stays bounded by the pool size no matter how many
* clients are constructed or whether anything disposes them.
*/
const connectionHolders = new Set<TenantAwareDBClient>();
export interface TenantDbClientStats {
/** Clients holding a checked-out connection right now. */
holdingConnection: number;
/** Longest any holder has gone without issuing a query. */
maxIdleMs: number;
}
export function getTenantDbClientStats(): TenantDbClientStats {
const now = Date.now();
let maxIdleMs = 0;
for (const client of connectionHolders) {
maxIdleMs = Math.max(maxIdleMs, now - client.lastActivityAt);
}
return { holdingConnection: connectionHolders.size, maxIdleMs };
}
export interface WatchdogOptions {
/** Force-dispose a client idle for longer than this. */
maxIdleMs: number;
/** How often to sweep. */
intervalMs: number;
onLeak?: (info: { idleMs: number; lastQuery: string | null }) => void;
}
/**
* Last line of defence against a connection leak.
*
* Disposal is driven by request lifecycle hooks, and the whole class of bug
* this guards against is a hook that does not fire. So the watchdog trusts no
* hook: it sweeps every live client and reclaims any that has gone quiet for
* longer than a request could plausibly stay quiet.
*
* The predicate is *idle* time (since the last query), not total age — a slow
* but healthy request keeps querying, while a leaked client never issues
* another statement, so its idle time grows without bound.
*/
export function startTenantDbClientWatchdog(options: WatchdogOptions): { stop: () => void } {
const timer = setInterval(() => {
const now = Date.now();
for (const client of connectionHolders) {
if (now - client.lastActivityAt < options.maxIdleMs) {
continue;
}
const info = { idleMs: now - client.lastActivityAt, lastQuery: client.lastQuery };
options.onLeak?.(info);
void client.dispose().catch(error => {
console.error('Watchdog failed to dispose leaked TenantAwareDBClient:', error);
});
}
}, options.intervalMs);
// Never hold the event loop open just to sweep.
timer.unref();
return { stop: () => clearInterval(timer) };
}
/**
* 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
*/
@Injectable({
scope: Scope.Operation,
global: true,
})
export class TenantAwareDBClient {
private mutex = new Mutex();
private storage = new AsyncLocalStorage<boolean>();
private activeClient: PoolClient | null = null;
private sessionOpen = false;
private transactionDepth = 0;
private isDisposed = false;
private authContext: AuthContext | null = null;
private authContextInitialized = false;
private clientErrorListener: ((error: Error) => void) | null = null;
/** Timestamp of the last query issued, for leak detection. See the watchdog. */
public lastActivityAt = Date.now();
/** First line of the last statement issued, to identify a leak's origin. */
public lastQuery: string | null = null;
public get holdsConnection(): boolean {
return this.activeClient !== null;
}
/**
* 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().
*/
public autoRelease: boolean;
constructor(
private dbProvider: DBProvider,
private authContextProvider: AuthContextProvider,
@Optional() @Inject(CONTEXT) context?: GraphQLModules.GlobalContext,
) {
// 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;
}
}
/** Records query activity so the watchdog can tell a busy client from a leaked one. */
private markActivity(text: string): void {
this.lastActivityAt = Date.now();
this.lastQuery = text.trim().split('\n')[0]?.slice(0, 120) ?? null;
}
/**
* Execute a query with RLS enforcement on the request-scoped session.
* Data-modifying statements are committed immediately.
*/
public async query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[],
): Promise<QueryResult<T> & { rowCount: number }> {
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) {
this.markActivity(text);
const result = await this.activeClient.query<T>(text, params);
this.markActivity(text);
return { ...result, rowCount: result.rowCount ?? 0 };
}
return this.mutex.runExclusive(async () => {
this.ensureNotDisposed();
const client = await this.ensureSession();
try {
this.markActivity(text);
const result = await client.query<T>(text, params);
this.markActivity(text);
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.
*/
public async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
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);
});
});
}
private async executeTransactionInternal<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.ensureSession();
this.transactionDepth++;
try {
let result: T;
// 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.
*/
private async ensureSession(): Promise<PoolClient> {
if (!this.activeClient) {
const client = await this.dbProvider.pool.connect();
// pg removes its own idle-client error handler while a client is checked
// out, leaving the borrower responsible for it. Without a listener here,
// a connection reset — including Postgres terminating the session via
// `idle_in_transaction_session_timeout` — emits an unhandled 'error'
// event, which surfaces as an uncaughtException and takes down the whole
// process. Absorb it and let the pool discard the connection instead.
const onClientError = (error: Error) => {
console.error('[db] Error on checked-out client, discarding connection:', error);
this.sessionOpen = false;
// Hand the connection back to the pool as destroyed. Merely dropping
// our own reference would leave the pool counting it as checked out
// forever — losing the slot permanently, which is precisely the leak
// this class exists to prevent.
this.releaseClient(true);
};
client.on('error', onClientError);
this.clientErrorListener = onClientError;
this.activeClient = client;
connectionHolders.add(this);
}
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)
}
this.releaseClient(true);
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.
*/
private async endSession(mode: 'COMMIT' | 'ROLLBACK'): Promise<void> {
if (!this.activeClient || !this.sessionOpen) {
return;
}
this.sessionOpen = false;
try {
await this.activeClient.query(mode);
} catch (error) {
console.error(`Failed to ${mode} transaction:`, error);
this.releaseClient(true);
}
}
private releaseClient(destroy = false): void {
connectionHolders.delete(this);
if (this.activeClient) {
if (this.clientErrorListener) {
this.activeClient.removeListener('error', this.clientErrorListener);
this.clientErrorListener = null;
}
try {
this.activeClient.release(destroy);
} catch (e) {
console.error('Error releasing client:', e);
}
this.activeClient = null;
}
}
/**
* Set PostgreSQL session variables for Row-Level Security.
*/
private async setRLSVariables(client: PoolClient): Promise<void> {
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.
*/
public async dispose(): Promise<void> {
if (this.isDisposed) return;
if (!this.activeClient) {
this.markDisposed();
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: (() => void) | undefined;
try {
release = await Promise.race([
this.mutex.acquire(),
new Promise<() => void>((_, 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.markDisposed();
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.markDisposed();
} finally {
release();
}
}
private markDisposed(): void {
this.isDisposed = true;
connectionHolders.delete(this);
}
private 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.
*/
private async ensureAuthContext(): Promise<void> {
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;
}
}