-
Notifications
You must be signed in to change notification settings - Fork 251
Migration scripts
Migration scripts automatically update plugin configurations when upgrading between versions. They ensure that courses using older plugin versions can seamlessly adopt new features, property changes, or structural improvements without manual JSON editing.
This page covers:
- When migrations are needed
- Migration structure and organization
- Writing migration functions
- Testing migrations
- Running migrations
- Creating migrations
- Best practices
- Common issues
Migrations are required when a plugin update introduces JSON schema changes that affect course configuration. Create a migration script when:
- Adding properties - New configuration options that need default values
- Removing properties - Deprecated attributes that should be cleaned up
- Renaming properties - Property name changes for consistency or clarity
- Restructuring data - Moving properties to different locations or nesting levels
- Changing property types - Converting values between types (string to boolean, etc.)
- Changing defaults - New default values that affect existing behavior
Do NOT create migrations for:
- CSS/LESS styling changes
- JavaScript refactoring or bug fixes
- Documentation updates (README, comments)
- example.json typo fixes when schema files didn't change
Each plugin's migrations live in a migrations/ folder at the plugin root. Migration files are organized by target major version:
adapt-contrib-myPlugin/
├── migrations/
│ ├── v1.js // Migrations targeting v1.x.x
│ ├── v3.js // Migrations targeting v3.x.x
│ └── v5.js // Migrations targeting v5.x.x
├── schema/
├── js/
└── package.json
Each file contains multiple migration blocks for incremental version steps (v1.0.0→v1.1.0, v1.1.0→v1.2.0, etc.).
import { describe, whereContent, whereFromPlugin, mutateContent, checkContent, updatePlugin, getCourse, getComponents } from 'adapt-migrations';
import _ from 'lodash';
describe('adapt-contrib-myPlugin - v1.0.0 to v1.1.0', async () => {
let components;
// Limit to courses using this plugin
whereContent('adapt-contrib-myPlugin - where component exists', async (content) => {
components = getComponents('myPlugin');
return Boolean(components.length);
});
// Limit to versions before v1.1.0
whereFromPlugin('adapt-contrib-myPlugin - from v1.0.0', {
name: 'adapt-contrib-myPlugin',
version: '<1.1.0'
});
// Add new property
mutateContent('adapt-contrib-myPlugin - add _enabled', async (content) => {
components.forEach(component => {
component._enabled = true;
});
return true;
});
// Validate the change
checkContent('adapt-contrib-myPlugin - check _enabled', async (content) => {
if (!components.every(c => c._enabled === true)) {
throw new Error('adapt-contrib-myPlugin - _enabled not added to all components');
}
return true;
});
// Record plugin update
updatePlugin('adapt-contrib-myPlugin - update to v1.1.0', {
name: 'adapt-contrib-myPlugin',
version: '1.1.0',
framework: '>=5.0.0'
});
});Wraps all migration logic for a version transition. Use clear version labels:
describe('adapt-contrib-myPlugin - v2.0.0 to v2.1.0', async () => {
// All migration functions go here
});Naming convention: {plugin-name} - v{from} to v{to}
Filters which courses the migration applies to. Returns true to run migration, false to skip:
whereContent('adapt-contrib-myPlugin - where component exists', async (content) => {
// Run only on courses that use this plugin
return Boolean(content.find(item => item._component === 'myPlugin'));
});Common patterns:
// Courses with specific component
content.find(item => item._component === 'myPlugin')
// Courses with extension at any level
content.find(item => item._extensions?._myExtension)
// Course-level configuration exists
getCourse(content)._myPlugin !== undefinedValidates plugin version before migration. Uses semantic versioning comparisons:
whereFromPlugin('adapt-contrib-myPlugin - from v1.0.0', {
name: 'adapt-contrib-myPlugin',
version: '<1.1.0' // Runs if current version is less than 1.1.0
});Version operators: <, <=, >, >=, =
Validates target plugin version. Useful for plugin replacement migrations:
whereToPlugin('adapt-contrib-pageNav - to v1.0.0', {
name: 'adapt-contrib-pageNav',
version: '>=1.0.0' // Runs if target version meets requirement
});Use case: When migrating from one plugin to another (e.g., quickNav → pageNav), check that the target plugin is present.
Adds a plugin to the course configuration. Used when introducing a new plugin dependency:
addPlugin('Add pageNav plugin', {
name: 'adapt-contrib-pageNav',
version: '1.0.0'
});Removes a plugin from the course configuration. Used when deprecating or replacing plugins:
removePlugin('Remove quickNav plugin', {
name: 'adapt-contrib-quickNav'
});Example plugin replacement workflow:
describe('QuickNav to PageNav migration', async () => {
whereFromPlugin('from quickNav', { name: 'adapt-contrib-quickNav' });
whereToPlugin('to pageNav', { name: 'adapt-contrib-pageNav' });
// Migrate settings from quickNav to pageNav format
mutateContent('transform quickNav config', async (content) => {
// Transform logic here
return true;
});
removePlugin('remove quickNav', { name: 'adapt-contrib-quickNav' });
addPlugin('add pageNav', { name: 'adapt-contrib-pageNav', version: '1.0.0' });
});Performs the actual content updates. Should handle a single, focused change:
mutateContent('adapt-contrib-myPlugin - add _enabled', async (content) => {
const components = getComponents('myPlugin');
components.forEach(component => {
component._enabled = true;
});
return true;
});Important: Use descriptive names that clearly state what property is being added/removed/changed.
Validates that mutations succeeded. Should throw an error if validation fails:
checkContent('adapt-contrib-myPlugin - check _enabled', async (content) => {
const components = getComponents('myPlugin');
if (!components.every(c => c._enabled === true)) {
throw new Error('adapt-contrib-myPlugin - _enabled not added');
}
return true;
});Every mutateContent must have a corresponding checkContent.
Records the new plugin version and framework requirement:
updatePlugin('adapt-contrib-myPlugin - update to v1.1.0', {
name: 'adapt-contrib-myPlugin', // Exact package.json name
version: '1.1.0', // Target version
framework: '>=5.0.0' // Minimum framework version
});Both name and framework are required.
mutateContent('adapt-contrib-myPlugin - add _customProp', async (content) => {
const components = getComponents('myPlugin');
components.forEach(component => {
component._customProp = 'default-value';
});
return true;
});mutateContent('adapt-contrib-myPlugin - add _classes to items', async (content) => {
const components = getComponents('myPlugin');
components.forEach(({ _items }) => {
_items?.forEach(item => {
item._classes = '';
});
});
return true;
});mutateContent('adapt-contrib-myPlugin - add globals', async (content) => {
const course = getCourse(content);
if (!_.has(course, '_globals._components._myPlugin')) {
_.set(course, '_globals._components._myPlugin', {});
}
course._globals._components._myPlugin._enabled = true;
return true;
});Some properties only apply to specific array items. Verify against example.json:
mutateContent('adapt-contrib-myPlugin - add _customRouteId to nav buttons', async (content) => {
const components = getComponents('myPlugin');
// Only these buttons get the property (verified in example.json)
const buttonsToInclude = ['_previous', '_root', '_up', '_next'];
components.forEach(component => {
buttonsToInclude.forEach(buttonName => {
if (component._buttons?.[buttonName]) {
component._buttons[buttonName]._customRouteId = '';
}
});
});
return true;
});mutateContent('adapt-contrib-myPlugin - remove _deprecated', async (content) => {
const components = getComponents('myPlugin');
components.forEach(component => {
_.unset(component, '_deprecated');
});
return true;
});
checkContent('adapt-contrib-myPlugin - check _deprecated removed', async (content) => {
const components = getComponents('myPlugin');
if (components.some(c => _.has(c, '_deprecated'))) {
throw new Error('adapt-contrib-myPlugin - _deprecated not removed');
}
return true;
});Never access null properties directly - this causes proxy errors. Extract to a variable first:
// WRONG - Causes proxy error
if (article._sideways._minHeight === null) { }
// CORRECT - Extract first
mutateContent('adapt-contrib-myPlugin - handle null _minHeight', async (content) => {
const articles = content.filter(item => item._type === 'article');
articles.forEach(article => {
if (_.has(article, '_sideways._minHeight')) {
const val = article._sideways._minHeight;
if (val === null) {
article._sideways._minHeight = 0;
}
}
});
return true;
});Create helper functions for type conversions:
const stringToBoolean = (str, defaultValue) => {
if (typeof str !== 'string') return defaultValue;
return str.toLowerCase() === 'true';
};
mutateContent('adapt-contrib-myPlugin - convert to boolean', async (content) => {
const components = getComponents('myPlugin');
components.forEach(component => {
component._boolProp = stringToBoolean(component._boolProp, true);
});
return true;
});
checkContent('adapt-contrib-myPlugin - check boolean type', async (content) => {
const components = getComponents('myPlugin');
if (!components.every(c => typeof c._boolProp === 'boolean')) {
throw new Error('adapt-contrib-myPlugin - _boolProp not converted to boolean');
}
return true;
});Common helper functions available from adapt-migrations:
import { getConfig, getCourse, getComponents } from 'adapt-migrations';
// Get config model
const config = getConfig();
// Get course model
const course = getCourse();
// Get all components of a specific type
const myPlugins = getComponents('myPlugin');Note: Helper functions must be called within migration function blocks (whereContent, mutateContent, checkContent) as they require the migration context to be available.
Always use lodash for nested property access to avoid errors:
import _ from 'lodash';
// Check if nested property exists
if (_.has(course, '_globals._components._myPlugin')) { }
// Safely set nested properties (creates intermediate objects)
_.set(course, '_globals._components._myPlugin._enabled', true);
// Safely remove properties
_.unset(component, '_deprecated');Each migration should have a minimum of 3 tests:
- Success test - Valid version + matching content (migration runs)
- Stop test - Already at target version (skip migration)
- Stop test - No matching content (skip migration)
Use the fromPlugins/content format (not deprecated from/course/articles):
import { testSuccessWhere, testStopWhere } from 'adapt-migrations';
testSuccessWhere('migrates from v1.0.0', {
fromPlugins: [
{ name: 'adapt-contrib-myPlugin', version: '1.0.0' }
],
content: [
{ _type: 'course', _globals: { _components: {} } },
{ _id: 'c-100', _component: 'myPlugin' }
]
});
testStopWhere('skips if already migrated', {
fromPlugins: [
{ name: 'adapt-contrib-myPlugin', version: '1.1.0' }
],
content: [
{ _type: 'course', _globals: { _components: {} } }
]
});
testStopWhere('skips if plugin not used', {
fromPlugins: [
{ name: 'adapt-contrib-myPlugin', version: '1.0.0' }
],
content: [
{ _type: 'course', _globals: { _components: {} } }
]
});# Run all migration tests
grunt migration:test
# Test specific plugin
grunt migration:test --file=adapt-contrib-myPlugin
# Test specific migration file
grunt migration:test --file=adapt-contrib-myPlugin/migrations/v5.jsBefore submitting migrations:
- All tests pass (0 failures)
- Minimum 3 tests per migration
- Modern test format used (fromPlugins/content)
- updatePlugin has both name and framework
- Property types are correct (boolean, string, number, etc.)
- Selective properties correctly distributed
- Final structure matches latest example.json
- No null access or proxy errors
- Each mutateContent has a checkContent
For new features where the version hasn't been assigned yet, use placeholders that are automatically replaced during release:
describe('adapt-contrib-myPlugin - @@CURRENT_VERSION to @@RELEASE_VERSION', async () => {
whereFromPlugin('adapt-contrib-myPlugin - from @@CURRENT_VERSION', {
name: 'adapt-contrib-myPlugin',
version: '<@@RELEASE_VERSION'
});
// migrations here
updatePlugin('adapt-contrib-myPlugin - update to @@RELEASE_VERSION', {
name: 'adapt-contrib-myPlugin',
version: '@@RELEASE_VERSION',
framework: '>=5.0.0'
});
});Placeholders:
-
@@CURRENT_VERSION- Replaced with previous version at release -
@@RELEASE_VERSION- Replaced with new version at release
Use hardcoded versions for historical migrations that are already released.
Follow these steps to create migrations for a plugin:
Check git tags and commits for schema changes:
# List all version tags
git tag -l 'v*' | sort -V
# View changes between versions
git diff v1.0.0 v1.1.0 -- schema/ example.json README.md *.schema*
# Check commit messages
git log v1.0.0..v1.1.0 --oneline
# Check framework requirements
git show v1.1.0:package.json | grep frameworkDiff schema files and example.json between versions to identify:
- Properties added, removed, or changed
- Default value changes
- Type conversions
- Structural reorganization
Look for changes in:
-
schema/directory files - Root-level
*.schemafiles (older plugins) -
example.json(validate against schema files)
Note: If only example.json changed but schema files didn't, it's likely a typo fix (no migration needed).
Determine what properties to add/remove/change:
- List each version transition that needs a migration
- Identify properties introduced in each specific version (no backfilling)
- Note edge cases (null handling, selective properties, type conversions)
- Verify which array items get selective properties (check example.json)
Create separate migrations for each version step (v4.1.0→v4.2.0, then v4.2.0→v4.2.1):
- Use modern test format (fromPlugins/content)
- Write mutateContent for each property change
- Write checkContent to validate each mutation
- Handle null values by extracting to variables first
- Use lodash for nested property access
- Include minimum 3 tests per migration
Run tests and validate output:
# Run all tests in migration file
npm test -- migrations/v5.js
# Run specific migration
npm test -- migrations/v5.js --testNamePattern="v5.0.0 to v5.1.0"Confirm final structure matches latest example.json:
- Verify property types (boolean, string, number, etc.)
- Check property nesting matches schema
- Ensure selective properties only appear on correct items
- Confirm no extra properties added or removed
Migrations use a capture-and-migrate workflow via grunt commands. This allows you to capture your current course state, update plugins, and then apply migrations to transform the content.
- Capture - Snapshot current course content and plugin versions
- Update - Update framework and plugins to newer versions
- Migrate - Apply migration scripts to transform captured content
- Test - Verify migrations work correctly
Before updating plugins, capture your current course configuration:
# Capture to default location (./migrations/)
grunt migration:capture
# Capture to custom directory
grunt migration:capture --capturedir=path/to/capturesThis creates:
-
capture_en.json(and other languages if present) -
captureLanguages.json(list of languages)
Update your framework and plugins to the target versions using your package manager or framework update process.
Apply migration scripts to transform the captured content:
# Migrate using default capture location
grunt migration:migrate
# Migrate from custom capture directory
grunt migration:migrate --capturedir=path/to/captures
# Migrate specific plugin only
grunt migration:migrate --file=adapt-contrib-narrativeOptions:
-
--capturedir=path- Custom directory for capture files -
--file=plugin-name- Migrate only scripts from specific plugin -
--customscriptsdir=path- Include custom transformation scripts
You can write custom migration scripts for one-off content transformations that don't belong to a specific plugin:
# Run migrations with custom scripts
grunt migration:migrate --customscriptsdir=path/to/custom/scriptsCustom scripts follow the same format as plugin migrations but don't require whereFromPlugin or updatePlugin functions. They're useful for:
- Bulk content updates across multiple plugins
- Custom JSON transformations specific to your project
- One-time data corrections
When developing migration scripts, use this workflow:
-
Write migration script in plugin's
migrations/folder -
Create test cases using
testSuccessWhereandtestStopWhere -
Run tests with
grunt migration:test - Verify output matches expected structure
- Iterate until all tests pass
- One change per mutateContent - Don't combine multiple property changes
- Incremental migrations - Separate migrations for each version (v4.1.0→v4.2.0, then v4.2.0→v4.2.1)
- No backfilling - Only add properties introduced in that specific version
- Descriptive naming - Clear, specific names for mutation and check functions
- Use lodash - Safe nested property access prevents errors
- Handle edge cases - Null values, missing properties, type conversions
- Validate everything - Every mutation needs a check
- Follow conventions - Match existing plugin migration style
-
Arrow function syntax - Omit brackets for single-statement returns:
return graphics.lengthinstead ofif (graphics.length) return true -
Use semantic versioning - Always use 3 digits:
>=5.5.0not>=5.5 -
One-line simple validations - Keep simple checks concise:
if (courseGraphicGlobals === undefined) throw new Error('...')
- Test all paths - Success case + skip conditions
- Modern format - Use fromPlugins/content, not deprecated format
- Realistic data - Test content should match actual course JSON structure
- Run before commit - Ensure all tests pass locally
- Update example.json - Show new properties in use
- Update README - Document breaking changes or new configuration
- Comment edge cases - Explain selective properties or special handling
Symptom: TypeError: Cannot access property of null
Cause: Direct null property access
Fix: Extract to variable first
// WRONG
if (article._sideways._minHeight === null) { }
// CORRECT
if (_.has(article, '_sideways._minHeight')) {
const val = article._sideways._minHeight;
if (val === null) { }
}Symptom: Property shows as undefined in checkContent
Cause: mutateContent not reaching the property
Fix: Use getCourse(), verify path with _.has()
const course = getCourse();
if (!_.has(course, '_myPlugin')) {
_.set(course, '_myPlugin', {});
}Symptom: TypeError: Cannot read properties of undefined (reading 'content')
Cause: Helper functions (getCourse, getConfig, getComponents) called outside migration function blocks
Fix: Only call helpers inside whereContent, mutateContent, or checkContent functions
// WRONG - Called outside function
const course = getCourse();
describe('My migration', async () => {
// ...
});
// CORRECT - Called inside function
describe('My migration', async () => {
let course;
mutateContent('update course', async (content) => {
course = getCourse(); // Now it has context
course._customProp = true;
return true;
});
});Symptom: Some array items missing property
Cause: Incomplete selective property logic
Fix: Verify item list against example.json
// Check example.json for which buttons get the property
const buttonsToInclude = ['_previous', '_root', '_up', '_next'];Symptom: Final content structure differs from example.json
Cause: Intermediate objects not created
Fix: Use _.has() before _.set()
if (!_.has(course, '_globals._components._myPlugin')) {
_.set(course, '_globals._components._myPlugin', {});
}- adapt-migrations Repository - Migration tool source code and API reference
- Adapt Framework - Core framework with example migrations
- Semantic Versioning - Version number specification
- Lodash Documentation - Helper library for safe property access
- Example Migrations - adapt-contrib-vanilla migrations
- Framework in Five Minutes
- Setting up Your Development Environment
- Manual Installation of the Adapt Framework
- Adapt Command Line Interface
- Common Issues
- Reporting Bugs
- Requesting Features
- Creating Your First Course
- Styling Your Course
- Configuring Your Project with config.json
- Content starts with course.json
- Course Localisation
- Compiling, testing and deploying your Adapt course
- Core Plugins in the Adapt Learning Framework
- Converting a Course from Framework Version 1 to Version 2
- Contributing to the Adapt Project
- Git Flow
- Adapt API
- Adapt Command Line Interface
- Core Events
- Core Model Attributes
- Core Modules
- Web Security Audit
- Peer Code Review
- Plugins
- Developing Plugins
- Developer's Guide: Components
- Developer's Guide: Theme
- Making a theme editable
- Developer's Guide: Menu
- Registering a Plugin
- Semantic Version Numbers
- Core Model Attributes
- Adapt Command Line Interface
- Adapt Framework Right to Left (RTL) Support
