mirror of
https://github.com/strapi/strapi.git
synced 2025-09-04 14:23:03 +00:00
feat(database): add internal migrations
This commit is contained in:
parent
4abb081aed
commit
c0de2c2711
7
docs/docs/docs/01-core/database/03-migrations.md
Normal file
7
docs/docs/docs/01-core/database/03-migrations.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Migrations
|
||||||
|
description: Conceptual guide to migrations in Strapi
|
||||||
|
tags:
|
||||||
|
- database
|
||||||
|
- migration
|
||||||
|
---
|
@ -0,0 +1,47 @@
|
|||||||
|
import { wrapTransaction } from '../common';
|
||||||
|
import { Database } from '../..';
|
||||||
|
|
||||||
|
describe('wrapTransaction', () => {
|
||||||
|
let db: Database;
|
||||||
|
let fn: jest.Mock;
|
||||||
|
const trx: jest.Mock = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = {
|
||||||
|
connection: {
|
||||||
|
transaction: jest.fn().mockImplementation((cb) => cb(trx)),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
fn = jest.fn().mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wrap the function in a transaction', async () => {
|
||||||
|
const wrappedFn = wrapTransaction(db)(fn);
|
||||||
|
await wrappedFn();
|
||||||
|
|
||||||
|
expect(db.connection.transaction).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
expect(fn).toHaveBeenCalledWith(trx, db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result of the wrapped function', async () => {
|
||||||
|
const result = {};
|
||||||
|
fn.mockResolvedValueOnce(result);
|
||||||
|
|
||||||
|
const wrappedFn = wrapTransaction(db)(fn);
|
||||||
|
const res = await wrappedFn();
|
||||||
|
|
||||||
|
expect(res).toBe(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rollback the transaction if the wrapped function throws an error', async () => {
|
||||||
|
const error = new Error('Test error');
|
||||||
|
fn.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const wrappedFn = wrapTransaction(db)(fn);
|
||||||
|
|
||||||
|
await expect(wrappedFn()).rejects.toThrow(error);
|
||||||
|
expect(db.connection.transaction).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
expect(fn).toHaveBeenCalledWith(trx, db);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,92 @@
|
|||||||
|
import { createStorage } from '../storage';
|
||||||
|
|
||||||
|
import { Database } from '../..';
|
||||||
|
|
||||||
|
describe('createStorage', () => {
|
||||||
|
let db: Database;
|
||||||
|
let tableName: string;
|
||||||
|
let storage: ReturnType<typeof createStorage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = {
|
||||||
|
getSchemaConnection: jest.fn().mockReturnValue({
|
||||||
|
hasTable: jest.fn().mockResolvedValue(false),
|
||||||
|
createTable: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
getConnection: jest.fn().mockReturnValue({
|
||||||
|
insert: jest.fn().mockReturnValue({
|
||||||
|
into: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
del: jest.fn().mockReturnValue({
|
||||||
|
where: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
select: jest.fn().mockReturnValue({
|
||||||
|
from: jest.fn().mockReturnValue({
|
||||||
|
orderBy: jest.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
tableName = 'migrations';
|
||||||
|
storage = createStorage({ db, tableName });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logMigration', () => {
|
||||||
|
it('should insert a new migration log', async () => {
|
||||||
|
const name = '20220101120000_create_users_table';
|
||||||
|
const time = new Date();
|
||||||
|
|
||||||
|
await storage.logMigration({ name });
|
||||||
|
|
||||||
|
expect(db.getConnection().insert).toHaveBeenCalledWith({ name, time });
|
||||||
|
expect(db.getConnection().insert({}).into).toHaveBeenCalledWith(tableName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unlogMigration', () => {
|
||||||
|
it('should delete a migration log', async () => {
|
||||||
|
const name = '20220101120000_create_users_table';
|
||||||
|
|
||||||
|
await storage.unlogMigration({ name });
|
||||||
|
|
||||||
|
expect(db.getConnection().del).toHaveBeenCalled();
|
||||||
|
expect(db.getConnection().del().where).toHaveBeenCalledWith({ name });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('executed', () => {
|
||||||
|
it('should create migration table if it does not exist', async () => {
|
||||||
|
await storage.executed();
|
||||||
|
|
||||||
|
expect(db.getSchemaConnection().hasTable).toHaveBeenCalledWith(tableName);
|
||||||
|
expect(db.getSchemaConnection().createTable).toHaveBeenCalledWith(
|
||||||
|
tableName,
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array if no migration has been executed', async () => {
|
||||||
|
const result = await storage.executed();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an array of executed migration names', async () => {
|
||||||
|
const logs = [
|
||||||
|
{ name: '20220101120000_create_users_table' },
|
||||||
|
{ name: '20220101130000_create_posts_table' },
|
||||||
|
];
|
||||||
|
|
||||||
|
(db.getSchemaConnection().hasTable as jest.Mock).mockResolvedValue(true);
|
||||||
|
(db.getConnection().select().from('').orderBy as jest.Mock).mockResolvedValue(logs);
|
||||||
|
|
||||||
|
const result = await storage.executed();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
'20220101120000_create_users_table',
|
||||||
|
'20220101130000_create_posts_table',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
26
packages/core/database/src/migrations/common.ts
Normal file
26
packages/core/database/src/migrations/common.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { Resolver } from 'umzug';
|
||||||
|
import type { Knex } from 'knex';
|
||||||
|
|
||||||
|
import type { Database } from '..';
|
||||||
|
|
||||||
|
export interface MigrationProvider {
|
||||||
|
shouldRun(): Promise<boolean>;
|
||||||
|
up(): Promise<void>;
|
||||||
|
down(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Context = { db: Database };
|
||||||
|
|
||||||
|
export type MigrationResolver = Resolver<Context>;
|
||||||
|
|
||||||
|
export type MigrationFn = (knex: Knex, db: Database) => Promise<void>;
|
||||||
|
|
||||||
|
export type Migration = {
|
||||||
|
name: string;
|
||||||
|
up: MigrationFn;
|
||||||
|
down: MigrationFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const wrapTransaction = (db: Database) => (fn: MigrationFn) => () => {
|
||||||
|
return db.connection.transaction((trx) => Promise.resolve(fn(trx, db)));
|
||||||
|
};
|
@ -1,93 +1,35 @@
|
|||||||
import path from 'node:path';
|
import { createUserMigrationProvider } from './users';
|
||||||
import fse from 'fs-extra';
|
import { createInternalMigrationProvider } from './internal';
|
||||||
import { Umzug } from 'umzug';
|
|
||||||
|
|
||||||
import type { Resolver } from 'umzug';
|
|
||||||
import type { Knex } from 'knex';
|
|
||||||
|
|
||||||
import { createStorage } from './storage';
|
|
||||||
|
|
||||||
|
import type { MigrationProvider } from './common';
|
||||||
import type { Database } from '..';
|
import type { Database } from '..';
|
||||||
|
|
||||||
export interface MigrationProvider {
|
export type { MigrationProvider };
|
||||||
shouldRun(): Promise<boolean>;
|
|
||||||
up(): Promise<void>;
|
|
||||||
down(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MigrationResolver = Resolver<{ db: Database }>;
|
|
||||||
|
|
||||||
const wrapTransaction = (db: Database) => (fn: (knex: Knex) => unknown) => () => {
|
|
||||||
return db.connection.transaction((trx) => Promise.resolve(fn(trx)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: check multiple commands in one sql statement
|
|
||||||
const migrationResolver: MigrationResolver = ({ name, path, context }) => {
|
|
||||||
const { db } = context;
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
throw new Error(`Migration ${name} has no path`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if sql file run with knex raw
|
|
||||||
if (path.match(/\.sql$/)) {
|
|
||||||
const sql = fse.readFileSync(path, 'utf8');
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
up: wrapTransaction(db)((knex) => knex.raw(sql)),
|
|
||||||
async down() {
|
|
||||||
throw new Error('Down migration is not supported for sql files');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: we can add some ts register if we want to handle ts migration files at some point
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const migration = require(path);
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
up: wrapTransaction(db)(migration.up),
|
|
||||||
down: wrapTransaction(db)(migration.down),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createUmzugProvider = (db: Database) => {
|
|
||||||
const migrationDir = path.join(strapi.dirs.app.root, 'database/migrations');
|
|
||||||
|
|
||||||
fse.ensureDirSync(migrationDir);
|
|
||||||
|
|
||||||
return new Umzug({
|
|
||||||
storage: createStorage({ db, tableName: 'strapi_migrations' }),
|
|
||||||
logger: console,
|
|
||||||
context: { db },
|
|
||||||
migrations: {
|
|
||||||
glob: ['*.{js,sql}', { cwd: migrationDir }],
|
|
||||||
resolve: migrationResolver,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: when needed => add internal migrations for core & plugins. How do we overlap them with users migrations ?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates migrations provider
|
|
||||||
* @type {import('.').createMigrationsProvider}
|
|
||||||
*/
|
|
||||||
export const createMigrationsProvider = (db: Database): MigrationProvider => {
|
export const createMigrationsProvider = (db: Database): MigrationProvider => {
|
||||||
const migrations = createUmzugProvider(db);
|
const providers = [createUserMigrationProvider(db), createInternalMigrationProvider(db)];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async shouldRun() {
|
async shouldRun() {
|
||||||
const pending = await migrations.pending();
|
const shouldRunResponses = await Promise.all(
|
||||||
|
providers.map((provider) => provider.shouldRun())
|
||||||
|
);
|
||||||
|
|
||||||
return pending.length > 0 && db.config?.settings?.runMigrations === true;
|
return shouldRunResponses.some((shouldRun) => shouldRun);
|
||||||
},
|
},
|
||||||
async up() {
|
async up() {
|
||||||
await migrations.up();
|
for (const provider of providers) {
|
||||||
|
if (await provider.shouldRun()) {
|
||||||
|
await provider.up();
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async down() {
|
async down() {
|
||||||
await migrations.down();
|
for (const provider of providers) {
|
||||||
|
if (await provider.shouldRun()) {
|
||||||
|
await provider.down();
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import type { Migration } from '../common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all the internal migrations. The array order will be the order in which they are executed.
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* name: 'some-name',
|
||||||
|
* async up(knex: Knex, db: Database) {},
|
||||||
|
* async down(knex: Knex, db: Database) {},
|
||||||
|
* },
|
||||||
|
*/
|
||||||
|
export const internalMigrations: Migration[] = [];
|
38
packages/core/database/src/migrations/internal.ts
Normal file
38
packages/core/database/src/migrations/internal.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Umzug } from 'umzug';
|
||||||
|
|
||||||
|
import { wrapTransaction } from './common';
|
||||||
|
import { internalMigrations } from './internal-migrations';
|
||||||
|
import { createStorage } from './storage';
|
||||||
|
|
||||||
|
import type { MigrationProvider } from './common';
|
||||||
|
import type { Database } from '..';
|
||||||
|
|
||||||
|
export const createInternalMigrationProvider = (db: Database): MigrationProvider => {
|
||||||
|
const context = { db };
|
||||||
|
|
||||||
|
const umzugProvider = new Umzug({
|
||||||
|
storage: createStorage({ db, tableName: 'strapi_migrations_internal' }),
|
||||||
|
logger: console,
|
||||||
|
context,
|
||||||
|
migrations: internalMigrations.map((migration) => {
|
||||||
|
return {
|
||||||
|
name: migration.name,
|
||||||
|
up: wrapTransaction(context.db)(migration.up),
|
||||||
|
down: wrapTransaction(context.db)(migration.down),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async shouldRun() {
|
||||||
|
const pendingMigrations = await umzugProvider.pending();
|
||||||
|
return pendingMigrations.length > 0;
|
||||||
|
},
|
||||||
|
async up() {
|
||||||
|
await umzugProvider.up();
|
||||||
|
},
|
||||||
|
async down() {
|
||||||
|
await umzugProvider.down();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -2,11 +2,11 @@ import type { Database } from '..';
|
|||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
db: Database;
|
db: Database;
|
||||||
tableName?: string;
|
tableName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createStorage = (opts: Options) => {
|
export const createStorage = (opts: Options) => {
|
||||||
const { db, tableName = 'strapi_migrations' } = opts;
|
const { db, tableName } = opts;
|
||||||
|
|
||||||
const hasMigrationTable = () => db.getSchemaConnection().hasTable(tableName);
|
const hasMigrationTable = () => db.getSchemaConnection().hasTable(tableName);
|
||||||
|
|
||||||
|
70
packages/core/database/src/migrations/users.ts
Normal file
70
packages/core/database/src/migrations/users.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fse from 'fs-extra';
|
||||||
|
import { Umzug } from 'umzug';
|
||||||
|
|
||||||
|
import { createStorage } from './storage';
|
||||||
|
import { wrapTransaction } from './common';
|
||||||
|
import type { MigrationProvider, MigrationResolver } from './common';
|
||||||
|
import type { Database } from '..';
|
||||||
|
|
||||||
|
// TODO: check multiple commands in one sql statement
|
||||||
|
const migrationResolver: MigrationResolver = ({ name, path, context }) => {
|
||||||
|
const { db } = context;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
throw new Error(`Migration ${name} has no path`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if sql file run with knex raw
|
||||||
|
if (path.match(/\.sql$/)) {
|
||||||
|
const sql = fse.readFileSync(path, 'utf8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
up: wrapTransaction(db)((knex) => knex.raw(sql)),
|
||||||
|
async down() {
|
||||||
|
throw new Error('Down migration is not supported for sql files');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: we can add some ts register if we want to handle ts migration files at some point
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const migration = require(path);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
up: wrapTransaction(db)(migration.up),
|
||||||
|
down: wrapTransaction(db)(migration.down),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserMigrationProvider = (db: Database): MigrationProvider => {
|
||||||
|
const dir = path.join(strapi.dirs.app.root, 'database/migrations');
|
||||||
|
|
||||||
|
fse.ensureDirSync(dir);
|
||||||
|
|
||||||
|
const context = { db };
|
||||||
|
|
||||||
|
const umzugProvider = new Umzug({
|
||||||
|
storage: createStorage({ db, tableName: 'strapi_migrations' }),
|
||||||
|
logger: console,
|
||||||
|
context,
|
||||||
|
migrations: {
|
||||||
|
glob: ['*.{js,sql}', { cwd: dir }],
|
||||||
|
resolve: migrationResolver,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async shouldRun() {
|
||||||
|
const pendingMigrations = await umzugProvider.pending();
|
||||||
|
return pendingMigrations.length > 0 && db.config?.settings?.runMigrations === true;
|
||||||
|
},
|
||||||
|
async up() {
|
||||||
|
await umzugProvider.up();
|
||||||
|
},
|
||||||
|
async down() {
|
||||||
|
await umzugProvider.down();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -16,7 +16,12 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import type { Database } from '..';
|
import type { Database } from '..';
|
||||||
|
|
||||||
const RESERVED_TABLE_NAMES = ['strapi_migrations', 'strapi_database_schema'];
|
// TODO: get that list dynamically instead
|
||||||
|
const RESERVED_TABLE_NAMES = [
|
||||||
|
'strapi_migrations',
|
||||||
|
'strapi_migrations_internal',
|
||||||
|
'strapi_database_schema',
|
||||||
|
];
|
||||||
|
|
||||||
const statuses = {
|
const statuses = {
|
||||||
CHANGED: 'CHANGED',
|
CHANGED: 'CHANGED',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user