Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions .claude/skills/create-database-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ description: Create a database migration to add a table, add columns to an exist

## Instructions

1. Change directories into `ghost/core`: `cd ghost/core`
2. Create a new, empty migration file using slimer: `slimer migration <name-of-database-migration>`. IMPORTANT: do not create the migration file manually; always use slimer to create the initial empty migration file.
3. The above command will create a new directory in `ghost/core/core/server/data/migrations/versions` if needed, and create the empty migration file with the appropriate name.
4. Update the migration file with the changes you want to make in the database, following the existing patterns in the codebase. Where appropriate, prefer to use the utility functions in `ghost/core/core/server/data/migrations/utils/*`.
5. Update the schema definition file in `ghost/core/core/server/data/schema/schema.js`, and make sure it aligns with the latest changes from the migration.
6. Test the migration manually: `yarn knex-migrator migrate --v {version directory} --force`
7. If adding or dropping a table, update `ghost/core/core/server/data/exporter/table-lists.js` as appropriate.
8. Run the schema integrity test, and update the hash: `yarn test:single test/unit/server/data/schema/integrity.test.js`
9. Run unit tests in Ghost core, and iterate until they pass: `cd ghost/core && yarn test:unit`
1. Create a new, empty migration file: `cd ghost/core && yarn migrate:create <kebab-case-slug>`. IMPORTANT: do not create the migration file manually; always use this script to create the initial empty migration file. The slug must be kebab-case (e.g. `add-column-to-posts`).
2. The above command will create a new directory in `ghost/core/core/server/data/migrations/versions` if needed, create the empty migration file with the appropriate name, and bump the core and admin package versions to RC if this is the first migration after a release.
3. Update the migration file with the changes you want to make in the database, following the existing patterns in the codebase. Where appropriate, prefer to use the utility functions in `ghost/core/core/server/data/migrations/utils/*`.
4. Update the schema definition file in `ghost/core/core/server/data/schema/schema.js`, and make sure it aligns with the latest changes from the migration.
5. Test the migration manually: `yarn knex-migrator migrate --v {version directory} --force`
6. If adding or dropping a table, update `ghost/core/core/server/data/exporter/table-lists.js` as appropriate.
7. Run the schema integrity test, and update the hash: `yarn test:single test/unit/server/data/schema/integrity.test.js`
8. Run unit tests in Ghost core, and iterate until they pass: `cd ghost/core && yarn test:unit`

## Examples
See [examples.md](examples.md) for example migrations.
Expand Down
128 changes: 128 additions & 0 deletions ghost/core/bin/create-migration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/* eslint-disable no-console, ghost/ghost-custom/no-native-error */

const path = require('path');
const fs = require('fs');
const semver = require('semver');

const MIGRATION_TEMPLATE = `const logging = require('@tryghost/logging');

// For DDL - schema changes
// const {createNonTransactionalMigration} = require('../../utils');

// For DML - data changes
// const {createTransactionalMigration} = require('../../utils');

// Or use a specific helper
// const {addTable, createAddColumnMigration} = require('../../utils');

module.exports = /**/;
`;

const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;

/**
* Validates that a slug is kebab-case (lowercase alphanumeric with single hyphens).
*/
function isValidSlug(slug) {
return typeof slug === 'string' && SLUG_PATTERN.test(slug);
}

/**
* Returns the migration version folder name for the given package version.
*
* semver.inc(v, 'minor') handles both cases:
* - Stable 6.18.0 → 6.19.0 (increments minor)
* - Prerelease 6.19.0-rc.0 → 6.19.0 (strips prerelease, keeps minor)
*
* Key invariant: 6.18.0 and 6.19.0-rc.0 both produce folder "6.19".
*/
function getNextMigrationVersion(version) {
const next = semver.inc(version, 'minor');
if (!next) {
throw new Error(`Invalid version: ${version}`);
}
return `${semver.major(next)}.${semver.minor(next)}`;
}

/**
* Creates a migration file and optionally bumps package versions to RC.
*
* @param {object} options
* @param {string} options.slug - The migration name in kebab-case
* @param {string} options.coreDir - Path to ghost/core directory
* @param {Date} [options.date] - Override the timestamp (for testing)
* @returns {{migrationPath: string, rcVersion: string|null}}
*/
function createMigration({slug, coreDir, date}) {
if (!isValidSlug(slug)) {
throw new Error(`Invalid slug: "${slug}". Use kebab-case (e.g. add-column-to-posts)`);
}

const migrationsDir = path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions');
const corePackagePath = path.join(coreDir, 'package.json');

const corePackage = JSON.parse(fs.readFileSync(corePackagePath, 'utf8'));
const currentVersion = corePackage.version;

const nextVersion = getNextMigrationVersion(currentVersion);
const versionDir = path.join(migrationsDir, nextVersion);

const timestamp = (date || new Date()).toISOString().slice(0, 19).replace('T', '-').replaceAll(':', '-');
const filename = `${timestamp}-${slug}.js`;
const migrationPath = path.join(versionDir, filename);

fs.mkdirSync(versionDir, {recursive: true});
try {
fs.writeFileSync(migrationPath, MIGRATION_TEMPLATE, {flag: 'wx'});
} catch (err) {
if (err.code === 'EEXIST') {
throw new Error(`Migration already exists: ${migrationPath}`);
}
throw err;
}

// Auto-bump to RC if this is a stable version
let rcVersion = null;
if (!semver.prerelease(currentVersion)) {
rcVersion = semver.inc(currentVersion, 'preminor', 'rc');

corePackage.version = rcVersion;
fs.writeFileSync(corePackagePath, JSON.stringify(corePackage, null, 2) + '\n');

const adminPackagePath = path.resolve(coreDir, '..', 'admin', 'package.json');
if (fs.existsSync(adminPackagePath)) {
const adminPackage = JSON.parse(fs.readFileSync(adminPackagePath, 'utf8'));
adminPackage.version = rcVersion;
fs.writeFileSync(adminPackagePath, JSON.stringify(adminPackage, null, 2) + '\n');
}
}

return {migrationPath, rcVersion};
}

// CLI entry point
if (require.main === module) {
const slug = process.argv[2];

if (!slug) {
console.error('Usage: yarn migrate:create <slug>');
console.error(' slug: kebab-case migration name (e.g. add-column-to-posts)');
process.exit(1);
}

try {
const coreDir = path.resolve(__dirname, '..');
const {migrationPath, rcVersion} = createMigration({slug, coreDir});

console.log(`Created migration: ${migrationPath}`);
if (rcVersion) {
console.log(`Bumped version to ${rcVersion}`);
}
} catch (err) {
console.error(err.message);
process.exit(1);
}
}

module.exports = {isValidSlug, getNextMigrationVersion, createMigration};
1 change: 1 addition & 0 deletions ghost/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dev": "node --watch --import=tsx index.js",
"build:assets": "yarn build:assets:css && yarn build:assets:js",
"build:assets:js": "node bin/minify-assets.js",
"migrate:create": "node bin/create-migration.js",
"build:assets:css": "postcss core/frontend/public/ghost.css --no-map --use cssnano -o core/frontend/public/ghost.min.css",
"build:tsc": "tsc",
"pretest": "yarn build:assets",
Expand Down
204 changes: 204 additions & 0 deletions ghost/core/test/unit/bin/create-migration.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
const assert = require('assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');

const {isValidSlug, getNextMigrationVersion, createMigration} = require('../../../bin/create-migration');

describe('bin/create-migration', function () {
describe('isValidSlug', function () {
it('accepts valid kebab-case slugs', function () {
assert.ok(isValidSlug('add-column'));
assert.ok(isValidSlug('fix-index'));
assert.ok(isValidSlug('a'));
assert.ok(isValidSlug('add-mentions-table'));
assert.ok(isValidSlug('a1'));
assert.ok(isValidSlug('123'));
});

it('rejects invalid slugs', function () {
assert.ok(!isValidSlug(undefined));
assert.ok(!isValidSlug(''));
assert.ok(!isValidSlug('has spaces'));
assert.ok(!isValidSlug('UPPER'));
assert.ok(!isValidSlug('camelCase'));
assert.ok(!isValidSlug('has_underscores'));
assert.ok(!isValidSlug('-leading'));
assert.ok(!isValidSlug('trailing-'));
assert.ok(!isValidSlug('double--hyphen'));
assert.ok(!isValidSlug('special!chars'));
assert.ok(!isValidSlug('path/slash'));
});
});

describe('getNextMigrationVersion', function () {
it('increments minor for stable versions', function () {
assert.equal(getNextMigrationVersion('6.18.0'), '6.19');
assert.equal(getNextMigrationVersion('5.75.0'), '5.76');
assert.equal(getNextMigrationVersion('6.0.0'), '6.1');
assert.equal(getNextMigrationVersion('7.12.3'), '7.13');
});

it('uses current minor for prerelease versions', function () {
assert.equal(getNextMigrationVersion('6.19.0-rc.0'), '6.19');
assert.equal(getNextMigrationVersion('6.19.0-rc.1'), '6.19');
assert.equal(getNextMigrationVersion('6.0.0-alpha.0'), '6.0');
assert.equal(getNextMigrationVersion('5.75.0-beta.1'), '5.75');
});

it('stable and its RC target the same folder', function () {
assert.equal(getNextMigrationVersion('6.18.0'), '6.19');
assert.equal(getNextMigrationVersion('6.19.0-rc.0'), '6.19');
});

it('throws for invalid versions', function () {
assert.throws(() => getNextMigrationVersion('not-a-version'), /Invalid version/);
});
});

describe('createMigration', function () {
let tmpDir;
let coreDir;

beforeEach(function () {
// Create a parent dir that mirrors the monorepo layout (core/ + admin/)
// so path.resolve(coreDir, '..', 'admin') stays inside the sandbox
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-migration-test-'));
coreDir = path.join(tmpDir, 'core');

fs.mkdirSync(path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions'), {recursive: true});
});
Comment thread
rob-ghost marked this conversation as resolved.

afterEach(function () {
fs.rmSync(tmpDir, {recursive: true, force: true});
});

function writePackageJson(dir, version) {
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({name: 'ghost', version}, null, 2) + '\n'
);
}

function readVersion(dir) {
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
}

it('creates a migration file in the correct version folder', function () {
writePackageJson(coreDir, '6.18.0');

const result = createMigration({
slug: 'add-column',
coreDir,
date: new Date('2026-02-23T10:30:00Z')
});

assert.ok(fs.existsSync(result.migrationPath));
assert.ok(result.migrationPath.includes(path.join('versions', '6.19')));
assert.ok(result.migrationPath.endsWith('2026-02-23-10-30-00-add-column.js'));
});

it('writes the migration template', function () {
writePackageJson(coreDir, '6.18.0');

const {migrationPath} = createMigration({
slug: 'test-migration',
coreDir,
date: new Date('2026-01-01T00:00:00Z')
});

const content = fs.readFileSync(migrationPath, 'utf8');
assert.ok(content.includes('require(\'@tryghost/logging\')'));
assert.ok(content.includes('createNonTransactionalMigration'));
assert.ok(content.includes('createTransactionalMigration'));
assert.ok(content.includes('module.exports = /**/;'));
});

it('creates the version directory if it does not exist', function () {
writePackageJson(coreDir, '6.18.0');

const versionDir = path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions', '6.19');
assert.ok(!fs.existsSync(versionDir));

createMigration({
slug: 'new-folder',
coreDir,
date: new Date('2026-01-01T00:00:00Z')
});

assert.ok(fs.existsSync(versionDir));
});

it('bumps to RC when current version is stable', function () {
writePackageJson(coreDir, '6.18.0');

const {rcVersion} = createMigration({
slug: 'first-migration',
coreDir,
date: new Date('2026-01-01T00:00:00Z')
});

assert.equal(rcVersion, '6.19.0-rc.0');
assert.equal(readVersion(coreDir), '6.19.0-rc.0');
});

it('bumps admin package.json when it exists', function () {
writePackageJson(coreDir, '6.18.0');

const adminDir = path.join(tmpDir, 'admin');
fs.mkdirSync(adminDir, {recursive: true});
writePackageJson(adminDir, '6.18.0');

createMigration({
slug: 'with-admin',
coreDir,
date: new Date('2026-01-01T00:00:00Z')
});

assert.equal(readVersion(adminDir), '6.19.0-rc.0');
});

it('does not bump when already a prerelease', function () {
writePackageJson(coreDir, '6.19.0-rc.0');

const {rcVersion} = createMigration({
slug: 'second-migration',
coreDir,
date: new Date('2026-01-01T00:00:00Z')
});

assert.equal(rcVersion, null);
assert.equal(readVersion(coreDir), '6.19.0-rc.0');
});

it('places RC migrations in the same folder as stable', function () {
writePackageJson(coreDir, '6.19.0-rc.0');

const result = createMigration({
slug: 'rc-migration',
coreDir,
date: new Date('2026-01-01T00:00:00Z')
});

assert.ok(result.migrationPath.includes(path.join('versions', '6.19')));
});

it('throws for invalid slug', function () {
writePackageJson(coreDir, '6.18.0');

assert.throws(
() => createMigration({slug: 'INVALID', coreDir}),
/Invalid slug/
);
});

it('throws for missing slug', function () {
writePackageJson(coreDir, '6.18.0');

assert.throws(
() => createMigration({slug: undefined, coreDir}),
/Invalid slug/
);
});
});
});
Loading