runMigrationCli function

Future<void> runMigrationCli(
  1. List<String> arguments, {
  2. required MigrationHistory history,
  3. required String directory,
  4. SchemaSnapshot? schema,
  5. MigrationConnection? connect,
  6. SchemaRenames renames = const SchemaRenames(),
  7. Map<String, Map<String, String>> using = const {},
  8. bool json = true,
})

Runs commands against a project's statically imported migration history.

Offline commands never call connect. Database commands close the returned runtime when finished. json selects machine-readable reports; saved schema and migration files remain Dart source. Errors are written to stderr and set the process exit code: 64 for usage errors, 2 for drift, or 1 for failure.

Implementation

Future<void> runMigrationCli(
  List<String> arguments, {
  required MigrationHistory history,
  required String directory,
  SchemaSnapshot? schema,
  MigrationConnection? connect,
  SchemaRenames renames = const SchemaRenames(),
  Map<String, Map<String, String>> using = const {},
  bool json = true,
}) async {
  void report(Map<String, Object?> value) {
    if (json) {
      stdout.writeln(const JsonEncoder.withIndent('  ').convert(value));
    } else {
      for (final entry in value.entries) {
        stdout.writeln(
          '${entry.key}: ${entry.value is String ? entry.value : jsonEncode(entry.value)}',
        );
      }
    }
  }

  void fail(String message, int code) {
    stderr.writeln(
      json ? jsonEncode({'error': message, 'exitCode': code}) : message,
    );
    exitCode = code;
  }

  try {
    if (arguments.isEmpty || arguments.singleOrNull == '--help') {
      stdout.writeln('''Migration commands:
  create <id> [--allow-destructive]
  record <id>                     Record a reviewed, unpublished edit
  check
  plan
  apply [--max-backfill-batches <count>]
  status
  verify
  baseline
  inspect <table>
Connection, target schema, renames and conversions belong in the Dart entrypoint.
Rebuild static imports with: dart run orm migration registry <directory>''');
      return;
    }
    final command = arguments.first;
    final rest = arguments.skip(1).toList();
    final expected =
        command == 'create' || command == 'record' || command == 'inspect'
        ? 1
        : 0;
    var destructive = false;
    int? batches;
    if (command == 'create' && rest.lastOrNull == '--allow-destructive') {
      destructive = true;
      rest.removeLast();
    }
    if (command == 'apply' &&
        rest.length == 2 &&
        rest.first == '--max-backfill-batches') {
      batches = int.tryParse(rest.last);
      if (batches == null || batches < 1) {
        throw const FormatException('Use a positive batch count.');
      }
      rest.clear();
    }
    if (!{
          'create',
          'record',
          'check',
          'plan',
          'apply',
          'status',
          'verify',
          'baseline',
          'inspect',
        }.contains(command) ||
        rest.length != expected ||
        rest.any((value) => value.startsWith('--'))) {
      throw const FormatException('Invalid migration command. Use --help.');
    }
    if (command == 'record') {
      final checksum = await recordMigration(
        rest.single,
        directory: directory,
        history: history,
      );
      report({'recorded': rest.single, 'checksum': checksum});
      return;
    }
    final migrations = history.checked;
    if (command == 'check') {
      report({
        'valid': true,
        'dialect': history.dialect.name,
        'migrations': migrations.map((m) => m.id).toList(),
      });
      return;
    }
    if (command == 'create') {
      if (schema == null) {
        throw const FormatException(
          'Provide the target schema in the Dart entrypoint.',
        );
      }
      final previous = migrations.lastOrNull;
      if (previous != null && previous.snapshot == null) {
        throw const FormatException(
          'The previous migration needs a historical schema.',
        );
      }
      final change = Migration.diff(
        rest.single,
        dialect: history.dialect,
        from: previous?.snapshot ?? SchemaSnapshot([]),
        to: schema,
        previous: previous?.checksum,
        renames: renames,
        using: using,
        allowDestructive: destructive,
      );
      if (change.steps.isEmpty) {
        report({'created': null, 'reason': 'Schema is unchanged.'});
      } else {
        final path = await writeMigration(
          change,
          directory: directory,
          history: history,
        );
        report({'created': path, 'checksum': change.checksum});
      }
      return;
    }
    if (command == 'verify' && schema == null) {
      throw const FormatException(
        'Provide the expected schema in the Dart entrypoint.',
      );
    }
    if (command == 'baseline' &&
        (migrations.isEmpty || migrations.last.snapshot == null)) {
      throw const FormatException(
        'Baseline requires a final historical schema.',
      );
    }
    if (connect == null) {
      throw const FormatException('Provide a database connection factory.');
    }
    final db = await connect(
      readOnly: !{'apply', 'baseline'}.contains(command),
    );
    try {
      if (db.dialect != history.dialect) {
        throw OrmException(
          'MIGRATION.TARGET',
          'History targets ${history.dialect.name}, connection is ${db.dialect.name}.',
        );
      }
      final migrator = Migrator(db);
      switch (command) {
        case 'plan':
          final pending = await migrator.plan(migrations);
          report({
            'atomic': !pending.any(
              (m) => m.steps.any(
                (s) => s is CheckedSql || s is CheckedTableSql || s is Backfill,
              ),
            ),
            'pending': [
              for (final m in pending)
                {
                  'id': m.id,
                  'checksum': m.checksum,
                  'steps': m.steps.map((s) => s.toJson()).toList(),
                },
            ],
            'progress': (await migrator.progress())
                .map((p) => p.toJson())
                .toList(),
          });
        case 'apply':
          final applied = await migrator.apply(
            migrations,
            maxBackfillBatches: batches,
          );
          report({
            'applied': applied,
            if (batches != null)
              'complete': (await migrator.plan(migrations)).isEmpty,
          });
        case 'status':
          report({
            'applied': [
              for (final m in await migrator.history())
                {'id': m.id, 'checksum': m.checksum},
            ],
            'progress': (await migrator.progress())
                .map((p) => p.toJson())
                .toList(),
          });
        case 'baseline' || 'verify':
          final result = command == 'baseline'
              ? await migrator.baseline(
                  migrations,
                  expected: migrations.last.snapshot!,
                )
              : await verifySchema(db, schema!);
          report({
            'matches': result.matches,
            'differences': result.differences,
            'unmanaged': [
              for (final o in result.unmanaged)
                {'kind': o.kind, 'name': o.name, 'definition': o.definition},
            ],
          });
          if (!result.matches) exitCode = 2;
        case 'inspect':
          final table = await inspectTable(db, rest.single);
          report({
            'table': table.name,
            'columns': [
              for (final c in table.columns)
                {
                  'name': c.name,
                  'storageType': c.storageType,
                  'nullable': c.nullable,
                  'default': c.defaultSql,
                  'generated': c.generated,
                  if (c.computed case final computed?)
                    'computed': {
                      'expression': computed.expression(db.dialect),
                      'storage': computed.storage.name,
                    },
                  if (c.integerBits != null) 'integerBits': c.integerBits,
                  if (c.decimalPrecision != null)
                    'decimalPrecision': c.decimalPrecision,
                  if (c.decimalPrecision != null)
                    'decimalScale': c.decimalScale ?? 0,
                  if (c.temporalPrecision != null)
                    'temporalPrecision': c.temporalPrecision,
                  if (c.collation != null) 'collation': c.collation,
                },
            ],
            'primaryKey': table.primaryKey,
            'uniqueKeys': table.uniqueKeys,
            'foreignKeys': [
              for (final k in table.foreignKeys)
                {
                  'columns': k.columns,
                  'target': k.target,
                  'targetColumns': k.targetColumns,
                  'onDelete': k.onDelete,
                },
            ],
            'indexes': [
              for (final i in table.indexes)
                {'name': i.name, 'columns': i.columns, 'unique': i.unique},
            ],
            'checks': [
              for (final check in table.checks)
                {'name': check.name, 'expression': check.expression},
            ],
            'unmanaged': [
              for (final o in table.unmanaged)
                {'kind': o.kind, 'name': o.name, 'definition': o.definition},
            ],
          });
      }
    } finally {
      await db.close();
    }
  } on FormatException catch (error) {
    fail(error.message, 64);
  } on ArgumentError catch (_) {
    fail('Invalid migration argument. Use --help.', 64);
  } catch (error) {
    fail(error.toString(), 1);
  }
}