tududi/docs/database.md
2026-03-15 09:07:21 +02:00

16 KiB

Database & Migrations

← Back to Index


Key Models

All Sequelize models are defined in /backend/models/ and associations are configured in /backend/models/index.js.

Core Models

Model File Purpose Key Fields
User user.js User accounts email, password (bcrypt), settings, preferences, timezone
Task task.js Tasks with recurrence name, due_date, priority, status, recurrence_type, parent_task_id
Project project.js Project grouping name, area_id, user_id
Area area.js Area categorization name, user_id
Note note.js Notes text, project_id, user_id
Tag tag.js Tags name, color, user_id
Permission permission.js Sharing/permissions user_id, resource_type, resource_uid, access_level
ApiToken apiToken.js API tokens user_id, token_hash, expires_at
RecurringCompletion recurringCompletion.js Recurring task history task_id, completed_at, due_date
TaskEvent taskEvent.js Task audit log task_id, user_id, action, changes
TaskAttachment taskAttachment.js File attachments task_id, filename, path
InboxItem inboxItem.js Inbox entries name, user_id
Notification notification.js User notifications user_id, type, read, linked_resource
Role role.js User roles name, is_admin
View view.js Saved views name, filters, user_id
Backup backup.js Backup records user_id, filename, created_at

Model Relationships

Defined in /backend/models/index.js:

// User relationships (one user has many resources)
User.hasMany(Task, { foreignKey: 'user_id' });
User.hasMany(Project, { foreignKey: 'user_id' });
User.hasMany(Area, { foreignKey: 'user_id' });
User.hasMany(Note, { foreignKey: 'user_id' });

// Hierarchical relationships
// Area > Project
Area.hasMany(Project, { foreignKey: 'area_id' });
Project.belongsTo(Area, { foreignKey: 'area_id' });

// Project > Task
Project.hasMany(Task, { foreignKey: 'project_id' });
Task.belongsTo(Project, { foreignKey: 'project_id' });

// Project > Note
Project.hasMany(Note, { foreignKey: 'project_id' });
Note.belongsTo(Project, { foreignKey: 'project_id' });

// Self-referential (subtasks and recurring tasks)
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });

// Many-to-many relationships (tags)
Task.belongsToMany(Tag, { through: 'tasks_tags' });
Tag.belongsToMany(Task, { through: 'tasks_tags' });

Project.belongsToMany(Tag, { through: 'projects_tags' });
Tag.belongsToMany(Project, { through: 'projects_tags' });

Note.belongsToMany(Tag, { through: 'notes_tags' });
Tag.belongsToMany(Note, { through: 'notes_tags' });

Database Schema Diagram

The following diagram visualizes the database structure and relationships:

%%{init: {'theme':'dark'}}%%
erDiagram
    User ||--o{ Task : owns
    User ||--o{ Project : owns
    User ||--o{ Area : owns
    User ||--o{ Note : owns
    User ||--o{ Tag : owns
    User ||--o{ ApiToken : has
    User ||--o{ Notification : receives
    User ||--o{ View : creates
    User ||--o{ InboxItem : has
    User ||--o{ Backup : has
    User ||--o| Role : has_role

    Area ||--o{ Project : contains

    Project ||--o{ Task : contains
    Project ||--o{ Note : contains
    Project }o--o{ Tag : tagged_with
    Project ||--o{ Permission : shared_via

    Task ||--o{ Task : subtasks
    Task ||--o{ TaskEvent : events
    Task ||--o{ TaskAttachment : attachments
    Task ||--o{ RecurringCompletion : completions
    Task }o--o{ Tag : tagged_with
    Task ||--o{ Permission : shared_via

    Note }o--o{ Tag : tagged_with
    Note ||--o{ Permission : shared_via

    User {
        int id PK
        string uid UK
        string email UK
        string password_hash
        string timezone
        json settings
        json preferences
        int role_id FK
        datetime created_at
        datetime updated_at
    }

    Role {
        int id PK
        string name
        boolean is_admin
    }

    Area {
        int id PK
        string uid UK
        string name
        text description
        int user_id FK
        datetime created_at
        datetime updated_at
    }

    Project {
        int id PK
        string uid UK
        string name
        text description
        int status
        date due_date
        int priority
        int area_id FK
        int user_id FK
        datetime created_at
        datetime updated_at
    }

    Task {
        int id PK
        string uid UK
        string name
        text description
        date due_date
        date defer_until
        int priority
        int status
        boolean is_habit
        string recurrence_type
        int recurrence_interval
        date recurrence_end_date
        int parent_task_id FK
        int project_id FK
        int user_id FK
        datetime created_at
        datetime updated_at
    }

    Note {
        int id PK
        string uid UK
        string name
        text text
        string color
        int project_id FK
        int user_id FK
        datetime created_at
        datetime updated_at
    }

    Tag {
        int id PK
        string uid UK
        string name UK
        string color
        int user_id FK
        datetime created_at
        datetime updated_at
    }

    TaskEvent {
        int id PK
        int task_id FK
        int user_id FK
        string action
        json changes
        datetime created_at
    }

    TaskAttachment {
        int id PK
        string uid UK
        int task_id FK
        string filename
        string path
        int size
        string mime_type
        datetime created_at
    }

    RecurringCompletion {
        int id PK
        int task_id FK
        date due_date
        datetime completed_at
        int user_id FK
    }

    Permission {
        int id PK
        int user_id FK
        string resource_type
        string resource_uid
        string access_level
        datetime created_at
    }

    ApiToken {
        int id PK
        string uid UK
        int user_id FK
        string name
        string token_hash
        datetime last_used_at
        datetime expires_at
        datetime created_at
    }

    Notification {
        int id PK
        string uid UK
        int user_id FK
        string type
        string message
        boolean read
        string linked_resource_type
        string linked_resource_uid
        datetime created_at
    }

    View {
        int id PK
        string uid UK
        int user_id FK
        string name
        string resource_type
        json filters
        json sort
        int position
        boolean pinned
        datetime created_at
        datetime updated_at
    }

    InboxItem {
        int id PK
        string uid UK
        int user_id FK
        string name
        text description
        datetime created_at
    }

    Backup {
        int id PK
        int user_id FK
        string filename
        string path
        int size
        datetime created_at
    }

Key Observations:

  • Hierarchical Structure: User → Area → Project → Task (with subtasks)
  • Self-Referential: Tasks can have subtasks via parent_task_id
  • Many-to-Many: Tags are shared across Tasks, Projects, and Notes (via junction tables)
  • Polymorphic Sharing: Permissions work across multiple resource types
  • Recurring Tasks: Tracked via RecurringCompletion records for history
  • Audit Trail: TaskEvent logs all task changes

Migration Workflow

Creating a New Migration

npm run migration:create -- --name add-field-to-tasks

This creates: /backend/migrations/YYYYMMDDHHMMSS-add-field-to-tasks.js

The timestamp ensures migrations run in chronological order.

Migration File Template

// backend/migrations/20260313120000-add-priority-level-to-tasks.js
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // Changes to apply
    await queryInterface.addColumn('Tasks', 'priority_level', {
      type: Sequelize.INTEGER,
      allowNull: true,
      defaultValue: null
    });
  },

  async down(queryInterface, Sequelize) {
    // How to reverse the changes
    await queryInterface.removeColumn('Tasks', 'priority_level');
  }
};

Running Migrations

# Apply all pending migrations
npm run migration:run

# Rollback last migration
npm run migration:undo

# Check migration status
npm run db:status

# Rollback all migrations (CAREFUL!)
npm run migration:undo:all

Migration Best Practices

1. Always Implement down Method

Make migrations reversible:

// ✅ Good - Reversible
async down(queryInterface, Sequelize) {
  await queryInterface.removeColumn('Tasks', 'priority_level');
}

// ❌ Bad - Not reversible
async down(queryInterface, Sequelize) {
  // Not implemented
}

2. Test Both Directions

# Test up
npm run migration:run

# Test down (rollback)
npm run migration:undo

# Re-apply to verify
npm run migration:run

3. Never Modify Released Migrations

Once a migration is released (in main branch or deployed), create a new one instead:

# ❌ Bad - Modifying existing migration
# Edit: migrations/20260101-create-tasks.js

# ✅ Good - Create new migration
npm run migration:create -- --name modify-tasks-table

4. Update Corresponding Model

After creating migration, update the Sequelize model:

// Migration adds field
await queryInterface.addColumn('Tasks', 'estimated_time', {
  type: Sequelize.INTEGER
});

// Update /backend/models/task.js
Task.init({
  // ... existing fields ...
  estimated_time: {
    type: DataTypes.INTEGER,
    allowNull: true
  }
});

5. Use Transactions for Data Migrations

async up(queryInterface, Sequelize) {
  const transaction = await queryInterface.sequelize.transaction();
  
  try {
    // Multiple operations in transaction
    await queryInterface.addColumn('Tasks', 'new_field', {...}, { transaction });
    
    // Data migration
    await queryInterface.sequelize.query(
      'UPDATE Tasks SET new_field = old_field WHERE old_field IS NOT NULL',
      { transaction }
    );
    
    await queryInterface.removeColumn('Tasks', 'old_field', { transaction });
    
    await transaction.commit();
  } catch (error) {
    await transaction.rollback();
    throw error;
  }
}

6. Include Migrations in PRs

When schema changes are needed, always include the migration file in your PR.


Common Migration Operations

Add Column

await queryInterface.addColumn('TableName', 'column_name', {
  type: Sequelize.STRING,
  allowNull: false,
  defaultValue: 'default'
});

Remove Column

await queryInterface.removeColumn('TableName', 'column_name');

Change Column Type

await queryInterface.changeColumn('TableName', 'column_name', {
  type: Sequelize.TEXT,  // Changed from STRING
  allowNull: true
});

Rename Column

await queryInterface.renameColumn('TableName', 'old_name', 'new_name');

Add Index

await queryInterface.addIndex('TableName', ['column_name'], {
  name: 'index_name',
  unique: false
});

Remove Index

await queryInterface.removeIndex('TableName', 'index_name');

Create Table

await queryInterface.createTable('NewTable', {
  id: {
    type: Sequelize.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  uid: {
    type: Sequelize.STRING(15),
    allowNull: false,
    unique: true
  },
  name: {
    type: Sequelize.STRING,
    allowNull: false
  },
  user_id: {
    type: Sequelize.INTEGER,
    allowNull: false,
    references: {
      model: 'Users',
      key: 'id'
    },
    onUpdate: 'CASCADE',
    onDelete: 'CASCADE'
  },
  created_at: {
    type: Sequelize.DATE,
    allowNull: false
  },
  updated_at: {
    type: Sequelize.DATE,
    allowNull: false
  }
});

Drop Table

await queryInterface.dropTable('TableName');

Add Foreign Key

await queryInterface.addConstraint('Tasks', {
  fields: ['project_id'],
  type: 'foreign key',
  name: 'tasks_project_fk',
  references: {
    table: 'Projects',
    field: 'id'
  },
  onDelete: 'SET NULL',
  onUpdate: 'CASCADE'
});

Database Configuration

Location

/backend/config/database.js

SQLite Performance Optimizations

// Applied in models/index.js
await sequelize.query('PRAGMA journal_mode=WAL'); // Write-Ahead Logging
await sequelize.query('PRAGMA synchronous=NORMAL'); // Faster with single user
await sequelize.query('PRAGMA busy_timeout=5000'); // 5s timeout
await sequelize.query('PRAGMA cache_size=-64000'); // 64MB cache
await sequelize.query('PRAGMA mmap_size=268435456'); // 256MB memory-mapped I/O
await sequelize.query('PRAGMA temp_store=MEMORY'); // Memory-based temp storage

Benefits:

  • WAL mode: Better concurrency for writes
  • Larger cache: Fewer disk reads
  • Memory-mapped I/O: Faster random access
  • Memory temp storage: Faster temp operations

Database File Location

  • Development: /backend/database.sqlite
  • Docker: Mounted volume at /app/backend/db/
  • Test: Separate test database (auto-created)

Database Management Commands

# Initialize database (create + migrate)
npm run db:init

# Reset database (WIPES ALL DATA!)
npm run db:reset

# Seed development data
npm run db:seed

# Check migration status
npm run db:status

# Create new migration
npm run migration:create -- --name description

# Run pending migrations
npm run migration:run

# Rollback last migration
npm run migration:undo

# Rollback all migrations (CAREFUL!)
npm run migration:undo:all

Model Definition Example

// /backend/models/task.js
'use strict';
const { Model, DataTypes } = require('sequelize');

module.exports = (sequelize) => {
  class Task extends Model {
    static associate(models) {
      // Defined in models/index.js
    }
  }

  Task.init({
    id: {
      type: DataTypes.INTEGER,
      primaryKey: true,
      autoIncrement: true
    },
    uid: {
      type: DataTypes.STRING(15),
      allowNull: false,
      unique: true
    },
    name: {
      type: DataTypes.STRING,
      allowNull: false
    },
    due_date: {
      type: DataTypes.DATEONLY,
      allowNull: true
    },
    priority: {
      type: DataTypes.INTEGER,
      defaultValue: 0,
      validate: {
        min: 0,
        max: 2
      }
    },
    status: {
      type: DataTypes.INTEGER,
      defaultValue: 0
    },
    // Recurrence fields
    recurrence_type: {
      type: DataTypes.STRING,
      allowNull: true
    },
    recurrence_interval: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    // Foreign keys
    user_id: {
      type: DataTypes.INTEGER,
      allowNull: false
    },
    project_id: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    parent_task_id: {
      type: DataTypes.INTEGER,
      allowNull: true
    }
  }, {
    sequelize,
    modelName: 'Task',
    tableName: 'Tasks',
    underscored: true,
    timestamps: true
  });

  return Task;
};

Special Task Model Fields

The Task model has special fields for recurring tasks:

// Recurrence pattern
recurrence_type: 'daily' | 'weekly' | 'monthly' | 'monthly_weekday' | 'monthly_last_day'
recurrence_interval: 1, 2, 3, ... (every N days/weeks/months)
recurrence_end_date: Optional end date for series

// Relationships
parent_task_id: Links subtasks to parent task (self-referential)
recurring_parent_id: Links recurring instances to original pattern

Task Status Values:

  • 0: Not started
  • 1: In progress
  • 2: Done/Completed
  • 3: Archived
  • 4: Waiting
  • 5: Cancelled
  • 6: Planned

Priority Values:

  • 0: Low
  • 1: Medium
  • 2: High

← Back to Index