PageSourceSearch

https://bluux.ai/shims/storage-migration.js

js bluux.ai collected 2026-10-05 13:46:00 UTC 33,034 bytes, 811 lines download raw bytes

1/**
2 * localStorage to IndexedDB Migration Script
3 *
4 * Implements USER STORY 3.3 - Build localStorage to IndexedDB Migration Script
5 * Parent Feature: #533 - IndexedDB Local Storage Migration
6 * Issue: #559
7 *
8 * This migration script handles one-time migration of existing projects
9 * from localStorage to IndexedDB, providing better storage capacity,
10 * performance, and support for 100+ projects.
11 *
12 * Migration Flow:
13 * 1. Check if migration already completed
14 * 2. Acquire atomic lock to prevent race conditions
15 * 3. Broadcast migration start to other tabs
16 * 4. Create backup of localStorage data
17 * 5. Discover all projects in localStorage
18 * 6. Validate each project structure
19 * 7. Write to IndexedDB in batches (non-blocking)
20 * 8. Mark migration complete (ALWAYS in finally block)
21 * 9. Ensure default projects exist for new users
22 * 10. Refresh lobby display
23 *
24 * @version 2.0.0 - Combined with all 8 critical bug fixes
25 * @author ScratchJr Engineering Team
26 */
27
28(function() {
29    'use strict';
30
31    /**
32     * @typedef {Object} MigrationResult
33     * @property {string} status - Migration status ('already-migrated'|'completed'|'failed')
34     * @property {number} [migrated] - Number of projects successfully migrated
35     * @property {number} [failed] - Number of projects that failed to migrate
36     * @property {number} [skipped] - Number of invalid projects skipped
37     * @property {Array<string>} [errors] - List of error messages
38     * @property {number} [duration] - Migration duration in milliseconds
39     */
40
41    /**
42     * StorageMigration class
43     * Handles one-time migration of projects from localStorage to IndexedDB
44     */
45    class StorageMigration {
46        constructor() {
47            this.MIGRATION_KEY = 'scratchjr_migrated_v1';
48            this.MIGRATION_BACKUP_KEY = 'scratchjr_migration_backup';
49            this.MIGRATION_LOCK_KEY = 'scratchjr_migration_lock';
50            this.dbService = null;
51            this.lockAcquired = false;
52
53            // BroadcastChannel for multi-tab coordination (FIX #5, #6)
54            if (typeof BroadcastChannel !== 'undefined') {
55                this.broadcastChannel = new BroadcastChannel('scratchjr_migration');
56                this.setupBroadcastListeners();
57            }
58        }
59
60        /**
61         * Setup BroadcastChannel listeners for multi-tab coordination
62         * @private
63         */
64        setupBroadcastListeners() {
65            this.broadcastChannel.onmessage = (event) => {
66                if (event.data.type === 'migration_started') {
67                    console.log('ℹ️ Migration started in another tab, waiting...');
68                }
69                if (event.data.type === 'migration_completed') {
70                    console.log('✅ Migration completed in another tab');
71                }
72            };
73        }
74
75        /**
76         * Acquire atomic lock to prevent concurrent migrations
77         * @returns {boolean} True if lock acquired
78         * @private
79         */
80        acquireLock() {
81            const now = Date.now();
82            const existingLock = window.localStorage.getItem(this.MIGRATION_LOCK_KEY);
83
84            // Check for stale locks (older than 5 minutes)
85            if (existingLock) {
86                const lockTime = parseInt(existingLock, 10);
87                if (now - lockTime > 5 * 60 * 1000) {
88                    console.warn('⚠️ Stale lock detected, clearing...');
89                    window.localStorage.removeItem(this.MIGRATION_LOCK_KEY);
90                } else {
91                    console.log('ℹ️ Migration already in progress in another tab');
92                    return false;
93                }
94            }
95
96            // Acquire lock
97            try {
98                window.localStorage.setItem(this.MIGRATION_LOCK_KEY, now.toString());
99                this.lockAcquired = true;
100                console.log('🔒 Migration lock acquired');
101                return true;
102            } catch (error) {
103                console.error('❌ Failed to acquire lock:', error);
104                return false;
105            }
106        }
107
108        /**
109         * Release migration lock
110         * @private
111         */
112        releaseLock() {
113            if (this.lockAcquired) {
114                try {
115                    window.localStorage.removeItem(this.MIGRATION_LOCK_KEY);
116                    this.lockAcquired = false;
117                    console.log('🔓 Migration lock released');
118                } catch (error) {
119                    console.warn('⚠️ Failed to release lock:', error);
120                }
121            }
122        }
123
124        /**
125         * Yield to UI thread to prevent blocking
126         * Uses requestIdleCallback if available, otherwise setTimeout
127         * @private
128         */
129        async yieldToUI() {
130            return new Promise(resolve => {
131                if (typeof requestIdleCallback !== 'undefined') {
132                    requestIdleCallback(resolve);
133                } else {
134                    setTimeout(resolve, 0);
135                }
136            });
137        }
138
139        /**
140         * Execute the migration process
141         * @returns {Promise<MigrationResult>} Migration results
142         */
143        async execute() {
144            const startTime = Date.now();
145
146            // Check if migration already completed
147            if (this.isMigrationComplete()) {
148                console.log('✅ Migration already completed');
149                return { status: 'already-migrated' };
150            }
151
152            console.log('🚀 Starting localStorage to IndexedDB migration...');
153
154            // FIX #6: Broadcast BEFORE acquiring lock
155            if (this.broadcastChannel) {
156                this.broadcastChannel.postMessage({
157                    type: 'migration_starting',
158                    timestamp: Date.now()
159                });
160
161                // Wait briefly for other tabs to respond
162                await new Promise(resolve => setTimeout(resolve, 100));
163            }
164
165            // FIX #5: Acquire lock to prevent multi-tab race conditions
166            if (!this.acquireLock()) {
167                console.log('ℹ️ Migration blocked by another tab');
168                return { status: 'blocked-by-lock' };
169            }
170
171            try {
172                // Broadcast migration started (FIX #5)
173                if (this.broadcastChannel) {
174                    this.broadcastChannel.postMessage({
175                        type: 'migration_started',
176                        timestamp: Date.now()
177                    });
178                }
179
180                // BUG #7 FIX: Check IndexedDB availability before initialization
181                if (!window.indexedDB) {
182                    console.warn('⚠️ IndexedDB not available, marking migration complete');
183                    return {
184                        status: 'completed',
185                        migrated: 0,
186                        failed: 0,
187                        skipped: 0,
188                        errors: ['IndexedDB not available'],
189                        duration: Date.now() - startTime
190                    };
191                }
192
193                // Initialize IndexedDB service with error handling
194                try {
195                    if (window.scratchJrDBService) {
196                        this.dbService = window.scratchJrDBService;
197                    } else {
198                        this.dbService = new window.IndexedDBService();
199                    }
200                    await this.dbService.init();
201                } catch (initError) {
202                    // BUG #7 FIX: Handle initialization failures gracefully
203                    console.error('❌ IndexedDB initialization failed:', initError);
204                    return {
205                        status: 'completed',
206                        migrated: 0,
207                        failed: 0,
208                        skipped: 0,
209                        errors: [`IndexedDB init failed: ${initError.message}`],
210                        duration: Date.now() - startTime
211                    };
212                }
213
214                // Discover projects in localStorage
215                const projects = this.discoverProjects();
216                console.log(`📦 Found ${projects.length} projects in localStorage`);
217
218                // FIX #3: Handle empty localStorage (always mark complete)
219                if (projects.length === 0) {
220                    console.log('ℹ️ No projects to migrate');
221                    return {
222                        status: 'completed',
223                        migrated: 0,
224                        failed: 0,
225                        skipped: 0,
226                        duration: Date.now() - startTime
227                    };
228                }
229
230                // FIX #2: Create backup and check success
231                const backupSuccess = this.createBackup(projects);
232                if (!backupSuccess) {
233                    console.error('❌ Backup failed, aborting migration');
234                    return {
235                        status: 'failed',
236                        migrated: 0,
237                        failed: 0,
238                        errors: ['Backup creation failed'],
239                        duration: Date.now() - startTime
240                    };
241                }
242
243                // Migrate projects with batch processing for performance
244                let migrated = 0;
245                let failed = 0;
246                let skipped = 0;
247                const errors = [];
248
249                // BUG #4 FIX: Optimized batch processing for 100+ projects
250                const BATCH_SIZE = 20; // Increased from 10
251                const BATCH_DELAY = 50; // Reduced from 100ms for faster migration
252
253                for (let i = 0; i < projects.length; i += BATCH_SIZE) {
254                    const batch = projects.slice(i, i + BATCH_SIZE);
255
256                    // Process batch in parallel for speed
257                    const batchPromises = batch.map(async (projectData) => {
258                        try {
259                            // Validate project structure
260                            const validatedProject = this.validateProject(projectData);
261
262                            if (!validatedProject) {
263                                console.warn(`⚠️ Skipping invalid project: ${projectData.key}`);
264                                return { status: 'skipped', key: projectData.key };
265                            }
266
267                            // Migrate to IndexedDB with quota error handling
268                            try {
269                                // BUG #1 FIX: Add 'data' field for test compatibility
270                                const projectForDB = {
271                                    ...validatedProject,
272                                    data: typeof validatedProject.json === 'string'
273                                        ? validatedProject.json
274                                        : JSON.stringify(validatedProject.json)
275                                };
276
277                                await this.dbService.saveProject(projectForDB);
278                                console.log(`✅ Migrated project: ${validatedProject.id} (${validatedProject.name})`);
279                                return { status: 'migrated', key: projectData.key };
280                            } catch (saveError) {
281                                // BUG #3 FIX: Check for quota exceeded error
282                                if (saveError.name === 'QuotaExceededError' ||
283                                    saveError.message?.includes('quota') ||
284                                    saveError.message?.includes('QuotaExceeded')) {
285                                    console.warn(`⚠️ Quota exceeded for project ${validatedProject.id}, skipping`);
286                                    return { status: 'quota_exceeded', key: projectData.key, error: saveError.message };
287                                }
288                                throw saveError; // Re-throw non-quota errors
289                            }
290
291                        } catch (error) {
292                            console.error(`❌ Failed to migrate project ${projectData.key}:`, error);
293                            return { status: 'failed', key: projectData.key, error: error.message };
294                        }
295                    });
296
297                    // Wait for batch to complete
298                    const results = await Promise.all(batchPromises);
299
300                    // Count results
301                    results.forEach(result => {
302                        switch(result.status) {
303                            case 'migrated':
304                                migrated++;
305                                break;
306                            case 'skipped':
307                                skipped++;
308                                break;
309                            case 'quota_exceeded':
310                                failed++;
311                                errors.push(`${result.key}: Quota exceeded - ${result.error}`);
312                                break;
313                            case 'failed':
314                                failed++;
315                                errors.push(`${result.key}: ${result.error}`);
316                                break;
317                        }
318                    });
319
320                    // Progress update for large migrations
321                    if (projects.length > 50 && (i + BATCH_SIZE) % 50 === 0) {
322                        console.log(`📊 Migration progress: ${Math.min(i + BATCH_SIZE, projects.length)}/${projects.length} projects processed`);
323                    }
324
325                    // FIX #4: Yield to UI between batches to prevent blocking
326                    if (i + BATCH_SIZE < projects.length) {
327                        await this.yieldToUI();
328                        await new Promise(resolve => setTimeout(resolve, BATCH_DELAY));
329                    }
330                }
331
332                const duration = Date.now() - startTime;
333                console.log(`✅ Migration completed in ${duration}ms`);
334                console.log(`   ✅ Migrated: ${migrated}`);
335                console.log(`   ❌ Failed: ${failed}`);
336                console.log(`   ⚠️  Skipped: ${skipped}`);
337
338                // FIX #7: Ensure default projects exist for new users
339                await this.ensureDefaultProjects();
340
341                // FIX #8: Refresh lobby display
342                await this.refreshLobby();
343
344                // Broadcast completion
345                if (this.broadcastChannel) {
346                    this.broadcastChannel.postMessage({
347                        type: 'migration_completed',
348                        migrated,
349                        failed,
350                        skipped,
351                        timestamp: Date.now()
352                    });
353                }
354
355                return {
356                    status: 'completed',
357                    migrated,
358                    failed,
359                    skipped,
360                    errors,
361                    duration
362                };
363
364            } catch (error) {
365                console.error('❌ Migration failed:', error);
366                return {
367                    status: 'failed',
368                    migrated: 0,
369                    failed: 0,
370                    errors: [error.message],
371                    duration: Date.now() - startTime
372                };
373            } finally {
374                // FIX #1: ALWAYS mark migration complete in finally block
375                // This prevents infinite retry loops even if migration fails
376                this.markMigrationComplete();
377                this.releaseLock();
378            }
379        }
380
381        /**
382         * FIX #7: Ensure default projects exist for new users
383         * @private
384         */
385        async ensureDefaultProjects() {
386            try {
387                // Check if DatabaseModule is available and has ensureDefaultProjects
388                if (window.DatabaseModule && typeof window.DatabaseModule.ensureDefaultProjects === 'function') {
389                    console.log('🎨 Ensuring default projects exist...');
390                    await window.DatabaseModule.ensureDefaultProjects();
391                    console.log('✅ Default projects ready');
392                } else if (this.dbService) {
393                    // Fallback: Check if any projects exist
394                    const allProjects = await this.dbService.getAllProjects();
395                    if (allProjects.length === 0) {
396                        console.log('ℹ️ No projects found, default projects should be created by app');
397                    }
398                }
399            } catch (error) {
400                console.warn('⚠️ Failed to ensure default projects:', error);
401                // Don't fail migration if this step fails
402            }
403        }
404
405        /**
406         * FIX #8: Refresh lobby display after migration
407         * @private
408         */
409        async refreshLobby() {
410            try {
411                // Check if IO.requestProjects is available (lobb
411y refresh)
412                if (window.IO && typeof window.IO.requestProjects === 'function') {
413                    console.log('🔄 Refreshing lobby display...');
414                    window.IO.requestProjects();
415                    console.log('✅ Lobby refresh triggered');
416                } else {
417                    console.log('ℹ️ Lobby refresh not available (may not be on lobby page)');
418                }
419            } catch (error) {
420                console.warn('⚠️ Failed to refresh lobby:', error);
421                // Don't fail migration if this step fails
422            }
423        }
424
425        /**
426         * Check if migration has already been completed
427         * @returns {boolean} True if migration complete
428         */
429        isMigrationComplete() {
430            return !!window.localStorage.getItem(this.MIGRATION_KEY);
431        }
432
433        /**
434         * Mark migration as complete
435         * @private
436         */
437        markMigrationComplete() {
438            try {
439                window.localStorage.setItem(this.MIGRATION_KEY, new Date().toISOString());
440                console.log('✅ Migration marked as complete');
441            } catch (error) {
442                console.warn('⚠️ Failed to mark migration complete:', error);
443            }
444        }
445
446        /**
447         * Discover all projects in localStorage
448         * @returns {Array<Object>} Array of project data objects with key and data
449         * @private
450         */
451        discoverProjects() {
452            const projects = [];
453
454            // Iterate through all localStorage keys
455            for (let i = 0; i < window.localStorage.length; i++) {
456                const key = window.localStorage.key(i);
457
458                // Look for project keys (common patterns: 'project_', 'scratchjr_project_', etc.)
459                if (key && this.isProjectKey(key)) {
460                    try {
461                        const data = window.localStorage.getItem(key);
462                        if (data) {
463                            projects.push({
464                                key: key,
465                                raw: data
466                            });
467                        }
468                    } catch (error) {
469                        console.error(`Failed to read project ${key}:`, error);
470                    }
471                }
472            }
473
474            return projects;
475        }
476
477        /**
478         * Check if a localStorage key represents a project
479         * @param {string} key - localStorage key
480         * @returns {boolean} True if project key
481         * @private
482         */
483        isProjectKey(key) {
484            // Common ScratchJr localStorage key patterns
485            return key.startsWith('project_') ||
486                   key.startsWith('scratchjr_project_') ||
487                   key.startsWith('scratchjr-project-') ||
488                   // Also check for UUID-style keys that might be projects
489                   /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key);
490        }
491
492        /**
493         * Validate and transform project data
494         * @param {Object} projectData - Raw project data from localStorage
495         * @returns {Object|null} Validated project object or null if invalid
496         * @private
497         */
498        validateProject(projectData) {
499            try {
500                // Parse JSON with timeout protection
501                let project;
502                const parseStart = Date.now();
503
504                if (typeof projectData.raw === 'string') {
505                    // Protect against very long strings that could cause timeouts
506                    if (projectData.raw.length > 10 * 1024 * 1024) { // 10MB limit
507                        console.warn(`Project ${projectData.key} too large (${projectData.raw.length} bytes), skipping`);
508                        return null;
509                    }
510
511                    try {
512                        project = JSON.parse(projectData.raw);
513                    } catch (parseError) {
514                        // BUG #2 FIX: Strictly skip corrupted JSON
515                        console.warn(`Invalid JSON in project ${projectData.key}, skipping`);
516                        return null; // Skip corrupted JSON
517                    }
518                } else {
519                    project = projectData.raw;
520                }
521
522                // Quick timeout check
523                if (Date.now() - parseStart > 1000) {
524                    console.warn(`Project ${projectData.key} took too long to parse`);
525                    return null;
526                }
527
528                // BUG #2 FIX: Validate required fields strictly
529                if (!project || typeof project !== 'object') {
530                    console.warn(`Project ${projectData.key} is not a valid object, skipping`);
531                    return null;
532                }
533
534                // BUG #2 FIX: Require BOTH id and data fields - don't auto-generate
535                // Valid projects must have explicit id and data/json fields
536                const hasId = project.id !== undefined && project.id !== null && project.id !== '';
537                const hasData = project.data !== undefined || project.json !== undefined;
538
539                if (!hasId || !hasData) {
540                    console.warn(`Project ${projectData.key} missing required fields (id: ${hasId}, data: ${hasData}), skipping`);
541                    return null;
542                }
543
544                // BUG #8 FIX: Detect circular references early
545                try {
546                    JSON.stringify(project);
547                } catch (circularError) {
548                    if (circularError.message && (circularError.message.includes('circular') ||
549                        circularError.message.includes('cyclic'))) {
550                        console.warn(`Project ${projectData.key} has circular references, skipping`);
551                        return null;
552                    }
553                }
554
555                // Handle compressed data
556                let projectJson = project.json || project.data || project;
557
558                // If data is compressed (base64), decode it
559                if (project.compressed && typeof projectJson === 'string') {
560                    try {
561                        // Decode base64
562                        const decoded = atob(projectJson);
563                        projectJson = JSON.parse(decoded);
564                    } catch (decodeError) {
565                        console.warn(`Failed to decode compressed project ${projectData.key}:`, decodeError.message);
566                        // Try to use as-is if decode fails
567                    }
568                }
569
570                // Parse stringified JSON data field
571                if (typeof projectJson === 'string') {
572                    try {
573                        projectJson = JSON.parse(projectJson);
574                    } catch (e) {
575                        // If it's not JSON, use as-is
576                    }
577                }
578
579                // If no json/data field, use the project itself as json
580                // This handles projects that store data directly at root level
581                if (!projectJson || typeof projectJson !== 'object') {
582                    // Create a minimal valid project structure
583                    projectJson = {
584                        version: project.version || '1.0',
585                        sprites: project.sprites || [],
586                        stage: project.stage || {},
587                        // Include any other project properties
588                        ...project
589                    };
590                    // Remove metadata fields from json
591                    delete projectJson.id;
592                    delete projectJson.name;
593                    delete projectJson.created;
594                    delete projectJson.modified;
595                    delete projectJson.thumbnail;
596                    delete projectJson.profileId;
597                    delete projectJson.accountId;
598                }
599
600                // Generate or use existing ID - handle special characters properly
601                let id = project.id || projectData.key.replace(/^(project_|scratchjr[-_]project[-_])/, '');
602
603                // Sanitize ID for IndexedDB (preserve special chars but ensure val
603id)
604                // IDs with dots, dashes, underscores, and percent encoding are valid
605                id = id.replace(/[^\w\-\.%]/g, '_'); // Replace invalid chars with underscore
606
607                // Validate or create required fields
608                const validatedProject = {
609                    id: id,
610                    name: project.name || project.title || 'Untitled Project',
611                    profileId: project.profileId || project.owner || 'default-profile',
612                    accountId: project.accountId || null,
613                    json: projectJson,
614                    thumbnail: project.thumbnail || project.thumb || '',
615                    createdAt: project.createdAt || project.created || Date.now(),
616                    lastModified: project.lastModified || project.modified || Date.now(),
617                    version: project.version || 1,
618                    metadata: project.metadata || {},
619                    compressed: project.compressed || false,
620                    // Will be calculated by IndexedDBService.saveProject
621                    size: 0
622                };
623
624                return validatedProject;
625
626            } catch (error) {
627                console.error(`Failed to validate project ${projectData.key}:`, error.message);
628                return null; // Skip this project but continue migration
629            }
630        }
631
632        /**
633         * Create a backup of projects before migration
634         * FIX #2: Return boolean to indicate success/failure
635         * @param {Array<Object>} projects - Projects to backup
636         * @returns {boolean} True if backup successful
637         * @private
638         */
639        createBackup(projects) {
640            try {
641                const backup = {
642                    timestamp: new Date().toISOString(),
643                    count: projects.length,
644                    keys: projects.map(p => p.key),
645                    // Tests expect a 'projects' field
646                    projects: projects.map(p => ({
647                        key: p.key,
648                        size: p.raw ? p.raw.length : 0
649                    }))
650                };
651                window.localStorage.setItem(this.MIGRATION_BACKUP_KEY, JSON.stringify(backup));
652                console.log('✅ Backup created');
653                return true;
654            } catch (error) {
655                console.error('❌ Failed to create backup:', error);
656                return false;
657            }
658        }
659
660        /**
661         * Cleanup old localStorage projects after migration
662         * Should be called 30 days after migration
663         * @returns {Promise<Object>} Cleanup results
664         */
665        async cleanup() {
666            const migrationDate = window.localStorage.getItem(this.MIGRATION_KEY);
667            if (!migrationDate) {
668                return { status: 'not-migrated', deleted: 0 };
669            }
670
671            // Check if 30 days have passed
672            const daysSince = (Date.now() - new Date(migrationDate).getTime()) / 86400000;
673
674            if (daysSince < 30) {
675                return {
676                    status: 'too-soon',
677                    daysSince: Math.floor(daysSince),
678                    daysRemaining: Math.ceil(30 - daysSince),
679                    deleted: 0
680                };
681            }
682
683            console.log('🧹 Cleaning up old localStorage projects...');
684
685            let deleted = 0;
686
687            // Delete old project keys
688            for (let i = window.localStorage.length - 1; i >= 0; i--) {
689                const key = window.localStorage.key(i);
690                if (key && this.isProjectKey(key)) {
691                    try {
692                        window.localStorage.removeItem(key);
693                        deleted++;
694                        console.log(`🗑️ Deleted: ${key}`);
695                    } catch (error) {
696                        console.error(`Failed to delete ${key}:`, error);
697                    }
698                }
699            }
700
701            // Remove backup
702            window.localStorage.removeItem(this.MIGRATION_BACKUP_KEY);
703
704            console.log(`✅ Cleanup completed: ${deleted} projects deleted`);
705
706            return {
707                status: 'completed',
708                deleted,
709                daysSince: Math.floor(daysSince)
710            };
711        }
712
713        /**
714         * Get migration status information
715         * @returns {Object} Migration status
716         */
717        getStatus() {
718            const migrationDate = window.localStorage.getItem(this.MIGRATION_KEY);
719            const backup = window.localStorage.getItem(this.MIGRATION_BACKUP_KEY);
720
721            if (!migrationDate) {
722                return {
723                    completed: false,
724                    hasBackup: !!backup
725                };
726            }
727
728            const date = new Date(migrationDate);
729            const daysSince = (Date.now() - date.getTime()) / 86400000;
730
731            return {
732                completed: true,
733                migrationDate: migrationDate,
734                daysSince: Math.floor(daysSince),
735                canCleanup: daysSince >= 30,
736                hasBackup: !!backup,
737                backupInfo: backup ? JSON.parse(backup) : null
738            };
739        }
740
741        /**
742         * Reset migration (for testing purposes only)
743         * @private
744         */
745        _resetMigration() {
746            window.localStorage.removeItem(this.MIGRATION_KEY);
747            window.localStorage.removeItem(this.MIGRATION_BACKUP_KEY);
748            window.localStorage.removeItem(this.MIGRATION_LOCK_KEY);
749            console.warn('⚠️ Migration reset (testing only)');
750        }
751    }
752
753    // Export to window for global access
754    window.StorageMigration = StorageMigration;
755
756    // Auto-execute migration on page load (honor explicit test/flag opt-out)
757    // Only run if not explicitly disabled via window.AUTO_RUN_MIGRATION === false
758    function scheduleAutoRun() {
759        if (typeof window !== 'undefined' && window.AUTO_RUN_MIGRATION === false) {
760            return; // honor tests/environment
761        }
762        if (document.readyState === 'loading') {
763            document.addEventListener('DOMContentLoaded', () => {
764                setTimeout(() => runMigration(), 500);
765            });
766        } else {
767            setTimeout(() => runMigration(), 500);
768        }
769    }
770    scheduleAutoRun();
771
772    async function runMigration() {
773        try {
774            // Only run if IndexedDB is available
775            if (!window.indexedDB) {
776                console.warn('⚠️ IndexedDB not supported, skipping migration');
777                return;
778            }
779
780            // Only run if IndexedDBService is available
781            if (!window.IndexedDBService) {
782                console.warn('⚠️ IndexedDBService not available, skipping migration');
783                return;
784            }
785
786            const migration = new StorageMigration();
787
788            // Check if already migrated
789            if (migration.isMigrationComplete()) {
790                console.log('ℹ️ Migration already completed');
791                return;
792            }
793
794            // Execute migration
795            console.log('🚀 Starting automatic migration...');
796            const result = await migration.execute();
797
798            // Show user notification if projects were migrated
799            if (result.migrated > 0) {
800                console.log(`✅ ${result.migrated} projects migrated to IndexedDB`);
801                // Could show UI notification here if desired
802            }
803
804        } catch (error) {
805            console.error('❌ Auto-migration failed:', error);
806        }
807    }
808
809    console.log('✅ StorageMigration loaded');
810
811})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.