drizzle-multitenant 1.0.0

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.
@@ -0,0 +1,866 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { mkdir, readdir, writeFile, readFile } from 'fs/promises';
4
+ import { resolve, join, extname, basename } from 'path';
5
+ import { Pool } from 'pg';
6
+ import ora from 'ora';
7
+ import chalk2 from 'chalk';
8
+ import Table from 'cli-table3';
9
+ import { pathToFileURL } from 'url';
10
+ import { existsSync } from 'fs';
11
+ import { createInterface } from 'readline';
12
+
13
+ var DEFAULT_MIGRATIONS_TABLE = "__drizzle_migrations";
14
+ var Migrator = class {
15
+ constructor(tenantConfig, migratorConfig) {
16
+ this.tenantConfig = tenantConfig;
17
+ this.migratorConfig = migratorConfig;
18
+ this.migrationsTable = migratorConfig.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE;
19
+ }
20
+ migrationsTable;
21
+ /**
22
+ * Migrate all tenants in parallel
23
+ */
24
+ async migrateAll(options = {}) {
25
+ const {
26
+ concurrency = 10,
27
+ onProgress,
28
+ onError,
29
+ dryRun = false
30
+ } = options;
31
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
32
+ const migrations = await this.loadMigrations();
33
+ const results = [];
34
+ let aborted = false;
35
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
36
+ const batch = tenantIds.slice(i, i + concurrency);
37
+ const batchResults = await Promise.all(
38
+ batch.map(async (tenantId) => {
39
+ if (aborted) {
40
+ return this.createSkippedResult(tenantId);
41
+ }
42
+ try {
43
+ onProgress?.(tenantId, "starting");
44
+ const result = await this.migrateTenant(tenantId, migrations, { dryRun, onProgress });
45
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
46
+ return result;
47
+ } catch (error6) {
48
+ onProgress?.(tenantId, "failed");
49
+ const action = onError?.(tenantId, error6);
50
+ if (action === "abort") {
51
+ aborted = true;
52
+ }
53
+ return this.createErrorResult(tenantId, error6);
54
+ }
55
+ })
56
+ );
57
+ results.push(...batchResults);
58
+ }
59
+ if (aborted) {
60
+ const remaining = tenantIds.slice(results.length);
61
+ for (const tenantId of remaining) {
62
+ results.push(this.createSkippedResult(tenantId));
63
+ }
64
+ }
65
+ return this.aggregateResults(results);
66
+ }
67
+ /**
68
+ * Migrate a single tenant
69
+ */
70
+ async migrateTenant(tenantId, migrations, options = {}) {
71
+ const startTime = Date.now();
72
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
73
+ const appliedMigrations = [];
74
+ const pool = await this.createPool(schemaName);
75
+ try {
76
+ await this.migratorConfig.hooks?.beforeTenant?.(tenantId);
77
+ await this.ensureMigrationsTable(pool, schemaName);
78
+ const allMigrations = migrations ?? await this.loadMigrations();
79
+ const applied = await this.getAppliedMigrations(pool, schemaName);
80
+ const appliedSet = new Set(applied.map((m) => m.name));
81
+ const pending = allMigrations.filter((m) => !appliedSet.has(m.name));
82
+ if (options.dryRun) {
83
+ return {
84
+ tenantId,
85
+ schemaName,
86
+ success: true,
87
+ appliedMigrations: pending.map((m) => m.name),
88
+ durationMs: Date.now() - startTime
89
+ };
90
+ }
91
+ for (const migration of pending) {
92
+ const migrationStart = Date.now();
93
+ options.onProgress?.(tenantId, "migrating", migration.name);
94
+ await this.migratorConfig.hooks?.beforeMigration?.(tenantId, migration.name);
95
+ await this.applyMigration(pool, schemaName, migration);
96
+ await this.migratorConfig.hooks?.afterMigration?.(
97
+ tenantId,
98
+ migration.name,
99
+ Date.now() - migrationStart
100
+ );
101
+ appliedMigrations.push(migration.name);
102
+ }
103
+ const result = {
104
+ tenantId,
105
+ schemaName,
106
+ success: true,
107
+ appliedMigrations,
108
+ durationMs: Date.now() - startTime
109
+ };
110
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
111
+ return result;
112
+ } catch (error6) {
113
+ const result = {
114
+ tenantId,
115
+ schemaName,
116
+ success: false,
117
+ appliedMigrations,
118
+ error: error6.message,
119
+ durationMs: Date.now() - startTime
120
+ };
121
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
122
+ return result;
123
+ } finally {
124
+ await pool.end();
125
+ }
126
+ }
127
+ /**
128
+ * Migrate specific tenants
129
+ */
130
+ async migrateTenants(tenantIds, options = {}) {
131
+ const migrations = await this.loadMigrations();
132
+ const results = [];
133
+ const { concurrency = 10, onProgress, onError } = options;
134
+ for (let i = 0; i < tenantIds.length; i += concurrency) {
135
+ const batch = tenantIds.slice(i, i + concurrency);
136
+ const batchResults = await Promise.all(
137
+ batch.map(async (tenantId) => {
138
+ try {
139
+ onProgress?.(tenantId, "starting");
140
+ const result = await this.migrateTenant(tenantId, migrations, { dryRun: options.dryRun ?? false, onProgress });
141
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
142
+ return result;
143
+ } catch (error6) {
144
+ onProgress?.(tenantId, "failed");
145
+ onError?.(tenantId, error6);
146
+ return this.createErrorResult(tenantId, error6);
147
+ }
148
+ })
149
+ );
150
+ results.push(...batchResults);
151
+ }
152
+ return this.aggregateResults(results);
153
+ }
154
+ /**
155
+ * Get migration status for all tenants
156
+ */
157
+ async getStatus() {
158
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
159
+ const migrations = await this.loadMigrations();
160
+ const statuses = [];
161
+ for (const tenantId of tenantIds) {
162
+ statuses.push(await this.getTenantStatus(tenantId, migrations));
163
+ }
164
+ return statuses;
165
+ }
166
+ /**
167
+ * Get migration status for a specific tenant
168
+ */
169
+ async getTenantStatus(tenantId, migrations) {
170
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
171
+ const pool = await this.createPool(schemaName);
172
+ try {
173
+ const allMigrations = migrations ?? await this.loadMigrations();
174
+ const tableExists = await this.migrationsTableExists(pool, schemaName);
175
+ if (!tableExists) {
176
+ return {
177
+ tenantId,
178
+ schemaName,
179
+ appliedCount: 0,
180
+ pendingCount: allMigrations.length,
181
+ pendingMigrations: allMigrations.map((m) => m.name),
182
+ status: allMigrations.length > 0 ? "behind" : "ok"
183
+ };
184
+ }
185
+ const applied = await this.getAppliedMigrations(pool, schemaName);
186
+ const appliedSet = new Set(applied.map((m) => m.name));
187
+ const pending = allMigrations.filter((m) => !appliedSet.has(m.name));
188
+ return {
189
+ tenantId,
190
+ schemaName,
191
+ appliedCount: applied.length,
192
+ pendingCount: pending.length,
193
+ pendingMigrations: pending.map((m) => m.name),
194
+ status: pending.length > 0 ? "behind" : "ok"
195
+ };
196
+ } catch (error6) {
197
+ return {
198
+ tenantId,
199
+ schemaName,
200
+ appliedCount: 0,
201
+ pendingCount: 0,
202
+ pendingMigrations: [],
203
+ status: "error",
204
+ error: error6.message
205
+ };
206
+ } finally {
207
+ await pool.end();
208
+ }
209
+ }
210
+ /**
211
+ * Create a new tenant schema and optionally apply migrations
212
+ */
213
+ async createTenant(tenantId, options = {}) {
214
+ const { migrate = true } = options;
215
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
216
+ const pool = new Pool({
217
+ connectionString: this.tenantConfig.connection.url,
218
+ ...this.tenantConfig.connection.poolConfig
219
+ });
220
+ try {
221
+ await pool.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
222
+ if (migrate) {
223
+ await this.migrateTenant(tenantId);
224
+ }
225
+ } finally {
226
+ await pool.end();
227
+ }
228
+ }
229
+ /**
230
+ * Drop a tenant schema
231
+ */
232
+ async dropTenant(tenantId, options = {}) {
233
+ const { cascade = true } = options;
234
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
235
+ const pool = new Pool({
236
+ connectionString: this.tenantConfig.connection.url,
237
+ ...this.tenantConfig.connection.poolConfig
238
+ });
239
+ try {
240
+ const cascadeSql = cascade ? "CASCADE" : "RESTRICT";
241
+ await pool.query(`DROP SCHEMA IF EXISTS "${schemaName}" ${cascadeSql}`);
242
+ } finally {
243
+ await pool.end();
244
+ }
245
+ }
246
+ /**
247
+ * Check if a tenant schema exists
248
+ */
249
+ async tenantExists(tenantId) {
250
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
251
+ const pool = new Pool({
252
+ connectionString: this.tenantConfig.connection.url,
253
+ ...this.tenantConfig.connection.poolConfig
254
+ });
255
+ try {
256
+ const result = await pool.query(
257
+ `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`,
258
+ [schemaName]
259
+ );
260
+ return result.rowCount !== null && result.rowCount > 0;
261
+ } finally {
262
+ await pool.end();
263
+ }
264
+ }
265
+ /**
266
+ * Load migration files from the migrations folder
267
+ */
268
+ async loadMigrations() {
269
+ const files = await readdir(this.migratorConfig.migrationsFolder);
270
+ const migrations = [];
271
+ for (const file of files) {
272
+ if (!file.endsWith(".sql")) continue;
273
+ const filePath = join(this.migratorConfig.migrationsFolder, file);
274
+ const content = await readFile(filePath, "utf-8");
275
+ const match = file.match(/^(\d+)_/);
276
+ const timestamp = match?.[1] ? parseInt(match[1], 10) : 0;
277
+ migrations.push({
278
+ name: basename(file, ".sql"),
279
+ path: filePath,
280
+ sql: content,
281
+ timestamp
282
+ });
283
+ }
284
+ return migrations.sort((a, b) => a.timestamp - b.timestamp);
285
+ }
286
+ /**
287
+ * Create a pool for a specific schema
288
+ */
289
+ async createPool(schemaName) {
290
+ return new Pool({
291
+ connectionString: this.tenantConfig.connection.url,
292
+ ...this.tenantConfig.connection.poolConfig,
293
+ options: `-c search_path="${schemaName}",public`
294
+ });
295
+ }
296
+ /**
297
+ * Ensure migrations table exists
298
+ */
299
+ async ensureMigrationsTable(pool, schemaName) {
300
+ await pool.query(`
301
+ CREATE TABLE IF NOT EXISTS "${schemaName}"."${this.migrationsTable}" (
302
+ id SERIAL PRIMARY KEY,
303
+ name VARCHAR(255) NOT NULL UNIQUE,
304
+ applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
305
+ )
306
+ `);
307
+ }
308
+ /**
309
+ * Check if migrations table exists
310
+ */
311
+ async migrationsTableExists(pool, schemaName) {
312
+ const result = await pool.query(
313
+ `SELECT 1 FROM information_schema.tables
314
+ WHERE table_schema = $1 AND table_name = $2`,
315
+ [schemaName, this.migrationsTable]
316
+ );
317
+ return result.rowCount !== null && result.rowCount > 0;
318
+ }
319
+ /**
320
+ * Get applied migrations for a schema
321
+ */
322
+ async getAppliedMigrations(pool, schemaName) {
323
+ const result = await pool.query(
324
+ `SELECT id, name, applied_at FROM "${schemaName}"."${this.migrationsTable}" ORDER BY id`
325
+ );
326
+ return result.rows.map((row) => ({
327
+ id: row.id,
328
+ name: row.name,
329
+ appliedAt: row.applied_at
330
+ }));
331
+ }
332
+ /**
333
+ * Apply a migration to a schema
334
+ */
335
+ async applyMigration(pool, schemaName, migration) {
336
+ const client = await pool.connect();
337
+ try {
338
+ await client.query("BEGIN");
339
+ await client.query(migration.sql);
340
+ await client.query(
341
+ `INSERT INTO "${schemaName}"."${this.migrationsTable}" (name) VALUES ($1)`,
342
+ [migration.name]
343
+ );
344
+ await client.query("COMMIT");
345
+ } catch (error6) {
346
+ await client.query("ROLLBACK");
347
+ throw error6;
348
+ } finally {
349
+ client.release();
350
+ }
351
+ }
352
+ /**
353
+ * Create a skipped result
354
+ */
355
+ createSkippedResult(tenantId) {
356
+ return {
357
+ tenantId,
358
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
359
+ success: false,
360
+ appliedMigrations: [],
361
+ error: "Skipped due to abort",
362
+ durationMs: 0
363
+ };
364
+ }
365
+ /**
366
+ * Create an error result
367
+ */
368
+ createErrorResult(tenantId, error6) {
369
+ return {
370
+ tenantId,
371
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
372
+ success: false,
373
+ appliedMigrations: [],
374
+ error: error6.message,
375
+ durationMs: 0
376
+ };
377
+ }
378
+ /**
379
+ * Aggregate migration results
380
+ */
381
+ aggregateResults(results) {
382
+ return {
383
+ total: results.length,
384
+ succeeded: results.filter((r) => r.success).length,
385
+ failed: results.filter((r) => !r.success && r.error !== "Skipped due to abort").length,
386
+ skipped: results.filter((r) => r.error === "Skipped due to abort").length,
387
+ details: results
388
+ };
389
+ }
390
+ };
391
+ function createMigrator(tenantConfig, migratorConfig) {
392
+ return new Migrator(tenantConfig, migratorConfig);
393
+ }
394
+ function createSpinner(text) {
395
+ return ora({
396
+ text,
397
+ color: "cyan"
398
+ });
399
+ }
400
+ function success(message) {
401
+ return chalk2.green("\u2713 ") + message;
402
+ }
403
+ function error(message) {
404
+ return chalk2.red("\u2717 ") + message;
405
+ }
406
+ function warning(message) {
407
+ return chalk2.yellow("\u26A0 ") + message;
408
+ }
409
+ function info(message) {
410
+ return chalk2.blue("\u2139 ") + message;
411
+ }
412
+ function dim(message) {
413
+ return chalk2.dim(message);
414
+ }
415
+ function bold(message) {
416
+ return chalk2.bold(message);
417
+ }
418
+ function red(message) {
419
+ return chalk2.red(message);
420
+ }
421
+ function createStatusTable(statuses) {
422
+ const table = new Table({
423
+ head: [
424
+ chalk2.cyan("Tenant"),
425
+ chalk2.cyan("Schema"),
426
+ chalk2.cyan("Applied"),
427
+ chalk2.cyan("Pending"),
428
+ chalk2.cyan("Status")
429
+ ],
430
+ style: {
431
+ head: [],
432
+ border: []
433
+ }
434
+ });
435
+ for (const status of statuses) {
436
+ const statusIcon = getStatusIcon(status.status);
437
+ const statusText = getStatusText(status.status);
438
+ table.push([
439
+ status.tenantId,
440
+ chalk2.dim(status.schemaName),
441
+ chalk2.green(status.appliedCount.toString()),
442
+ status.pendingCount > 0 ? chalk2.yellow(status.pendingCount.toString()) : chalk2.dim("0"),
443
+ `${statusIcon} ${statusText}`
444
+ ]);
445
+ }
446
+ return table.toString();
447
+ }
448
+ function createResultsTable(results) {
449
+ const table = new Table({
450
+ head: [
451
+ chalk2.cyan("Tenant"),
452
+ chalk2.cyan("Migrations"),
453
+ chalk2.cyan("Duration"),
454
+ chalk2.cyan("Status")
455
+ ],
456
+ style: {
457
+ head: [],
458
+ border: []
459
+ }
460
+ });
461
+ for (const result of results) {
462
+ const statusIcon = result.success ? chalk2.green("\u2713") : chalk2.red("\u2717");
463
+ const statusText = result.success ? chalk2.green("OK") : chalk2.red(result.error ?? "Failed");
464
+ table.push([
465
+ result.tenantId,
466
+ result.appliedMigrations.length.toString(),
467
+ `${result.durationMs}ms`,
468
+ `${statusIcon} ${statusText}`
469
+ ]);
470
+ }
471
+ return table.toString();
472
+ }
473
+ function createPendingSummary(statuses) {
474
+ const pendingMap = /* @__PURE__ */ new Map();
475
+ for (const status of statuses) {
476
+ for (const migration of status.pendingMigrations) {
477
+ pendingMap.set(migration, (pendingMap.get(migration) || 0) + 1);
478
+ }
479
+ }
480
+ if (pendingMap.size === 0) {
481
+ return chalk2.green("\nAll tenants are up to date.");
482
+ }
483
+ const lines = [chalk2.yellow("\nPending migrations:")];
484
+ for (const [migration, count] of pendingMap.entries()) {
485
+ lines.push(
486
+ ` ${chalk2.dim("-")} ${migration} ${chalk2.dim(`(${count} tenant${count > 1 ? "s" : ""})`)}`
487
+ );
488
+ }
489
+ lines.push(
490
+ chalk2.dim("\nRun 'drizzle-multitenant migrate --all' to apply pending migrations.")
491
+ );
492
+ return lines.join("\n");
493
+ }
494
+ function getStatusIcon(status) {
495
+ switch (status) {
496
+ case "ok":
497
+ return chalk2.green("\u2713");
498
+ case "behind":
499
+ return chalk2.yellow("\u26A0");
500
+ case "error":
501
+ return chalk2.red("\u2717");
502
+ }
503
+ }
504
+ function getStatusText(status) {
505
+ switch (status) {
506
+ case "ok":
507
+ return chalk2.green("OK");
508
+ case "behind":
509
+ return chalk2.yellow("Behind");
510
+ case "error":
511
+ return chalk2.red("Error");
512
+ }
513
+ }
514
+ var CONFIG_FILE_NAMES = [
515
+ "tenant.config.ts",
516
+ "tenant.config.js",
517
+ "tenant.config.mjs",
518
+ "drizzle-multitenant.config.ts",
519
+ "drizzle-multitenant.config.js",
520
+ "drizzle-multitenant.config.mjs"
521
+ ];
522
+ async function loadConfig(configPath) {
523
+ const cwd = process.cwd();
524
+ let configFile;
525
+ if (configPath) {
526
+ configFile = resolve(cwd, configPath);
527
+ if (!existsSync(configFile)) {
528
+ throw new Error(`Config file not found: ${configFile}`);
529
+ }
530
+ } else {
531
+ for (const name of CONFIG_FILE_NAMES) {
532
+ const path = resolve(cwd, name);
533
+ if (existsSync(path)) {
534
+ configFile = path;
535
+ break;
536
+ }
537
+ }
538
+ }
539
+ if (!configFile) {
540
+ throw new Error(
541
+ "Config file not found. Create a tenant.config.ts or use --config flag."
542
+ );
543
+ }
544
+ const ext = extname(configFile);
545
+ if (ext === ".ts") {
546
+ await registerTypeScript();
547
+ }
548
+ const configUrl = pathToFileURL(configFile).href;
549
+ const module = await import(configUrl);
550
+ const exported = module.default ?? module;
551
+ if (!exported.connection || !exported.isolation || !exported.schemas) {
552
+ throw new Error(
553
+ "Invalid config file. Expected an object with connection, isolation, and schemas properties."
554
+ );
555
+ }
556
+ return {
557
+ config: exported,
558
+ migrationsFolder: exported.migrations?.tenantFolder,
559
+ tenantDiscovery: exported.migrations?.tenantDiscovery
560
+ };
561
+ }
562
+ async function registerTypeScript() {
563
+ try {
564
+ await import('tsx/esm');
565
+ } catch {
566
+ try {
567
+ await import('ts-node/esm');
568
+ } catch {
569
+ throw new Error(
570
+ "TypeScript config requires tsx or ts-node. Install with: npm install -D tsx"
571
+ );
572
+ }
573
+ }
574
+ }
575
+ function resolveMigrationsFolder(folder) {
576
+ const cwd = process.cwd();
577
+ const defaultFolder = "./drizzle/tenant";
578
+ const resolved = resolve(cwd, folder ?? defaultFolder);
579
+ if (!existsSync(resolved)) {
580
+ throw new Error(`Migrations folder not found: ${resolved}`);
581
+ }
582
+ return resolved;
583
+ }
584
+
585
+ // src/cli/commands/migrate.ts
586
+ 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").action(async (options) => {
587
+ const spinner = createSpinner("Loading configuration...");
588
+ try {
589
+ spinner.start();
590
+ const { config, migrationsFolder, tenantDiscovery } = await loadConfig(options.config);
591
+ const folder = options.migrationsFolder ? resolveMigrationsFolder(options.migrationsFolder) : resolveMigrationsFolder(migrationsFolder);
592
+ let discoveryFn;
593
+ if (options.tenant) {
594
+ discoveryFn = async () => [options.tenant];
595
+ } else if (options.tenants) {
596
+ const tenantIds2 = options.tenants.split(",").map((id) => id.trim());
597
+ discoveryFn = async () => tenantIds2;
598
+ } else if (options.all) {
599
+ if (!tenantDiscovery) {
600
+ throw new Error(
601
+ "No tenant discovery function configured. Add migrations.tenantDiscovery to your config."
602
+ );
603
+ }
604
+ discoveryFn = tenantDiscovery;
605
+ } else {
606
+ spinner.stop();
607
+ console.log(error("Please specify --all, --tenant, or --tenants"));
608
+ console.log(dim("\nExamples:"));
609
+ console.log(dim(" npx drizzle-multitenant migrate --all"));
610
+ console.log(dim(" npx drizzle-multitenant migrate --tenant=tenant-uuid"));
611
+ console.log(dim(" npx drizzle-multitenant migrate --tenants=tenant-1,tenant-2"));
612
+ process.exit(1);
613
+ }
614
+ spinner.text = "Discovering tenants...";
615
+ const migrator = createMigrator(config, {
616
+ migrationsFolder: folder,
617
+ tenantDiscovery: discoveryFn
618
+ });
619
+ const tenantIds = await discoveryFn();
620
+ if (tenantIds.length === 0) {
621
+ spinner.stop();
622
+ console.log(warning("No tenants found."));
623
+ return;
624
+ }
625
+ spinner.text = `Found ${tenantIds.length} tenant${tenantIds.length > 1 ? "s" : ""}`;
626
+ spinner.succeed();
627
+ if (options.dryRun) {
628
+ console.log(info(bold("\nDry run mode - no changes will be made\n")));
629
+ }
630
+ console.log(info(`Migrating ${tenantIds.length} tenant${tenantIds.length > 1 ? "s" : ""}...
631
+ `));
632
+ const concurrency = parseInt(options.concurrency, 10);
633
+ let completed = 0;
634
+ const results = await migrator.migrateAll({
635
+ concurrency,
636
+ dryRun: options.dryRun,
637
+ onProgress: (tenantId, status, migrationName) => {
638
+ if (status === "completed") {
639
+ completed++;
640
+ const progress = `[${completed}/${tenantIds.length}]`;
641
+ console.log(`${dim(progress)} ${success(tenantId)}`);
642
+ } else if (status === "failed") {
643
+ completed++;
644
+ const progress = `[${completed}/${tenantIds.length}]`;
645
+ console.log(`${dim(progress)} ${error(tenantId)}`);
646
+ } else if (status === "migrating" && migrationName) {
647
+ }
648
+ },
649
+ onError: (tenantId, err) => {
650
+ console.log(error(`${tenantId}: ${err.message}`));
651
+ return "continue";
652
+ }
653
+ });
654
+ console.log("\n" + bold("Results:"));
655
+ console.log(createResultsTable(results.details));
656
+ console.log("\n" + bold("Summary:"));
657
+ console.log(` Total: ${results.total}`);
658
+ console.log(` Succeeded: ${success(results.succeeded.toString())}`);
659
+ if (results.failed > 0) {
660
+ console.log(` Failed: ${error(results.failed.toString())}`);
661
+ }
662
+ if (results.skipped > 0) {
663
+ console.log(` Skipped: ${warning(results.skipped.toString())}`);
664
+ }
665
+ if (results.failed > 0) {
666
+ process.exit(1);
667
+ }
668
+ } catch (err) {
669
+ spinner.fail(err.message);
670
+ process.exit(1);
671
+ }
672
+ });
673
+ var statusCommand = new Command("status").description("Show migration status for all tenants").option("-c, --config <path>", "Path to config file").option("--migrations-folder <path>", "Path to migrations folder").action(async (options) => {
674
+ const spinner = createSpinner("Loading configuration...");
675
+ try {
676
+ spinner.start();
677
+ const { config, migrationsFolder, tenantDiscovery } = await loadConfig(options.config);
678
+ if (!tenantDiscovery) {
679
+ throw new Error(
680
+ "No tenant discovery function configured. Add migrations.tenantDiscovery to your config."
681
+ );
682
+ }
683
+ const folder = options.migrationsFolder ? resolveMigrationsFolder(options.migrationsFolder) : resolveMigrationsFolder(migrationsFolder);
684
+ spinner.text = "Discovering tenants...";
685
+ const migrator = createMigrator(config, {
686
+ migrationsFolder: folder,
687
+ tenantDiscovery
688
+ });
689
+ spinner.text = "Fetching migration status...";
690
+ const statuses = await migrator.getStatus();
691
+ spinner.succeed(`Found ${statuses.length} tenant${statuses.length > 1 ? "s" : ""}`);
692
+ console.log("\n" + bold("Migration Status:"));
693
+ console.log(createStatusTable(statuses));
694
+ console.log(createPendingSummary(statuses));
695
+ } catch (err) {
696
+ spinner.fail(err.message);
697
+ process.exit(1);
698
+ }
699
+ });
700
+ 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) => {
701
+ const spinner = createSpinner("Loading configuration...");
702
+ try {
703
+ spinner.start();
704
+ const { migrationsFolder: configFolder } = await loadConfig(options.config);
705
+ const cwd = process.cwd();
706
+ let folder;
707
+ if (options.migrationsFolder) {
708
+ folder = resolve(cwd, options.migrationsFolder);
709
+ } else if (configFolder) {
710
+ folder = resolve(cwd, configFolder);
711
+ } else {
712
+ folder = resolve(cwd, options.type === "shared" ? "./drizzle/shared" : "./drizzle/tenant");
713
+ }
714
+ if (!existsSync(folder)) {
715
+ await mkdir(folder, { recursive: true });
716
+ spinner.text = `Created migrations folder: ${folder}`;
717
+ }
718
+ spinner.text = "Generating migration...";
719
+ const files = existsSync(folder) ? await readdir(folder) : [];
720
+ const sqlFiles = files.filter((f) => f.endsWith(".sql"));
721
+ let maxSequence = 0;
722
+ for (const file of sqlFiles) {
723
+ const match = file.match(/^(\d+)_/);
724
+ if (match) {
725
+ const seq = parseInt(match[1], 10);
726
+ if (seq > maxSequence) {
727
+ maxSequence = seq;
728
+ }
729
+ }
730
+ }
731
+ const nextSequence = (maxSequence + 1).toString().padStart(4, "0");
732
+ const safeName = options.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
733
+ const fileName = `${nextSequence}_${safeName}.sql`;
734
+ const filePath = join(folder, fileName);
735
+ const template = `-- Migration: ${options.name}
736
+ -- Created at: ${(/* @__PURE__ */ new Date()).toISOString()}
737
+ -- Type: ${options.type}
738
+
739
+ -- Write your SQL migration here
740
+
741
+ `;
742
+ await writeFile(filePath, template, "utf-8");
743
+ spinner.succeed("Migration generated");
744
+ console.log("\n" + success(`Created: ${dim(filePath)}`));
745
+ console.log(dim("\nEdit this file to add your migration SQL."));
746
+ } catch (err) {
747
+ spinner.fail(err.message);
748
+ process.exit(1);
749
+ }
750
+ });
751
+ var tenantCreateCommand = new Command("tenant:create").description("Create a new tenant schema and apply all migrations").requiredOption("--id <tenantId>", "Tenant ID").option("-c, --config <path>", "Path to config file").option("--migrations-folder <path>", "Path to migrations folder").option("--no-migrate", "Skip applying migrations after creating schema").action(async (options) => {
752
+ const spinner = createSpinner("Loading configuration...");
753
+ try {
754
+ spinner.start();
755
+ const { config, migrationsFolder } = await loadConfig(options.config);
756
+ const folder = options.migrationsFolder ? resolveMigrationsFolder(options.migrationsFolder) : resolveMigrationsFolder(migrationsFolder);
757
+ const migrator = createMigrator(config, {
758
+ migrationsFolder: folder,
759
+ tenantDiscovery: async () => []
760
+ });
761
+ const schemaName = config.isolation.schemaNameTemplate(options.id);
762
+ spinner.text = `Checking if tenant ${options.id} exists...`;
763
+ const exists = await migrator.tenantExists(options.id);
764
+ if (exists) {
765
+ spinner.warn(`Tenant ${options.id} already exists (${schemaName})`);
766
+ if (options.migrate) {
767
+ spinner.start();
768
+ spinner.text = "Applying pending migrations...";
769
+ const result = await migrator.migrateTenant(options.id);
770
+ if (result.appliedMigrations.length > 0) {
771
+ spinner.succeed(`Applied ${result.appliedMigrations.length} migration(s)`);
772
+ for (const migration of result.appliedMigrations) {
773
+ console.log(` ${dim("-")} ${migration}`);
774
+ }
775
+ } else {
776
+ spinner.succeed("No pending migrations");
777
+ }
778
+ }
779
+ return;
780
+ }
781
+ spinner.text = `Creating tenant schema ${schemaName}...`;
782
+ await migrator.createTenant(options.id, {
783
+ migrate: options.migrate
784
+ });
785
+ spinner.succeed(`Tenant ${options.id} created`);
786
+ console.log("\n" + success("Schema created: ") + dim(schemaName));
787
+ if (options.migrate) {
788
+ console.log(success("All migrations applied"));
789
+ } else {
790
+ console.log(warning("Migrations skipped. Run migrate to apply."));
791
+ }
792
+ console.log(dim("\nYou can now use this tenant:"));
793
+ console.log(dim(` const db = tenants.getDb('${options.id}');`));
794
+ } catch (err) {
795
+ spinner.fail(err.message);
796
+ process.exit(1);
797
+ }
798
+ });
799
+ var tenantDropCommand = new Command("tenant:drop").description("Drop a tenant schema (DESTRUCTIVE)").requiredOption("--id <tenantId>", "Tenant ID").option("-c, --config <path>", "Path to config file").option("--migrations-folder <path>", "Path to migrations folder").option("-f, --force", "Skip confirmation prompt").option("--no-cascade", "Use RESTRICT instead of CASCADE").action(async (options) => {
800
+ const spinner = createSpinner("Loading configuration...");
801
+ try {
802
+ spinner.start();
803
+ const { config, migrationsFolder } = await loadConfig(options.config);
804
+ const folder = options.migrationsFolder ? resolveMigrationsFolder(options.migrationsFolder) : resolveMigrationsFolder(migrationsFolder);
805
+ const migrator = createMigrator(config, {
806
+ migrationsFolder: folder,
807
+ tenantDiscovery: async () => []
808
+ });
809
+ const schemaName = config.isolation.schemaNameTemplate(options.id);
810
+ spinner.text = `Checking if tenant ${options.id} exists...`;
811
+ const exists = await migrator.tenantExists(options.id);
812
+ if (!exists) {
813
+ spinner.warn(`Tenant ${options.id} does not exist`);
814
+ return;
815
+ }
816
+ spinner.stop();
817
+ if (!options.force) {
818
+ console.log(red(bold("\n\u26A0\uFE0F WARNING: This action is DESTRUCTIVE and IRREVERSIBLE!")));
819
+ console.log(dim(`
820
+ You are about to drop schema: ${schemaName}`));
821
+ console.log(dim("All tables and data in this schema will be permanently deleted.\n"));
822
+ const confirmed = await askConfirmation(
823
+ `Type "${options.id}" to confirm deletion: `,
824
+ options.id
825
+ );
826
+ if (!confirmed) {
827
+ console.log("\n" + warning("Operation cancelled."));
828
+ return;
829
+ }
830
+ }
831
+ spinner.start();
832
+ spinner.text = `Dropping tenant schema ${schemaName}...`;
833
+ await migrator.dropTenant(options.id, {
834
+ cascade: options.cascade
835
+ });
836
+ spinner.succeed(`Tenant ${options.id} dropped`);
837
+ console.log("\n" + success("Schema deleted: ") + dim(schemaName));
838
+ } catch (err) {
839
+ spinner.fail(err.message);
840
+ process.exit(1);
841
+ }
842
+ });
843
+ async function askConfirmation(question, expected) {
844
+ const rl = createInterface({
845
+ input: process.stdin,
846
+ output: process.stdout
847
+ });
848
+ return new Promise((resolve3) => {
849
+ rl.question(question, (answer) => {
850
+ rl.close();
851
+ resolve3(answer.trim() === expected);
852
+ });
853
+ });
854
+ }
855
+
856
+ // src/cli/index.ts
857
+ var program = new Command();
858
+ program.name("drizzle-multitenant").description("Multi-tenancy toolkit for Drizzle ORM").version("0.3.0");
859
+ program.addCommand(migrateCommand);
860
+ program.addCommand(statusCommand);
861
+ program.addCommand(generateCommand);
862
+ program.addCommand(tenantCreateCommand);
863
+ program.addCommand(tenantDropCommand);
864
+ program.parse();
865
+ //# sourceMappingURL=index.js.map
866
+ //# sourceMappingURL=index.js.map