Run database migrations when opening board. Not when updating WeKan.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2025-10-11 19:23:47 +03:00
parent ea30612013
commit 2b5c56484a
20 changed files with 2508 additions and 118 deletions

View file

@ -0,0 +1,203 @@
/**
* Board Conversion Service
* Handles conversion of boards from old database structure to new structure
* without running migrations that could cause downtime
*/
import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/lib/reactiveCache';
// Reactive variables for conversion progress
export const conversionProgress = new ReactiveVar(0);
export const conversionStatus = new ReactiveVar('');
export const conversionEstimatedTime = new ReactiveVar('');
export const isConverting = new ReactiveVar(false);
class BoardConverter {
constructor() {
this.conversionCache = new Map(); // Cache converted board IDs
}
/**
* Check if a board needs conversion
* @param {string} boardId - The board ID to check
* @returns {boolean} - True if board needs conversion
*/
needsConversion(boardId) {
if (this.conversionCache.has(boardId)) {
return false; // Already converted
}
try {
const board = ReactiveCache.getBoard(boardId);
if (!board) return false;
// Check if any lists in this board don't have swimlaneId
const lists = ReactiveCache.getLists({
boardId: boardId,
$or: [
{ swimlaneId: { $exists: false } },
{ swimlaneId: '' },
{ swimlaneId: null }
]
});
return lists.length > 0;
} catch (error) {
console.error('Error checking if board needs conversion:', error);
return false;
}
}
/**
* Convert a board from old structure to new structure
* @param {string} boardId - The board ID to convert
* @returns {Promise<boolean>} - True if conversion was successful
*/
async convertBoard(boardId) {
if (this.conversionCache.has(boardId)) {
return true; // Already converted
}
isConverting.set(true);
conversionProgress.set(0);
conversionStatus.set('Starting board conversion...');
try {
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error('Board not found');
}
// Get the default swimlane for this board
const defaultSwimlane = board.getDefaultSwimline();
if (!defaultSwimlane) {
throw new Error('No default swimlane found for board');
}
// Get all lists that need conversion
const listsToConvert = ReactiveCache.getLists({
boardId: boardId,
$or: [
{ swimlaneId: { $exists: false } },
{ swimlaneId: '' },
{ swimlaneId: null }
]
});
if (listsToConvert.length === 0) {
this.conversionCache.set(boardId, true);
isConverting.set(false);
return true;
}
conversionStatus.set(`Converting ${listsToConvert.length} lists...`);
const startTime = Date.now();
const totalLists = listsToConvert.length;
let convertedLists = 0;
// Convert lists in batches to avoid blocking the UI
const batchSize = 10;
for (let i = 0; i < listsToConvert.length; i += batchSize) {
const batch = listsToConvert.slice(i, i + batchSize);
// Process batch
await this.processBatch(batch, defaultSwimlane._id);
convertedLists += batch.length;
const progress = Math.round((convertedLists / totalLists) * 100);
conversionProgress.set(progress);
// Calculate estimated time remaining
const elapsed = Date.now() - startTime;
const rate = convertedLists / elapsed; // lists per millisecond
const remaining = totalLists - convertedLists;
const estimatedMs = remaining / rate;
conversionStatus.set(`Converting list ${convertedLists} of ${totalLists}...`);
conversionEstimatedTime.set(this.formatTime(estimatedMs));
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 10));
}
// Mark as converted
this.conversionCache.set(boardId, true);
conversionStatus.set('Board conversion completed!');
conversionProgress.set(100);
// Clear status after a delay
setTimeout(() => {
isConverting.set(false);
conversionStatus.set('');
conversionProgress.set(0);
conversionEstimatedTime.set('');
}, 2000);
return true;
} catch (error) {
console.error('Error converting board:', error);
conversionStatus.set(`Conversion failed: ${error.message}`);
isConverting.set(false);
return false;
}
}
/**
* Process a batch of lists for conversion
* @param {Array} batch - Array of lists to convert
* @param {string} defaultSwimlaneId - Default swimlane ID
*/
async processBatch(batch, defaultSwimlaneId) {
const updates = batch.map(list => ({
_id: list._id,
swimlaneId: defaultSwimlaneId
}));
// Update lists in batch
updates.forEach(update => {
ReactiveCache.getCollection('lists').update(update._id, {
$set: { swimlaneId: update.swimlaneId }
});
});
}
/**
* Format time in milliseconds to human readable format
* @param {number} ms - Time in milliseconds
* @returns {string} - Formatted time string
*/
formatTime(ms) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
const remainingSeconds = seconds % 60;
return `${hours}h ${remainingMinutes}m ${remainingSeconds}s`;
} else if (minutes > 0) {
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
} else {
return `${seconds}s`;
}
}
/**
* Clear conversion cache (useful for testing)
*/
clearCache() {
this.conversionCache.clear();
}
}
// Export singleton instance
export const boardConverter = new BoardConverter();

View file

@ -0,0 +1,775 @@
/**
* Migration Manager
* Handles all database migrations as steps during board loading
* with detailed progress tracking and background persistence
*/
import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/lib/reactiveCache';
// Reactive variables for migration progress
export const migrationProgress = new ReactiveVar(0);
export const migrationStatus = new ReactiveVar('');
export const migrationCurrentStep = new ReactiveVar('');
export const migrationSteps = new ReactiveVar([]);
export const isMigrating = new ReactiveVar(false);
export const migrationEstimatedTime = new ReactiveVar('');
class MigrationManager {
constructor() {
this.migrationCache = new Map(); // Cache completed migrations
this.steps = this.initializeMigrationSteps();
this.currentStepIndex = 0;
this.startTime = null;
}
/**
* Initialize all migration steps with their details
*/
initializeMigrationSteps() {
return [
{
id: 'board-background-color',
name: 'Board Background Colors',
description: 'Setting up board background colors',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-cardcounterlist-allowed',
name: 'Card Counter List Settings',
description: 'Adding card counter list permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-boardmemberlist-allowed',
name: 'Board Member List Settings',
description: 'Adding board member list permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'lowercase-board-permission',
name: 'Board Permission Standardization',
description: 'Converting board permissions to lowercase',
weight: 1,
completed: false,
progress: 0
},
{
id: 'change-attachments-type-for-non-images',
name: 'Attachment Type Standardization',
description: 'Updating attachment types for non-images',
weight: 2,
completed: false,
progress: 0
},
{
id: 'card-covers',
name: 'Card Covers System',
description: 'Setting up card cover functionality',
weight: 2,
completed: false,
progress: 0
},
{
id: 'use-css-class-for-boards-colors',
name: 'Board Color CSS Classes',
description: 'Converting board colors to CSS classes',
weight: 2,
completed: false,
progress: 0
},
{
id: 'denormalize-star-number-per-board',
name: 'Board Star Counts',
description: 'Calculating star counts per board',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-member-isactive-field',
name: 'Member Activity Status',
description: 'Adding member activity tracking',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-sort-checklists',
name: 'Checklist Sorting',
description: 'Adding sort order to checklists',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-swimlanes',
name: 'Swimlanes System',
description: 'Setting up swimlanes functionality',
weight: 4,
completed: false,
progress: 0
},
{
id: 'add-views',
name: 'Board Views',
description: 'Adding board view options',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-checklist-items',
name: 'Checklist Items',
description: 'Setting up checklist items system',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-card-types',
name: 'Card Types',
description: 'Adding card type functionality',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-custom-fields-to-cards',
name: 'Custom Fields',
description: 'Adding custom fields to cards',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-requester-field',
name: 'Requester Field',
description: 'Adding requester field to cards',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-assigner-field',
name: 'Assigner Field',
description: 'Adding assigner field to cards',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-parent-field-to-cards',
name: 'Card Parent Relationships',
description: 'Adding parent field to cards',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-subtasks-boards',
name: 'Subtasks Boards',
description: 'Setting up subtasks board functionality',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-subtasks-sort',
name: 'Subtasks Sorting',
description: 'Adding sort order to subtasks',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-subtasks-allowed',
name: 'Subtasks Permissions',
description: 'Adding subtasks permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-authenticationMethod',
name: 'Authentication Methods',
description: 'Adding authentication method tracking',
weight: 2,
completed: false,
progress: 0
},
{
id: 'remove-tag',
name: 'Remove Tag Field',
description: 'Removing deprecated tag field',
weight: 1,
completed: false,
progress: 0
},
{
id: 'remove-customFields-references-broken',
name: 'Fix Custom Fields References',
description: 'Fixing broken custom field references',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-product-name',
name: 'Product Name Settings',
description: 'Adding product name configuration',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-logo',
name: 'Hide Logo Setting',
description: 'Adding hide logo option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-card-counter-list',
name: 'Hide Card Counter Setting',
description: 'Adding hide card counter option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-board-member-list',
name: 'Hide Board Member List Setting',
description: 'Adding hide board member list option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-displayAuthenticationMethod',
name: 'Display Authentication Method',
description: 'Adding authentication method display option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-defaultAuthenticationMethod',
name: 'Default Authentication Method',
description: 'Setting default authentication method',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-templates',
name: 'Board Templates',
description: 'Setting up board templates system',
weight: 3,
completed: false,
progress: 0
},
{
id: 'fix-circular-reference_',
name: 'Fix Circular References',
description: 'Fixing circular references in cards',
weight: 2,
completed: false,
progress: 0
},
{
id: 'mutate-boardIds-in-customfields',
name: 'Custom Fields Board IDs',
description: 'Updating board IDs in custom fields',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-missing-created-and-modified',
name: 'Missing Timestamps',
description: 'Adding missing created and modified timestamps',
weight: 4,
completed: false,
progress: 0
},
{
id: 'fix-incorrect-dates',
name: 'Fix Incorrect Dates',
description: 'Correcting incorrect date values',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-assignee',
name: 'Assignee Field',
description: 'Adding assignee field to cards',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-profile-showDesktopDragHandles',
name: 'Desktop Drag Handles',
description: 'Adding desktop drag handles preference',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-profile-hiddenMinicardLabelText',
name: 'Hidden Minicard Labels',
description: 'Adding hidden minicard label text preference',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-receiveddate-allowed',
name: 'Received Date Permissions',
description: 'Adding received date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-startdate-allowed',
name: 'Start Date Permissions',
description: 'Adding start date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-duedate-allowed',
name: 'Due Date Permissions',
description: 'Adding due date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-enddate-allowed',
name: 'End Date Permissions',
description: 'Adding end date permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-members-allowed',
name: 'Members Permissions',
description: 'Adding members permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-assignee-allowed',
name: 'Assignee Permissions',
description: 'Adding assignee permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-labels-allowed',
name: 'Labels Permissions',
description: 'Adding labels permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-checklists-allowed',
name: 'Checklists Permissions',
description: 'Adding checklists permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-attachments-allowed',
name: 'Attachments Permissions',
description: 'Adding attachments permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-comments-allowed',
name: 'Comments Permissions',
description: 'Adding comments permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-assigned-by-allowed',
name: 'Assigned By Permissions',
description: 'Adding assigned by permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-requested-by-allowed',
name: 'Requested By Permissions',
description: 'Adding requested by permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-activities-allowed',
name: 'Activities Permissions',
description: 'Adding activities permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-description-title-allowed',
name: 'Description Title Permissions',
description: 'Adding description title permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-description-text-allowed',
name: 'Description Text Permissions',
description: 'Adding description text permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-description-text-allowed-on-minicard',
name: 'Minicard Description Permissions',
description: 'Adding minicard description permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-sort-field-to-boards',
name: 'Board Sort Field',
description: 'Adding sort field to boards',
weight: 2,
completed: false,
progress: 0
},
{
id: 'add-default-profile-view',
name: 'Default Profile View',
description: 'Setting default profile view',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-logo-by-default',
name: 'Hide Logo Default',
description: 'Setting hide logo as default',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-card-counter-list-by-default',
name: 'Hide Card Counter Default',
description: 'Setting hide card counter as default',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-hide-board-member-list-by-default',
name: 'Hide Board Member List Default',
description: 'Setting hide board member list as default',
weight: 1,
completed: false,
progress: 0
},
{
id: 'add-card-number-allowed',
name: 'Card Number Permissions',
description: 'Adding card number permissions',
weight: 1,
completed: false,
progress: 0
},
{
id: 'assign-boardwise-card-numbers',
name: 'Board Card Numbers',
description: 'Assigning board-wise card numbers',
weight: 3,
completed: false,
progress: 0
},
{
id: 'add-card-details-show-lists',
name: 'Card Details Show Lists',
description: 'Adding card details show lists option',
weight: 1,
completed: false,
progress: 0
},
{
id: 'migrate-attachments-collectionFS-to-ostrioFiles',
name: 'Migrate Attachments to Meteor-Files',
description: 'Migrating attachments from CollectionFS to Meteor-Files',
weight: 8,
completed: false,
progress: 0
},
{
id: 'migrate-avatars-collectionFS-to-ostrioFiles',
name: 'Migrate Avatars to Meteor-Files',
description: 'Migrating avatars from CollectionFS to Meteor-Files',
weight: 6,
completed: false,
progress: 0
},
{
id: 'migrate-attachment-drop-index-cardId',
name: 'Drop Attachment Index',
description: 'Dropping old attachment index',
weight: 1,
completed: false,
progress: 0
},
{
id: 'migrate-attachment-migration-fix-source-import',
name: 'Fix Attachment Source Import',
description: 'Fixing attachment source import field',
weight: 2,
completed: false,
progress: 0
},
{
id: 'attachment-cardCopy-fix-boardId-etc',
name: 'Fix Attachment Card Copy',
description: 'Fixing attachment card copy board IDs',
weight: 2,
completed: false,
progress: 0
},
{
id: 'remove-unused-planning-poker',
name: 'Remove Planning Poker',
description: 'Removing unused planning poker fields',
weight: 1,
completed: false,
progress: 0
},
{
id: 'remove-user-profile-hiddenSystemMessages',
name: 'Remove Hidden System Messages',
description: 'Removing hidden system messages field',
weight: 1,
completed: false,
progress: 0
},
{
id: 'remove-user-profile-hideCheckedItems',
name: 'Remove Hide Checked Items',
description: 'Removing hide checked items field',
weight: 1,
completed: false,
progress: 0
},
{
id: 'migrate-lists-to-per-swimlane',
name: 'Migrate Lists to Per-Swimlane',
description: 'Migrating lists to per-swimlane structure',
weight: 5,
completed: false,
progress: 0
}
];
}
/**
* Check if any migrations need to be run
*/
needsMigration() {
// Check if any migration step is not completed
return this.steps.some(step => !step.completed);
}
/**
* Get total weight of all migrations
*/
getTotalWeight() {
return this.steps.reduce((total, step) => total + step.weight, 0);
}
/**
* Get completed weight
*/
getCompletedWeight() {
return this.steps.reduce((total, step) => {
return total + (step.completed ? step.weight : step.progress * step.weight / 100);
}, 0);
}
/**
* Start migration process
*/
async startMigration() {
if (isMigrating.get()) {
return; // Already migrating
}
isMigrating.set(true);
migrationSteps.set([...this.steps]);
this.startTime = Date.now();
try {
// Start server-side migration
Meteor.call('migration.start', (error, result) => {
if (error) {
console.error('Failed to start migration:', error);
migrationStatus.set(`Migration failed: ${error.message}`);
isMigrating.set(false);
}
});
// Poll for progress updates
this.pollMigrationProgress();
} catch (error) {
console.error('Migration failed:', error);
migrationStatus.set(`Migration failed: ${error.message}`);
isMigrating.set(false);
}
}
/**
* Poll for migration progress updates
*/
pollMigrationProgress() {
const pollInterval = setInterval(() => {
Meteor.call('migration.getProgress', (error, result) => {
if (error) {
console.error('Failed to get migration progress:', error);
clearInterval(pollInterval);
return;
}
if (result) {
migrationProgress.set(result.progress);
migrationStatus.set(result.status);
migrationCurrentStep.set(result.currentStep);
migrationSteps.set(result.steps);
isMigrating.set(result.isMigrating);
// Update local steps
if (result.steps) {
this.steps = result.steps;
}
// If migration is complete, stop polling
if (!result.isMigrating && result.progress === 100) {
clearInterval(pollInterval);
// Clear status after delay
setTimeout(() => {
migrationStatus.set('');
migrationProgress.set(0);
migrationEstimatedTime.set('');
}, 3000);
}
}
});
}, 1000); // Poll every second
}
/**
* Run a single migration step
*/
async runMigrationStep(step) {
// Simulate migration progress
const steps = 10;
for (let i = 0; i <= steps; i++) {
step.progress = (i / steps) * 100;
this.updateProgress();
// Simulate work
await new Promise(resolve => setTimeout(resolve, 50));
}
// In a real implementation, this would call the actual migration
// For now, we'll simulate the migration
console.log(`Running migration: ${step.name}`);
}
/**
* Update progress variables
*/
updateProgress() {
const totalWeight = this.getTotalWeight();
const completedWeight = this.getCompletedWeight();
const progress = Math.round((completedWeight / totalWeight) * 100);
migrationProgress.set(progress);
migrationSteps.set([...this.steps]);
// Calculate estimated time remaining
if (this.startTime && progress > 0) {
const elapsed = Date.now() - this.startTime;
const rate = progress / elapsed; // progress per millisecond
const remaining = 100 - progress;
const estimatedMs = remaining / rate;
migrationEstimatedTime.set(this.formatTime(estimatedMs));
}
}
/**
* Format time in milliseconds to human readable format
*/
formatTime(ms) {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
const remainingSeconds = seconds % 60;
return `${hours}h ${remainingMinutes}m ${remainingSeconds}s`;
} else if (minutes > 0) {
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
} else {
return `${seconds}s`;
}
}
/**
* Clear migration cache (for testing)
*/
clearCache() {
this.migrationCache.clear();
this.steps.forEach(step => {
step.completed = false;
step.progress = 0;
});
}
}
// Export singleton instance
export const migrationManager = new MigrationManager();