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

16 KiB
Raw Blame History

DB Seeders Module

Overview

The DB Seeders module provides initial data population for the database using the typed Umzug v3 runner in backend/src/db/umzug.ts. Seeders create essential system data (users, roles, permissions) and optional sample data for development and testing.

Location: backend/src/db/seeders/

Files: 3 TypeScript ESM seeder entries. There are no JavaScript seeder bridges after the backend ESM migration.


Architecture

backend/src/db/seeders/
├── 20200430130759-admin-user.ts       # Initial admin users
├── 20200430130760-user-roles.ts       # RBAC roles + permissions
└── 20231127130745-sample-data.ts      # Sample data, opt-in

Execution Order: Seeders run in timestamp order (oldest first).


Configuration

NPM Scripts

# Run pending seeders
npm run db:seed

# Undo all seeders
npm run db:seed:undo

# Full database reset (includes seeding)
npm run db:reset

Server Startup

Seeders run automatically via npm start:

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

Seeder Files

1. Admin User Seeder (20200430130759-admin-user.ts)

Purpose: Create initial system users.

The implementation uses reusable contracts from backend/src/types/db-seeders.ts. Umzug executes the TypeScript source in development and the compiled JavaScript file from dist/ in production builds.

Users Created:

User Email Role Assignment
Admin config.admin_email Administrator
John john@doe.com Account Manager
Client client@hello.com Platform Owner

Key Features:

  • Uses bcrypt for password hashing with configured salt rounds
  • Hardcoded UUIDs for consistent user IDs
  • Reads credentials from config.ts (environment variables)
  • Idempotent on repeated db:seed: existing seed user IDs are selected first, and only missing rows are inserted. This prevents duplicate-key failures when SequelizeData lacks the seeder entry but users already exist.
const existingRows =
  await queryInterface.sequelize.query<AdminUserSeedExistingIdRow>(
    'SELECT "id" FROM "users" WHERE "id" IN (:ids)',
    {
      replacements: { ids: seedUserIds },
      type: QueryTypes.SELECT,
    },
  );

const existingIds = new Set(existingRows.map((row) => row.id));
const rowsToInsert = createAdminUserRows().filter(
  (row) => !existingIds.has(row.id),
);

if (rowsToInsert.length > 0) {
  await queryInterface.bulkInsert('users', rowsToInsert);
}

2. User Roles Seeder (20200430130760-user-roles.ts)

Purpose: Create the complete RBAC (Role-Based Access Control) system.

The implementation uses reusable contracts from backend/src/types/db-seeders.ts. Umzug stores stable seeder execution names in SequelizeData so repeated startup seeding does not rerun completed seed data.

Data Created:

Data Type Count Description
Roles 7 User role definitions
Permissions 54 CRUD permissions for 13 entities + special
Role-Permission Links 200+ M:N relationships
Join Table 1 rolesPermissionsPermissions table

Key Features:

  • Uses stable named role and permission definitions, then reuses existing DB IDs when the same role/permission name is already present.
  • Inserts only missing roles, permissions, and role-permission links. This keeps repeated db:seed safe.
  • Keeps the existing startup behavior that assigns system users to seeded roles by email after RBAC data exists.

Roles

const roles = [
  'Administrator',      // Full system access
  'PlatformOwner',      // Full project/content access
  'AccountManager',     // User and project management
  'TourDesigner',       // Content creation and editing
  'ContentReviewer',    // Read + limited update access
  'AnalyticsViewer',    // Read-only access
  'Public',             // Public/unauthenticated access
];

Permission Generation Pattern

// Generates CREATE, READ, UPDATE, DELETE permissions per entity
function createPermissions(name) {
  return [
    { name: `CREATE_${name.toUpperCase()}` },
    { name: `READ_${name.toUpperCase()}` },
    { name: `UPDATE_${name.toUpperCase()}` },
    { name: `DELETE_${name.toUpperCase()}` },
  ];
}

const entities = [
  'users', 'roles', 'permissions', 'projects', 'project_memberships',
  'assets', 'asset_variants', 'presigned_url_requests', 'tour_pages',
  'project_audio_tracks', 'publish_events', 'pwa_caches', 'access_logs'
];

// Creates 52 permissions (13 entities × 4 CRUD operations)
await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions));

// Plus special permissions
await queryInterface.bulkInsert('permissions', [
  { name: 'READ_API_DOCS' },
  { name: 'CREATE_SEARCH' },
]);

ID Map Pattern

// Consistent UUID generation using key-based map
const idMap = new Map();

function getId(key) {
  if (idMap.has(key)) {
    return idMap.get(key);
  }
  const id = uuid();
  idMap.set(key, id);
  return id;
}

// Usage - same key always returns same UUID within seeder run
getId('Administrator')  // Returns consistent UUID
getId('CREATE_USERS')   // Returns consistent UUID

Permission Matrix

Role Users Projects Assets Tour Pages Access Logs
Administrator CRUD CRUD CRUD CRUD CRUD
PlatformOwner CRUD CRUD CRUD CRUD CRUD
AccountManager RU CRU CRU CRU R
TourDesigner R RU CRU CRU R
ContentReviewer R RU RU RU R
AnalyticsViewer R R R R R
Public - - - - -

Legend: C=Create, R=Read, U=Update, D=Delete

Join Table Creation

// Creates M:N relationship table directly in seeder
await queryInterface.sequelize.query(`
  CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" (
    "createdAt" timestamp with time zone not null,
    "updatedAt" timestamp with time zone not null,
    "roles_permissionsId" uuid not null,
    "permissionId" uuid not null,
    primary key ("roles_permissionsId", "permissionId"),
    constraint "rolesPermissionsPermissions_roles_permissions_fk"
      foreign key ("roles_permissionsId") references "roles"("id")
      on delete cascade on update cascade,
    constraint "rolesPermissionsPermissions_permission_fk"
      foreign key ("permissionId") references "permissions"("id")
      on delete cascade on update cascade
  );
`);

3. Sample Data Seeder (20231127130745-sample-data.ts)

Purpose: Create sample data for development and testing.

The implementation lives in 20231127130745-sample-data.ts. Demo-only Sequelize operations use reusable SampleDataModel and SampleDataAssociationRecord contracts from backend/src/types/db-seeders.ts; the production service model overloads stay separate. Umzug records the seeder under its legacy .js name for storage compatibility only.

Opt-In Activation:

# Enable sample data seeding
export ENABLE_SAMPLE_DATA=true
npm run db:seed

Check in Code:

const sampleDataSeeder: SequelizeSeeder = {
  async up() {
    if (process.env.ENABLE_SAMPLE_DATA !== 'true') return;
    // ... seed data
  },
};

Data Created:

Entity Records Description
Projects 3 Sample tour projects
Project Memberships 3 User-project associations
Assets 3 Images, videos, audio
Asset Variants 3 Thumbnail/preview variants
Presigned URL Requests 3 Upload/download requests
Tour Pages 3 Sample tour pages
Project Audio Tracks 3 Background audio
Publish Events 3 Deployment history
PWA Caches 3 Offline cache configs
Access Logs 3 Visitor tracking

Sample Projects

const ProjectsData = [
  {
    name: 'Cardiff Arena Tour',
    slug: 'cardiff-arena',
    description: 'Interactive arena tour for visitors and event planners.',
    logo_url: 'https://cdn.platform.com/cardiff/logo.png',
    favicon_url: 'https://cdn.platform.com/cardiff/favicon.ico',
    og_image_url: 'https://cdn.platform.com/cardiff/og.jpg',
  },
  {
    name: 'Riverside Park Walkthrough',
    slug: 'riverside-park',
    description: 'Offline-ready guided walkthrough for the city park.',
    // ...
  },
  {
    name: 'Mall Central Experience',
    slug: 'mall-central',
    description: 'Retail complex presentation with navigation and galleries.',
    // ...
  },
];

Association Helper Pattern

// Associates records after bulk creation using Sequelize model methods
async function associateAssetWithProject() {
  const relatedProject = await Projects.findOne({
    offset: Math.floor(Math.random() * (await Projects.count())),
  });
  const asset = await Assets.findOne({
    order: [['id', 'ASC']],
    offset: 0,
  });
  if (asset?.setProject) {
    await asset.setProject(relatedProject);
  }
}

Seeder Patterns

1. bulkInsert Pattern

Purpose: Insert multiple records efficiently.

await queryInterface.bulkInsert('tableName', [
  {
    id: uuid(),
    field1: 'value1',
    createdAt: new Date(),
    updatedAt: new Date(),
  },
  // ... more records
]);

2. bulkDelete Pattern

Purpose: Remove seeded data during rollback.

async down(queryInterface, Sequelize) {
  await queryInterface.bulkDelete('users', {
    id: { [Sequelize.Op.in]: ids }
  });
}

3. Conditional Execution Pattern

Purpose: Enable/disable seeders based on environment.

Seeder-only environment gates are intentionally allowed to read process.env directly. Runtime app configuration must go through backend/src/config.ts, but seeders and scripts are bootstrap/data-loading entrypoints and are listed as exceptions in AGENTS.md.

The resilience/config hardening work does not require seeder changes because it does not introduce database schema, RBAC permissions, seed users, roles, default records, or sample-data entities.

up: async () => {
  if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
    return;  // Skip seeding
  }
  // ... proceed with seeding
}

4. ID Consistency Pattern

Purpose: Use deterministic IDs for reliable down() migrations.

// Hardcoded IDs for rollback capability
const ids = [
  '193bf4b5-9f07-4bd5-9a43-e7e41f3e96af',
  'af5a87be-8f9c-4630-902a-37a60b7005ba',
];

// OR: Map-based consistent generation
const idMap = new Map();
function getId(key) {
  if (!idMap.has(key)) {
    idMap.set(key, uuid());
  }
  return idMap.get(key);
}

5. Model-Based Association Pattern

Purpose: Create relationships using Sequelize models after bulk insert.

// Use model methods for associations (more readable)
const project = await Projects.findOne({ where: { slug: 'test' } });
const asset = await Assets.findOne({ order: [['id', 'ASC']] });
await asset.setProject(project);

6. Raw SQL Pattern

Purpose: Create structures not managed by Sequelize models.

// Create join table directly via SQL
await queryInterface.sequelize.query(`
  CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" (
    ...
  );
`);

// Create indexes
await queryInterface.sequelize.query(
  'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");'
);

Execution Flow

npm run db:seed
    │
    ▼
┌────────────────────────────────────┐
│ 20200430130759-admin-user.ts       │
│   ├─ Typed ESM source              │
│   ├─ Hash passwords                │
│   └─ Insert missing seed users      │
└────────────────────────────────────┘
    │
    ▼
┌────────────────────────────────────┐
│ 20200430130760-user-roles.ts       │
│   ├─ Typed ESM source              │
│   ├─ Insert missing roles          │
│   ├─ Insert missing permissions    │
│   ├─ Create join table             │
│   ├─ Insert missing RBAC links      │
│   └─ Update user app_roleId        │
└────────────────────────────────────┘
    │
    ▼
┌────────────────────────────────────┐
│ 20231127130745-sample-data.ts      │
│   ├─ Check ENABLE_SAMPLE_DATA      │
│   ├─ Skip if not enabled           │
│   ├─ Insert sample records         │
│   └─ Create associations           │
└────────────────────────────────────┘

Best Practices

1. Use Consistent IDs

// Good - allows rollback
const ids = ['uuid-1', 'uuid-2'];
await queryInterface.bulkInsert('table', records.map((r, i) => ({ id: ids[i], ...r })));

// Down migration can target specific IDs
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });

2. Always Include Timestamps

{
  field: 'value',
  createdAt: new Date(),
  updatedAt: new Date(),
}

3. Handle Errors

try {
  await queryInterface.bulkInsert('users', [...]);
} catch (error) {
  console.error('Error during bulkInsert:', error);
  throw error;
}

4. Environment-Aware Seeding

// Production - only essential data
// Development - include sample data
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
  return;
}

5. Idempotent Where Possible

// Use IF NOT EXISTS for table/index creation
await queryInterface.sequelize.query(`
  CREATE TABLE IF NOT EXISTS "tableName" (...)
`);

await queryInterface.sequelize.query(`
  CREATE INDEX IF NOT EXISTS "indexName" ON "tableName" (...)
`);

Data Dependencies

admin-user.ts
    │
    │ Creates missing users with stable IDs
    ▼
user-roles.ts
    │
    │ Creates/reuses roles and permissions, then references user emails to assign roles
    │ UPDATE users SET app_roleId = '...' WHERE email = '...'
    ▼
sample-data.ts
    │
    │ Uses Sequelize models to find existing users/projects
    │ Creates associations via model methods
    ▼

Running Seeders

Development Setup

cd backend
npm run db:seed

With Sample Data

export ENABLE_SAMPLE_DATA=true
npm run db:seed

Fresh Database

npm run db:reset  # drop, create, migrate, seed

Undo Seeders

npm run db:seed:undo  # Runs all down() methods in reverse order

Seeder Inventory

# Timestamp Name Records Required
1 20200430130759 admin-user 3 users Yes
2 20200430130760 user-roles 7 roles, 54 permissions, 200+ links Yes
3 20231127130745 sample-data 30+ sample records No (opt-in)

Environment Variables

Variable Purpose Default
ENABLE_SAMPLE_DATA Enable sample data seeder false
ADMIN_EMAIL Admin user email (from config)
ADMIN_PASS Admin user password (from config)
USER_PASS Default user password (from config)