feat: Add production-ready utilities and performance improvements

Security & Data Integrity:
- Centralized transaction helper with deadlock retry (exponential backoff)
- SafeQueryBuilder for safe parameterized queries
- Zod-based input validation middleware
- Audit logging to Outline's events table

Performance:
- Cursor-based pagination for large datasets
- Pool monitoring with configurable alerts
- Database index migrations for optimal query performance

Changes:
- Refactored bulk-operations, desk-sync, export-import to use centralized transaction helper
- Added 7 new utility modules (audit, monitoring, pagination, query-builder, transaction, validation)
- Created migrations/001_indexes.sql with 40+ recommended indexes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 15:23:32 +00:00
parent 7c83a9e168
commit b4ba42cbf1
13 changed files with 2240 additions and 57 deletions

327
src/utils/pagination.ts Normal file
View File

@@ -0,0 +1,327 @@
/**
* MCP Outline PostgreSQL - Cursor-Based Pagination
* Efficient pagination using cursors instead of OFFSET
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool, QueryResultRow } from 'pg';
import { logger } from './logger.js';
/**
* Cursor pagination input arguments
*/
export interface CursorPaginationArgs {
/** Maximum number of items to return (default: 25, max: 100) */
limit?: number;
/** Cursor for next page (base64 encoded) */
cursor?: string;
/** Direction of pagination (default: 'desc') */
direction?: 'asc' | 'desc';
}
/**
* Cursor pagination result
*/
export interface CursorPaginationResult<T> {
/** Array of items */
items: T[];
/** Cursor for the next page (null if no more pages) */
nextCursor: string | null;
/** Cursor for the previous page (null if first page) */
prevCursor: string | null;
/** Whether there are more items */
hasMore: boolean;
/** Total count (optional, requires additional query) */
totalCount?: number;
}
/**
* Cursor data structure (encoded in base64)
*/
interface CursorData {
/** Value of the cursor field */
v: string | number;
/** Direction for this cursor */
d: 'asc' | 'desc';
/** Secondary sort field value (for stable sorting) */
s?: string;
}
/**
* Encode cursor data to base64
*/
export function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64url');
}
/**
* Decode cursor from base64
*/
export function decodeCursor(cursor: string): CursorData | null {
try {
const decoded = Buffer.from(cursor, 'base64url').toString('utf-8');
return JSON.parse(decoded) as CursorData;
} catch (error) {
logger.warn('Invalid cursor', { cursor, error: (error as Error).message });
return null;
}
}
/**
* Options for cursor-based pagination
*/
export interface PaginateOptions {
/** The field to use for cursor (default: 'createdAt') */
cursorField?: string;
/** Secondary sort field for stability (default: 'id') */
secondaryField?: string;
/** Whether to include total count (requires extra query) */
includeTotalCount?: boolean;
/** Default limit if not specified */
defaultLimit?: number;
/** Maximum allowed limit */
maxLimit?: number;
}
const DEFAULT_OPTIONS: Required<PaginateOptions> = {
cursorField: 'createdAt',
secondaryField: 'id',
includeTotalCount: false,
defaultLimit: 25,
maxLimit: 100,
};
/**
* Build cursor-based pagination query parts
*
* @param args - Pagination arguments
* @param options - Pagination options
* @returns Query parts (WHERE clause, ORDER BY, LIMIT, parameters)
*/
export function buildCursorQuery(
args: CursorPaginationArgs,
options?: PaginateOptions
): {
cursorCondition: string;
orderBy: string;
limit: number;
params: any[];
paramIndex: number;
} {
const opts = { ...DEFAULT_OPTIONS, ...options };
const direction = args.direction || 'desc';
const limit = Math.min(Math.max(1, args.limit || opts.defaultLimit), opts.maxLimit);
const params: any[] = [];
let paramIndex = 1;
let cursorCondition = '';
// Parse cursor if provided
if (args.cursor) {
const cursorData = decodeCursor(args.cursor);
if (cursorData) {
// Build cursor condition with secondary field for stability
const op = direction === 'desc' ? '<' : '>';
if (cursorData.s) {
// Compound cursor: (cursorField, secondaryField) comparison
cursorCondition = `("${opts.cursorField}", "${opts.secondaryField}") ${op} ($${paramIndex}, $${paramIndex + 1})`;
params.push(cursorData.v, cursorData.s);
paramIndex += 2;
} else {
// Simple cursor
cursorCondition = `"${opts.cursorField}" ${op} $${paramIndex}`;
params.push(cursorData.v);
paramIndex += 1;
}
}
}
// Build ORDER BY
const orderDirection = direction.toUpperCase();
const orderBy = `"${opts.cursorField}" ${orderDirection}, "${opts.secondaryField}" ${orderDirection}`;
return {
cursorCondition,
orderBy,
limit: limit + 1, // Fetch one extra to detect hasMore
params,
paramIndex,
};
}
/**
* Process query results for cursor pagination
*
* @param rows - Query result rows
* @param limit - Original limit (not the +1 used in query)
* @param cursorField - Field used for cursor
* @param secondaryField - Secondary field for stable cursor
* @param direction - Pagination direction
* @returns Pagination result
*/
export function processCursorResults<T extends QueryResultRow>(
rows: T[],
limit: number,
cursorField: string = 'createdAt',
secondaryField: string = 'id',
direction: 'asc' | 'desc' = 'desc'
): CursorPaginationResult<T> {
// Check if there are more results
const hasMore = rows.length > limit;
// Remove the extra item used for hasMore detection
const items = hasMore ? rows.slice(0, limit) : rows;
// Build next cursor
let nextCursor: string | null = null;
if (hasMore && items.length > 0) {
const lastItem = items[items.length - 1];
nextCursor = encodeCursor({
v: lastItem[cursorField],
d: direction,
s: lastItem[secondaryField],
});
}
// Build prev cursor (first item, opposite direction)
let prevCursor: string | null = null;
if (items.length > 0) {
const firstItem = items[0];
prevCursor = encodeCursor({
v: firstItem[cursorField],
d: direction === 'desc' ? 'asc' : 'desc',
s: firstItem[secondaryField],
});
}
return {
items,
nextCursor,
prevCursor,
hasMore,
};
}
/**
* Execute a cursor-paginated query
*
* This is a high-level helper that combines building and processing.
*
* @param pool - PostgreSQL pool
* @param baseQuery - Base SELECT query (without WHERE/ORDER BY/LIMIT)
* @param baseParams - Parameters for the base query
* @param args - Pagination arguments
* @param options - Pagination options
* @returns Paginated results
*
* @example
* ```typescript
* const result = await paginateWithCursor(
* pool,
* 'SELECT * FROM documents WHERE "deletedAt" IS NULL AND "collectionId" = $1',
* [collectionId],
* { limit: 25, cursor: args.cursor },
* { cursorField: 'createdAt' }
* );
* ```
*/
export async function paginateWithCursor<T extends QueryResultRow>(
pool: Pool,
baseQuery: string,
baseParams: any[],
args: CursorPaginationArgs,
options?: PaginateOptions
): Promise<CursorPaginationResult<T>> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const limit = Math.min(Math.max(1, args.limit || opts.defaultLimit), opts.maxLimit);
const direction = args.direction || 'desc';
// Build cursor query parts
const { cursorCondition, orderBy, limit: queryLimit, params: cursorParams, paramIndex } =
buildCursorQuery(args, opts);
// Combine base params with cursor params
const allParams = [...baseParams, ...cursorParams];
// Adjust parameter placeholders in base query if needed
// Base query params are $1, $2, etc.
// Cursor params start at $N where N = baseParams.length + 1
// Build final query
let query = baseQuery;
// Add cursor condition if present
if (cursorCondition) {
// Check if base query has WHERE
if (baseQuery.toUpperCase().includes('WHERE')) {
query += ` AND ${cursorCondition}`;
} else {
query += ` WHERE ${cursorCondition}`;
}
}
// Add ORDER BY and LIMIT
query += ` ORDER BY ${orderBy}`;
query += ` LIMIT ${queryLimit}`;
// Execute query
const result = await pool.query<T>(query, allParams);
// Process results
const paginationResult = processCursorResults(
result.rows,
limit,
opts.cursorField,
opts.secondaryField,
direction
);
// Optionally get total count
if (opts.includeTotalCount) {
const countQuery = baseQuery.replace(/SELECT .* FROM/, 'SELECT COUNT(*) as count FROM');
const countResult = await pool.query<{ count: string }>(countQuery, baseParams);
paginationResult.totalCount = parseInt(countResult.rows[0]?.count || '0', 10);
}
return paginationResult;
}
/**
* Convert offset-based pagination to cursor-based response format
* Useful for backwards compatibility
*/
export function offsetToCursorResult<T>(
items: T[],
offset: number,
limit: number,
totalCount?: number
): CursorPaginationResult<T> {
const hasMore = totalCount !== undefined ? offset + items.length < totalCount : items.length === limit;
return {
items,
nextCursor: hasMore ? encodeCursor({ v: offset + limit, d: 'desc' }) : null,
prevCursor: offset > 0 ? encodeCursor({ v: Math.max(0, offset - limit), d: 'desc' }) : null,
hasMore,
totalCount,
};
}
/**
* Validate pagination arguments
*/
export function validatePaginationArgs(
args: CursorPaginationArgs,
options?: Pick<PaginateOptions, 'maxLimit' | 'defaultLimit'>
): { limit: number; cursor: string | null; direction: 'asc' | 'desc' } {
const maxLimit = options?.maxLimit ?? 100;
const defaultLimit = options?.defaultLimit ?? 25;
return {
limit: Math.min(Math.max(1, args.limit ?? defaultLimit), maxLimit),
cursor: args.cursor || null,
direction: args.direction || 'desc',
};
}