Repository: 2060-io/message-pickup-repository
Pull request #66: feat: Implement SQL-based schema versioning and migration execution
Author: login: gabrielmatau79
Created: 2025-04-16T15:12:12Z | Merged: 2025-04-24T11:54:15Z

Resolve issue #65 Add SQL-based migration support for schema versioning

### What's included

- Add `buildPgDatabaseWithMigrations` with support for file-based .sql migrations
- Introduce schema_version table for version tracking
- Add buildPgDatabaseWithMigrations function with ordered execution logic into PostgresMessagePickupRepository
- Add setupMigrations.ts to scaffold initial migration files


Diff:
diff --git a/packages/postgres/docs/Database-build-with-migrations.md b/packages/postgres/docs/Database-build-with-migrations.md
new file mode 100644
index 0000000..e5700e2
--- /dev/null
+++ b/packages/postgres/docs/Database-build-with-migrations.md
@@ -0,0 +1,100 @@
+# Database Build PostgreSQL SQL and Migrations
+
+This guide explains how to manage and apply versioned `.sql` migrations used by the `PostgresMessagePickupRepository`.
+
+## 📦 Overview
+
+This system uses plain `.sql` files to track schema evolution over time. Each file:
+
+- Has a version number prefix
+- Is applied **once**
+- Is tracked via a `schema_version` table
+- Lives in the `migrations/` folder inside the package
+
+The function `buildPgDatabaseWithMigrations()` will:
+
+1. Create the target database if it doesn't exist.
+2. Run all SQL files in `migrations/` in version order (e.g. `001-init.sql`, `002-add-index.sql`).
+3. Insert each version into the `schema_version` table once applied.
+
+## 📁 Directory Structure
+
+```bash
+migrations/
+├── 001-initial-schema.sql
+├── 002-upgrade-schema.sql
+├── 003-add-index.sql
+```
+
+## ✅ Naming Convention
+
+- Use **3-digit prefix** followed by a descriptive name, ending in `.sql`
+- Example:
+  - `001-initial-schema.sql`
+  - `002-add-live-session-table.sql`
+  - `003-create-indexes.sql`
+
+The prefix determines execution order.
+
+## 🛠 How to Add a New Migration
+
+1. Create a new `.sql` file in `src/migrations/` (before build).
+1. Name it using the next available version number (e.g. `004-add-new-column.sql`).
+1. Place only **idempotent SQL** (i.e. can safely be re-run).
+
+Example migration:
+
+```sql
+-- 004-add-received_at.sql
+ALTER TABLE queued_message ADD COLUMN IF NOT EXISTS received_at TIMESTAMP;
+```
+
+1. Run `yarn build` to copy the file to `build/migrations/`.
+
+## 🚀 How to Apply Migrations
+
+Migrations are automatically applied when this function is called:
+
+```ts
+await buildPgDatabaseWithMigrations(
+  {
+    user: 'postgres',
+    password: 'postgres',
+    host: 'localhost',
+  },
+  'messagepickuprepository',
+)
+```
+
+Or if used in the repository:
+
+```ts
+await messageRepository.initialize({ agent })
+```
+
+## 🧪 Migration Tracking Table
+
+A table called `schema_version` will be created automatically:
+
+```sql
+CREATE TABLE schema_version (
+  id SERIAL PRIMARY KEY,
+  version INTEGER NOT NULL,
+  updated_at TIMESTAMP DEFAULT now()
+);
+```
+
+Only migrations with a higher version number than the latest applied will run.
+
+## 🧼 Best Practices
+
+- Do **not** rename or reorder migration files after they've been applied.
+- Do **not** delete old migration files unless you're resetting the database.
+- Always test your `.sql` files locally before pushing them to production.
+- Make sure each file is **atomic** (can succeed or fail independently).
+
+## 🧩 Troubleshooting
+
+- Missing folder at runtime? Ensure `migrations/` is copied to `build/` during `yarn build`
+- Error running `.sql`? Validate syntax using `psql` or pgAdmin first
+- Migration failed? Check the logs — execution will continue but the schema version won’t advance
diff --git a/packages/postgres/package.json b/packages/postgres/package.json
index bd75932..634eff1 100644
--- a/packages/postgres/package.json
+++ b/packages/postgres/package.json
@@ -18,9 +18,10 @@
     "directory": "packages/postgres"
   },
   "scripts": {
-    "build": "yarn run clean && yarn run compile",
+    "build": "yarn run clean && yarn run compile && yarn run copy:migrations",
     "clean": "rimraf -rf ./build",
     "compile": "tsc -p tsconfig.build.json",
+    "copy:migrations": "cp -r src/migrations build/migrations",
     "prepublishOnly": "yarn run build",
     "test": "jest"
   },
diff --git a/packages/postgres/src/PostgresMessagePickupRepository.ts b/packages/postgres/src/PostgresMessagePickupRepository.ts
index fa54d1f..b209440 100644
--- a/packages/postgres/src/PostgresMessagePickupRepository.ts
+++ b/packages/postgres/src/PostgresMessagePickupRepository.ts
@@ -20,24 +20,14 @@ import {
 } from '@credo-ts/core/build/modules/message-pickup/MessagePickupSession'
 import { Client, Pool } from 'pg'
 import PGPubsub from 'pg-pubsub'
-import {
-  createTableLive,
-  createTableMessage,
-  createTypeMessageState,
-  liveSessionTableIndex,
-  liveSessionTableMigration,
-  liveSessionTableName,
-  messageTableIndex,
-  messagesTableMigration,
-  messagesTableName,
-} from './config/dbCollections'
 import {
   ExtendedMessagePickupSession,
   MessageQueuedEvent,
   MessageQueuedEventType,
   PostgresMessagePickupRepositoryConfig,
 } from './interfaces'
-
+import { buildPgDatabaseWithMigrations } from './utils/buildPgDatabaseWithMigrations'
+import { error } from 'node:console'
 @injectable()
 export class PostgresMessagePickupRepository implements MessagePickupRepository {
   private logger?: Logger
@@ -83,7 +73,15 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
   public async initialize(options: { agent: Agent }): Promise<void> {
     try {
       // Initialize the database
-      await this.buildPgDatabase()
+      await buildPgDatabaseWithMigrations(
+        this.logger,
+        {
+          user: this.postgresUser,
+          password: this.postgresPassword,
+          host: this.postgresHost,
+        },
+        this.postgresDatabaseName,
+      )
       this.logger?.info('[initialize] The database has been build successfully')
 
       // Configure PostgreSQL pool for the messages collections
@@ -157,7 +155,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
       if (deleteMessages) {
         const query = `
         SELECT id, encrypted_message, state 
-        FROM ${messagesTableName} 
+        FROM queued_message 
         WHERE (connection_id = $1 OR $2 = ANY (recipient_dids)) AND state = 'pending' 
         ORDER BY created_at 
         LIMIT $3
@@ -179,11 +177,11 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
 
       // Use UPDATE and RETURNING to fetch and update messages in one step
       const query = `
-      UPDATE ${messagesTableName}
+      UPDATE queued_message
       SET state = 'sending'
       WHERE id IN (
         SELECT id 
-        FROM ${messagesTableName} 
+        FROM queued_message 
         WHERE (connection_id = $1 OR $2 = ANY (recipient_dids)) 
         AND state = 'pending' 
         ORDER BY created_at 
@@ -228,7 +226,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
       // Query to count pending messages for the specified connection ID
       const query = `
       SELECT COUNT(*) AS count 
-      FROM ${messagesTableName} 
+      FROM queued_message 
       WHERE connection_id = $1 AND state = 'pending'
     `
       const params = [connectionId]
@@ -274,7 +272,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
 
       // Insert message into database
       const query = `
-        INSERT INTO ${messagesTableName}(connection_id, recipient_dids, encrypted_message, state) 
+        INSERT INTO queued_message(connection_id, recipient_dids, encrypted_message, state) 
         VALUES($1, $2, $3, $4) 
         RETURNING id, created_at, encrypted_message
       `
@@ -350,7 +348,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
       const placeholders = messageIds.map((_, index) => `$${index + 2}`).join(', ')
 
       // Construct the SQL DELETE query
-      const query = `DELETE FROM ${messagesTableName} WHERE connection_id = $1 AND id IN (${placeholders})`
+      const query = `DELETE FROM queued_message WHERE connection_id = $1 AND id IN (${placeholders})`
 
       // Combine connectionId with messageIds as query parameters
       const queryParams = [connectionId, ...messageIds]
@@ -414,130 +412,6 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
     }
   }
 
-  /**
-   * This method allow create database and tables they are used for the operation of the messageRepository
-   *
-   */
-  private async buildPgDatabase(): Promise<void> {
-    this.logger?.info(`[buildPgDatabase] PostgresDbService Initializing ${this.postgresDatabaseName}`)
-
-    const clientConfig = {
-      user: this.postgresUser,
-      host: this.postgresHost,
-      password: this.postgresPassword,
-      port: 5432,
-      database: 'postgres',
-    }
-
-    const poolConfig = {
-      ...clientConfig,
-      database: this.postgresDatabaseName,
-    }
-
-    const client = new Client(clientConfig)
-
-    try {
-      await client.connect()
-
-      // Use advisory lock to prevent
-      await client.query('SELECT pg_advisory_lock(99998)')
-
-      // Check if the database already exists.
-      const result = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [this.postgresDatabaseName])
-      this.logger?.debug(`[buildPgDatabase] PostgresDbService exist ${result.rowCount}`)
-
-      if (result.rowCount === 0) {
-        // If it doesn't exist, create the database.
-        await client.query(`CREATE DATABASE ${this.postgresDatabaseName}`)
-        this.logger?.info(`[buildPgDatabase] PostgresDbService Database "${this.postgresDatabaseName}" created.`)
-      }
-
-      await client.query('SELECT pg_advisory_unlock(99998)')
-
-      // Create a new client connected to the specific database.
-      const dbClient = new Client(poolConfig)
-
-      try {
-        await dbClient.connect()
-
-        // Use advisory lock to prevent race conditions
-        await dbClient.query('SELECT pg_advisory_lock(99999)')
-
-        // Check if the enum type 'message_state' exists
-        const messageStateTypeExists = await dbClient.query(
-          `SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') AS exists`,
-        )
-
-        if (!messageStateTypeExists.rows[0].exists) {
-          await dbClient.query(createTypeMessageState)
-          this.logger?.info(`[buildPgDatabase] Type 'message_state' created`)
-        }
-
-        // Check if the messages table exists
-        const messageTableExists = await dbClient.query(
-          `SELECT to_regclass('${messagesTableName}') IS NOT NULL AS exists`,
-        )
-
-        if (!messageTableExists.rows[0].exists) {
-          await dbClient.query(createTableMessage)
-          await dbClient.query(messageTableIndex)
-          this.logger?.info(`[buildPgDatabase] Table "${messagesTableName}" created`)
-        }
-
-        // Check if the table exists.
-        const liveTableResult = await dbClient.query(`SELECT to_regclass('${liveSessionTableName}')`)
-        if (!liveTableResult.rows[0].to_regclass) {
-          // If it doesn't exist, create the table.
-          await dbClient.query(createTableLive)
-          await dbClient.query(liveSessionTableIndex)
-          this.logger?.info(`[buildPgDatabase] PostgresDbService Table "${liveSessionTableName}" created.`)
-        } else {
-          // If the table exists, clean it (truncate or delete, depending on your requirements).
-          await dbClient.query(`TRUNCATE TABLE ${liveSessionTableName}`)
-          this.logger?.info(`[buildPgDatabase] PostgresDbService Table "${liveSessionTableName}" cleared.`)
-        }
-
-        // verify if needs migrations old database
-
-        const queuedmessageExists = await dbClient.query(`SELECT to_regclass('queuedmessage') IS NOT NULL AS exists`)
-        const livesessionExists = await dbClient.query(`SELECT to_regclass('livesession') IS NOT NULL AS exists`)
-
-        const shouldRunMigration = queuedmessageExists.rows[0].exists && livesessionExists.rows[0].exists
-
-        if (shouldRunMigration) {
-          this.logger?.info(`[buildPgDatabase] Running migration to new schema`)
-
-          try {
-            // Execute new schema migrations (create new tables)
-            await dbClient.query(messagesTableMigration)
-            await dbClient.query(liveSessionTableMigration)
-
-            this.logger?.info(`[buildPgDatabase]  Migration completed successfully`)
-
-            // Drop old legacy tables after migration
-            await dbClient.query('DROP TABLE IF EXISTS queuedmessage;')
-            await dbClient.query('DROP TABLE IF EXISTS livesession;')
-
-            this.logger?.info(`[buildPgDatabase] Old legacy tables dropped`)
-          } catch (error) {
-            this.logger?.error(`[buildPgDatabase] Error while running schema migration ${error}`)
-          }
-        } else {
-          this.logger?.info(`[buildPgDatabase] Skipping migration (tables not found)`)
-        }
-
-        // Unlock after table creation
-        await dbClient.query('SELECT pg_advisory_unlock(99999)')
-      } finally {
-        await dbClient.end()
-      }
-    } catch (error) {
-      this.logger?.error(`[buildPgDatabase] PostgresDbService Error creating database: ${error}`)
-    } finally {
-      await client.end()
-    }
-  }
-
   /**
    * This function checks that messages from the connectionId, which were left in the 'sending'
    * state after a liveSessionRemove event, are updated to the 'pending' state for subsequent sending
@@ -548,13 +422,13 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
     try {
       this.logger?.debug(`[checkQueueMessages] Init verify messages state 'sending'`)
       const messagesToSend = await this.messagesCollection?.query(
-        `SELECT * FROM ${messagesTableName} WHERE state = $1 and connection_id = $2`,
+        `SELECT * FROM queued_message WHERE state = $1 and connection_id = $2`,
         ['sending', connectionID],
       )
       if (messagesToSend && messagesToSend.rows.length > 0) {
         for (const message of messagesToSend.rows) {
           // Update the message state to 'pending'
-          await this.messagesCollection?.query(`UPDATE ${messagesTableName} SET state = $1 WHERE id = $2`, [
+          await this.messagesCollection?.query(`UPDATE queued_message SET state = $1 WHERE id = $2`, [
             'pending',
             message.id,
           ])
@@ -597,7 +471,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
     if (!connectionId) throw new Error('connectionId is not defined')
     try {
       const queryLiveSession = await this.messagesCollection?.query(
-        `SELECT session_id, connection_id, protocol_version FROM ${liveSessionTableName} WHERE connection_id = $1 LIMIT $2`,
+        `SELECT session_id, connection_id, protocol_version FROM live_session WHERE connection_id = $1 LIMIT $2`,
         [connectionId, 1],
       )
       // Check if liveSession is not empty (record found)
@@ -623,7 +497,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
     if (!session) throw new Error('session is not defined')
     try {
       const insertMessageDB = await this.messagesCollection?.query(
-        `INSERT INTO ${liveSessionTableName} (session_id, connection_id, protocol_version, instance) VALUES($1, $2, $3, $4) RETURNING session_id`,
+        `INSERT INTO live_session (session_id, connection_id, protocol_version, instance) VALUES($1, $2, $3, $4) RETURNING session_id`,
         [id, connectionId, protocolVersion, instance],
       )
       const liveSessionId = insertMessageDB?.rows[0].sessionid
@@ -642,7 +516,7 @@ export class PostgresMessagePickupRepository implements MessagePickupRepository
     if (!connectionId) throw new Error('connectionId is not defined')
     try {
       // Construct the SQL query with the placeholders
-      const query = `DELETE FROM ${liveSessionTableName} WHERE connection_id = $1`
+      const query = `DELETE FROM live_session WHERE connection_id = $1`
 
       // Add connectionId  for query parameters
       const queryParams = [connectionId]
diff --git a/packages/postgres/src/config/dbCollections.ts b/packages/postgres/src/config/dbCollections.ts
deleted file mode 100644
index ba30baa..0000000
--- a/packages/postgres/src/config/dbCollections.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-export const messagesTableName = 'queued_message'
-
-export const createTypeMessageState = `CREATE TYPE message_state AS ENUM ('pending', 'sending');`
-
-export const createTableMessage = `
-CREATE TABLE IF NOT EXISTS ${messagesTableName} (
-  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
-  connection_id VARCHAR(255),
-  recipient_dids TEXT[],
-  encrypted_message JSONB,
-  state message_state NOT NULL,
-  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-`
-
-export const liveSessionTableName = 'live_session'
-
-export const createTableLive = `
-CREATE TABLE IF NOT EXISTS ${liveSessionTableName} (
-  session_id VARCHAR(255) PRIMARY KEY,
-  connection_id VARCHAR(50),
-  protocol_version VARCHAR(50),
-  instance VARCHAR(100),
-  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-);
-`
-
-export const messageTableIndex = `CREATE INDEX IF NOT EXISTS "${messagesTableName}_connection_id_idx" ON "${messagesTableName}" (connection_id);`
-
-export const liveSessionTableIndex = `CREATE INDEX IF NOT EXISTS "${liveSessionTableName}_connection_id_idx" ON "${liveSessionTableName}" USING btree (connection_id);`
-
-export const messagesTableMigration = `
-    INSERT INTO queued_message (id, connection_id, recipient_dids, encrypted_message, state, created_at)
-    SELECT
-      gen_random_uuid(),  -- nuevo id
-      connectionid,
-      recipientKeys,
-      encryptedMessage,
-      'pending',
-      created_at
-    FROM queuedmessage
-  `
-
-export const liveSessionTableMigration = `
-    INSERT INTO live_session (session_id, connection_id, protocol_version, instance, created_at)
-    SELECT
-      sessionid,
-      connectionid,
-      protocolVersion,
-      instance,
-      created_at
-    FROM livesession
-  `
diff --git a/packages/postgres/src/migrations/001-initial-schema.sql b/packages/postgres/src/migrations/001-initial-schema.sql
new file mode 100644
index 0000000..74993ea
--- /dev/null
+++ b/packages/postgres/src/migrations/001-initial-schema.sql
@@ -0,0 +1,23 @@
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
+
+CREATE TABLE IF NOT EXISTS livesession (
+  sessionid VARCHAR(255) PRIMARY KEY,
+  connectionid VARCHAR(50),
+  protocolVersion VARCHAR(50),
+  role VARCHAR(50),
+  instance VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS livesession_connectionid ON livesession USING btree (connectionid);
\ No newline at end of file
diff --git a/packages/postgres/src/migrations/002-schema-upgrade.sql b/packages/postgres/src/migrations/002-schema-upgrade.sql
new file mode 100644
index 0000000..6a72c53
--- /dev/null
+++ b/packages/postgres/src/migrations/002-schema-upgrade.sql
@@ -0,0 +1,59 @@
+-- Create new type
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') THEN
+    CREATE TYPE message_state AS ENUM ('pending', 'sending');
+  END IF;
+END$$;
+
+-- Create new tables with updated schema
+
+CREATE TABLE IF NOT EXISTS queued_message (
+  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+  connection_id UUID,
+  recipient_dids TEXT[],
+  encrypted_message JSONB,
+  state message_state NOT NULL,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queued_message_connection_id_idx ON queued_message (connection_id);
+
+CREATE INDEX IF NOT EXISTS queued_message_connection_id_state_idx ON queued_message (connection_id, state);
+
+CREATE INDEX IF NOT EXISTS queued_message_created_at_idx ON queued_message (created_at);
+
+CREATE TABLE IF NOT EXISTS live_session (
+  session_id UUID PRIMARY KEY,
+  connection_id VARCHAR(50),
+  protocol_version VARCHAR(50),
+  instance VARCHAR(100),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS live_session_connection_id_idx ON live_session USING btree (connection_id);
+
+-- Migrate data from old tables into new ones
+
+INSERT INTO queued_message (id, connection_id, recipient_dids, encrypted_message, state, created_at)
+SELECT
+  gen_random_uuid(),
+  connectionId::uuid,
+  recipientKeys,
+  encryptedMessage,
+  'pending',
+  created_at
+FROM queuedmessage;
+
+INSERT INTO live_session (session_id, connection_id, protocol_version, instance, created_at)
+SELECT
+  sessionid::uuid,
+  connectionid,
+  protocolVersion,
+  instance,
+  created_at
+FROM livesession;
+
+DROP TABLE IF EXISTS queuedmessage;
+
+DROP TABLE IF EXISTS livesession;
\ No newline at end of file
diff --git a/packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts b/packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts
new file mode 100644
index 0000000..8263b6b
--- /dev/null
+++ b/packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts
@@ -0,0 +1,108 @@
+import { Client, Pool } from 'pg'
+import fs from 'fs'
+import path from 'path'
+import { Logger } from '@credo-ts/core'
+
+/**
+ * Runs all pending SQL migrations in a target PostgreSQL database.
+ * - Automatically creates the database if it does not exist.
+ * - Applies versioned `.sql` files from a directory.
+ *
+ * @param logger - Optional Credo-compatible logger instance
+ * @param postgresConfig - Connection config to connect as superuser (to create DB if needed).
+ * @param targetDatabase - Name of the target database to migrate.
+ * @param migrationsDir - Directory where migration `.sql` files are located.
+ */
+export async function buildPgDatabaseWithMigrations(
+  logger: Logger | undefined,
+  postgresConfig: { user: string; password: string; host: string; port?: number },
+  targetDatabase: string,
+  migrationsDir: string = path.resolve(__dirname, '../migrations'),
+): Promise<void> {
+  const adminClient = new Client(postgresConfig)
+
+  try {
+    await adminClient.connect()
+
+    // Try to create the target database if it doesn't exist
+    const existsResult = await adminClient.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [targetDatabase])
+
+    if (existsResult.rowCount === 0) {
+      await adminClient.query(`CREATE DATABASE ${targetDatabase}`)
+      logger?.info(`[migration] Database "${targetDatabase}" created.`)
+    } else {
+      logger?.info(`[migration] Database "${targetDatabase}" already exists.`)
+    }
+
+    await adminClient.end()
+  } catch (error) {
+    logger?.error(`[migration] Failed to create database "${targetDatabase}": ${(error as Error).message}`)
+    await adminClient.end()
+    throw error
+  }
+
+  // Now connect to the target database using a pool
+  const pool = new Pool({ ...postgresConfig, database: targetDatabase })
+
+  const client = await pool.connect()
+
+  try {
+    await client.query('BEGIN')
+
+    await client.query(`
+      CREATE TABLE IF NOT EXISTS schema_version (
+        id SERIAL PRIMARY KEY,
+        version INTEGER NOT NULL,
+        updated_at TIMESTAMP DEFAULT now()
+      )
+    `)
+
+    const result = await client.query('SELECT version FROM schema_version ORDER BY id DESC LIMIT 1')
+    const currentVersion = result.rows.length > 0 ? result.rows[0].version : 0
+    logger?.info(`[migration] Current schema version: ${currentVersion}`)
+
+    if (!fs.existsSync(migrationsDir)) {
+      logger?.warn(`[migration] Migrations directory not found: ${migrationsDir}. Skipping.`)
+      await client.query('COMMIT')
+      return
+    }
+
+    const files = fs
+      .readdirSync(migrationsDir)
+      .filter((file) => /^\d{3,}-[\w\-]+\.sql$/.test(file))
+      .sort()
+
+    for (const file of files) {
+      const versionMatch = file.match(/^(\d{3,})-/)
+      if (!versionMatch) {
+        logger?.warn(`[migration] Skipping invalid filename: ${file}`)
+        continue
+      }
+
+      const version = parseInt(versionMatch[1], 10)
+      if (version <= currentVersion) {
+        logger?.debug(`[migration] Skipping already applied: ${file}`)
+        continue
+      }
+
+      const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8')
+      logger?.info(`[migration] Applying migration: ${file}`)
+
+      try {
+        await client.query(sql)
+        await client.query(`INSERT INTO schema_version (version) VALUES ($1)`, [version])
+        logger?.info(`[migration] Applied successfully: ${file}`)
+      } catch (fileErr) {
+        throw new Error(`[migration] Failed to apply ${file}: ${(fileErr as Error).message}`)
+      }
+    }
+
+    await client.query('COMMIT')
+  } catch (err) {
+    logger?.error(`[migration] Migration process failed: ${(err as Error).message}`)
+    await client.query('ROLLBACK')
+  } finally {
+    client.release()
+    await pool.end()
+  }
+}


Reviews:
user: login: lotharking
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-22T16:45:26Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T19:19:23Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T19:34:31Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T19:39:10Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T19:49:19Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T19:53:37Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T21:38:33Z
user: login: gabrielmatau79
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T21:38:44Z
user: login: lotharking
state: APPROVED
author_association: NONE
submitted_at: 2025-04-23T14:10:45Z
user: login: genaris
body: Well done. I think it is a good minimalistic way to support migrations.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T23:06:14Z

Review comments:
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
path: packages/postgres/src/utils/setupMigration.ts
user: login: lotharking
body: IMHO, ensuring that all tables have appropriate primary keys and indices for optimized query performance is essential, especially as the tables grow. For instance, adding an index on the created_at and state columns can significantly improve search performance
created_at: 2025-04-22T16:25:08Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
+
+CREATE TABLE IF NOT EXISTS livesession (
+  sessionid VARCHAR(255) PRIMARY KEY,
+  connectionid VARCHAR(50),
+  protocolVersion VARCHAR(50),
+  role VARCHAR(50),
+  instance VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS livesession_connectionid ON livesession USING btree (connectionid);
+      `.trim(),
+    },
+    {
+      name: '002-schema-upgrade.sql',
+      content: `
+-- Create new type
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') THEN
+    CREATE TYPE message_state AS ENUM ('pending', 'sending');
+  END IF;
+END$$;
+
+-- Create new tables with updated schema
+
+CREATE TABLE IF NOT EXISTS queued_message (
+  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+  connection_id VARCHAR(255),
+  recipient_dids TEXT[],
+  encrypted_message JSONB,
+  state message_state NOT NULL,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queued_message_connection_id_idx ON queued_message (connection_id);
+
+CREATE TABLE IF NOT EXISTS live_session (
+  session_id VARCHAR(255) PRIMARY KEY,
path: packages/postgres/src/utils/setupMigration.ts
user: login: lotharking
body: I prefer reviewing the use of `VARCHAR(255)` for primary keys. I think using `UUID` as the primary key type would be more consistent and reliable in terms of uniqueness across multiple systems and data sources.

created_at: 2025-04-22T16:26:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
+
+CREATE TABLE IF NOT EXISTS livesession (
+  sessionid VARCHAR(255) PRIMARY KEY,
+  connectionid VARCHAR(50),
+  protocolVersion VARCHAR(50),
+  role VARCHAR(50),
+  instance VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS livesession_connectionid ON livesession USING btree (connectionid);
+      `.trim(),
+    },
+    {
+      name: '002-schema-upgrade.sql',
+      content: `
+-- Create new type
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') THEN
+    CREATE TYPE message_state AS ENUM ('pending', 'sending');
+  END IF;
+END$$;
+
+-- Create new tables with updated schema
+
+CREATE TABLE IF NOT EXISTS queued_message (
+  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+  connection_id VARCHAR(255),
+  recipient_dids TEXT[],
+  encrypted_message JSONB,
+  state message_state NOT NULL,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queued_message_connection_id_idx ON queued_message (connection_id);
+
+CREATE TABLE IF NOT EXISTS live_session (
+  session_id VARCHAR(255) PRIMARY KEY,
+  connection_id VARCHAR(50),
+  protocol_version VARCHAR(50),
+  instance VARCHAR(100),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS live_session_connection_id_idx ON live_session USING btree (connection_id);
+
+-- Migrate data from old tables into new ones
+
+INSERT INTO queued_message (id, connection_id, recipient_dids, encrypted_message, state, created_at)
+SELECT
+  gen_random_uuid(),
+  connectionId,
+  recipientKeys,
+  encryptedMessage,
+  'pending',
+  created_at
+FROM queuedmessage;
+
+INSERT INTO live_session (session_id, connection_id, protocol_version, instance, created_at)
+SELECT
+  sessionid,
+  connectionid,
+  protocolVersion,
+  instance,
+  created_at
+FROM livesession;
+      `.trim(),
+    },
+  ]
+
+  for (const file of files) {
+    const filePath = path.join(migrationsDir, file.name)
+
+    if (!fs.existsSync(filePath)) {
+      fs.writeFileSync(filePath, file.content, 'utf-8')
+      console.info(`✅ Created migration file: ${file.name}`)
+    } else {
+      console.warn(`⚠️  Skipped existing file: ${file.name}`)
+    }
+  }
+}
path: packages/postgres/src/utils/setupMigration.ts
user: login: lotharking
body: Are you cleaning up the old tables after the data migration into the main tables?
created_at: 2025-04-22T16:29:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
+
+CREATE TABLE IF NOT EXISTS livesession (
+  sessionid VARCHAR(255) PRIMARY KEY,
+  connectionid VARCHAR(50),
+  protocolVersion VARCHAR(50),
+  role VARCHAR(50),
+  instance VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS livesession_connectionid ON livesession USING btree (connectionid);
+      `.trim(),
+    },
+    {
+      name: '002-schema-upgrade.sql',
+      content: `
+-- Create new type
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') THEN
+    CREATE TYPE message_state AS ENUM ('pending', 'sending');
path: packages/postgres/src/utils/setupMigration.ts
user: login: lotharking
body: Why not create a new table to store the state? In the future, editing the state directly in the `queued_message` table could become confusing.
created_at: 2025-04-22T16:32:54Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
path: packages/postgres/src/utils/setupMigration.ts
user: login: lotharking
body: Why do you use TypeORM? This library has migration integrations, creates the migrations table, allows rollback, and offers other useful functions.
created_at: 2025-04-22T16:35:06Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
path: packages/postgres/src/utils/setupMigration.ts
user: login: lotharking
body: Are you adding tests for CI/CD process on Github?
created_at: 2025-04-22T16:38:26Z
author_association: NONE
diff_hunk: @@ -0,0 +1,117 @@
+import { Client, Pool } from 'pg'
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Runs all pending SQL migrations in a target PostgreSQL database.
+ * - Automatically creates the database if it does not exist.
+ * - Applies versioned `.sql` files from a directory.
+ *
+ * @param postgresConfig - Connection config to connect as superuser (to create DB if needed).
+ * @param targetDatabase - Name of the target database to migrate.
+ * @param migrationsDir - Directory where migration `.sql` files are located.
+ */
+export async function buildPgDatabaseWithMigrations(
+  postgresConfig: { user: string; password: string; host: string; port?: number },
+  targetDatabase: string,
+  migrationsDir: string = path.resolve(__dirname, '../migrations'),
+): Promise<void> {
+  const adminClient = new Client({
path: packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts
user: login: lotharking
body: AdminClient and pool could be refactored
created_at: 2025-04-22T16:42:12Z
author_association: NONE
diff_hunk: @@ -0,0 +1,117 @@
+import { Client, Pool } from 'pg'
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Runs all pending SQL migrations in a target PostgreSQL database.
+ * - Automatically creates the database if it does not exist.
+ * - Applies versioned `.sql` files from a directory.
+ *
+ * @param postgresConfig - Connection config to connect as superuser (to create DB if needed).
+ * @param targetDatabase - Name of the target database to migrate.
+ * @param migrationsDir - Directory where migration `.sql` files are located.
+ */
+export async function buildPgDatabaseWithMigrations(
+  postgresConfig: { user: string; password: string; host: string; port?: number },
+  targetDatabase: string,
+  migrationsDir: string = path.resolve(__dirname, '../migrations'),
+): Promise<void> {
+  const adminClient = new Client({
+    user: postgresConfig.user,
+    password: postgresConfig.password,
+    host: postgresConfig.host,
+    port: postgresConfig.port ?? 5432,
+    database: 'postgres',
+  })
+
+  try {
+    await adminClient.connect()
+
+    // Try to create the target database if it doesn't exist
+    const existsResult = await adminClient.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [targetDatabase])
+
+    if (existsResult.rowCount === 0) {
+      await adminClient.query(`CREATE DATABASE ${targetDatabase}`)
+      console.info(`[migration] Database "${targetDatabase}" created.`)
+    } else {
+      console.info(`[migration] Database "${targetDatabase}" already exists.`)
+    }
+
+    await adminClient.end()
+  } catch (error) {
+    console.error(`[migration] Failed to create database "${targetDatabase}": ${(error as Error).message}`)
+    await adminClient.end()
+    throw error
+  }
+
+  // Now connect to the target database using a pool
+  const pool = new Pool({
+    user: postgresConfig.user,
+    password: postgresConfig.password,
+    host: postgresConfig.host,
+    port: postgresConfig.port ?? 5432,
+    database: targetDatabase,
+  })
+
+  const client = await pool.connect()
+
+  try {
+    await client.query('BEGIN')
+
+    await client.query(`
+      CREATE TABLE IF NOT EXISTS schema_version (
+        id SERIAL PRIMARY KEY,
+        version INTEGER NOT NULL,
+        updated_at TIMESTAMP DEFAULT now()
+      )
+    `)
+
+    const result = await client.query('SELECT version FROM schema_version ORDER BY id DESC LIMIT 1')
+    const currentVersion = result.rows.length > 0 ? result.rows[0].version : 0
+    console.info(`[migration] Current schema version: ${currentVersion}`)
+
+    if (!fs.existsSync(migrationsDir)) {
+      console.warn(`[migration] Migrations directory not found: ${migrationsDir}. Skipping.`)
+      await client.query('COMMIT')
+      return
+    }
+
+    const files = fs
+      .readdirSync(migrationsDir)
+      .filter((file) => /^\d{3,}-[\w\-]+\.sql$/.test(file))
+      .sort()
+
+    for (const file of files) {
+      const versionMatch = file.match(/^(\d{3,})-/)
+      if (!versionMatch) {
+        console.warn(`[migration] Skipping invalid filename: ${file}`)
+        continue
+      }
+
+      const version = parseInt(versionMatch[1], 10)
+      if (version <= currentVersion) {
+        console.debug(`[migration] Skipping already applied: ${file}`)
+        continue
+      }
+
+      const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8')
+      console.info(`[migration] Applying migration: ${file}`)
+
+      try {
+        await client.query(sql)
+        await client.query(`INSERT INTO schema_version (version) VALUES ($1)`, [version])
+        console.info(`[migration] Applied successfully: ${file}`)
+      } catch (fileErr) {
+        throw new Error(`[migration] Failed to apply ${file}: ${(fileErr as Error).message}`)
+      }
+    }
+
+    await client.query('COMMIT')
+  } catch (err) {
+    await client.query('ROLLBACK')
+    console.error('[migration] Migration process failed:', (err as Error).message)
path: packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts
user: login: lotharking
body: Could add the log first to add clarity to the process
created_at: 2025-04-22T16:44:03Z
author_association: NONE
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
path: packages/postgres/src/utils/setupMigration.ts
user: login: gabrielmatau79
body: You're absolutely right. The SQL scripts in the migration folder have the correct structure, ensuring what you mentioned. This script was created to generate the initial SQL migration files, and I'm not sure if it's worth keeping.
created_at: 2025-04-22T19:19:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
+
+CREATE TABLE IF NOT EXISTS livesession (
+  sessionid VARCHAR(255) PRIMARY KEY,
+  connectionid VARCHAR(50),
+  protocolVersion VARCHAR(50),
+  role VARCHAR(50),
+  instance VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS livesession_connectionid ON livesession USING btree (connectionid);
+      `.trim(),
+    },
+    {
+      name: '002-schema-upgrade.sql',
+      content: `
+-- Create new type
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') THEN
+    CREATE TYPE message_state AS ENUM ('pending', 'sending');
+  END IF;
+END$$;
+
+-- Create new tables with updated schema
+
+CREATE TABLE IF NOT EXISTS queued_message (
+  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+  connection_id VARCHAR(255),
+  recipient_dids TEXT[],
+  encrypted_message JSONB,
+  state message_state NOT NULL,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queued_message_connection_id_idx ON queued_message (connection_id);
+
+CREATE TABLE IF NOT EXISTS live_session (
+  session_id VARCHAR(255) PRIMARY KEY,
+  connection_id VARCHAR(50),
+  protocol_version VARCHAR(50),
+  instance VARCHAR(100),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS live_session_connection_id_idx ON live_session USING btree (connection_id);
+
+-- Migrate data from old tables into new ones
+
+INSERT INTO queued_message (id, connection_id, recipient_dids, encrypted_message, state, created_at)
+SELECT
+  gen_random_uuid(),
+  connectionId,
+  recipientKeys,
+  encryptedMessage,
+  'pending',
+  created_at
+FROM queuedmessage;
+
+INSERT INTO live_session (session_id, connection_id, protocol_version, instance, created_at)
+SELECT
+  sessionid,
+  connectionid,
+  protocolVersion,
+  instance,
+  created_at
+FROM livesession;
+      `.trim(),
+    },
+  ]
+
+  for (const file of files) {
+    const filePath = path.join(migrationsDir, file.name)
+
+    if (!fs.existsSync(filePath)) {
+      fs.writeFileSync(filePath, file.content, 'utf-8')
+      console.info(`✅ Created migration file: ${file.name}`)
+    } else {
+      console.warn(`⚠️  Skipped existing file: ${file.name}`)
+    }
+  }
+}
path: packages/postgres/src/utils/setupMigration.ts
user: login: gabrielmatau79
body: Yes, the SQL scripts should include the commands to clean up and drop the tables, just like migrations/002-schema-upgrade.sql, which is the latest database version used by this package.
created_at: 2025-04-22T19:34:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
path: packages/postgres/src/utils/setupMigration.ts
user: login: gabrielmatau79
body: This script was initially created to generate the first SQL migration files. It contains both the initial version of the Message Pickup Repository database and the latest version currently used by the package. The fundamental purpose was to preserve the database history.

However, to avoid confusion, I will remove this script. From now on, SQL files will be created directly with the corresponding migration queries.
created_at: 2025-04-22T19:39:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Bootstrap initial migration files using actual SQL definitions from v1 and v2 schema.
+ * Output: src/migrations/001-initial-schema.sql and 002-schema-upgrade.sql
+ */
+
+function setupMigrations() {
+  const migrationsDir = path.resolve(__dirname, '../migrations')
+
+  if (!fs.existsSync(migrationsDir)) {
+    fs.mkdirSync(migrationsDir, { recursive: true })
+    console.info(`📁 Created migrations directory: ${migrationsDir}`)
+  } else {
+    console.info(`✅ Migrations directory already exists: ${migrationsDir}`)
+  }
+
+  const files: { name: string; content: string }[] = [
+    {
+      name: '001-initial-schema.sql',
+      content: `
+-- Create initial tables
+
+CREATE TABLE IF NOT EXISTS queuedmessage (
+  id VARCHAR(20) DEFAULT substr(md5(random()::text), 1, 20) PRIMARY KEY,
+  connectionId VARCHAR(255),
+  recipientKeys TEXT[],
+  encryptedMessage JSONB,
+  state VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queuedmessage_connectionId_index ON queuedmessage (connectionId);
+
+CREATE TABLE IF NOT EXISTS livesession (
+  sessionid VARCHAR(255) PRIMARY KEY,
+  connectionid VARCHAR(50),
+  protocolVersion VARCHAR(50),
+  role VARCHAR(50),
+  instance VARCHAR(50),
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS livesession_connectionid ON livesession USING btree (connectionid);
+      `.trim(),
+    },
+    {
+      name: '002-schema-upgrade.sql',
+      content: `
+-- Create new type
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_state') THEN
+    CREATE TYPE message_state AS ENUM ('pending', 'sending');
+  END IF;
+END$$;
+
+-- Create new tables with updated schema
+
+CREATE TABLE IF NOT EXISTS queued_message (
+  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+  connection_id VARCHAR(255),
+  recipient_dids TEXT[],
+  encrypted_message JSONB,
+  state message_state NOT NULL,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS queued_message_connection_id_idx ON queued_message (connection_id);
+
+CREATE TABLE IF NOT EXISTS live_session (
+  session_id VARCHAR(255) PRIMARY KEY,
path: packages/postgres/src/utils/setupMigration.ts
user: login: gabrielmatau79
body: I'll update the schema to reflect this  thanks again for the suggestion!
created_at: 2025-04-22T19:49:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,125 @@
+import fs from 'fs'
+import path from 'path'
path: packages/postgres/src/utils/setupMigration.ts
user: login: gabrielmatau79
body: setupMigration has been removed to avoid confusion. The SQL scripts are now located in the migrations folder. the monorepo has a Continuous Integration 
created_at: 2025-04-22T19:53:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,117 @@
+import { Client, Pool } from 'pg'
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Runs all pending SQL migrations in a target PostgreSQL database.
+ * - Automatically creates the database if it does not exist.
+ * - Applies versioned `.sql` files from a directory.
+ *
+ * @param postgresConfig - Connection config to connect as superuser (to create DB if needed).
+ * @param targetDatabase - Name of the target database to migrate.
+ * @param migrationsDir - Directory where migration `.sql` files are located.
+ */
+export async function buildPgDatabaseWithMigrations(
+  postgresConfig: { user: string; password: string; host: string; port?: number },
+  targetDatabase: string,
+  migrationsDir: string = path.resolve(__dirname, '../migrations'),
+): Promise<void> {
+  const adminClient = new Client({
+    user: postgresConfig.user,
+    password: postgresConfig.password,
+    host: postgresConfig.host,
+    port: postgresConfig.port ?? 5432,
+    database: 'postgres',
+  })
+
+  try {
+    await adminClient.connect()
+
+    // Try to create the target database if it doesn't exist
+    const existsResult = await adminClient.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [targetDatabase])
+
+    if (existsResult.rowCount === 0) {
+      await adminClient.query(`CREATE DATABASE ${targetDatabase}`)
+      console.info(`[migration] Database "${targetDatabase}" created.`)
+    } else {
+      console.info(`[migration] Database "${targetDatabase}" already exists.`)
+    }
+
+    await adminClient.end()
+  } catch (error) {
+    console.error(`[migration] Failed to create database "${targetDatabase}": ${(error as Error).message}`)
+    await adminClient.end()
+    throw error
+  }
+
+  // Now connect to the target database using a pool
+  const pool = new Pool({
+    user: postgresConfig.user,
+    password: postgresConfig.password,
+    host: postgresConfig.host,
+    port: postgresConfig.port ?? 5432,
+    database: targetDatabase,
+  })
+
+  const client = await pool.connect()
+
+  try {
+    await client.query('BEGIN')
+
+    await client.query(`
+      CREATE TABLE IF NOT EXISTS schema_version (
+        id SERIAL PRIMARY KEY,
+        version INTEGER NOT NULL,
+        updated_at TIMESTAMP DEFAULT now()
+      )
+    `)
+
+    const result = await client.query('SELECT version FROM schema_version ORDER BY id DESC LIMIT 1')
+    const currentVersion = result.rows.length > 0 ? result.rows[0].version : 0
+    console.info(`[migration] Current schema version: ${currentVersion}`)
+
+    if (!fs.existsSync(migrationsDir)) {
+      console.warn(`[migration] Migrations directory not found: ${migrationsDir}. Skipping.`)
+      await client.query('COMMIT')
+      return
+    }
+
+    const files = fs
+      .readdirSync(migrationsDir)
+      .filter((file) => /^\d{3,}-[\w\-]+\.sql$/.test(file))
+      .sort()
+
+    for (const file of files) {
+      const versionMatch = file.match(/^(\d{3,})-/)
+      if (!versionMatch) {
+        console.warn(`[migration] Skipping invalid filename: ${file}`)
+        continue
+      }
+
+      const version = parseInt(versionMatch[1], 10)
+      if (version <= currentVersion) {
+        console.debug(`[migration] Skipping already applied: ${file}`)
+        continue
+      }
+
+      const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8')
+      console.info(`[migration] Applying migration: ${file}`)
+
+      try {
+        await client.query(sql)
+        await client.query(`INSERT INTO schema_version (version) VALUES ($1)`, [version])
+        console.info(`[migration] Applied successfully: ${file}`)
+      } catch (fileErr) {
+        throw new Error(`[migration] Failed to apply ${file}: ${(fileErr as Error).message}`)
+      }
+    }
+
+    await client.query('COMMIT')
+  } catch (err) {
+    await client.query('ROLLBACK')
+    console.error('[migration] Migration process failed:', (err as Error).message)
path: packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts
user: login: gabrielmatau79
body: Resolved!
created_at: 2025-04-22T21:38:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,117 @@
+import { Client, Pool } from 'pg'
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Runs all pending SQL migrations in a target PostgreSQL database.
+ * - Automatically creates the database if it does not exist.
+ * - Applies versioned `.sql` files from a directory.
+ *
+ * @param postgresConfig - Connection config to connect as superuser (to create DB if needed).
+ * @param targetDatabase - Name of the target database to migrate.
+ * @param migrationsDir - Directory where migration `.sql` files are located.
+ */
+export async function buildPgDatabaseWithMigrations(
+  postgresConfig: { user: string; password: string; host: string; port?: number },
+  targetDatabase: string,
+  migrationsDir: string = path.resolve(__dirname, '../migrations'),
+): Promise<void> {
+  const adminClient = new Client({
path: packages/postgres/src/utils/buildPgDatabaseWithMigrations.ts
user: login: gabrielmatau79
body: Resolved!
created_at: 2025-04-22T21:38:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: gabrielmatau79
created_at: 2025-04-22T21:45:48Z
body: Thanks for the feedback! @lotharking  Based on the suggestions and review comments, the following changes have been applied:

- Logger integration into buildPgDatabaseWithMigrations.
- Refactored shared connection config into baseConfig to reduce duplication
- Changed column types in live_session session_id: VARCHAR(255) → UUID
- Changed column types in queued_message connection_id: VARCHAR(255) → UUID
- Improved migration performance with new indexes 
- Removed setupMigrations.ts script
author_association: CONTRIBUTOR
user: login: lotharking
created_at: 2025-04-23T14:11:30Z
body: i don't have any problem, it looks ok for me. What do you think @genaris ?
author_association: NONE