39948-vm/backend/docs/modules/db-migrations.md
2026-07-03 16:11:24 +02:00

23 KiB

DB Migrations Module

Overview

The DB Migrations module manages database schema evolution using a typed Umzug v3 runner. It provides version-controlled, reversible database changes that run automatically on server startup.

Location: backend/src/db/migrations/

Files: 34+ migration files (as of June 2026)

Migration safety policy: Do not rewrite, rename, or reformat already applied migration files. Production databases track migration names in SequelizeMeta, and fresh databases must replay the same schema history.


Architecture

backend/
├── package.json                    # Umzug-backed migration scripts
└── src/db/
    ├── db-config.ts               # Database connection settings
    ├── umzug.ts                   # Typed Umzug runner
    └── migrations/                 # Migration files
        ├── package.json            # CommonJS boundary for legacy migrations
        ├── 20260319000001-*.js    # Foreign key constraints
        ├── 20260319000002-*.js    # Column cleanup
        ├── 20260326000001-*.js    # Table rename
        ├── 20260326000002-*.js    # ENUM to TEXT conversion
        ├── 20260326000003-*.js    # Create table
        ├── 20260326000004-*.js    # Data backfill
        ├── 20260326000005-*.js    # Environment fix
        ├── 20260326000006-*.js    # Cross-environment copy
        ├── 20260326043002-*.js    # NOT NULL enforcement
        ├── 20260326050442-*.js    # Column removal
        ├── 20260326054410-*.js    # Column removal
        ├── 20260326060000-*.js    # JSON data transformation
        ├── 20260326060001-*.js    # Table drop (page_elements)
        ├── 20260326060002-*.js    # Table drop (page_links)
        ├── 20260326060003-*.js    # Table drop (transitions)
        ├── 20260326171017-*.js    # Missing data insertion
        ├── 20260327000001-*.js    # Full data sync
        ├── 20260331024423-*.js    # Column removal (theme/css)
        ├── 20260331054340-*.js    # Duplicate cleanup
        ├── 20260331063424-*.js    # Invalid data cleanup
        ├── 20260403000001-*.js    # Background video settings
        ├── 20260409000001-*.js    # Design dimensions (projects)
        ├── 20260409111309-*.js    # Design dimensions (tour_pages)
        ├── 20260605000001-*.js    # Background audio settings
        ├── 20260626000001-*.js    # Private production presentation access
        ├── 20260626000002-*.js    # Account manager user creation permission
        ├── 20260628000001-*.js    # Global UI-control settings tables
        └── 20260628000005-*.js    # Existing-project UI-control snapshots

Configuration

Umzug Runner

backend/src/db/umzug.ts owns migration and seeder execution. It uses official Umzug types, SequelizeStorage, and the existing storage tables:

Flow Files Storage Table Stored Names
Migrations src/db/migrations/*.js SequelizeMeta *.js
Seeders src/db/seeders/*.ts in source, dist/src/db/seeders/*.js in build SequelizeData stable *.js names

Seeder files are typed ESM source, and the runner stores stable execution names so already executed seeders are not treated as pending.

NPM Scripts

# Run pending migrations
npm run db:migrate

# Undo last migration
npm run db:migrate:undo

# Undo all migrations
npm run db:migrate:undo:all

# Check migration status
npm run db:migrate:status

# Full database reset (drop, create, migrate, seed)
npm run db:reset

# Seed data
npm run db:seed

Migration File Structure

Standard Template

'use strict';

/**
 * Migration: [Description]
 *
 * [Explanation of what this migration does and why]
 */
module.exports = {
  async up(queryInterface, Sequelize) {
    // Apply changes
  },

  async down(queryInterface, Sequelize) {
    // Revert changes
  },
};

Use one project-wide migration template for new schema changes. Do not modify already applied migration files to match newer style choices.

Naming Convention

YYYYMMDDHHMMSS-descriptive-name.js

Examples:
- 20260319000001-add-foreign-key-constraints.js
- 20260326060000-convert-targetpageid-to-slug.js
- 20260326060001-drop-page-elements-table.js
- 20260628000001-create-ui-control-settings.js
- 20260628000005-snapshot-existing-project-ui-controls.js

Migration Patterns

1. Transaction Wrapper Pattern

Purpose: Ensure atomic operations - all changes succeed or all fail.

module.exports = {
  async up(queryInterface) {
    const transaction = await queryInterface.sequelize.transaction();

    try {
      // Multiple operations...
      await queryInterface.addColumn('table', 'column', { ... }, { transaction });
      await queryInterface.addIndex('table', ['column'], { transaction });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  },
};

Used in: Foreign key constraints, ENUM conversions, data transformations


2. Idempotent Check Pattern

Purpose: Safely re-run migrations without errors.

// Check if table/column/constraint exists before modifying
const [results] = await queryInterface.sequelize.query(
  `SELECT column_name FROM information_schema.columns
   WHERE table_name = 'tableName' AND column_name = 'columnName'`,
  { transaction }
);

if (results.length > 0) {
  console.log('Column already exists, skipping');
  return;
}

// Proceed with modification
await queryInterface.addColumn('tableName', 'columnName', { ... });

Used in: All migrations that add columns, constraints, or tables


3. Helper Function Pattern

Purpose: Reduce repetition for bulk operations.

module.exports = {
  async up(queryInterface) {
    const transaction = await queryInterface.sequelize.transaction();

    // Define reusable helper
    const addForeignKey = async (tableName, columnName, references, onDelete) => {
      const constraintName = `${tableName}_${columnName}_fkey`;

      // Check existence
      const [results] = await queryInterface.sequelize.query(
        `SELECT constraint_name FROM information_schema.table_constraints
         WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
        { transaction }
      );

      if (results.length === 0) {
        await queryInterface.addConstraint(tableName, {
          fields: [columnName],
          type: 'foreign key',
          name: constraintName,
          references,
          onDelete,
          onUpdate: 'CASCADE',
          transaction,
        });
        console.log(`Added FK: ${constraintName}`);
      }
    };

    // Use helper multiple times
    await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
    await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
    // ... more FKs
  },
};

Used in: Foreign key constraints, column removal, index management


4. Safe Table Drop Pattern

Purpose: Prevent accidental data loss when dropping tables.

module.exports = {
  async up(queryInterface) {
    // Verify table is empty before dropping
    const [results] = await queryInterface.sequelize.query(
      'SELECT COUNT(*) as count FROM table_name'
    );
    const count = parseInt(results[0].count, 10);

    if (count > 0) {
      throw new Error(
        `Cannot drop table_name: it contains ${count} records. ` +
        `Please migrate or delete them first.`
      );
    }

    await queryInterface.dropTable('table_name');
    console.log('Dropped table_name (was empty)');
  },

  async down(queryInterface, Sequelize) {
    // Full table recreation with all columns, indexes, and constraints
    await queryInterface.createTable('table_name', {
      id: { type: Sequelize.UUID, ... },
      // ... all columns
    });
  },
};

Used in: drop-page-elements-table, drop-page-links-table, drop-transitions-table


5. ENUM to TEXT Conversion Pattern

Purpose: Convert restrictive ENUMs to flexible TEXT while preserving data.

module.exports = {
  async up(queryInterface, Sequelize) {
    const transaction = await queryInterface.sequelize.transaction();

    try {
      // 1. Create temporary TEXT column
      await queryInterface.addColumn('table', 'column_text', {
        type: Sequelize.TEXT,
        allowNull: true,
      }, { transaction });

      // 2. Copy ENUM values to TEXT
      await queryInterface.sequelize.query(
        `UPDATE table SET column_text = column::TEXT`,
        { transaction }
      );

      // 3. Drop old ENUM column
      await queryInterface.removeColumn('table', 'column', { transaction });

      // 4. Rename TEXT column
      await queryInterface.renameColumn('table', 'column_text', 'column', { transaction });

      // 5. Add NOT NULL constraint
      await queryInterface.changeColumn('table', 'column', {
        type: Sequelize.TEXT,
        allowNull: false,
      }, { transaction });

      // 6. Drop ENUM type
      await queryInterface.sequelize.query(
        `DROP TYPE IF EXISTS "enum_table_column"`,
        { transaction }
      );

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  },

  async down(queryInterface, Sequelize) {
    // Recreate ENUM type
    await queryInterface.sequelize.query(`
      CREATE TYPE "enum_table_column" AS ENUM ('value1', 'value2', 'value3')
    `);
    // ... reverse the process
  },
};

Used in: convert-element-type-enum-to-text


6. Data Backfill Pattern

Purpose: Populate new tables/columns with data from existing records.

// Define default data
const DEFAULT_ELEMENT_TYPES = [
  { element_type: 'navigation_next', name: 'Forward Button', sort_order: 1, settings_json: {...} },
  { element_type: 'navigation_prev', name: 'Back Button', sort_order: 2, settings_json: {...} },
  // ... more defaults
];

module.exports = {
  async up(queryInterface, Sequelize) {
    // Get existing records
    const [projects] = await queryInterface.sequelize.query(
      `SELECT id FROM projects WHERE "deletedAt" IS NULL`,
      { type: Sequelize.QueryTypes.SELECT }
    );

    // For each project, check and insert missing records
    for (const project of projects) {
      const [existing] = await queryInterface.sequelize.query(
        `SELECT element_type FROM project_element_defaults
         WHERE "projectId" = :projectId AND "deletedAt" IS NULL`,
        { replacements: { projectId: project.id }, type: Sequelize.QueryTypes.SELECT }
      );

      const existingTypes = new Set(existing.map(d => d.element_type));

      for (const defaultType of DEFAULT_ELEMENT_TYPES) {
        if (!existingTypes.has(defaultType.element_type)) {
          await queryInterface.sequelize.query(`
            INSERT INTO project_element_defaults (...)
            VALUES (gen_random_uuid(), :element_type, :name, ...)
          `, { replacements: { ... } });
        }
      }
    }
  },

  async down(queryInterface) {
    // Delete only records created by this migration
    await queryInterface.sequelize.query(
      `DELETE FROM project_element_defaults WHERE snapshot_version = 1`
    );
  },
};

Used in: backfill-project-element-defaults, sync-all-element-type-defaults


7. Cross-Environment Data Copy Pattern

Purpose: Copy content between environments (dev → stage → production).

module.exports = {
  async up(queryInterface, Sequelize) {
    const projects = await queryInterface.sequelize.query(
      `SELECT id FROM projects WHERE "deletedAt" IS NULL`,
      { type: Sequelize.QueryTypes.SELECT }
    );

    for (const project of projects) {
      // Check if target environment already has content
      const [stageCheck] = await queryInterface.sequelize.query(
        `SELECT COUNT(*)::int as count FROM tour_pages
         WHERE "projectId" = '${project.id}' AND environment = 'stage'`
      );

      if (stageCheck?.count > 0) continue;

      // Copy with INSERT...SELECT, generating new UUIDs
      await queryInterface.sequelize.query(`
        INSERT INTO tour_pages (id, slug, name, ..., environment, source_key, ...)
        SELECT
          gen_random_uuid(),
          slug, name, ...,
          'stage',        -- New environment
          id::text,       -- Track source record for rollback
          ...
        FROM tour_pages
        WHERE "projectId" = '${project.id}' AND environment = 'dev'
      `);

      // Copy related records using source_key for ID mapping
      await queryInterface.sequelize.query(`
        INSERT INTO page_elements (id, ..., "pageId", ...)
        SELECT
          gen_random_uuid(), ...,
          stage_page.id,  -- Map to new page ID
          ...
        FROM page_elements pe
        INNER JOIN tour_pages dev_page ON pe."pageId" = dev_page.id
        INNER JOIN tour_pages stage_page ON stage_page.source_key = dev_page.id::text
        WHERE dev_page.environment = 'dev'
      `);
    }
  },

  async down(queryInterface) {
    // Delete records with source_key (created by migration)
    await queryInterface.sequelize.query(
      `DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`
    );
  },
};

Used in: copy-dev-to-stage


8. JSON Field Transformation Pattern

Purpose: Transform data stored in JSON columns.

module.exports = {
  async up(queryInterface, Sequelize) {
    const transaction = await queryInterface.sequelize.transaction();

    try {
      // Get all records with JSON data
      const [records] = await queryInterface.sequelize.query(
        `SELECT id, "projectId", environment, slug, json_column
         FROM table_name WHERE json_column IS NOT NULL`,
        { transaction }
      );

      // Build lookup maps for ID → slug transformations
      const slugById = new Map();
      records.forEach(r => slugById.set(r.id, { projectId: r.projectId, slug: r.slug }));

      // Transform each record
      for (const record of records) {
        const jsonData = typeof record.json_column === 'string'
          ? JSON.parse(record.json_column)
          : record.json_column;

        let hasChanges = false;

        // Transform JSON structure
        if (jsonData.elements) {
          jsonData.elements.forEach(element => {
            if (element.targetPageId) {
              const target = slugById.get(element.targetPageId);
              if (target) {
                element.targetPageSlug = target.slug;
                delete element.targetPageId;
                hasChanges = true;
              }
            }
          });
        }

        if (hasChanges) {
          await queryInterface.sequelize.query(
            `UPDATE table_name SET json_column = :json WHERE id = :id`,
            {
              replacements: { json: JSON.stringify(jsonData), id: record.id },
              transaction
            }
          );
        }
      }

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  },
};

Used in: convert-targetpageid-to-slug


9. Constraint Enforcement Pattern

Purpose: Add NOT NULL constraints after fixing existing NULL values.

module.exports = {
  async up(queryInterface) {
    // First, fix any NULL values
    await queryInterface.sequelize.query(
      `UPDATE table_name SET column = 'default' WHERE column IS NULL`
    );

    // Then add NOT NULL constraint with default
    await queryInterface.sequelize.query(`
      ALTER TABLE table_name
        ALTER COLUMN column SET NOT NULL,
        ALTER COLUMN column SET DEFAULT 'default'
    `);
  },

  async down(queryInterface) {
    await queryInterface.sequelize.query(`
      ALTER TABLE table_name
        ALTER COLUMN column DROP NOT NULL,
        ALTER COLUMN column DROP DEFAULT
    `);
  },
};

Used in: enforce-environment-not-null


10. Safe Down Migration Pattern

Purpose: Handle cases where down migration isn't meaningful.

module.exports = {
  async up(queryInterface, Sequelize) {
    // Add/update missing data
    // ...
  },

  async down(_queryInterface, _Sequelize) {
    // This migration only adds missing data, not destructive
    console.log('No down migration needed - this migration only adds missing data.');
  },
};

Used in: sync-all-element-type-defaults


Migration Categories

Schema Changes

Migration Description
add-foreign-key-constraints Add FK constraints to all model associations
create-project-element-defaults Create new table with indexes
drop-page-elements-table Drop unused table
drop-page-links-table Drop unused table
drop-transitions-table Drop unused table

Column Modifications

Migration Description
remove-redundant-deletion-columns Remove is_deleted, deleted_at_time
remove-project-phase-column Remove redundant phase column
remove-entry-page-slug-column Remove unused column
convert-element-type-enum-to-text ENUM → TEXT for flexibility
enforce-environment-not-null Add NOT NULL constraint
remove-unused-theme-columns-from-projects Remove theme_config_json, custom_css_json, cdn_base_url
add-background-video-settings Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages
add-design-dimensions-to-projects Add design_width, design_height to projects table
add-design-dimensions-to-tour-pages Add design_width, design_height to tour_pages table

Table Renames

Migration Description
rename-ui-elements-to-element-type-defaults Rename for clarity

Data Migrations

Migration Description
backfill-project-element-defaults Populate new table for existing projects
copy-dev-to-stage Initialize stage environment
convert-targetpageid-to-slug Transform JSON navigation references
fix-project-audio-tracks-environment Fix environment values
add-missing-element-type-defaults Insert missing default rows
sync-all-element-type-defaults Full sync of all 11 element types
remove-duplicate-element-type-defaults Remove duplicate records created during earlier migrations
cleanup-invalid-element-type-defaults Clean up invalid entries and ensure data integrity

Foreign Key Strategies

Strategy When to Use Example
CASCADE Delete child when parent deleted assets.projectId → projects.id
SET NULL Preserve record, nullify FK publish_events.userId → users.id (audit trail)
SET NULL + allowNull: true Optional FK users.app_roleId → roles.id
// CASCADE - delete assets when project is deleted
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');

// SET NULL - preserve audit log when user is deleted
await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'SET NULL');

Best Practices

1. Always Use Transactions

const transaction = await queryInterface.sequelize.transaction();
try {
  // ... operations
  await transaction.commit();
} catch (error) {
  await transaction.rollback();
  throw error;
}

2. Check Before Modify

// Always check existence before adding/removing
const tableExists = await queryInterface.sequelize.query(
  `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`
);

3. Log Progress

console.log(`Migrating project ${projectId}: ${addedCount} records added`);
console.log('Migration complete: All foreign keys added');

4. Safe Drops

// Never drop non-empty tables silently
if (count > 0) {
  throw new Error(`Cannot drop table: contains ${count} records`);
}

5. Reversible Operations

// Down migration should restore previous state
async down(queryInterface, Sequelize) {
  // Recreate everything that was dropped
  await queryInterface.createTable('table_name', { ... });
  await queryInterface.addIndex('table_name', [...]);
}

6. Use Parameterized Queries

// Good - prevents SQL injection
await queryInterface.sequelize.query(
  `UPDATE table SET column = :value WHERE id = :id`,
  { replacements: { value: 'safe', id: record.id } }
);

// Avoid - SQL injection risk
await queryInterface.sequelize.query(
  `UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`
);

Running Migrations

Development

cd backend
npm run db:migrate

Server Startup

Migrations run automatically via npm start:

{
  "scripts": {
    "start": "npm run db:migrate && npm run db:seed && npm run watch"
  }
}

Migration Status

npm run db:migrate:status

Undo Migrations

# Undo last migration
npm run db:migrate:undo

# Undo all migrations (dangerous!)
npm run db:migrate:undo:all

Create New Migration

Create new migration files manually under backend/src/db/migrations/ only when a schema change is required. Keep every migration reversible or document an explicit rollback/backup plan.


Current Migration Inventory

# Timestamp Name Type
1 20260319000001 add-foreign-key-constraints Schema
2 20260319000002 remove-redundant-deletion-columns Column
3 20260326000001 rename-ui-elements-to-element-type-defaults Rename
4 20260326000002 convert-element-type-enum-to-text Column
5 20260326000003 create-project-element-defaults Schema
6 20260326000004 backfill-project-element-defaults Data
7 20260326000005 fix-project-audio-tracks-environment Data
8 20260326000006 copy-dev-to-stage Data
9 20260326043002 enforce-environment-not-null Column
10 20260326050442 remove-project-phase-column Column
11 20260326054410 remove-entry-page-slug-column Column
12 20260326060000 convert-targetpageid-to-slug Data
13 20260326060001 drop-page-elements-table Schema
14 20260326060002 drop-page-links-table Schema
15 20260326060003 drop-transitions-table Schema
16 20260326171017 add-missing-element-type-defaults Data
17 20260327000001 sync-all-element-type-defaults Data
18 20260331024423 remove-unused-theme-columns-from-projects Column
19 20260331054340 remove-duplicate-element-type-defaults Data
20 20260331063424 cleanup-invalid-element-type-defaults Data
21 20260403000001 add-background-video-settings Column
22 20260409000001 add-design-dimensions-to-projects Column
23 20260409111309 add-design-dimensions-to-tour-pages Column
24 20260605000001 add-background-audio-settings Column