@accounter/server
Version:
Accounter GraphQL server
240 lines (239 loc) • 10.8 kB
TypeScript
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;
}