Skip to content

Transactions ​

While PostgREST doesn't support traditional database transactions like ORM, PGRestify provides transaction-like patterns and coordination mechanisms to help manage complex operations across multiple tables. This guide covers the available patterns and best practices for handling multi-step operations.

Overview ​

Transaction handling in PGRestify includes:

  • Pseudo-Transactions: Coordination mechanisms that simulate transaction behavior
  • Compensating Actions: Manual rollback patterns for failed operations
  • Atomic Operations: Single-request operations that are naturally atomic
  • Error Handling: Comprehensive error handling for multi-step operations
  • PostgreSQL Functions: Using RPC calls for true database-level transactions

PostgREST Transaction Limitations ​

PostgREST operates over HTTP and doesn't maintain persistent connections, which means traditional transactions aren't supported. However, there are several patterns to work around this limitation.

What PostgREST Doesn't Support ​

typescript
// ❌ This doesn't exist in PostgREST/PGRestify
const transaction = await dataManager.startTransaction();
try {
  await userRepository.save(user);
  await postRepository.save(post);
  await transaction.commit();
} catch (error) {
  await transaction.rollback();
  throw error;
}

What PGRestify Provides Instead ​

typescript
// ✅ Pseudo-transaction with error handling and compensation
const result = await dataManager.transaction(async (manager) => {
  const userRepo = manager.getRepository<User>('users');
  const postRepo = manager.getRepository<Post>('posts');
  
  // Operations with manual rollback logic
  const user = await userRepo.insert(userData);
  try {
    const post = await postRepo.insert({ ...postData, author_id: user[0].id });
    return { user: user[0], post: post[0] };
  } catch (error) {
    // Compensating action: delete the user if post creation failed
    await userRepo.delete({ id: user[0].id });
    throw error;
  }
});

Pseudo-Transaction Patterns ​

Basic Pseudo-Transaction ​

The DataManager provides a transaction method that coordinates operations:

typescript
import { createClient } from '@webcoded/pgrestify';

interface User {
  id: string;
  email: string;
  first_name: string;
  last_name: string;
  active: boolean;
}

interface Post {
  id: string;
  title: string;
  content: string;
  author_id: string;
  published: boolean;
}

const client = createClient({ url: 'http://localhost:3000' });
const dataManager = client.dataManager;

async function createUserWithPost(userData: Partial<User>, postData: Partial<Post>) {
  return await dataManager.transaction(async (manager) => {
    const userRepo = manager.getRepository<User>('users');
    const postRepo = manager.getRepository<Post>('posts');
    
    // Step 1: Create user
    const createdUsers = await userRepo.insert(userData);
    const user = createdUsers[0];
    
    if (!user) {
      throw new Error('Failed to create user');
    }
    
    try {
      // Step 2: Create post linked to user
      const postWithAuthor = {
        ...postData,
        author_id: user.id
      };
      
      const createdPosts = await postRepo.insert(postWithAuthor);
      const post = createdPosts[0];
      
      if (!post) {
        throw new Error('Failed to create post');
      }
      
      return { user, post };
      
    } catch (postError) {
      // Compensating action: delete the user
      try {
        await userRepo.delete({ id: user.id });
      } catch (cleanupError) {
        console.error('Failed to cleanup user after post creation failure:', cleanupError);
      }
      throw postError;
    }
  });
}

Complex Multi-Table Operations ​

Handle operations across multiple related tables:

typescript
interface UserProfile {
  id: string;
  user_id: string;
  bio: string;
  avatar_url: string;
}

interface UserRole {
  user_id: string;
  role: string;
  assigned_at: string;
}

async function createCompleteUser(
  userData: Partial<User>, 
  profileData: Partial<UserProfile>,
  roleData: Partial<UserRole>
) {
  const createdEntities: {
    user?: User;
    profile?: UserProfile;
    role?: UserRole;
  } = {};
  
  try {
    return await dataManager.transaction(async (manager) => {
      const userRepo = manager.getRepository<User>('users');
      const profileRepo = manager.getRepository<UserProfile>('user_profiles');
      const roleRepo = manager.getRepository<UserRole>('user_roles');
      
      // Step 1: Create user
      const users = await userRepo.insert(userData);
      createdEntities.user = users[0];
      
      if (!createdEntities.user) {
        throw new Error('Failed to create user');
      }
      
      // Step 2: Create profile
      const profileWithUserId = {
        ...profileData,
        user_id: createdEntities.user.id
      };
      
      const profiles = await profileRepo.insert(profileWithUserId);
      createdEntities.profile = profiles[0];
      
      if (!createdEntities.profile) {
        throw new Error('Failed to create user profile');
      }
      
      // Step 3: Assign role
      const roleWithUserId = {
        ...roleData,
        user_id: createdEntities.user.id,
        assigned_at: new Date().toISOString()
      };
      
      const roles = await roleRepo.insert(roleWithUserId);
      createdEntities.role = roles[0];
      
      if (!createdEntities.role) {
        throw new Error('Failed to assign user role');
      }
      
      return {
        user: createdEntities.user,
        profile: createdEntities.profile,
        role: createdEntities.role
      };
    });
    
  } catch (error) {
    // Comprehensive cleanup
    await cleanupFailedUserCreation(createdEntities);
    throw error;
  }
}

async function cleanupFailedUserCreation(entities: {
  user?: User;
  profile?: UserProfile;
  role?: UserRole;
}) {
  const userRepo = dataManager.getRepository<User>('users');
  const profileRepo = dataManager.getRepository<UserProfile>('user_profiles');
  const roleRepo = dataManager.getRepository<UserRole>('user_roles');
  
  try {
    // Clean up in reverse order
    if (entities.role && entities.user) {
      await roleRepo.delete({ user_id: entities.user.id });
    }
    
    if (entities.profile && entities.user) {
      await profileRepo.delete({ user_id: entities.user.id });
    }
    
    if (entities.user) {
      await userRepo.delete({ id: entities.user.id });
    }
    
  } catch (cleanupError) {
    console.error('Failed to cleanup entities:', cleanupError);
    // In a real application, you might want to log this for manual cleanup
  }
}

Atomic Operations ​

Single-Request Atomicity ​

Some operations are naturally atomic within PostgREST:

typescript
async function atomicOperations() {
  const userRepo = dataManager.getRepository<User>('users');
  
  // ✅ These operations are atomic (single request)
  
  // Bulk insert - all succeed or all fail
  const users = await userRepo.insertMany([
    { email: 'user1@example.com', first_name: 'User', last_name: 'One' },
    { email: 'user2@example.com', first_name: 'User', last_name: 'Two' },
    { email: 'user3@example.com', first_name: 'User', last_name: 'Three' }
  ]);
  
  // Bulk update - all matching records updated atomically
  const updatedUsers = await userRepo.update(
    { role: 'user' },
    { active: true, updated_at: new Date().toISOString() }
  );
  
  // Upsert operation - atomic insert or update
  const upsertedUser = await userRepo.save({
    email: 'unique@example.com',
    first_name: 'Unique',
    last_name: 'User'
  });
  
  console.log('Atomic operations completed successfully');
}

Query Builder Atomic Operations ​

typescript
async function complexAtomicOperations() {
  const userRepo = dataManager.getRepository<User>('users');
  
  // Atomic bulk operations with conditions
  const result = await userRepo
    .getQueryBuilder()
    .update({
      active: false,
      deactivated_at: new Date().toISOString()
    })
    .lt('last_login', '2023-01-01')
    .eq('role', 'user')
    .execute();
  
  console.log(`Deactivated ${result.data?.length || 0} inactive users`);
  
  // Atomic deletion with complex conditions
  const deletedUsers = await userRepo
    .getQueryBuilder()
    .delete()
    .eq('active', false)
    .is('email', null)
    .lt('created_at', '2022-01-01')
    .execute();
  
  console.log(`Deleted ${deletedUsers.data?.length || 0} stale accounts`);
}

PostgreSQL Function Transactions ​

For true database-level transactions, use PostgreSQL functions via RPC:

Creating Transaction Functions ​

sql
-- Create a PostgreSQL function for transactional operations
CREATE OR REPLACE FUNCTION create_user_with_posts(
  p_email TEXT,
  p_first_name TEXT,
  p_last_name TEXT,
  p_post_titles TEXT[]
) RETURNS JSON AS $$
DECLARE
  v_user_id UUID;
  v_title TEXT;
  v_result JSON;
BEGIN
  -- Start implicit transaction
  
  -- Insert user
  INSERT INTO users (email, first_name, last_name, active)
  VALUES (p_email, p_first_name, p_last_name, true)
  RETURNING id INTO v_user_id;
  
  -- Insert posts for the user
  FOREACH v_title IN ARRAY p_post_titles LOOP
    INSERT INTO posts (title, content, author_id, published)
    VALUES (
      v_title, 
      'Default content for ' || v_title,
      v_user_id,
      false
    );
  END LOOP;
  
  -- Return the created user with post count
  SELECT json_build_object(
    'user_id', v_user_id,
    'email', p_email,
    'posts_created', array_length(p_post_titles, 1)
  ) INTO v_result;
  
  RETURN v_result;
  
EXCEPTION
  WHEN OTHERS THEN
    -- Transaction will be automatically rolled back
    RAISE EXCEPTION 'Failed to create user with posts: %', SQLERRM;
END;
$$ LANGUAGE plpgsql;

Using RPC Transactions ​

typescript
interface CreateUserWithPostsArgs {
  p_email: string;
  p_first_name: string;
  p_last_name: string;
  p_post_titles: string[];
}

interface CreateUserWithPostsResult {
  user_id: string;
  email: string;
  posts_created: number;
}

async function createUserWithPostsTransactional(
  email: string,
  firstName: string,
  lastName: string,
  postTitles: string[]
) {
  try {
    // Call PostgreSQL function - this is a true database transaction
    const result = await client.rpc<CreateUserWithPostsArgs, CreateUserWithPostsResult>(
      'create_user_with_posts',
      {
        p_email: email,
        p_first_name: firstName,
        p_last_name: lastName,
        p_post_titles: postTitles
      }
    ).execute();
    
    if (result.error) {
      throw result.error;
    }
    
    console.log('User and posts created successfully:', result.data);
    return result.data;
    
  } catch (error) {
    console.error('Transaction failed (automatic rollback):', error);
    throw error;
  }
}

// Usage
const userResult = await createUserWithPostsTransactional(
  'author@example.com',
  'Jane',
  'Author',
  ['My First Post', 'Getting Started', 'Advanced Tips']
);

Error Handling Patterns ​

Comprehensive Error Handling ​

typescript
import { PostgRESTError } from '@webcoded/pgrestify';

async function robustTransactionPattern<T>(
  operation: () => Promise<T>
): Promise<T> {
  const startTime = Date.now();
  
  try {
    const result = await operation();
    
    console.log(`Transaction completed successfully in ${Date.now() - startTime}ms`);
    return result;
    
  } catch (error) {
    const duration = Date.now() - startTime;
    
    if (error instanceof PostgRESTError) {
      console.error(`PostgREST error after ${duration}ms:`, {
        status: error.statusCode,
        message: error.message,
        details: error.details
      });
      
      // Handle specific error types
      switch (error.statusCode) {
        case 409: // Conflict (unique constraint violation)
          throw new Error('Data conflict: Record already exists');
        case 403: // Forbidden (RLS policy violation)
          throw new Error('Access denied: Insufficient permissions');
        case 400: // Bad request (data validation error)
          throw new Error('Invalid data: ' + error.message);
        default:
          throw new Error(`Database error: ${error.message}`);
      }
    } else {
      console.error(`Application error after ${duration}ms:`, error);
      throw error;
    }
  }
}

// Usage with comprehensive error handling
async function createUserSafely(userData: Partial<User>) {
  return await robustTransactionPattern(async () => {
    return await dataManager.transaction(async (manager) => {
      const userRepo = manager.getRepository<User>('users');
      
      // Validate input
      if (!userData.email || !userData.first_name) {
        throw new Error('Email and first name are required');
      }
      
      // Check if user already exists
      const existingUser = await userRepo.findOne({ email: userData.email });
      if (existingUser) {
        throw new Error('User with this email already exists');
      }
      
      // Create user
      const users = await userRepo.insert({
        ...userData,
        active: true,
        created_at: new Date().toISOString()
      });
      
      return users[0];
    });
  });
}

Retry Mechanisms ​

typescript
async function withRetry<T>(
  operation: () => Promise<T>,
  maxRetries: number = 3,
  delayMs: number = 1000
): Promise<T> {
  let lastError: Error;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error as Error;
      
      // Don't retry certain errors
      if (error instanceof PostgRESTError) {
        if (error.statusCode === 400 || error.statusCode === 401 || error.statusCode === 403) {
          throw error; // Don't retry client errors
        }
      }
      
      if (attempt === maxRetries) {
        throw lastError;
      }
      
      console.warn(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`);
      await new Promise(resolve => setTimeout(resolve, delayMs));
      delayMs *= 2; // Exponential backoff
    }
  }
  
  throw lastError!;
}

// Usage with retry
async function reliableUserCreation(userData: Partial<User>) {
  return await withRetry(
    () => createUserSafely(userData),
    3, // 3 attempts
    1000 // Start with 1 second delay
  );
}

Best Practices ​

1. Design for Idempotency ​

Make operations safe to retry:

typescript
async function idempotentUserCreation(userData: Partial<User>) {
  const userRepo = dataManager.getRepository<User>('users');
  
  // Check if user already exists
  const existingUser = await userRepo.findOne({ email: userData.email });
  if (existingUser) {
    console.log('User already exists, returning existing user');
    return existingUser;
  }
  
  // Create new user
  const users = await userRepo.insert(userData);
  return users[0];
}

2. Use Compensating Actions ​

Implement manual rollback logic:

typescript
const rollbackActions: (() => Promise<void>)[] = [];

try {
  // Step 1
  const user = await userRepo.insert(userData);
  rollbackActions.push(() => userRepo.delete({ id: user[0].id }));
  
  // Step 2
  const profile = await profileRepo.insert({ user_id: user[0].id, ...profileData });
  rollbackActions.push(() => profileRepo.delete({ id: profile[0].id }));
  
  // Step 3
  const posts = await postRepo.insertMany(postData.map(p => ({ ...p, author_id: user[0].id })));
  rollbackActions.push(() => postRepo.delete({ author_id: user[0].id }));
  
} catch (error) {
  // Execute rollback actions in reverse order
  for (const rollback of rollbackActions.reverse()) {
    try {
      await rollback();
    } catch (rollbackError) {
      console.error('Rollback failed:', rollbackError);
    }
  }
  throw error;
}

3. Prefer PostgreSQL Functions for Critical Transactions ​

For operations that must be truly atomic, use PostgreSQL functions:

sql
CREATE OR REPLACE FUNCTION transfer_post_ownership(
  p_post_id UUID,
  p_old_owner_id UUID,
  p_new_owner_id UUID
) RETURNS JSON AS $$
BEGIN
  -- Verify ownership
  IF NOT EXISTS (
    SELECT 1 FROM posts 
    WHERE id = p_post_id AND author_id = p_old_owner_id
  ) THEN
    RAISE EXCEPTION 'Post not found or not owned by specified user';
  END IF;
  
  -- Verify new owner exists
  IF NOT EXISTS (
    SELECT 1 FROM users 
    WHERE id = p_new_owner_id AND active = true
  ) THEN
    RAISE EXCEPTION 'New owner not found or inactive';
  END IF;
  
  -- Transfer ownership atomically
  UPDATE posts 
  SET author_id = p_new_owner_id, updated_at = NOW()
  WHERE id = p_post_id;
  
  -- Log the transfer
  INSERT INTO ownership_transfers (post_id, old_owner_id, new_owner_id, transferred_at)
  VALUES (p_post_id, p_old_owner_id, p_new_owner_id, NOW());
  
  RETURN json_build_object(
    'success', true,
    'post_id', p_post_id,
    'transferred_to', p_new_owner_id
  );
END;
$$ LANGUAGE plpgsql;

Summary ​

While PGRestify doesn't provide traditional transactions like ORM, it offers several patterns for managing complex operations:

  • Pseudo-Transactions: Coordination mechanisms through DataManager
  • Compensating Actions: Manual rollback patterns for error recovery
  • Atomic Operations: Single-request operations that are naturally atomic
  • RPC Functions: True database transactions via PostgreSQL functions
  • Error Handling: Comprehensive error handling and retry mechanisms
  • Best Practices: Idempotency, rollback strategies, and critical operation patterns

Choose the appropriate pattern based on your consistency requirements and the criticality of the operation.

Released under the MIT License.