mirror of
https://github.com/strapi/strapi.git
synced 2025-09-02 21:32:43 +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 fse from 'fs-extra';
|
||||
import { Umzug } from 'umzug';
|
||||
|
||||
import type { Resolver } from 'umzug';
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
import { createStorage } from './storage';
|
||||
import { createUserMigrationProvider } from './users';
|
||||
import { createInternalMigrationProvider } from './internal';
|
||||
|
||||
import type { MigrationProvider } from './common';
|
||||
import type { Database } from '..';
|
||||
|
||||
export interface MigrationProvider {
|
||||
shouldRun(): Promise<boolean>;
|
||||
up(): Promise<void>;
|
||||
down(): Promise<void>;
|
||||
}
|
||||
export type { MigrationProvider };
|
||||
|
||||
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 => {
|
||||
const migrations = createUmzugProvider(db);
|
||||
const providers = [createUserMigrationProvider(db), createInternalMigrationProvider(db)];
|
||||
|
||||
return {
|
||||
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() {
|
||||
await migrations.up();
|
||||
for (const provider of providers) {
|
||||
if (await provider.shouldRun()) {
|
||||
await provider.up();
|
||||
}
|
||||
}
|
||||
},
|
||||
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 {
|
||||
db: Database;
|
||||
tableName?: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export const createStorage = (opts: Options) => {
|
||||
const { db, tableName = 'strapi_migrations' } = opts;
|
||||
const { db, tableName } = opts;
|
||||
|
||||
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';
|
||||
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 = {
|
||||
CHANGED: 'CHANGED',
|
||||
|
Loading…
x
Reference in New Issue
Block a user