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.
@@ -1,4 +1,4 @@
1
- import { C as Config } from '../types-DKVaTaIb.js';
1
+ import { C as Config } from '../types-B5eSRLFW.js';
2
2
  import { Pool } from 'pg';
3
3
  import 'drizzle-orm/node-postgres';
4
4
 
@@ -191,6 +191,67 @@ interface AppliedMigration {
191
191
  hash?: string;
192
192
  appliedAt: Date;
193
193
  }
194
+ /**
195
+ * Sync status for a single tenant
196
+ */
197
+ interface TenantSyncStatus {
198
+ tenantId: string;
199
+ schemaName: string;
200
+ /** Migrations in disk but not tracked in database */
201
+ missing: string[];
202
+ /** Migrations tracked in database but not found in disk */
203
+ orphans: string[];
204
+ /** Whether the tenant is in sync */
205
+ inSync: boolean;
206
+ /** Table format used */
207
+ format: TableFormat | null;
208
+ /** Error if any */
209
+ error?: string;
210
+ }
211
+ /**
212
+ * Aggregate sync status
213
+ */
214
+ interface SyncStatus {
215
+ total: number;
216
+ inSync: number;
217
+ outOfSync: number;
218
+ error: number;
219
+ details: TenantSyncStatus[];
220
+ }
221
+ /**
222
+ * Sync result for a single tenant
223
+ */
224
+ interface TenantSyncResult {
225
+ tenantId: string;
226
+ schemaName: string;
227
+ success: boolean;
228
+ /** Migrations that were marked as applied */
229
+ markedMigrations: string[];
230
+ /** Orphan records that were removed */
231
+ removedOrphans: string[];
232
+ error?: string;
233
+ durationMs: number;
234
+ }
235
+ /**
236
+ * Aggregate sync results
237
+ */
238
+ interface SyncResults {
239
+ total: number;
240
+ succeeded: number;
241
+ failed: number;
242
+ details: TenantSyncResult[];
243
+ }
244
+ /**
245
+ * Options for sync operations
246
+ */
247
+ interface SyncOptions {
248
+ /** Number of concurrent operations */
249
+ concurrency?: number;
250
+ /** Progress callback */
251
+ onProgress?: (tenantId: string, status: 'starting' | 'syncing' | 'completed' | 'failed') => void;
252
+ /** Error handler */
253
+ onError?: MigrationErrorHandler;
254
+ }
194
255
 
195
256
  /**
196
257
  * Parallel migration engine for multi-tenant applications
@@ -235,6 +296,43 @@ declare class Migrator<TTenantSchema extends Record<string, unknown>, TSharedSch
235
296
  * Check if a tenant schema exists
236
297
  */
237
298
  tenantExists(tenantId: string): Promise<boolean>;
299
+ /**
300
+ * Mark migrations as applied without executing SQL
301
+ * Useful for syncing tracking state with already-applied migrations
302
+ */
303
+ markAsApplied(tenantId: string, options?: {
304
+ onProgress?: MigrateOptions['onProgress'];
305
+ }): Promise<TenantMigrationResult>;
306
+ /**
307
+ * Mark migrations as applied for all tenants without executing SQL
308
+ * Useful for syncing tracking state with already-applied migrations
309
+ */
310
+ markAllAsApplied(options?: MigrateOptions): Promise<MigrationResults>;
311
+ /**
312
+ * Get sync status for all tenants
313
+ * Detects divergences between migrations on disk and tracking in database
314
+ */
315
+ getSyncStatus(): Promise<SyncStatus>;
316
+ /**
317
+ * Get sync status for a specific tenant
318
+ */
319
+ getTenantSyncStatus(tenantId: string, migrations?: MigrationFile[]): Promise<TenantSyncStatus>;
320
+ /**
321
+ * Mark missing migrations as applied for a tenant
322
+ */
323
+ markMissing(tenantId: string): Promise<TenantSyncResult>;
324
+ /**
325
+ * Mark missing migrations as applied for all tenants
326
+ */
327
+ markAllMissing(options?: SyncOptions): Promise<SyncResults>;
328
+ /**
329
+ * Remove orphan migration records for a tenant
330
+ */
331
+ cleanOrphans(tenantId: string): Promise<TenantSyncResult>;
332
+ /**
333
+ * Remove orphan migration records for all tenants
334
+ */
335
+ cleanAllOrphans(options?: SyncOptions): Promise<SyncResults>;
238
336
  /**
239
337
  * Load migration files from the migrations folder
240
338
  */
@@ -268,6 +366,11 @@ declare class Migrator<TTenantSchema extends Record<string, unknown>, TSharedSch
268
366
  * Apply a migration to a schema
269
367
  */
270
368
  private applyMigration;
369
+ /**
370
+ * Record a migration as applied without executing SQL
371
+ * Used by markAsApplied to sync tracking state
372
+ */
373
+ private recordMigration;
271
374
  /**
272
375
  * Create a skipped result
273
376
  */
@@ -280,6 +383,18 @@ declare class Migrator<TTenantSchema extends Record<string, unknown>, TSharedSch
280
383
  * Aggregate migration results
281
384
  */
282
385
  private aggregateResults;
386
+ /**
387
+ * Create a skipped sync result
388
+ */
389
+ private createSkippedSyncResult;
390
+ /**
391
+ * Create an error sync result
392
+ */
393
+ private createErrorSyncResult;
394
+ /**
395
+ * Aggregate sync results
396
+ */
397
+ private aggregateSyncResults;
283
398
  }
284
399
  /**
285
400
  * Create a migrator instance
@@ -380,6 +380,372 @@ var Migrator = class {
380
380
  await pool.end();
381
381
  }
382
382
  }
383
+ /**
384
+ * Mark migrations as applied without executing SQL
385
+ * Useful for syncing tracking state with already-applied migrations
386
+ */
387
+ async markAsApplied(tenantId, options = {}) {
388
+ const startTime = Date.now();
389
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
390
+ const markedMigrations = [];
391
+ const pool = await this.createPool(schemaName);
392
+ try {
393
+ await this.migratorConfig.hooks?.beforeTenant?.(tenantId);
394
+ const format = await this.getOrDetectFormat(pool, schemaName);
395
+ await this.ensureMigrationsTable(pool, schemaName, format);
396
+ const allMigrations = await this.loadMigrations();
397
+ const applied = await this.getAppliedMigrations(pool, schemaName, format);
398
+ const appliedSet = new Set(applied.map((m) => m.identifier));
399
+ const pending = allMigrations.filter(
400
+ (m) => !this.isMigrationApplied(m, appliedSet, format)
401
+ );
402
+ for (const migration of pending) {
403
+ const migrationStart = Date.now();
404
+ options.onProgress?.(tenantId, "migrating", migration.name);
405
+ await this.migratorConfig.hooks?.beforeMigration?.(tenantId, migration.name);
406
+ await this.recordMigration(pool, schemaName, migration, format);
407
+ await this.migratorConfig.hooks?.afterMigration?.(
408
+ tenantId,
409
+ migration.name,
410
+ Date.now() - migrationStart
411
+ );
412
+ markedMigrations.push(migration.name);
413
+ }
414
+ const result = {
415
+ tenantId,
416
+ schemaName,
417
+ success: true,
418
+ appliedMigrations: markedMigrations,
419
+ durationMs: Date.now() - startTime,
420
+ format: format.format
421
+ };
422
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
423
+ return result;
424
+ } catch (error) {
425
+ const result = {
426
+ tenantId,
427
+ schemaName,
428
+ success: false,
429
+ appliedMigrations: markedMigrations,
430
+ error: error.message,
431
+ durationMs: Date.now() - startTime
432
+ };
433
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
434
+ return result;
435
+ } finally {
436
+ await pool.end();
437
+ }
438
+ }
439
+ /**
440
+ * Mark migrations as applied for all tenants without executing SQL
441
+ * Useful for syncing tracking state with already-applied migrations
442
+ */
443
+ async markAllAsApplied(options = {}) {
444
+ const {
445
+ concurrency = 10,
446
+ onProgress,
447
+ onError
448
+ } = options;
449
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
450
+ const results = [];
451
+ let aborted = false;
452
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
453
+ const batch = tenantIds.slice(i, i + concurrency);
454
+ const batchResults = await Promise.all(
455
+ batch.map(async (tenantId) => {
456
+ if (aborted) {
457
+ return this.createSkippedResult(tenantId);
458
+ }
459
+ try {
460
+ onProgress?.(tenantId, "starting");
461
+ const result = await this.markAsApplied(tenantId, { onProgress });
462
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
463
+ return result;
464
+ } catch (error) {
465
+ onProgress?.(tenantId, "failed");
466
+ const action = onError?.(tenantId, error);
467
+ if (action === "abort") {
468
+ aborted = true;
469
+ }
470
+ return this.createErrorResult(tenantId, error);
471
+ }
472
+ })
473
+ );
474
+ results.push(...batchResults);
475
+ }
476
+ if (aborted) {
477
+ const remaining = tenantIds.slice(results.length);
478
+ for (const tenantId of remaining) {
479
+ results.push(this.createSkippedResult(tenantId));
480
+ }
481
+ }
482
+ return this.aggregateResults(results);
483
+ }
484
+ /**
485
+ * Get sync status for all tenants
486
+ * Detects divergences between migrations on disk and tracking in database
487
+ */
488
+ async getSyncStatus() {
489
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
490
+ const migrations = await this.loadMigrations();
491
+ const statuses = [];
492
+ for (const tenantId of tenantIds) {
493
+ statuses.push(await this.getTenantSyncStatus(tenantId, migrations));
494
+ }
495
+ return {
496
+ total: statuses.length,
497
+ inSync: statuses.filter((s) => s.inSync && !s.error).length,
498
+ outOfSync: statuses.filter((s) => !s.inSync && !s.error).length,
499
+ error: statuses.filter((s) => !!s.error).length,
500
+ details: statuses
501
+ };
502
+ }
503
+ /**
504
+ * Get sync status for a specific tenant
505
+ */
506
+ async getTenantSyncStatus(tenantId, migrations) {
507
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
508
+ const pool = await this.createPool(schemaName);
509
+ try {
510
+ const allMigrations = migrations ?? await this.loadMigrations();
511
+ const migrationNames = new Set(allMigrations.map((m) => m.name));
512
+ const migrationHashes = new Set(allMigrations.map((m) => m.hash));
513
+ const tableExists = await this.migrationsTableExists(pool, schemaName);
514
+ if (!tableExists) {
515
+ return {
516
+ tenantId,
517
+ schemaName,
518
+ missing: allMigrations.map((m) => m.name),
519
+ orphans: [],
520
+ inSync: allMigrations.length === 0,
521
+ format: null
522
+ };
523
+ }
524
+ const format = await this.getOrDetectFormat(pool, schemaName);
525
+ const applied = await this.getAppliedMigrations(pool, schemaName, format);
526
+ const appliedIdentifiers = new Set(applied.map((m) => m.identifier));
527
+ const missing = allMigrations.filter((m) => !this.isMigrationApplied(m, appliedIdentifiers, format)).map((m) => m.name);
528
+ const orphans = applied.filter((m) => {
529
+ if (format.columns.identifier === "name") {
530
+ return !migrationNames.has(m.identifier);
531
+ }
532
+ return !migrationHashes.has(m.identifier) && !migrationNames.has(m.identifier);
533
+ }).map((m) => m.identifier);
534
+ return {
535
+ tenantId,
536
+ schemaName,
537
+ missing,
538
+ orphans,
539
+ inSync: missing.length === 0 && orphans.length === 0,
540
+ format: format.format
541
+ };
542
+ } catch (error) {
543
+ return {
544
+ tenantId,
545
+ schemaName,
546
+ missing: [],
547
+ orphans: [],
548
+ inSync: false,
549
+ format: null,
550
+ error: error.message
551
+ };
552
+ } finally {
553
+ await pool.end();
554
+ }
555
+ }
556
+ /**
557
+ * Mark missing migrations as applied for a tenant
558
+ */
559
+ async markMissing(tenantId) {
560
+ const startTime = Date.now();
561
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
562
+ const markedMigrations = [];
563
+ const pool = await this.createPool(schemaName);
564
+ try {
565
+ const syncStatus = await this.getTenantSyncStatus(tenantId);
566
+ if (syncStatus.error) {
567
+ return {
568
+ tenantId,
569
+ schemaName,
570
+ success: false,
571
+ markedMigrations: [],
572
+ removedOrphans: [],
573
+ error: syncStatus.error,
574
+ durationMs: Date.now() - startTime
575
+ };
576
+ }
577
+ if (syncStatus.missing.length === 0) {
578
+ return {
579
+ tenantId,
580
+ schemaName,
581
+ success: true,
582
+ markedMigrations: [],
583
+ removedOrphans: [],
584
+ durationMs: Date.now() - startTime
585
+ };
586
+ }
587
+ const format = await this.getOrDetectFormat(pool, schemaName);
588
+ await this.ensureMigrationsTable(pool, schemaName, format);
589
+ const allMigrations = await this.loadMigrations();
590
+ const missingSet = new Set(syncStatus.missing);
591
+ for (const migration of allMigrations) {
592
+ if (missingSet.has(migration.name)) {
593
+ await this.recordMigration(pool, schemaName, migration, format);
594
+ markedMigrations.push(migration.name);
595
+ }
596
+ }
597
+ return {
598
+ tenantId,
599
+ schemaName,
600
+ success: true,
601
+ markedMigrations,
602
+ removedOrphans: [],
603
+ durationMs: Date.now() - startTime
604
+ };
605
+ } catch (error) {
606
+ return {
607
+ tenantId,
608
+ schemaName,
609
+ success: false,
610
+ markedMigrations,
611
+ removedOrphans: [],
612
+ error: error.message,
613
+ durationMs: Date.now() - startTime
614
+ };
615
+ } finally {
616
+ await pool.end();
617
+ }
618
+ }
619
+ /**
620
+ * Mark missing migrations as applied for all tenants
621
+ */
622
+ async markAllMissing(options = {}) {
623
+ const { concurrency = 10, onProgress, onError } = options;
624
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
625
+ const results = [];
626
+ let aborted = false;
627
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
628
+ const batch = tenantIds.slice(i, i + concurrency);
629
+ const batchResults = await Promise.all(
630
+ batch.map(async (tenantId) => {
631
+ if (aborted) {
632
+ return this.createSkippedSyncResult(tenantId);
633
+ }
634
+ try {
635
+ onProgress?.(tenantId, "starting");
636
+ const result = await this.markMissing(tenantId);
637
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
638
+ return result;
639
+ } catch (error) {
640
+ onProgress?.(tenantId, "failed");
641
+ const action = onError?.(tenantId, error);
642
+ if (action === "abort") {
643
+ aborted = true;
644
+ }
645
+ return this.createErrorSyncResult(tenantId, error);
646
+ }
647
+ })
648
+ );
649
+ results.push(...batchResults);
650
+ }
651
+ return this.aggregateSyncResults(results);
652
+ }
653
+ /**
654
+ * Remove orphan migration records for a tenant
655
+ */
656
+ async cleanOrphans(tenantId) {
657
+ const startTime = Date.now();
658
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
659
+ const removedOrphans = [];
660
+ const pool = await this.createPool(schemaName);
661
+ try {
662
+ const syncStatus = await this.getTenantSyncStatus(tenantId);
663
+ if (syncStatus.error) {
664
+ return {
665
+ tenantId,
666
+ schemaName,
667
+ success: false,
668
+ markedMigrations: [],
669
+ removedOrphans: [],
670
+ error: syncStatus.error,
671
+ durationMs: Date.now() - startTime
672
+ };
673
+ }
674
+ if (syncStatus.orphans.length === 0) {
675
+ return {
676
+ tenantId,
677
+ schemaName,
678
+ success: true,
679
+ markedMigrations: [],
680
+ removedOrphans: [],
681
+ durationMs: Date.now() - startTime
682
+ };
683
+ }
684
+ const format = await this.getOrDetectFormat(pool, schemaName);
685
+ const identifierColumn = format.columns.identifier;
686
+ for (const orphan of syncStatus.orphans) {
687
+ await pool.query(
688
+ `DELETE FROM "${schemaName}"."${format.tableName}" WHERE "${identifierColumn}" = $1`,
689
+ [orphan]
690
+ );
691
+ removedOrphans.push(orphan);
692
+ }
693
+ return {
694
+ tenantId,
695
+ schemaName,
696
+ success: true,
697
+ markedMigrations: [],
698
+ removedOrphans,
699
+ durationMs: Date.now() - startTime
700
+ };
701
+ } catch (error) {
702
+ return {
703
+ tenantId,
704
+ schemaName,
705
+ success: false,
706
+ markedMigrations: [],
707
+ removedOrphans,
708
+ error: error.message,
709
+ durationMs: Date.now() - startTime
710
+ };
711
+ } finally {
712
+ await pool.end();
713
+ }
714
+ }
715
+ /**
716
+ * Remove orphan migration records for all tenants
717
+ */
718
+ async cleanAllOrphans(options = {}) {
719
+ const { concurrency = 10, onProgress, onError } = options;
720
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
721
+ const results = [];
722
+ let aborted = false;
723
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
724
+ const batch = tenantIds.slice(i, i + concurrency);
725
+ const batchResults = await Promise.all(
726
+ batch.map(async (tenantId) => {
727
+ if (aborted) {
728
+ return this.createSkippedSyncResult(tenantId);
729
+ }
730
+ try {
731
+ onProgress?.(tenantId, "starting");
732
+ const result = await this.cleanOrphans(tenantId);
733
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
734
+ return result;
735
+ } catch (error) {
736
+ onProgress?.(tenantId, "failed");
737
+ const action = onError?.(tenantId, error);
738
+ if (action === "abort") {
739
+ aborted = true;
740
+ }
741
+ return this.createErrorSyncResult(tenantId, error);
742
+ }
743
+ })
744
+ );
745
+ results.push(...batchResults);
746
+ }
747
+ return this.aggregateSyncResults(results);
748
+ }
383
749
  /**
384
750
  * Load migration files from the migrations folder
385
751
  */
@@ -509,6 +875,19 @@ var Migrator = class {
509
875
  client.release();
510
876
  }
511
877
  }
878
+ /**
879
+ * Record a migration as applied without executing SQL
880
+ * Used by markAsApplied to sync tracking state
881
+ */
882
+ async recordMigration(pool, schemaName, migration, format) {
883
+ const { identifier, timestamp, timestampType } = format.columns;
884
+ const identifierValue = identifier === "name" ? migration.name : migration.hash;
885
+ const timestampValue = timestampType === "bigint" ? Date.now() : /* @__PURE__ */ new Date();
886
+ await pool.query(
887
+ `INSERT INTO "${schemaName}"."${format.tableName}" ("${identifier}", "${timestamp}") VALUES ($1, $2)`,
888
+ [identifierValue, timestampValue]
889
+ );
890
+ }
512
891
  /**
513
892
  * Create a skipped result
514
893
  */
@@ -547,6 +926,45 @@ var Migrator = class {
547
926
  details: results
548
927
  };
549
928
  }
929
+ /**
930
+ * Create a skipped sync result
931
+ */
932
+ createSkippedSyncResult(tenantId) {
933
+ return {
934
+ tenantId,
935
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
936
+ success: false,
937
+ markedMigrations: [],
938
+ removedOrphans: [],
939
+ error: "Skipped due to abort",
940
+ durationMs: 0
941
+ };
942
+ }
943
+ /**
944
+ * Create an error sync result
945
+ */
946
+ createErrorSyncResult(tenantId, error) {
947
+ return {
948
+ tenantId,
949
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
950
+ success: false,
951
+ markedMigrations: [],
952
+ removedOrphans: [],
953
+ error: error.message,
954
+ durationMs: 0
955
+ };
956
+ }
957
+ /**
958
+ * Aggregate sync results
959
+ */
960
+ aggregateSyncResults(results) {
961
+ return {
962
+ total: results.length,
963
+ succeeded: results.filter((r) => r.success).length,
964
+ failed: results.filter((r) => !r.success).length,
965
+ details: results
966
+ };
967
+ }
550
968
  };
551
969
  function createMigrator(tenantConfig, migratorConfig) {
552
970
  return new Migrator(tenantConfig, migratorConfig);