drizzle-multitenant 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -370,6 +370,372 @@ var Migrator = class {
370
370
  await pool.end();
371
371
  }
372
372
  }
373
+ /**
374
+ * Mark migrations as applied without executing SQL
375
+ * Useful for syncing tracking state with already-applied migrations
376
+ */
377
+ async markAsApplied(tenantId, options = {}) {
378
+ const startTime = Date.now();
379
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
380
+ const markedMigrations = [];
381
+ const pool = await this.createPool(schemaName);
382
+ try {
383
+ await this.migratorConfig.hooks?.beforeTenant?.(tenantId);
384
+ const format = await this.getOrDetectFormat(pool, schemaName);
385
+ await this.ensureMigrationsTable(pool, schemaName, format);
386
+ const allMigrations = await this.loadMigrations();
387
+ const applied = await this.getAppliedMigrations(pool, schemaName, format);
388
+ const appliedSet = new Set(applied.map((m) => m.identifier));
389
+ const pending = allMigrations.filter(
390
+ (m) => !this.isMigrationApplied(m, appliedSet, format)
391
+ );
392
+ for (const migration of pending) {
393
+ const migrationStart = Date.now();
394
+ options.onProgress?.(tenantId, "migrating", migration.name);
395
+ await this.migratorConfig.hooks?.beforeMigration?.(tenantId, migration.name);
396
+ await this.recordMigration(pool, schemaName, migration, format);
397
+ await this.migratorConfig.hooks?.afterMigration?.(
398
+ tenantId,
399
+ migration.name,
400
+ Date.now() - migrationStart
401
+ );
402
+ markedMigrations.push(migration.name);
403
+ }
404
+ const result = {
405
+ tenantId,
406
+ schemaName,
407
+ success: true,
408
+ appliedMigrations: markedMigrations,
409
+ durationMs: Date.now() - startTime,
410
+ format: format.format
411
+ };
412
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
413
+ return result;
414
+ } catch (error2) {
415
+ const result = {
416
+ tenantId,
417
+ schemaName,
418
+ success: false,
419
+ appliedMigrations: markedMigrations,
420
+ error: error2.message,
421
+ durationMs: Date.now() - startTime
422
+ };
423
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
424
+ return result;
425
+ } finally {
426
+ await pool.end();
427
+ }
428
+ }
429
+ /**
430
+ * Mark migrations as applied for all tenants without executing SQL
431
+ * Useful for syncing tracking state with already-applied migrations
432
+ */
433
+ async markAllAsApplied(options = {}) {
434
+ const {
435
+ concurrency = 10,
436
+ onProgress,
437
+ onError
438
+ } = options;
439
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
440
+ const results = [];
441
+ let aborted = false;
442
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
443
+ const batch = tenantIds.slice(i, i + concurrency);
444
+ const batchResults = await Promise.all(
445
+ batch.map(async (tenantId) => {
446
+ if (aborted) {
447
+ return this.createSkippedResult(tenantId);
448
+ }
449
+ try {
450
+ onProgress?.(tenantId, "starting");
451
+ const result = await this.markAsApplied(tenantId, { onProgress });
452
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
453
+ return result;
454
+ } catch (error2) {
455
+ onProgress?.(tenantId, "failed");
456
+ const action = onError?.(tenantId, error2);
457
+ if (action === "abort") {
458
+ aborted = true;
459
+ }
460
+ return this.createErrorResult(tenantId, error2);
461
+ }
462
+ })
463
+ );
464
+ results.push(...batchResults);
465
+ }
466
+ if (aborted) {
467
+ const remaining = tenantIds.slice(results.length);
468
+ for (const tenantId of remaining) {
469
+ results.push(this.createSkippedResult(tenantId));
470
+ }
471
+ }
472
+ return this.aggregateResults(results);
473
+ }
474
+ /**
475
+ * Get sync status for all tenants
476
+ * Detects divergences between migrations on disk and tracking in database
477
+ */
478
+ async getSyncStatus() {
479
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
480
+ const migrations = await this.loadMigrations();
481
+ const statuses = [];
482
+ for (const tenantId of tenantIds) {
483
+ statuses.push(await this.getTenantSyncStatus(tenantId, migrations));
484
+ }
485
+ return {
486
+ total: statuses.length,
487
+ inSync: statuses.filter((s) => s.inSync && !s.error).length,
488
+ outOfSync: statuses.filter((s) => !s.inSync && !s.error).length,
489
+ error: statuses.filter((s) => !!s.error).length,
490
+ details: statuses
491
+ };
492
+ }
493
+ /**
494
+ * Get sync status for a specific tenant
495
+ */
496
+ async getTenantSyncStatus(tenantId, migrations) {
497
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
498
+ const pool = await this.createPool(schemaName);
499
+ try {
500
+ const allMigrations = migrations ?? await this.loadMigrations();
501
+ const migrationNames = new Set(allMigrations.map((m) => m.name));
502
+ const migrationHashes = new Set(allMigrations.map((m) => m.hash));
503
+ const tableExists = await this.migrationsTableExists(pool, schemaName);
504
+ if (!tableExists) {
505
+ return {
506
+ tenantId,
507
+ schemaName,
508
+ missing: allMigrations.map((m) => m.name),
509
+ orphans: [],
510
+ inSync: allMigrations.length === 0,
511
+ format: null
512
+ };
513
+ }
514
+ const format = await this.getOrDetectFormat(pool, schemaName);
515
+ const applied = await this.getAppliedMigrations(pool, schemaName, format);
516
+ const appliedIdentifiers = new Set(applied.map((m) => m.identifier));
517
+ const missing = allMigrations.filter((m) => !this.isMigrationApplied(m, appliedIdentifiers, format)).map((m) => m.name);
518
+ const orphans = applied.filter((m) => {
519
+ if (format.columns.identifier === "name") {
520
+ return !migrationNames.has(m.identifier);
521
+ }
522
+ return !migrationHashes.has(m.identifier) && !migrationNames.has(m.identifier);
523
+ }).map((m) => m.identifier);
524
+ return {
525
+ tenantId,
526
+ schemaName,
527
+ missing,
528
+ orphans,
529
+ inSync: missing.length === 0 && orphans.length === 0,
530
+ format: format.format
531
+ };
532
+ } catch (error2) {
533
+ return {
534
+ tenantId,
535
+ schemaName,
536
+ missing: [],
537
+ orphans: [],
538
+ inSync: false,
539
+ format: null,
540
+ error: error2.message
541
+ };
542
+ } finally {
543
+ await pool.end();
544
+ }
545
+ }
546
+ /**
547
+ * Mark missing migrations as applied for a tenant
548
+ */
549
+ async markMissing(tenantId) {
550
+ const startTime = Date.now();
551
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
552
+ const markedMigrations = [];
553
+ const pool = await this.createPool(schemaName);
554
+ try {
555
+ const syncStatus = await this.getTenantSyncStatus(tenantId);
556
+ if (syncStatus.error) {
557
+ return {
558
+ tenantId,
559
+ schemaName,
560
+ success: false,
561
+ markedMigrations: [],
562
+ removedOrphans: [],
563
+ error: syncStatus.error,
564
+ durationMs: Date.now() - startTime
565
+ };
566
+ }
567
+ if (syncStatus.missing.length === 0) {
568
+ return {
569
+ tenantId,
570
+ schemaName,
571
+ success: true,
572
+ markedMigrations: [],
573
+ removedOrphans: [],
574
+ durationMs: Date.now() - startTime
575
+ };
576
+ }
577
+ const format = await this.getOrDetectFormat(pool, schemaName);
578
+ await this.ensureMigrationsTable(pool, schemaName, format);
579
+ const allMigrations = await this.loadMigrations();
580
+ const missingSet = new Set(syncStatus.missing);
581
+ for (const migration of allMigrations) {
582
+ if (missingSet.has(migration.name)) {
583
+ await this.recordMigration(pool, schemaName, migration, format);
584
+ markedMigrations.push(migration.name);
585
+ }
586
+ }
587
+ return {
588
+ tenantId,
589
+ schemaName,
590
+ success: true,
591
+ markedMigrations,
592
+ removedOrphans: [],
593
+ durationMs: Date.now() - startTime
594
+ };
595
+ } catch (error2) {
596
+ return {
597
+ tenantId,
598
+ schemaName,
599
+ success: false,
600
+ markedMigrations,
601
+ removedOrphans: [],
602
+ error: error2.message,
603
+ durationMs: Date.now() - startTime
604
+ };
605
+ } finally {
606
+ await pool.end();
607
+ }
608
+ }
609
+ /**
610
+ * Mark missing migrations as applied for all tenants
611
+ */
612
+ async markAllMissing(options = {}) {
613
+ const { concurrency = 10, onProgress, onError } = options;
614
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
615
+ const results = [];
616
+ let aborted = false;
617
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
618
+ const batch = tenantIds.slice(i, i + concurrency);
619
+ const batchResults = await Promise.all(
620
+ batch.map(async (tenantId) => {
621
+ if (aborted) {
622
+ return this.createSkippedSyncResult(tenantId);
623
+ }
624
+ try {
625
+ onProgress?.(tenantId, "starting");
626
+ const result = await this.markMissing(tenantId);
627
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
628
+ return result;
629
+ } catch (error2) {
630
+ onProgress?.(tenantId, "failed");
631
+ const action = onError?.(tenantId, error2);
632
+ if (action === "abort") {
633
+ aborted = true;
634
+ }
635
+ return this.createErrorSyncResult(tenantId, error2);
636
+ }
637
+ })
638
+ );
639
+ results.push(...batchResults);
640
+ }
641
+ return this.aggregateSyncResults(results);
642
+ }
643
+ /**
644
+ * Remove orphan migration records for a tenant
645
+ */
646
+ async cleanOrphans(tenantId) {
647
+ const startTime = Date.now();
648
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
649
+ const removedOrphans = [];
650
+ const pool = await this.createPool(schemaName);
651
+ try {
652
+ const syncStatus = await this.getTenantSyncStatus(tenantId);
653
+ if (syncStatus.error) {
654
+ return {
655
+ tenantId,
656
+ schemaName,
657
+ success: false,
658
+ markedMigrations: [],
659
+ removedOrphans: [],
660
+ error: syncStatus.error,
661
+ durationMs: Date.now() - startTime
662
+ };
663
+ }
664
+ if (syncStatus.orphans.length === 0) {
665
+ return {
666
+ tenantId,
667
+ schemaName,
668
+ success: true,
669
+ markedMigrations: [],
670
+ removedOrphans: [],
671
+ durationMs: Date.now() - startTime
672
+ };
673
+ }
674
+ const format = await this.getOrDetectFormat(pool, schemaName);
675
+ const identifierColumn = format.columns.identifier;
676
+ for (const orphan of syncStatus.orphans) {
677
+ await pool.query(
678
+ `DELETE FROM "${schemaName}"."${format.tableName}" WHERE "${identifierColumn}" = $1`,
679
+ [orphan]
680
+ );
681
+ removedOrphans.push(orphan);
682
+ }
683
+ return {
684
+ tenantId,
685
+ schemaName,
686
+ success: true,
687
+ markedMigrations: [],
688
+ removedOrphans,
689
+ durationMs: Date.now() - startTime
690
+ };
691
+ } catch (error2) {
692
+ return {
693
+ tenantId,
694
+ schemaName,
695
+ success: false,
696
+ markedMigrations: [],
697
+ removedOrphans,
698
+ error: error2.message,
699
+ durationMs: Date.now() - startTime
700
+ };
701
+ } finally {
702
+ await pool.end();
703
+ }
704
+ }
705
+ /**
706
+ * Remove orphan migration records for all tenants
707
+ */
708
+ async cleanAllOrphans(options = {}) {
709
+ const { concurrency = 10, onProgress, onError } = options;
710
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
711
+ const results = [];
712
+ let aborted = false;
713
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
714
+ const batch = tenantIds.slice(i, i + concurrency);
715
+ const batchResults = await Promise.all(
716
+ batch.map(async (tenantId) => {
717
+ if (aborted) {
718
+ return this.createSkippedSyncResult(tenantId);
719
+ }
720
+ try {
721
+ onProgress?.(tenantId, "starting");
722
+ const result = await this.cleanOrphans(tenantId);
723
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
724
+ return result;
725
+ } catch (error2) {
726
+ onProgress?.(tenantId, "failed");
727
+ const action = onError?.(tenantId, error2);
728
+ if (action === "abort") {
729
+ aborted = true;
730
+ }
731
+ return this.createErrorSyncResult(tenantId, error2);
732
+ }
733
+ })
734
+ );
735
+ results.push(...batchResults);
736
+ }
737
+ return this.aggregateSyncResults(results);
738
+ }
373
739
  /**
374
740
  * Load migration files from the migrations folder
375
741
  */
@@ -499,6 +865,19 @@ var Migrator = class {
499
865
  client.release();
500
866
  }
501
867
  }
868
+ /**
869
+ * Record a migration as applied without executing SQL
870
+ * Used by markAsApplied to sync tracking state
871
+ */
872
+ async recordMigration(pool, schemaName, migration, format) {
873
+ const { identifier, timestamp, timestampType } = format.columns;
874
+ const identifierValue = identifier === "name" ? migration.name : migration.hash;
875
+ const timestampValue = timestampType === "bigint" ? Date.now() : /* @__PURE__ */ new Date();
876
+ await pool.query(
877
+ `INSERT INTO "${schemaName}"."${format.tableName}" ("${identifier}", "${timestamp}") VALUES ($1, $2)`,
878
+ [identifierValue, timestampValue]
879
+ );
880
+ }
502
881
  /**
503
882
  * Create a skipped result
504
883
  */
@@ -537,6 +916,45 @@ var Migrator = class {
537
916
  details: results
538
917
  };
539
918
  }
919
+ /**
920
+ * Create a skipped sync result
921
+ */
922
+ createSkippedSyncResult(tenantId) {
923
+ return {
924
+ tenantId,
925
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
926
+ success: false,
927
+ markedMigrations: [],
928
+ removedOrphans: [],
929
+ error: "Skipped due to abort",
930
+ durationMs: 0
931
+ };
932
+ }
933
+ /**
934
+ * Create an error sync result
935
+ */
936
+ createErrorSyncResult(tenantId, error2) {
937
+ return {
938
+ tenantId,
939
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
940
+ success: false,
941
+ markedMigrations: [],
942
+ removedOrphans: [],
943
+ error: error2.message,
944
+ durationMs: 0
945
+ };
946
+ }
947
+ /**
948
+ * Aggregate sync results
949
+ */
950
+ aggregateSyncResults(results) {
951
+ return {
952
+ total: results.length,
953
+ succeeded: results.filter((r) => r.success).length,
954
+ failed: results.filter((r) => !r.success).length,
955
+ details: results
956
+ };
957
+ }
540
958
  };
541
959
  function createMigrator(tenantConfig, migratorConfig) {
542
960
  return new Migrator(tenantConfig, migratorConfig);
@@ -969,7 +1387,7 @@ function handleError(err) {
969
1387
  }
970
1388
 
971
1389
  // src/cli/commands/migrate.ts
972
- var migrateCommand = new Command("migrate").description("Apply pending migrations to tenant schemas").option("-c, --config <path>", "Path to config file").option("-a, --all", "Migrate all tenants").option("-t, --tenant <id>", "Migrate a specific tenant").option("--tenants <ids>", "Migrate specific tenants (comma-separated)").option("--concurrency <number>", "Number of concurrent migrations", "10").option("--dry-run", "Show what would be applied without executing").option("--migrations-folder <path>", "Path to migrations folder").addHelpText("after", `
1390
+ var migrateCommand = new Command("migrate").description("Apply pending migrations to tenant schemas").option("-c, --config <path>", "Path to config file").option("-a, --all", "Migrate all tenants").option("-t, --tenant <id>", "Migrate a specific tenant").option("--tenants <ids>", "Migrate specific tenants (comma-separated)").option("--concurrency <number>", "Number of concurrent migrations", "10").option("--dry-run", "Show what would be applied without executing").option("--mark-applied", "Mark migrations as applied without executing SQL").option("--migrations-folder <path>", "Path to migrations folder").addHelpText("after", `
973
1391
  Examples:
974
1392
  $ drizzle-multitenant migrate --all
975
1393
  $ drizzle-multitenant migrate --tenant=my-tenant
@@ -977,6 +1395,7 @@ Examples:
977
1395
  $ drizzle-multitenant migrate --all --dry-run
978
1396
  $ drizzle-multitenant migrate --all --concurrency=5
979
1397
  $ drizzle-multitenant migrate --all --json
1398
+ $ drizzle-multitenant migrate --all --mark-applied
980
1399
  `).action(async (options) => {
981
1400
  const startTime = Date.now();
982
1401
  const ctx = getOutputContext();
@@ -1036,7 +1455,11 @@ Examples:
1036
1455
  if (options.dryRun) {
1037
1456
  log(info(bold("\nDry run mode - no changes will be made\n")));
1038
1457
  }
1039
- log(info(`Migrating ${tenantIds.length} tenant${tenantIds.length > 1 ? "s" : ""}...
1458
+ if (options.markApplied) {
1459
+ log(info(bold("\nMark-applied mode - migrations will be recorded without executing SQL\n")));
1460
+ }
1461
+ const actionLabel = options.markApplied ? "Marking" : "Migrating";
1462
+ log(info(`${actionLabel} ${tenantIds.length} tenant${tenantIds.length > 1 ? "s" : ""}...
1040
1463
  `));
1041
1464
  const migrator = createMigrator(config, {
1042
1465
  migrationsFolder: folder,
@@ -1046,9 +1469,8 @@ Examples:
1046
1469
  const concurrency = parseInt(options.concurrency || "10", 10);
1047
1470
  const progressBar = createProgressBar({ total: tenantIds.length });
1048
1471
  progressBar.start();
1049
- const results = await migrator.migrateAll({
1472
+ const migrateOptions = {
1050
1473
  concurrency,
1051
- dryRun: !!options.dryRun,
1052
1474
  onProgress: (tenantId, status, migrationName) => {
1053
1475
  if (status === "completed") {
1054
1476
  progressBar.increment({ tenant: tenantId, status: "success" });
@@ -1057,14 +1479,16 @@ Examples:
1057
1479
  progressBar.increment({ tenant: tenantId, status: "error" });
1058
1480
  debug(`Failed: ${tenantId}`);
1059
1481
  } else if (status === "migrating" && migrationName) {
1060
- debug(`${tenantId}: Applying ${migrationName}`);
1482
+ const actionVerb = options.markApplied ? "Marking" : "Applying";
1483
+ debug(`${tenantId}: ${actionVerb} ${migrationName}`);
1061
1484
  }
1062
1485
  },
1063
1486
  onError: (tenantId, err) => {
1064
1487
  debug(`Error on ${tenantId}: ${err.message}`);
1065
1488
  return "continue";
1066
1489
  }
1067
- });
1490
+ };
1491
+ const results = options.markApplied ? await migrator.markAllAsApplied(migrateOptions) : await migrator.migrateAll({ ...migrateOptions, dryRun: !!options.dryRun });
1068
1492
  progressBar.stop();
1069
1493
  const totalDuration = Date.now() - startTime;
1070
1494
  if (ctx.jsonMode) {
@@ -1189,6 +1613,261 @@ Examples:
1189
1613
  handleError(err);
1190
1614
  }
1191
1615
  });
1616
+ var syncCommand = new Command("sync").description("Detect and fix divergences between migrations on disk and tracking in database").option("-c, --config <path>", "Path to config file").option("-s, --status", "Show sync status without making changes").option("--mark-missing", "Mark missing migrations as applied").option("--clean-orphans", "Remove orphan records from tracking table").option("--concurrency <number>", "Number of concurrent operations", "10").option("--migrations-folder <path>", "Path to migrations folder").addHelpText("after", `
1617
+ Examples:
1618
+ $ drizzle-multitenant sync --status
1619
+ $ drizzle-multitenant sync --mark-missing
1620
+ $ drizzle-multitenant sync --clean-orphans
1621
+ $ drizzle-multitenant sync --mark-missing --clean-orphans
1622
+ $ drizzle-multitenant sync --status --json
1623
+ `).action(async (options) => {
1624
+ const startTime = Date.now();
1625
+ const ctx = getOutputContext();
1626
+ const spinner = createSpinner("Loading configuration...");
1627
+ try {
1628
+ spinner.start();
1629
+ const { config, migrationsFolder, migrationsTable, tenantDiscovery } = await loadConfig(options.config);
1630
+ if (!tenantDiscovery) {
1631
+ throw CLIErrors.noTenantDiscovery();
1632
+ }
1633
+ const folder = options.migrationsFolder ? resolveMigrationsFolder(options.migrationsFolder) : resolveMigrationsFolder(migrationsFolder);
1634
+ debug(`Using migrations folder: ${folder}`);
1635
+ const migrator = createMigrator(config, {
1636
+ migrationsFolder: folder,
1637
+ ...migrationsTable && { migrationsTable },
1638
+ tenantDiscovery
1639
+ });
1640
+ const showStatus = options.status || !options.markMissing && !options.cleanOrphans;
1641
+ if (showStatus) {
1642
+ spinner.text = "Fetching sync status...";
1643
+ const syncStatus = await migrator.getSyncStatus();
1644
+ spinner.succeed(`Found ${syncStatus.total} tenant${syncStatus.total > 1 ? "s" : ""}`);
1645
+ if (ctx.jsonMode) {
1646
+ const jsonOutput = {
1647
+ tenants: syncStatus.details.map((s) => ({
1648
+ id: s.tenantId,
1649
+ schema: s.schemaName,
1650
+ format: s.format,
1651
+ inSync: s.inSync,
1652
+ missing: s.missing,
1653
+ orphans: s.orphans,
1654
+ error: s.error
1655
+ })),
1656
+ summary: {
1657
+ total: syncStatus.total,
1658
+ inSync: syncStatus.inSync,
1659
+ outOfSync: syncStatus.outOfSync,
1660
+ error: syncStatus.error
1661
+ }
1662
+ };
1663
+ outputJson(jsonOutput);
1664
+ return;
1665
+ }
1666
+ log("\n" + bold("Sync Status:"));
1667
+ log(createSyncStatusTable(syncStatus.details));
1668
+ log(createSyncSummary(syncStatus));
1669
+ return;
1670
+ }
1671
+ const concurrency = parseInt(options.concurrency || "10", 10);
1672
+ if (options.markMissing) {
1673
+ spinner.text = "Marking missing migrations...";
1674
+ spinner.succeed();
1675
+ const tenantIds = await tenantDiscovery();
1676
+ log(info(`
1677
+ Marking missing migrations for ${tenantIds.length} tenant${tenantIds.length > 1 ? "s" : ""}...
1678
+ `));
1679
+ const progressBar = createProgressBar({ total: tenantIds.length });
1680
+ progressBar.start();
1681
+ const results = await migrator.markAllMissing({
1682
+ concurrency,
1683
+ onProgress: (tenantId, status) => {
1684
+ if (status === "completed") {
1685
+ progressBar.increment({ tenant: tenantId, status: "success" });
1686
+ } else if (status === "failed") {
1687
+ progressBar.increment({ tenant: tenantId, status: "error" });
1688
+ }
1689
+ },
1690
+ onError: (tenantId, err) => {
1691
+ debug(`Error on ${tenantId}: ${err.message}`);
1692
+ return "continue";
1693
+ }
1694
+ });
1695
+ progressBar.stop();
1696
+ const totalDuration = Date.now() - startTime;
1697
+ if (ctx.jsonMode) {
1698
+ outputJson({
1699
+ action: "mark-missing",
1700
+ results: results.details.map((r) => ({
1701
+ tenantId: r.tenantId,
1702
+ schema: r.schemaName,
1703
+ success: r.success,
1704
+ markedMigrations: r.markedMigrations,
1705
+ durationMs: r.durationMs,
1706
+ error: r.error
1707
+ })),
1708
+ summary: {
1709
+ total: results.total,
1710
+ succeeded: results.succeeded,
1711
+ failed: results.failed,
1712
+ durationMs: totalDuration
1713
+ }
1714
+ });
1715
+ process.exit(results.failed > 0 ? 1 : 0);
1716
+ }
1717
+ log("\n" + bold("Results:"));
1718
+ log(createSyncResultsTable(results.details, "mark-missing"));
1719
+ log("\n" + bold("Summary:"));
1720
+ log(` Total: ${results.total}`);
1721
+ log(` Succeeded: ${success(results.succeeded.toString())}`);
1722
+ if (results.failed > 0) {
1723
+ log(` Failed: ${error(results.failed.toString())}`);
1724
+ }
1725
+ log(` Duration: ${dim(formatDuration2(totalDuration))}`);
1726
+ }
1727
+ if (options.cleanOrphans) {
1728
+ if (options.markMissing) {
1729
+ log("\n");
1730
+ }
1731
+ spinner.text = "Cleaning orphan records...";
1732
+ spinner.succeed();
1733
+ const tenantIds = await tenantDiscovery();
1734
+ log(info(`
1735
+ Cleaning orphan records for ${tenantIds.length} tenant${tenantIds.length > 1 ? "s" : ""}...
1736
+ `));
1737
+ const progressBar = createProgressBar({ total: tenantIds.length });
1738
+ progressBar.start();
1739
+ const results = await migrator.cleanAllOrphans({
1740
+ concurrency,
1741
+ onProgress: (tenantId, status) => {
1742
+ if (status === "completed") {
1743
+ progressBar.increment({ tenant: tenantId, status: "success" });
1744
+ } else if (status === "failed") {
1745
+ progressBar.increment({ tenant: tenantId, status: "error" });
1746
+ }
1747
+ },
1748
+ onError: (tenantId, err) => {
1749
+ debug(`Error on ${tenantId}: ${err.message}`);
1750
+ return "continue";
1751
+ }
1752
+ });
1753
+ progressBar.stop();
1754
+ const totalDuration = Date.now() - startTime;
1755
+ if (ctx.jsonMode) {
1756
+ outputJson({
1757
+ action: "clean-orphans",
1758
+ results: results.details.map((r) => ({
1759
+ tenantId: r.tenantId,
1760
+ schema: r.schemaName,
1761
+ success: r.success,
1762
+ removedOrphans: r.removedOrphans,
1763
+ durationMs: r.durationMs,
1764
+ error: r.error
1765
+ })),
1766
+ summary: {
1767
+ total: results.total,
1768
+ succeeded: results.succeeded,
1769
+ failed: results.failed,
1770
+ durationMs: totalDuration
1771
+ }
1772
+ });
1773
+ process.exit(results.failed > 0 ? 1 : 0);
1774
+ }
1775
+ log("\n" + bold("Results:"));
1776
+ log(createSyncResultsTable(results.details, "clean-orphans"));
1777
+ log("\n" + bold("Summary:"));
1778
+ log(` Total: ${results.total}`);
1779
+ log(` Succeeded: ${success(results.succeeded.toString())}`);
1780
+ if (results.failed > 0) {
1781
+ log(` Failed: ${error(results.failed.toString())}`);
1782
+ }
1783
+ log(` Duration: ${dim(formatDuration2(totalDuration))}`);
1784
+ }
1785
+ } catch (err) {
1786
+ spinner.fail(err.message);
1787
+ handleError(err);
1788
+ }
1789
+ });
1790
+ function formatDuration2(ms) {
1791
+ if (ms < 1e3) {
1792
+ return `${ms}ms`;
1793
+ }
1794
+ if (ms < 6e4) {
1795
+ return `${(ms / 1e3).toFixed(1)}s`;
1796
+ }
1797
+ const mins = Math.floor(ms / 6e4);
1798
+ const secs = Math.round(ms % 6e4 / 1e3);
1799
+ return `${mins}m ${secs}s`;
1800
+ }
1801
+ function createSyncStatusTable(details) {
1802
+ if (details.length === 0) {
1803
+ return " No tenants found.\n";
1804
+ }
1805
+ const lines = [];
1806
+ for (const detail of details) {
1807
+ if (detail.error) {
1808
+ lines.push(` ${error(detail.tenantId)}: ${dim(detail.error)}`);
1809
+ } else if (detail.inSync) {
1810
+ lines.push(` ${success(detail.tenantId)}: ${dim("In sync")}`);
1811
+ } else {
1812
+ const issues = [];
1813
+ if (detail.missing.length > 0) {
1814
+ issues.push(`${detail.missing.length} missing`);
1815
+ }
1816
+ if (detail.orphans.length > 0) {
1817
+ issues.push(`${detail.orphans.length} orphan${detail.orphans.length > 1 ? "s" : ""}`);
1818
+ }
1819
+ lines.push(` ${warning(detail.tenantId)}: ${issues.join(", ")}`);
1820
+ if (detail.missing.length > 0) {
1821
+ lines.push(` ${dim("Missing:")} ${detail.missing.slice(0, 3).join(", ")}${detail.missing.length > 3 ? `, +${detail.missing.length - 3} more` : ""}`);
1822
+ }
1823
+ if (detail.orphans.length > 0) {
1824
+ lines.push(` ${dim("Orphans:")} ${detail.orphans.slice(0, 3).join(", ")}${detail.orphans.length > 3 ? `, +${detail.orphans.length - 3} more` : ""}`);
1825
+ }
1826
+ }
1827
+ }
1828
+ return lines.join("\n") + "\n";
1829
+ }
1830
+ function createSyncSummary(status) {
1831
+ const lines = [];
1832
+ lines.push("\n" + bold("Summary:"));
1833
+ lines.push(` Total: ${status.total}`);
1834
+ lines.push(` In Sync: ${success(status.inSync.toString())}`);
1835
+ if (status.outOfSync > 0) {
1836
+ lines.push(` Out of Sync: ${warning(status.outOfSync.toString())}`);
1837
+ }
1838
+ if (status.error > 0) {
1839
+ lines.push(` Errors: ${error(status.error.toString())}`);
1840
+ }
1841
+ if (status.outOfSync > 0) {
1842
+ lines.push("\n" + dim("Run with --mark-missing to mark missing migrations as applied."));
1843
+ lines.push(dim("Run with --clean-orphans to remove orphan records."));
1844
+ }
1845
+ return lines.join("\n");
1846
+ }
1847
+ function createSyncResultsTable(details, action) {
1848
+ if (details.length === 0) {
1849
+ return " No tenants processed.\n";
1850
+ }
1851
+ const lines = [];
1852
+ for (const detail of details) {
1853
+ if (detail.error) {
1854
+ lines.push(` ${error(detail.tenantId)}: ${dim(detail.error)}`);
1855
+ } else if (action === "mark-missing") {
1856
+ if (detail.markedMigrations.length > 0) {
1857
+ lines.push(` ${success(detail.tenantId)}: Marked ${detail.markedMigrations.length} migration${detail.markedMigrations.length > 1 ? "s" : ""}`);
1858
+ } else {
1859
+ lines.push(` ${dim(detail.tenantId)}: Nothing to mark`);
1860
+ }
1861
+ } else {
1862
+ if (detail.removedOrphans.length > 0) {
1863
+ lines.push(` ${success(detail.tenantId)}: Removed ${detail.removedOrphans.length} orphan${detail.removedOrphans.length > 1 ? "s" : ""}`);
1864
+ } else {
1865
+ lines.push(` ${dim(detail.tenantId)}: No orphans found`);
1866
+ }
1867
+ }
1868
+ }
1869
+ return lines.join("\n") + "\n";
1870
+ }
1192
1871
  var generateCommand = new Command("generate").description("Generate a new migration file").requiredOption("-n, --name <name>", "Migration name").option("-c, --config <path>", "Path to config file").option("--type <type>", "Migration type: tenant or shared", "tenant").option("--migrations-folder <path>", "Path to migrations folder").action(async (options) => {
1193
1872
  const spinner = createSpinner("Loading configuration...");
1194
1873
  try {
@@ -2066,6 +2745,7 @@ Documentation:
2066
2745
  `);
2067
2746
  program.addCommand(migrateCommand);
2068
2747
  program.addCommand(statusCommand);
2748
+ program.addCommand(syncCommand);
2069
2749
  program.addCommand(generateCommand);
2070
2750
  program.addCommand(tenantCreateCommand);
2071
2751
  program.addCommand(tenantDropCommand);