UNPKG

@accounter/server

Version:
240 lines (239 loc) 10.8 kB
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'; import { AuthContextProvider } from '../auth/providers/auth-context.provider.js'; import { DBProvider } from './db.provider.js'; export declare function isDataModifyingQuery(text: string): boolean; 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 declare function getTenantDbClientStats(): TenantDbClientStats; export interface WatchdogOptions { /** Force-dispose a client idle for longer than this. */ maxIdleMs: number; /** * Ceiling for a client whose GraphQL operation is *still executing*. * * A request can legitimately go quiet on the database for minutes while it * does external work — a document upload fetches the file, pushes it to * Cloudinary and waits on OCR before it writes anything. Reclaiming its client * on the plain idle rule would leave the request alive but its database * connection gone, so the write at the end fails with "already disposed" after * all that work. Such a client is therefore given a longer rope; it is not * exempt, because the flag saying it is executing lives on a request context * that a missed hook could leave set forever. * * Defaults to {@link maxIdleMs} when unset. */ activeMaxIdleMs?: number; /** * Ceiling for a client whose caller has already hung up. * * Disposal is deferred for these (the operation keeps running and still has to * write), so this is what bounds that deferral. It is deliberately much * tighter than {@link activeMaxIdleMs}: an aborted request that is genuinely * still working keeps issuing queries and never comes near it, while one whose * execution died with the connection — a query urql cancelled on the next * keystroke — goes silent immediately and is reclaimed within a sweep or two * rather than being held for the full active ceiling. * * Must stay above the statement timeout: a long query only bumps activity at * its start and end, so a shorter ceiling would reclaim a connection mid-query. */ abortedMaxIdleMs?: number; /** How often to sweep. */ intervalMs: number; onLeak?: (info: { idleMs: number; lastQuery: string | null; operationInFlight: boolean; requestAborted: boolean; }) => 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 declare function startTenantDbClientWatchdog(options: WatchdogOptions): { stop: () => void; }; /** * 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 */ export declare class TenantAwareDBClient { private dbProvider; private authContextProvider; private mutex; private storage; private activeClient; private sessionOpen; private transactionDepth; private isDisposed; private disposalRequested; private authContext; private authContextInitialized; private clientErrorListener; private readonly context; /** Timestamp of the last query issued, for leak detection. See the watchdog. */ lastActivityAt: number; /** First line of the last statement issued, to identify a leak's origin. */ lastQuery: string | null; get holdsConnection(): boolean; /** * Whether the GraphQL operation that owns this client is still executing. * * Set by `dbCleanupPlugin` around execution. It is what tells a request that * has gone quiet on the database — because it is fetching a file or waiting on * OCR — apart from one that has gone away. */ get operationInFlight(): boolean; /** * Whether the caller of the owning request has hung up. Set when a deferred * disposal is recorded; puts the client on the watchdog's short leash, which * is what bounds the deferral. */ get requestAborted(): boolean; /** * 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: boolean; constructor(dbProvider: DBProvider, authContextProvider: AuthContextProvider, context?: GraphQLModules.GlobalContext); /** Records query activity so the watchdog can tell a busy client from a leaked one. */ private markActivity; /** * Execute a query with RLS enforcement on the request-scoped session. * Data-modifying statements are committed immediately. */ query<T extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]): Promise<QueryResult<T> & { rowCount: number; }>; private queryOnSession; /** * Execute a function within a transaction block. * Handles nested transactions using SAVEPOINTs. The outermost scope is * committed immediately on success. */ transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T>; private executeTransactionInternal; /** * 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 ensureSession; /** * 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 endSession; private releaseClient; /** * Set PostgreSQL session variables for Row-Level Security. */ private setRLSVariables; /** * Hand the pooled connection back for the duration of long *non-database* * work, without ending the client's life. * * Document ingestion is the motivating case: between one query and the next it * downloads a file, uploads it to Cloudinary and waits on OCR — minutes during * which the connection would otherwise sit checked out and `idle in * transaction`, occupying a pool slot and pinning the oldest transaction * snapshot. Any open read session is committed (writes commit as they go * anyway) and a fresh one opens lazily on the next query. * * A no-op inside an explicit `transaction()` scope, whose atomicity depends on * keeping the connection, and a no-op once disposed. */ releaseIdleConnection(): Promise<void>; /** * Dispose, but not out from under a request that is still running. * * Used for client-side aborts: the HTTP connection going away says nothing * about the mutation still executing on this server, and JavaScript cannot * cancel the promise chain it is running on. Disposing there is what turned a * timed-out document upload into an "already disposed" failure at the final * INSERT — minutes of fetching and OCR thrown away at the last step, with the * charge left untouched. * * So while the operation is in flight the disposal is only *recorded*; the * work finishes and writes, and the client is released at the next natural * point — the end-of-execution hook, or the completion of its final query. The * watchdog remains the backstop for a request that never completes at all. * * @returns whether disposal was deferred (`true`) rather than performed. */ disposeWhenIdle(): Promise<boolean>; /** Run a disposal that was deferred by {@link disposeWhenIdle}, if it is now due. */ private disposeIfRequested; /** * 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. */ dispose(): Promise<void>; private markDisposed; private ensureNotDisposed; /** * Lazy initialization of auth context on first use. * This ensures the async provider is called only when needed. */ private ensureAuthContext; }