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>
328 lines
9.0 KiB
TypeScript
328 lines
9.0 KiB
TypeScript
/**
|
|
* 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',
|
|
};
|
|
}
|