Skip to content

Migration scripts

Brad Simpson edited this page Jan 29, 2026 · 3 revisions

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

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

Migration Structure

File Organization

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.).

File Structure

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'
  });
});

Writing Migrations

Function Reference

describe()

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}

whereContent()

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 !== undefined

whereFromPlugin()

Validates 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: <, <=, >, >=, =

whereToPlugin()

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.

addPlugin()

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'
});

removePlugin()

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' });
});

mutateContent()

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.

checkContent()

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.

updatePlugin()

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.

Common Patterns

Adding Component Properties

mutateContent('adapt-contrib-myPlugin - add _customProp', async (content) => {
  const components = getComponents('myPlugin');
  components.forEach(component => {
    component._customProp = 'default-value';
  });
  return true;
});

Adding Properties to Array Items

mutateContent('adapt-contrib-myPlugin - add _classes to items', async (content) => {
  const components = getComponents('myPlugin');
  components.forEach(({ _items }) => {
    _items?.forEach(item => {
      item._classes = '';
    });
  });
  return true;
});

Modifying Course Globals

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;
});

Selective Array Properties

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;
});

Removing Properties

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;
});

Handling Null Values

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;
});

Type Conversions

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;
});

Helper Functions

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.

Using Lodash for Safety

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');

Testing Migrations

Test Structure

Each migration should have a minimum of 3 tests:

  1. Success test - Valid version + matching content (migration runs)
  2. Stop test - Already at target version (skip migration)
  3. Stop test - No matching content (skip migration)

Modern Test Format

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: {} } }
  ]
});

Running Tests

# 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.js

Validation Checklist

Before 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

Version Placeholders

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.

Creating Migrations

Follow these steps to create migrations for a plugin:

1. Review Version History

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 framework

2. Compare Schemas

Diff 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 *.schema files (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).

3. Plan Migrations

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)

4. Write Migration Code

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

5. Test Thoroughly

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"

6. Validate Output

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

Running Migrations

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.

Workflow Overview

  1. Capture - Snapshot current course content and plugin versions
  2. Update - Update framework and plugins to newer versions
  3. Migrate - Apply migration scripts to transform captured content
  4. Test - Verify migrations work correctly

Capture Current State

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/captures

This creates:

  • capture_en.json (and other languages if present)
  • captureLanguages.json (list of languages)

Update Plugins

Update your framework and plugins to the target versions using your package manager or framework update process.

Run Migrations

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-narrative

Options:

  • --capturedir=path - Custom directory for capture files
  • --file=plugin-name - Migrate only scripts from specific plugin
  • --customscriptsdir=path - Include custom transformation scripts

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/scripts

Custom 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

Testing Workflow

When developing migration scripts, use this workflow:

  1. Write migration script in plugin's migrations/ folder
  2. Create test cases using testSuccessWhere and testStopWhere
  3. Run tests with grunt migration:test
  4. Verify output matches expected structure
  5. Iterate until all tests pass

Best Practices

Migration Design

  • 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

Code Quality

  • 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.length instead of if (graphics.length) return true
  • Use semantic versioning - Always use 3 digits: >=5.5.0 not >=5.5
  • One-line simple validations - Keep simple checks concise: if (courseGraphicGlobals === undefined) throw new Error('...')

Testing

  • 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

Documentation

  • 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

Common Issues

Proxy Errors on Null

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) { }
}

checkContent Fails with "undefined"

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', {});
}

Helper Function Context Error

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;
  });
});

Selective Properties Not Applied

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'];

Structure Doesn't Match example.json

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', {});
}

Additional Resources

Clone this wiki locally