Skip to content

Feature Request: Add snapshot support in Postgres module #952

@Nikola-Milovic

Description

@Nikola-Milovic

Hey!

The test containers for go have this awesome snapshot support which makes migrating the db and cleaning it up before every test a breeze, and it would help out in node environments as well.

I created my own wrapper for the time being, I can look into creating a PR.

export class PostgresContainerWrapper {
  private container: PostgreSqlContainer;
  private snapshotName = 'migrated_template';
  private startedContainer: StartedPostgreSqlContainer | null = null;

  constructor(image?: string) {
    this.container = new PostgreSqlContainer(image);
  }

  /**
   * Forwards method calls to the underlying container
   */
  public withDatabase(database: string): this {
    this.container.withDatabase(database);
    return this;
  }

  /**
   * Forwards method calls to the underlying container
   */
  public withUsername(username: string): this {
    this.container.withUsername(username);
    return this;
  }

  /**
   * Forwards method calls to the underlying container
   */
  public withPassword(password: string): this {
    this.container.withPassword(password);
    return this;
  }

  /**
   * Forwards method calls to the underlying container
   */
  public withExposedPorts(...ports: number[]): this {
    this.container.withExposedPorts(...ports);
    return this;
  }

  /**
   * Forwards method calls to the underlying container
   */
  public withEnvironment(environment: Record<string, string>): this {
    this.container.withEnvironment(environment);
    return this;
  }

  /**
   * Starts the container if not already started
   */
  public async start(): Promise<StartedPostgreSqlContainer> {
    if (!this.startedContainer) {
      this.startedContainer = await this.container.start();
    }
    return this.startedContainer;
  }

  /**
   * Stops the container if it's running
   */
  public async stop(): Promise<void> {
    if (this.startedContainer) {
      await this.startedContainer.stop();
      this.startedContainer = null;
    }
  }

  /**
   * Takes a snapshot of the current state of the database as a template, which can then be restored using
   * the restore method. By default, the snapshot will be created under a database called migrated_template.
   *
   * If a snapshot already exists under the given/default name, it will be overwritten with the new snapshot.
   *
   * @param opts Optional snapshot configuration options
   * @returns Promise resolving when snapshot is complete
   */
  public async snapshot(opts: SnapshotOption[] = []): Promise<void> {
    const startedContainer = await this.start();
    const snapshotName = this.checkSnapshotConfig(opts);

    // Execute the commands to create the snapshot, in order
    await this.execCommandsSQL([
      // Update pg_database to remove the template flag, then drop the database if it exists.
      // This is needed because dropping a template database will fail.
      `UPDATE pg_database SET datistemplate = FALSE WHERE datname = '${snapshotName}'`,
      `DROP DATABASE IF EXISTS "${snapshotName}"`,
      // Create a copy of the database to another database to use as a template now that it was fully migrated
      `CREATE DATABASE "${snapshotName}" WITH TEMPLATE "${startedContainer.getDatabase()}" OWNER "${startedContainer.getUsername()}"`,
      // Snapshot the template database so we can restore it onto our original database going forward
      `ALTER DATABASE "${snapshotName}" WITH is_template = TRUE`,
    ]);

    this.snapshotName = snapshotName;
  }

  /**
   * Restores the database to a specific snapshot. By default, it will restore the last snapshot taken on the
   * database by the snapshot method. If a snapshot name is provided, it will instead try to restore the snapshot by name.
   *
   * @param opts Optional snapshot configuration options
   * @returns Promise resolving when restore is complete
   */
  public async restore(opts: SnapshotOption[] = []): Promise<void> {
    const startedContainer = await this.start();
    const snapshotName = this.checkSnapshotConfig(opts);

    // Execute the commands to restore the snapshot, in order
    await this.execCommandsSQL([
      // Drop the entire database by connecting to the postgres global database
      `DROP DATABASE "${startedContainer.getDatabase()}" WITH (FORCE)`,
      // Then restore the previous snapshot
      `CREATE DATABASE "${startedContainer.getDatabase()}" WITH TEMPLATE "${snapshotName}" OWNER "${startedContainer.getUsername()}"`,
    ]);
  }

  /**
   * Checks the snapshot configuration and returns the snapshot name to use
   */
  private checkSnapshotConfig(opts: SnapshotOption[]): string {
    const config: SnapshotConfig = {};
    for (const opt of opts) {
      Object.assign(config, opt(config));
    }

    const snapshotName = config.snapshotName || this.snapshotName;

    // Make sure we have started container before checking database name
    if (!this.startedContainer) {
      throw new Error('Container must be started before taking or restoring snapshots');
    }

    if (this.startedContainer.getDatabase() === 'postgres') {
      throw new Error('Cannot restore the postgres system database as it cannot be dropped to be restored');
    }

    return snapshotName;
  }

  /**
   * Executes a series of SQL commands against the Postgres database
   * @param commands SQL commands to execute
   */
  private async execCommandsSQL(commands: string[]): Promise<void> {
    if (!this.startedContainer) {
      throw new Error('Container must be started before executing SQL commands');
    }

    try {
      // Connect to the postgres database (not the app database)
      const connectionString = this.startedContainer.getConnectionUri().replace(
        `/${this.startedContainer.getDatabase()}`,
        '/postgres'
      );

      // Use the pg client from the container's dependencies
      const { Client } = await import('pg');
      const client = new Client({
        connectionString,
        ssl: false,
      });

      await client.connect();

      try {
        // Execute each command
        for (const command of commands) {
          await client.query(command);
        }
      } finally {
        await client.end();
      }
    } catch (error) {
      // Fall back to using the docker exec approach if direct connection fails
      console.log('Direct database connection failed, falling back to docker exec:', error);
      await this.execCommandsFallback(commands);
    }
  }

  /**
   * Fallback method to execute SQL commands using docker exec if direct connection fails
   * @param commands SQL commands to execute
   */
  private async execCommandsFallback(commands: string[]): Promise<void> {
    if (!this.startedContainer) {
      throw new Error('Container must be started before executing fallback commands');
    }

    for (const command of commands) {
      try {
        const result = await this.startedContainer.exec([
          'psql',
          '-v',
          'ON_ERROR_STOP=1',
          '-U',
          this.startedContainer.getUsername(),
          '-d',
          'postgres',
          '-c',
          command,
        ]);

        if (result.exitCode !== 0) {
          throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output}`);
        }
      } catch (error) {
        console.error(`Failed to execute command: ${command}`, error);
        throw error;
      }
    }
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions