42 changed files with 3133 additions and 50 deletions
@ -0,0 +1,39 @@ |
|||
import {Inject, Injectable} from '../di' |
|||
import {ConstraintType, DatabaseService, FieldType, Migration, Schema} from '../orm' |
|||
|
|||
/** |
|||
* Migration that creates the sessions table used by the ORMSession backend. |
|||
*/ |
|||
@Injectable() |
|||
export default class CreateSessionsTableMigration extends Migration { |
|||
@Inject() |
|||
protected readonly db!: DatabaseService |
|||
|
|||
async up(): Promise<void> { |
|||
const schema: Schema = this.db.get().schema() |
|||
const table = await schema.table('sessions') |
|||
|
|||
table.primaryKey('session_uuid', FieldType.varchar) |
|||
.required() |
|||
|
|||
table.column('session_data') |
|||
.type(FieldType.json) |
|||
.required() |
|||
.default('{}') |
|||
|
|||
table.constraint('session_uuid_ck') |
|||
.type(ConstraintType.Check) |
|||
.expression('LENGTH(session_uuid) > 0') |
|||
|
|||
await schema.commit(table) |
|||
} |
|||
|
|||
async down(): Promise<void> { |
|||
const schema: Schema = this.db.get().schema() |
|||
const table = await schema.table('sessions') |
|||
|
|||
table.dropIfExists() |
|||
|
|||
await schema.commit(table) |
|||
} |
|||
} |
@ -0,0 +1,47 @@ |
|||
import {Inject, Injectable} from '../di' |
|||
import {DatabaseService, FieldType, Migration, Schema} from '../orm' |
|||
|
|||
/** |
|||
* Migration that creates the users table used by @extollo/lib.auth. |
|||
*/ |
|||
@Injectable() |
|||
export default class CreateUsersTableMigration extends Migration { |
|||
@Inject() |
|||
protected readonly db!: DatabaseService |
|||
|
|||
async up(): Promise<void> { |
|||
const schema: Schema = this.db.get().schema() |
|||
const table = await schema.table('users') |
|||
|
|||
table.primaryKey('user_id') |
|||
.required() |
|||
|
|||
table.column('first_name') |
|||
.type(FieldType.varchar) |
|||
.required() |
|||
|
|||
table.column('last_name') |
|||
.type(FieldType.varchar) |
|||
.required() |
|||
|
|||
table.column('password_hash') |
|||
.type(FieldType.text) |
|||
.nullable() |
|||
|
|||
table.column('username') |
|||
.type(FieldType.varchar) |
|||
.required() |
|||
.unique() |
|||
|
|||
await schema.commit(table) |
|||
} |
|||
|
|||
async down(): Promise<void> { |
|||
const schema: Schema = this.db.get().schema() |
|||
const table = await schema.table('users') |
|||
|
|||
table.dropIfExists() |
|||
|
|||
await schema.commit(table) |
|||
} |
|||
} |
@ -0,0 +1,48 @@ |
|||
import {Directive, OptionDefinition} from '../../cli' |
|||
import {Injectable} from '../../di' |
|||
import {stringToPascal} from '../../util' |
|||
import {templateMigration} from '../template/migration' |
|||
|
|||
/** |
|||
* CLI directive that creates migration classes from template. |
|||
*/ |
|||
@Injectable() |
|||
export class CreateMigrationDirective extends Directive { |
|||
getDescription(): string { |
|||
return 'create a new migration' |
|||
} |
|||
|
|||
getKeywords(): string | string[] { |
|||
return ['create-migration', 'make-migration'] |
|||
} |
|||
|
|||
getOptions(): OptionDefinition[] { |
|||
return [ |
|||
'{description} | Description of what the migration does', |
|||
] |
|||
} |
|||
|
|||
getHelpText(): string { |
|||
return [ |
|||
'Creates a new migration file in `src/app/migrations`.', |
|||
'To use, specify a string describing what the migration does. For example:', |
|||
'./ex create-migration "Add version column to sessions table"', |
|||
].join('\n\n') |
|||
} |
|||
|
|||
async handle(): Promise<void> { |
|||
const description = this.option('description') |
|||
const className = `${stringToPascal(description)}Migration` |
|||
const fileName = `${(new Date()).toISOString()}_${className}.migration.ts` |
|||
const path = this.app().path('..', 'src', 'app', 'migrations', fileName) |
|||
|
|||
// Create the migrations directory, if it doesn't already exist
|
|||
await path.concat('..').mkdir() |
|||
|
|||
// Render the template
|
|||
const rendered = await templateMigration.render(className, className, path) |
|||
await path.write(rendered) |
|||
|
|||
this.success(`Created migration: ${className}`) |
|||
} |
|||
} |
@ -0,0 +1,117 @@ |
|||
import {Directive, OptionDefinition} from '../../cli' |
|||
import {Container, Inject, Injectable} from '../../di' |
|||
import {EventBus} from '../../event/EventBus' |
|||
import {Migrator} from '../migrations/Migrator' |
|||
import {Migrations} from '../services/Migrations' |
|||
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent' |
|||
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent' |
|||
import {EventSubscription} from '../../event/types' |
|||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError' |
|||
|
|||
/** |
|||
* CLI directive that applies migrations using the default Migrator. |
|||
* @fixme Support dry run mode |
|||
*/ |
|||
@Injectable() |
|||
export class MigrateDirective extends Directive { |
|||
@Inject() |
|||
protected readonly bus!: EventBus |
|||
|
|||
@Inject('injector') |
|||
protected readonly injector!: Container |
|||
|
|||
/** Event bus subscriptions. */ |
|||
protected subscriptions: EventSubscription[] = [] |
|||
|
|||
getKeywords(): string | string[] { |
|||
return ['migrate'] |
|||
} |
|||
|
|||
getDescription(): string { |
|||
return 'apply pending migrations' |
|||
} |
|||
|
|||
getOptions(): OptionDefinition[] { |
|||
return [ |
|||
'--package -p {name} | apply migrations for a specific namespace', |
|||
'--identifier -i {name} | apply a specific migration, by identifier', |
|||
] |
|||
} |
|||
|
|||
getHelpText(): string { |
|||
return [ |
|||
'Migrations are single-run code patches used to track changes to things like database schemata.', |
|||
'', |
|||
'You can create migrations in your app using the ./ex command and they can be applied and rolled-back.', |
|||
'', |
|||
'./ex migrate:create "Add version column to sessions table"', |
|||
'', |
|||
'Modules and packages can also register their own migrations. These are run by default.', |
|||
'', |
|||
'To run the migrations for a specific package, and no others, use the --package option. Example:', |
|||
'', |
|||
'./ex migrate --package @extollo', |
|||
'', |
|||
].join('\n') |
|||
} |
|||
|
|||
async handle(): Promise<void> { |
|||
await this.registerListeners() |
|||
|
|||
const namespace = this.option('package') |
|||
const identifier = this.option('identifier') |
|||
|
|||
let identifiers |
|||
if ( namespace ) { |
|||
identifiers = (this.injector.make<Migrations>(Migrations)) |
|||
.all(namespace) |
|||
.map(id => `${namespace}:${id}`) |
|||
} |
|||
|
|||
if ( identifier ) { |
|||
if ( !identifiers ) { |
|||
identifiers = [identifier] |
|||
} |
|||
|
|||
identifiers = identifiers.filter(x => x === identifier) |
|||
} |
|||
|
|||
let error |
|||
try { |
|||
await (this.injector.make<Migrator>(Migrator)).migrate(identifiers) |
|||
} catch (e) { |
|||
if ( e instanceof NothingToMigrateError ) { |
|||
this.info(e.message) |
|||
} else { |
|||
error = e |
|||
this.error(e) |
|||
} |
|||
} finally { |
|||
await this.removeListeners() |
|||
} |
|||
|
|||
if ( error ) { |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Register event bus listeners to print messages for the user. |
|||
* @protected |
|||
*/ |
|||
protected async registerListeners(): Promise<void> { |
|||
this.subscriptions.push(await this.bus.subscribe(ApplyingMigrationEvent, event => { |
|||
this.info(`Applying migration ${event.migration.identifier}...`) |
|||
})) |
|||
|
|||
this.subscriptions.push(await this.bus.subscribe(AppliedMigrationEvent, event => { |
|||
this.success(`Applied migration: ${event.migration.identifier}`) |
|||
})) |
|||
} |
|||
|
|||
/** Remove event bus listeners before finish. */ |
|||
protected async removeListeners(): Promise<void> { |
|||
await Promise.all(this.subscriptions.map(x => x.unsubscribe())) |
|||
this.subscriptions = [] |
|||
} |
|||
} |
@ -0,0 +1,102 @@ |
|||
import {Directive, OptionDefinition} from '../../cli' |
|||
import {Container, Inject, Injectable} from '../../di' |
|||
import {EventBus} from '../../event/EventBus' |
|||
import {Migrator} from '../migrations/Migrator' |
|||
import {Migrations} from '../services/Migrations' |
|||
import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrationEvent' |
|||
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent' |
|||
import {EventSubscription} from '../../event/types' |
|||
import {NothingToMigrateError} from '../migrations/NothingToMigrateError' |
|||
|
|||
/** |
|||
* CLI directive that undoes applied migrations using the default Migrator. |
|||
* @fixme Support dry run mode |
|||
*/ |
|||
@Injectable() |
|||
export class RollbackDirective extends Directive { |
|||
@Inject() |
|||
protected readonly bus!: EventBus |
|||
|
|||
@Inject('injector') |
|||
protected readonly injector!: Container |
|||
|
|||
@Inject() |
|||
protected readonly migrations!: Migrations |
|||
|
|||
/** Event bus subscriptions. */ |
|||
protected subscriptions: EventSubscription[] = [] |
|||
|
|||
getKeywords(): string | string[] { |
|||
return ['rollback'] |
|||
} |
|||
|
|||
getDescription(): string { |
|||
return 'roll-back applied migrations' |
|||
} |
|||
|
|||
getOptions(): OptionDefinition[] { |
|||
return [ |
|||
'--identifier -i {name} | roll-back a specific migration, by identifier', |
|||
] |
|||
} |
|||
|
|||
getHelpText(): string { |
|||
return [ |
|||
'Use this command to undo one or more migrations that were applied.', |
|||
'', |
|||
'By default, the command will undo all of the migrations applied the last time the migrate command was run.', |
|||
'', |
|||
'To undo a specific migration, pass its identifier using the --identifier option.', |
|||
'', |
|||
].join('\n') |
|||
} |
|||
|
|||
async handle(): Promise<void> { |
|||
await this.registerListeners() |
|||
|
|||
const identifier = this.option('identifier') |
|||
|
|||
let identifiers |
|||
if ( identifier ) { |
|||
identifiers = [identifier] |
|||
} |
|||
|
|||
let error |
|||
try { |
|||
await (this.injector.make<Migrator>(Migrator)).rollback(identifiers) |
|||
} catch (e) { |
|||
if ( e instanceof NothingToMigrateError ) { |
|||
this.info(e.message) |
|||
} else { |
|||
error = e |
|||
this.error(e) |
|||
} |
|||
} finally { |
|||
await this.removeListeners() |
|||
} |
|||
|
|||
if ( error ) { |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Register event-bus listeners to print messages for the user. |
|||
* @protected |
|||
*/ |
|||
protected async registerListeners(): Promise<void> { |
|||
this.subscriptions.push(await this.bus.subscribe(RollingBackMigrationEvent, event => { |
|||
this.info(`Rolling-back migration ${event.migration.identifier}...`) |
|||
})) |
|||
|
|||
this.subscriptions.push(await this.bus.subscribe(RolledBackMigrationEvent, event => { |
|||
this.success(`Rolled-back migration: ${event.migration.identifier}`) |
|||
})) |
|||
} |
|||
|
|||
/** Remove event bus listeners before finish. */ |
|||
protected async removeListeners(): Promise<void> { |
|||
await Promise.all(this.subscriptions.map(x => x.unsubscribe())) |
|||
this.subscriptions = [] |
|||
} |
|||
} |
@ -0,0 +1,179 @@ |
|||
import {Container, Inject, Injectable} from '../../di' |
|||
import {Migrator} from './Migrator' |
|||
import {DatabaseService} from '../DatabaseService' |
|||
import {FieldType} from '../types' |
|||
import {Migration} from './Migration' |
|||
import {Builder} from '../builder/Builder' |
|||
|
|||
/** |
|||
* Migrator implementation that tracks applied migrations in a database table. |
|||
* @todo allow configuring more of this |
|||
*/ |
|||
@Injectable() |
|||
export class DatabaseMigrator extends Migrator { |
|||
@Inject() |
|||
protected readonly db!: DatabaseService |
|||
|
|||
@Inject('injector') |
|||
protected readonly injector!: Container |
|||
|
|||
/** True if we've initialized the migrator. */ |
|||
protected initialized = false |
|||
|
|||
public async initialize(): Promise<void> { |
|||
await super.initialize() |
|||
|
|||
if ( this.initialized ) { |
|||
return |
|||
} |
|||
|
|||
const schema = this.db.get().schema() |
|||
if ( !(await schema.hasTable('migrations')) ) { |
|||
const table = await schema.table('migrations') |
|||
|
|||
table.primaryKey('id', FieldType.serial).required() |
|||
|
|||
table.column('identifier') |
|||
.type(FieldType.varchar) |
|||
.required() |
|||
|
|||
table.column('applygroup') |
|||
.type(FieldType.integer) |
|||
.required() |
|||
|
|||
table.column('applydate') |
|||
.type(FieldType.timestamp) |
|||
.required() |
|||
|
|||
await schema.commit(table) |
|||
} |
|||
|
|||
this.initialized = true |
|||
} |
|||
|
|||
async has(migration: Migration): Promise<boolean> { |
|||
return this.builder() |
|||
.connection('default') |
|||
.select('id') |
|||
.from('migrations') |
|||
.where('identifier', '=', migration.identifier) |
|||
.exists() |
|||
} |
|||
|
|||
async markApplied(migrations: Migration | Migration[], applyDate: Date = new Date()): Promise<void> { |
|||
if ( !Array.isArray(migrations) ) { |
|||
migrations = [migrations] |
|||
} |
|||
|
|||
const applyGroup = await this.getNextGroupIdentifier() |
|||
const rows = migrations.map(migration => { |
|||
return { |
|||
applygroup: applyGroup, |
|||
applydate: applyDate, |
|||
identifier: migration.identifier, |
|||
} |
|||
}) |
|||
|
|||
await this.builder() |
|||
.connection('default') |
|||
.table('migrations') |
|||
.insert(rows) |
|||
} |
|||
|
|||
async unmarkApplied(migrations: Migration | Migration[]): Promise<void> { |
|||
if ( !Array.isArray(migrations) ) { |
|||
migrations = [migrations] |
|||
} |
|||
|
|||
const identifiers = migrations.map(migration => migration.identifier) |
|||
|
|||
await this.builder() |
|||
.connection('default') |
|||
.table('migrations') |
|||
.whereIn('identifier', identifiers) |
|||
.delete() |
|||
} |
|||
|
|||
async getLastApplyGroup(): Promise<string[]> { |
|||
const applyGroup = await this.builder() |
|||
.connection('default') |
|||
.select('applygroup') |
|||
.from('migrations') |
|||
.get() |
|||
.max<number>('applygroup') |
|||
|
|||
return this.builder() |
|||
.connection('default') |
|||
.select('identifier') |
|||
.from('migrations') |
|||
.where('applygroup', '=', applyGroup) |
|||
.get() |
|||
.asyncPipe() |
|||
.tap(coll => { |
|||
return coll.pluck<string>('identifier') |
|||
}) |
|||
.tap(coll => { |
|||
return coll.all() |
|||
}) |
|||
.resolve() |
|||
} |
|||
|
|||
/** |
|||
* Helper method to look up the next `applygroup` that should be used. |
|||
* @protected |
|||
*/ |
|||
protected async getNextGroupIdentifier(): Promise<number> { |
|||
const current = await this.builder() |
|||
.connection('default') |
|||
.select('applygroup') |
|||
.from('migrations') |
|||
.get() |
|||
.max<number>('applygroup') |
|||
|
|||
return (current ?? 0) + 1 |
|||
} |
|||
|
|||
/** |
|||
* Given a list of migration identifiers, filter out those that have been applied. |
|||
* @override to make this more efficient |
|||
* @param identifiers |
|||
* @protected |
|||
*/ |
|||
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> { |
|||
const existing = await this.builder() |
|||
.connection('default') |
|||
.select('identifier') |
|||
.from('migrations') |
|||
.whereIn('identifier', identifiers) |
|||
.get() |
|||
.pluck<string>('identifier') |
|||
|
|||
return identifiers.filter(id => !existing.includes(id)) |
|||
} |
|||
|
|||
/** |
|||
* Given a list of migration identifiers, filter out those that have not been applied. |
|||
* @override to make this more efficient |
|||
* @param identifiers |
|||
* @protected |
|||
*/ |
|||
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> { |
|||
const existing = await this.builder() |
|||
.connection('default') |
|||
.select('identifier') |
|||
.from('migrations') |
|||
.whereIn('identifier', identifiers) |
|||
.get() |
|||
.pluck<string>('identifier') |
|||
|
|||
return existing.all() |
|||
} |
|||
|
|||
/** |
|||
* Get a query builder instance. |
|||
* @protected |
|||
*/ |
|||
protected builder(): Builder { |
|||
return this.injector.make<Builder>(Builder) |
|||
} |
|||
} |
@ -0,0 +1,39 @@ |
|||
import {Injectable} from '../../di' |
|||
import {Awaitable} from '../../util' |
|||
|
|||
/** |
|||
* Abstract base-class for one-time migrations. |
|||
*/ |
|||
@Injectable() |
|||
export abstract class Migration { |
|||
/** Set by the Migrations unit on load. */ |
|||
protected migrationIdentifier!: string |
|||
|
|||
/** |
|||
* Sets the migration identifier. |
|||
* This is used internally when the Migrations service loads |
|||
* the migration files to determine the ID from the file-name. |
|||
* It shouldn't be used externally. |
|||
* @param name |
|||
*/ |
|||
public setMigrationIdentifier(name: string): void { |
|||
this.migrationIdentifier = name |
|||
} |
|||
|
|||
/** |
|||
* Get the unique identifier of this migration. |
|||
*/ |
|||
public get identifier(): string { |
|||
return this.migrationIdentifier |
|||
} |
|||
|
|||
/** |
|||
* Apply the migration. |
|||
*/ |
|||
abstract up(): Awaitable<void> |
|||
|
|||
/** |
|||
* Undo the migration. |
|||
*/ |
|||
abstract down(): Awaitable<void> |
|||
} |
@ -0,0 +1,295 @@ |
|||
import {Container, Inject, Injectable} from '../../di' |
|||
import {Awaitable, collect, ErrorWithContext} from '../../util' |
|||
import {Migration} from './Migration' |
|||
import {Migrations} from '../services/Migrations' |
|||
import {EventBus} from '../../event/EventBus' |
|||
import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent' |
|||
import {AppliedMigrationEvent} from './events/AppliedMigrationEvent' |
|||
import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent' |
|||
import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent' |
|||
import {NothingToMigrateError} from './NothingToMigrateError' |
|||
|
|||
/** |
|||
* Manages single-run patches/migrations. |
|||
*/ |
|||
@Injectable() |
|||
export abstract class Migrator { |
|||
@Inject() |
|||
protected readonly migrations!: Migrations |
|||
|
|||
@Inject() |
|||
protected readonly bus!: EventBus |
|||
|
|||
@Inject('injector') |
|||
protected readonly injector!: Container |
|||
|
|||
/** |
|||
* Should resolve true if the given migration has already been applied. |
|||
* @param migration |
|||
*/ |
|||
public abstract has(migration: Migration): Awaitable<boolean> |
|||
|
|||
/** |
|||
* Should mark the given migrations as being applied. |
|||
* |
|||
* If a date is specified, then that is the timestamp when the migrations |
|||
* were applied, otherwise, use `new Date()`. |
|||
* |
|||
* @param migrations |
|||
* @param date |
|||
*/ |
|||
public abstract markApplied(migrations: Migration | Migration[], date?: Date): Awaitable<void> |
|||
|
|||
/** |
|||
* Should un-mark the given migrations as being applied. |
|||
* @param migration |
|||
*/ |
|||
public abstract unmarkApplied(migration: Migration | Migration[]): Awaitable<void> |
|||
|
|||
/** |
|||
* Get the identifiers of the last group of migrations that were applied. |
|||
*/ |
|||
public abstract getLastApplyGroup(): Awaitable<string[]> |
|||
|
|||
/** |
|||
* Do any initial setup required to get the migrator ready. |
|||
* This can be overridden by implementation classes to do any necessary setup. |
|||
*/ |
|||
public initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
|
|||
|
|||
/** |
|||
* Apply pending migrations. |
|||
* |
|||
* If identifiers are specified, only the pending migrations with those |
|||
* identifiers are applied. If none are specified, all pending migrations |
|||
* will be applied. |
|||
* |
|||
* @param identifiers |
|||
*/ |
|||
public async migrate(identifiers?: string[]): Promise<void> { |
|||
await this.initialize() |
|||
|
|||
if ( !identifiers ) { |
|||
identifiers = this.getAllMigrationIdentifiers() |
|||
} |
|||
|
|||
identifiers = (await this.filterAppliedMigrations(identifiers)).sort() |
|||
if ( !identifiers.length ) { |
|||
throw new NothingToMigrateError() |
|||
} |
|||
|
|||
const migrations = collect(identifiers) |
|||
.map(id => { |
|||
const migration = this.migrations.get(id) |
|||
|
|||
if ( !migration ) { |
|||
throw new ErrorWithContext(`Unable to find migration with identifier: ${id}`, { |
|||
identifier: id, |
|||
}) |
|||
} |
|||
|
|||
return migration |
|||
}) |
|||
|
|||
await migrations.promiseMap(migration => { |
|||
return this.apply(migration) |
|||
}) |
|||
|
|||
await this.markApplied(migrations.all()) |
|||
} |
|||
|
|||
/** |
|||
* Rollback applied migrations. |
|||
* |
|||
* If specified, only applied migrations with the given identifiers will |
|||
* be rolled back. If not specified, then the last "batch" of applied |
|||
* migrations will be rolled back. |
|||
* |
|||
* @param identifiers |
|||
*/ |
|||
public async rollback(identifiers?: string[]): Promise<void> { |
|||
await this.initialize() |
|||
|
|||
if ( !identifiers ) { |
|||
identifiers = await this.getLastApplyGroup() |
|||
} |
|||
|
|||
identifiers = (await this.filterPendingMigrations(identifiers)).sort() |
|||
if ( !identifiers.length ) { |
|||
throw new NothingToMigrateError() |
|||
} |
|||
|
|||
const migrations = collect(identifiers) |
|||
.map(id => { |
|||
const migration = this.migrations.get(id) |
|||
|
|||
if ( !migration ) { |
|||
throw new ErrorWithContext(`Unable to find migration with identifier: ${id}`, { |
|||
identifier: id, |
|||
}) |
|||
} |
|||
|
|||
return migration |
|||
}) |
|||
|
|||
await migrations.promiseMap(migration => { |
|||
return this.undo(migration) |
|||
}) |
|||
|
|||
await this.unmarkApplied(migrations.all()) |
|||
} |
|||
|
|||
/** |
|||
* Apply a single migration. |
|||
* @param migration |
|||
*/ |
|||
public async apply(migration: Migration): Promise<void> { |
|||
await this.initialize() |
|||
|
|||
await this.applying(migration) |
|||
|
|||
await migration.up() |
|||
|
|||
await this.applied(migration) |
|||
} |
|||
|
|||
/** |
|||
* Rollback a single migration. |
|||
* @param migration |