Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion pgpm/core/src/migrate/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Logger } from '@pgpmjs/logger';
import { errors } from '@pgpmjs/types';
import { errors, extractPgErrorFields, formatPgError, formatPgErrorFields } from '@pgpmjs/types';
import { readFileSync } from 'fs';
import { dirname,join } from 'path';
import { Pool } from 'pg';
Expand Down Expand Up @@ -225,6 +225,15 @@ export class PgpmMigrate {
errorLines.push(` Error Code: ${error.code || 'N/A'}`);
errorLines.push(` Error Message: ${error.message || 'N/A'}`);

// Add extended PostgreSQL error fields
const pgFields = extractPgErrorFields(error);
if (pgFields) {
const fieldLines = formatPgErrorFields(pgFields);
if (fieldLines.length > 0) {
fieldLines.forEach(line => errorLines.push(` ${line}`));
}
}

// Show SQL script preview for debugging
if (cleanDeploySql) {
const sqlLines = cleanDeploySql.split('\n');
Expand Down Expand Up @@ -264,6 +273,14 @@ export class PgpmMigrate {
log.error(errorLines.join('\n'));

failed = change.name;

// Enhance the thrown error message with PostgreSQL extended fields
// This ensures callers get the same enhanced error format as PgTestClient
if (!(error as any).__pgpmEnhanced) {
error.message = formatPgError(error);
(error as any).__pgpmEnhanced = true;
}

throw error; // Re-throw to trigger rollback if in transaction
}

Expand Down Expand Up @@ -345,6 +362,13 @@ export class PgpmMigrate {

log.error(`Failed to revert ${change.name}:`, error);
failed = change.name;

// Enhance the thrown error message with PostgreSQL extended fields
if (!(error as any).__pgpmEnhanced) {
error.message = formatPgError(error);
(error as any).__pgpmEnhanced = true;
}

throw error; // Re-throw to trigger rollback if in transaction
}
}
Expand Down
79 changes: 76 additions & 3 deletions pgpm/core/src/migrate/sql/procedures.sql
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ CREATE PROCEDURE pgpm_migrate.deploy(
LANGUAGE plpgsql AS $$
DECLARE
v_change_id TEXT;
-- Error diagnostic variables
v_sqlstate TEXT;
v_message TEXT;
v_detail TEXT;
v_hint TEXT;
v_context TEXT;
v_schema_name TEXT;
v_table_name TEXT;
v_column_name TEXT;
v_constraint_name TEXT;
v_datatype_name TEXT;
BEGIN
-- Ensure package exists
CALL pgpm_migrate.register_package(p_package);
Expand Down Expand Up @@ -97,7 +108,30 @@ BEGIN
BEGIN
EXECUTE p_deploy_sql;
EXCEPTION WHEN OTHERS THEN
RAISE;
-- Capture all error diagnostics to preserve them in the re-raised exception
GET STACKED DIAGNOSTICS
v_sqlstate = RETURNED_SQLSTATE,
v_message = MESSAGE_TEXT,
v_detail = PG_EXCEPTION_DETAIL,
v_hint = PG_EXCEPTION_HINT,
v_context = PG_EXCEPTION_CONTEXT,
v_schema_name = SCHEMA_NAME,
v_table_name = TABLE_NAME,
v_column_name = COLUMN_NAME,
v_constraint_name = CONSTRAINT_NAME,
v_datatype_name = PG_DATATYPE_NAME;

-- Re-raise with all captured diagnostics preserved
RAISE EXCEPTION USING
ERRCODE = v_sqlstate,
MESSAGE = v_message,
DETAIL = v_detail,
HINT = v_hint,
SCHEMA = v_schema_name,
TABLE = v_table_name,
COLUMN = v_column_name,
CONSTRAINT = v_constraint_name,
DATATYPE = v_datatype_name;
END;
END IF;

Expand All @@ -124,6 +158,18 @@ CREATE PROCEDURE pgpm_migrate.revert(
p_revert_sql TEXT
)
LANGUAGE plpgsql AS $$
DECLARE
-- Error diagnostic variables
v_sqlstate TEXT;
v_message TEXT;
v_detail TEXT;
v_hint TEXT;
v_context TEXT;
v_schema_name TEXT;
v_table_name TEXT;
v_column_name TEXT;
v_constraint_name TEXT;
v_datatype_name TEXT;
BEGIN
-- Check if deployed
IF NOT pgpm_migrate.is_deployed(p_package, p_change_name) THEN
Expand Down Expand Up @@ -165,8 +211,35 @@ BEGIN
END;
END IF;

-- Execute revert
EXECUTE p_revert_sql;
-- Execute revert with error diagnostics preservation
BEGIN
EXECUTE p_revert_sql;
EXCEPTION WHEN OTHERS THEN
-- Capture all error diagnostics to preserve them in the re-raised exception
GET STACKED DIAGNOSTICS
v_sqlstate = RETURNED_SQLSTATE,
v_message = MESSAGE_TEXT,
v_detail = PG_EXCEPTION_DETAIL,
v_hint = PG_EXCEPTION_HINT,
v_context = PG_EXCEPTION_CONTEXT,
v_schema_name = SCHEMA_NAME,
v_table_name = TABLE_NAME,
v_column_name = COLUMN_NAME,
v_constraint_name = CONSTRAINT_NAME,
v_datatype_name = PG_DATATYPE_NAME;

-- Re-raise with all captured diagnostics preserved
RAISE EXCEPTION USING
ERRCODE = v_sqlstate,
MESSAGE = v_message,
DETAIL = v_detail,
HINT = v_hint,
SCHEMA = v_schema_name,
TABLE = v_table_name,
COLUMN = v_column_name,
CONSTRAINT = v_constraint_name,
DATATYPE = v_datatype_name;
END;

-- Remove from deployed
DELETE FROM pgpm_migrate.changes
Expand Down
21 changes: 20 additions & 1 deletion pgpm/core/src/migrate/utils/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Logger } from '@pgpmjs/logger';
import { extractPgErrorFields, formatPgErrorFields } from '@pgpmjs/types';
import { Pool, PoolClient } from 'pg';

const log = new Logger('migrate:transaction');
Expand Down Expand Up @@ -88,6 +89,15 @@ export async function withTransaction<T>(
errorLines.push(`Error Code: ${error.code || 'N/A'}`);
errorLines.push(`Error Message: ${error.message || 'N/A'}`);

// Add extended PostgreSQL error fields
const pgFields = extractPgErrorFields(error);
if (pgFields) {
const fieldLines = formatPgErrorFields(pgFields);
if (fieldLines.length > 0) {
errorLines.push(...fieldLines);
}
}

// Log query history for debugging
if (queryHistory.length > 0) {
errorLines.push('Query history for this transaction:');
Expand Down Expand Up @@ -150,11 +160,20 @@ export async function executeQuery(
errorLines.push(`Query failed after ${duration}ms:`);
errorLines.push(` Query: ${query.split('\n')[0].trim()}`);
if (params && params.length > 0) {
errorLines.push(` Params: ${JSON.stringify(params.slice(0, 3))}${params.length > 3 ? '...' : ''}`);
errorLines.push(` Params: ${JSON.stringify(params)}`);
}
errorLines.push(` Error Code: ${error.code || 'N/A'}`);
errorLines.push(` Error Message: ${error.message || 'N/A'}`);

// Add extended PostgreSQL error fields
const pgFields = extractPgErrorFields(error);
if (pgFields) {
const fieldLines = formatPgErrorFields(pgFields);
if (fieldLines.length > 0) {
fieldLines.forEach(line => errorLines.push(` ${line}`));
}
}

// Provide debugging hints for common errors
if (error.code === '42P01') {
errorLines.push('💡 Hint: Relation (table/view) does not exist. Check if migrations are applied in correct order.');
Expand Down
1 change: 1 addition & 0 deletions pgpm/types/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './error';
export * from './error-factory';
export * from './pg-error-format';
export * from './pgpm';
export * from './jobs';
export * from './update';
157 changes: 157 additions & 0 deletions pgpm/types/src/pg-error-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* PostgreSQL Error Formatting Utilities
*
* Extracts and formats extended PostgreSQL error fields from pg library errors.
* These fields provide additional context for debugging database errors.
*/

/**
* Extended PostgreSQL error fields available from pg-protocol.
* These fields are populated by PostgreSQL when an error occurs.
*/
export interface PgErrorFields {
/** PostgreSQL error code (e.g., '42P01' for undefined table) */
code?: string;
/** Additional detail about the error */
detail?: string;
/** Suggestion for fixing the error */
hint?: string;
/** PL/pgSQL call stack or context */
where?: string;
/** Character position in the query where the error occurred */
position?: string;
/** Position in internal query */
internalPosition?: string;
/** Internal query that caused the error */
internalQuery?: string;
/** Schema name related to the error */
schema?: string;
/** Table name related to the error */
table?: string;
/** Column name related to the error */
column?: string;
/** Data type name related to the error */
dataType?: string;
/** Constraint name related to the error */
constraint?: string;
/** Source file in PostgreSQL where error was generated */
file?: string;
/** Line number in PostgreSQL source file */
line?: string;
/** PostgreSQL routine that generated the error */
routine?: string;
}

/**
* Context about the query that caused the error.
*/
export interface PgErrorContext {
/** The SQL query that was executed */
query?: string;
/** Parameter values passed to the query */
values?: any[];
}

/**
* Extract PostgreSQL error fields from an error object.
* Returns null if the error doesn't appear to be a PostgreSQL error.
*
* @param err - The error object to extract fields from
* @returns PgErrorFields if the error has PG fields, null otherwise
*/
export function extractPgErrorFields(err: unknown): PgErrorFields | null {
if (!err || typeof err !== 'object') {
return null;
}

const e = err as Record<string, unknown>;

// Check if this looks like a PostgreSQL error (has code or detail)
if (!e.code && !e.detail && !e.where) {
return null;
}

const fields: PgErrorFields = {};

if (typeof e.code === 'string') fields.code = e.code;
if (typeof e.detail === 'string') fields.detail = e.detail;
if (typeof e.hint === 'string') fields.hint = e.hint;
if (typeof e.where === 'string') fields.where = e.where;
if (typeof e.position === 'string') fields.position = e.position;
if (typeof e.internalPosition === 'string') fields.internalPosition = e.internalPosition;
if (typeof e.internalQuery === 'string') fields.internalQuery = e.internalQuery;
if (typeof e.schema === 'string') fields.schema = e.schema;
if (typeof e.table === 'string') fields.table = e.table;
if (typeof e.column === 'string') fields.column = e.column;
if (typeof e.dataType === 'string') fields.dataType = e.dataType;
if (typeof e.constraint === 'string') fields.constraint = e.constraint;
if (typeof e.file === 'string') fields.file = e.file;
if (typeof e.line === 'string') fields.line = e.line;
if (typeof e.routine === 'string') fields.routine = e.routine;

return fields;
}

/**
* Format PostgreSQL error fields into an array of human-readable lines.
* Only includes fields that are present and non-empty.
*
* @param fields - The PostgreSQL error fields to format
* @returns Array of formatted lines
*/
export function formatPgErrorFields(fields: PgErrorFields): string[] {
const lines: string[] = [];

if (fields.detail) lines.push(`Detail: ${fields.detail}`);
if (fields.hint) lines.push(`Hint: ${fields.hint}`);
if (fields.where) lines.push(`Where: ${fields.where}`);
if (fields.schema) lines.push(`Schema: ${fields.schema}`);
if (fields.table) lines.push(`Table: ${fields.table}`);
if (fields.column) lines.push(`Column: ${fields.column}`);
if (fields.dataType) lines.push(`Data Type: ${fields.dataType}`);
if (fields.constraint) lines.push(`Constraint: ${fields.constraint}`);
if (fields.position) lines.push(`Position: ${fields.position}`);
if (fields.internalQuery) lines.push(`Internal Query: ${fields.internalQuery}`);
if (fields.internalPosition) lines.push(`Internal Position: ${fields.internalPosition}`);

return lines;
}

/**
* Format a PostgreSQL error with full context for debugging.
* Combines the original error message with extended PostgreSQL fields
* and optional query context.
*
* @param err - The error object
* @param context - Optional query context (SQL and parameters)
* @returns Formatted error string with all available information
*/
export function formatPgError(err: unknown, context?: PgErrorContext): string {
if (!err || typeof err !== 'object') {
return String(err);
}

const e = err as Record<string, unknown>;
const message = typeof e.message === 'string' ? e.message : String(err);

const lines: string[] = [message];

// Add PostgreSQL error fields
const pgFields = extractPgErrorFields(err);
if (pgFields) {
const fieldLines = formatPgErrorFields(pgFields);
if (fieldLines.length > 0) {
lines.push(...fieldLines);
}
}

// Add query context
if (context?.query) {
lines.push(`Query: ${context.query}`);
}
if (context?.values !== undefined) {
lines.push(`Values: ${JSON.stringify(context.values)}`);
}

return lines.join('\n');
}
Loading