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.
package/dist/index.js ADDED
@@ -0,0 +1,935 @@
1
+ import { Pool } from 'pg';
2
+ import { drizzle } from 'drizzle-orm/node-postgres';
3
+ import { LRUCache } from 'lru-cache';
4
+ import { AsyncLocalStorage } from 'async_hooks';
5
+ import { readdir, readFile } from 'fs/promises';
6
+ import { join, basename } from 'path';
7
+ import { sql, getTableName } from 'drizzle-orm';
8
+
9
+ // src/config.ts
10
+ function defineConfig(config) {
11
+ validateConfig(config);
12
+ return config;
13
+ }
14
+ function validateConfig(config) {
15
+ if (!config.connection.url) {
16
+ throw new Error("[drizzle-multitenant] connection.url is required");
17
+ }
18
+ if (!config.isolation.strategy) {
19
+ throw new Error("[drizzle-multitenant] isolation.strategy is required");
20
+ }
21
+ if (config.isolation.strategy !== "schema") {
22
+ throw new Error(
23
+ `[drizzle-multitenant] isolation.strategy "${config.isolation.strategy}" is not yet supported. Only "schema" is currently available.`
24
+ );
25
+ }
26
+ if (!config.isolation.schemaNameTemplate) {
27
+ throw new Error("[drizzle-multitenant] isolation.schemaNameTemplate is required");
28
+ }
29
+ if (typeof config.isolation.schemaNameTemplate !== "function") {
30
+ throw new Error("[drizzle-multitenant] isolation.schemaNameTemplate must be a function");
31
+ }
32
+ if (!config.schemas.tenant) {
33
+ throw new Error("[drizzle-multitenant] schemas.tenant is required");
34
+ }
35
+ if (config.isolation.maxPools !== void 0 && config.isolation.maxPools < 1) {
36
+ throw new Error("[drizzle-multitenant] isolation.maxPools must be at least 1");
37
+ }
38
+ if (config.isolation.poolTtlMs !== void 0 && config.isolation.poolTtlMs < 0) {
39
+ throw new Error("[drizzle-multitenant] isolation.poolTtlMs must be non-negative");
40
+ }
41
+ }
42
+
43
+ // src/types.ts
44
+ var DEFAULT_CONFIG = {
45
+ maxPools: 50,
46
+ poolTtlMs: 60 * 60 * 1e3,
47
+ // 1 hour
48
+ cleanupIntervalMs: 6e4,
49
+ // 1 minute
50
+ poolConfig: {
51
+ max: 10,
52
+ idleTimeoutMillis: 3e4,
53
+ connectionTimeoutMillis: 5e3
54
+ }
55
+ };
56
+
57
+ // src/pool.ts
58
+ var PoolManager = class {
59
+ constructor(config) {
60
+ this.config = config;
61
+ const maxPools = config.isolation.maxPools ?? DEFAULT_CONFIG.maxPools;
62
+ this.pools = new LRUCache({
63
+ max: maxPools,
64
+ dispose: (entry, key) => {
65
+ this.disposePoolEntry(entry, key);
66
+ },
67
+ noDisposeOnSet: true
68
+ });
69
+ }
70
+ pools;
71
+ tenantIdBySchema = /* @__PURE__ */ new Map();
72
+ sharedPool = null;
73
+ sharedDb = null;
74
+ cleanupInterval = null;
75
+ disposed = false;
76
+ /**
77
+ * Get or create a database connection for a tenant
78
+ */
79
+ getDb(tenantId) {
80
+ this.ensureNotDisposed();
81
+ const schemaName = this.config.isolation.schemaNameTemplate(tenantId);
82
+ let entry = this.pools.get(schemaName);
83
+ if (!entry) {
84
+ entry = this.createPoolEntry(tenantId, schemaName);
85
+ this.pools.set(schemaName, entry);
86
+ this.tenantIdBySchema.set(schemaName, tenantId);
87
+ void this.config.hooks?.onPoolCreated?.(tenantId);
88
+ }
89
+ entry.lastAccess = Date.now();
90
+ return entry.db;
91
+ }
92
+ /**
93
+ * Get or create the shared database connection
94
+ */
95
+ getSharedDb() {
96
+ this.ensureNotDisposed();
97
+ if (!this.sharedDb) {
98
+ this.sharedPool = new Pool({
99
+ connectionString: this.config.connection.url,
100
+ ...DEFAULT_CONFIG.poolConfig,
101
+ ...this.config.connection.poolConfig
102
+ });
103
+ this.sharedPool.on("error", (err) => {
104
+ void this.config.hooks?.onError?.("shared", err);
105
+ });
106
+ this.sharedDb = drizzle(this.sharedPool, {
107
+ schema: this.config.schemas.shared
108
+ });
109
+ }
110
+ return this.sharedDb;
111
+ }
112
+ /**
113
+ * Get schema name for a tenant
114
+ */
115
+ getSchemaName(tenantId) {
116
+ return this.config.isolation.schemaNameTemplate(tenantId);
117
+ }
118
+ /**
119
+ * Check if a pool exists for a tenant
120
+ */
121
+ hasPool(tenantId) {
122
+ const schemaName = this.config.isolation.schemaNameTemplate(tenantId);
123
+ return this.pools.has(schemaName);
124
+ }
125
+ /**
126
+ * Get count of active pools
127
+ */
128
+ getPoolCount() {
129
+ return this.pools.size;
130
+ }
131
+ /**
132
+ * Get all active tenant IDs
133
+ */
134
+ getActiveTenantIds() {
135
+ return Array.from(this.tenantIdBySchema.values());
136
+ }
137
+ /**
138
+ * Manually evict a tenant pool
139
+ */
140
+ async evictPool(tenantId) {
141
+ const schemaName = this.config.isolation.schemaNameTemplate(tenantId);
142
+ const entry = this.pools.get(schemaName);
143
+ if (entry) {
144
+ this.pools.delete(schemaName);
145
+ this.tenantIdBySchema.delete(schemaName);
146
+ await this.closePool(entry.pool, tenantId);
147
+ }
148
+ }
149
+ /**
150
+ * Start automatic cleanup of idle pools
151
+ */
152
+ startCleanup() {
153
+ if (this.cleanupInterval) return;
154
+ const poolTtlMs = this.config.isolation.poolTtlMs ?? DEFAULT_CONFIG.poolTtlMs;
155
+ const cleanupIntervalMs = DEFAULT_CONFIG.cleanupIntervalMs;
156
+ this.cleanupInterval = setInterval(() => {
157
+ void this.cleanupIdlePools(poolTtlMs);
158
+ }, cleanupIntervalMs);
159
+ this.cleanupInterval.unref();
160
+ }
161
+ /**
162
+ * Stop automatic cleanup
163
+ */
164
+ stopCleanup() {
165
+ if (this.cleanupInterval) {
166
+ clearInterval(this.cleanupInterval);
167
+ this.cleanupInterval = null;
168
+ }
169
+ }
170
+ /**
171
+ * Dispose all pools and cleanup resources
172
+ */
173
+ async dispose() {
174
+ if (this.disposed) return;
175
+ this.disposed = true;
176
+ this.stopCleanup();
177
+ const closePromises = [];
178
+ for (const [schemaName, entry] of this.pools.entries()) {
179
+ const tenantId = this.tenantIdBySchema.get(schemaName);
180
+ closePromises.push(this.closePool(entry.pool, tenantId ?? schemaName));
181
+ }
182
+ this.pools.clear();
183
+ this.tenantIdBySchema.clear();
184
+ if (this.sharedPool) {
185
+ closePromises.push(this.closePool(this.sharedPool, "shared"));
186
+ this.sharedPool = null;
187
+ this.sharedDb = null;
188
+ }
189
+ await Promise.all(closePromises);
190
+ }
191
+ /**
192
+ * Create a new pool entry for a tenant
193
+ */
194
+ createPoolEntry(tenantId, schemaName) {
195
+ const pool = new Pool({
196
+ connectionString: this.config.connection.url,
197
+ ...DEFAULT_CONFIG.poolConfig,
198
+ ...this.config.connection.poolConfig,
199
+ options: `-c search_path=${schemaName},public`
200
+ });
201
+ pool.on("error", async (err) => {
202
+ void this.config.hooks?.onError?.(tenantId, err);
203
+ await this.evictPool(tenantId);
204
+ });
205
+ const db = drizzle(pool, {
206
+ schema: this.config.schemas.tenant
207
+ });
208
+ return {
209
+ db,
210
+ pool,
211
+ lastAccess: Date.now(),
212
+ schemaName
213
+ };
214
+ }
215
+ /**
216
+ * Dispose a pool entry (called by LRU cache)
217
+ */
218
+ disposePoolEntry(entry, schemaName) {
219
+ const tenantId = this.tenantIdBySchema.get(schemaName);
220
+ this.tenantIdBySchema.delete(schemaName);
221
+ void this.closePool(entry.pool, tenantId ?? schemaName).then(() => {
222
+ if (tenantId) {
223
+ void this.config.hooks?.onPoolEvicted?.(tenantId);
224
+ }
225
+ });
226
+ }
227
+ /**
228
+ * Close a pool gracefully
229
+ */
230
+ async closePool(pool, identifier) {
231
+ try {
232
+ await pool.end();
233
+ } catch (error) {
234
+ void this.config.hooks?.onError?.(identifier, error);
235
+ }
236
+ }
237
+ /**
238
+ * Cleanup pools that have been idle for too long
239
+ */
240
+ async cleanupIdlePools(poolTtlMs) {
241
+ const now = Date.now();
242
+ const toEvict = [];
243
+ for (const [schemaName, entry] of this.pools.entries()) {
244
+ if (now - entry.lastAccess > poolTtlMs) {
245
+ toEvict.push(schemaName);
246
+ }
247
+ }
248
+ for (const schemaName of toEvict) {
249
+ const tenantId = this.tenantIdBySchema.get(schemaName);
250
+ if (tenantId) {
251
+ await this.evictPool(tenantId);
252
+ }
253
+ }
254
+ }
255
+ /**
256
+ * Ensure the manager hasn't been disposed
257
+ */
258
+ ensureNotDisposed() {
259
+ if (this.disposed) {
260
+ throw new Error("[drizzle-multitenant] TenantManager has been disposed");
261
+ }
262
+ }
263
+ };
264
+
265
+ // src/manager.ts
266
+ function createTenantManager(config) {
267
+ const poolManager = new PoolManager(config);
268
+ poolManager.startCleanup();
269
+ return {
270
+ getDb(tenantId) {
271
+ return poolManager.getDb(tenantId);
272
+ },
273
+ getSharedDb() {
274
+ return poolManager.getSharedDb();
275
+ },
276
+ getSchemaName(tenantId) {
277
+ return poolManager.getSchemaName(tenantId);
278
+ },
279
+ hasPool(tenantId) {
280
+ return poolManager.hasPool(tenantId);
281
+ },
282
+ getPoolCount() {
283
+ return poolManager.getPoolCount();
284
+ },
285
+ getActiveTenantIds() {
286
+ return poolManager.getActiveTenantIds();
287
+ },
288
+ async evictPool(tenantId) {
289
+ await poolManager.evictPool(tenantId);
290
+ },
291
+ async dispose() {
292
+ await poolManager.dispose();
293
+ }
294
+ };
295
+ }
296
+ function createTenantContext(manager) {
297
+ const storage = new AsyncLocalStorage();
298
+ function getTenantOrNull() {
299
+ return storage.getStore();
300
+ }
301
+ function getTenant() {
302
+ const context = getTenantOrNull();
303
+ if (!context) {
304
+ throw new Error(
305
+ "[drizzle-multitenant] No tenant context found. Make sure you are calling this within runWithTenant()."
306
+ );
307
+ }
308
+ return context;
309
+ }
310
+ function getTenantId() {
311
+ return getTenant().tenantId;
312
+ }
313
+ function getTenantDb() {
314
+ const tenantId = getTenantId();
315
+ return manager.getDb(tenantId);
316
+ }
317
+ function getSharedDb() {
318
+ return manager.getSharedDb();
319
+ }
320
+ function isInTenantContext() {
321
+ return getTenantOrNull() !== void 0;
322
+ }
323
+ function runWithTenant(context, callback) {
324
+ if (!context.tenantId) {
325
+ throw new Error("[drizzle-multitenant] tenantId is required in context");
326
+ }
327
+ return storage.run(context, callback);
328
+ }
329
+ return {
330
+ runWithTenant,
331
+ getTenant,
332
+ getTenantOrNull,
333
+ getTenantId,
334
+ getTenantDb,
335
+ getSharedDb,
336
+ isInTenantContext
337
+ };
338
+ }
339
+ var DEFAULT_MIGRATIONS_TABLE = "__drizzle_migrations";
340
+ var Migrator = class {
341
+ constructor(tenantConfig, migratorConfig) {
342
+ this.tenantConfig = tenantConfig;
343
+ this.migratorConfig = migratorConfig;
344
+ this.migrationsTable = migratorConfig.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE;
345
+ }
346
+ migrationsTable;
347
+ /**
348
+ * Migrate all tenants in parallel
349
+ */
350
+ async migrateAll(options = {}) {
351
+ const {
352
+ concurrency = 10,
353
+ onProgress,
354
+ onError,
355
+ dryRun = false
356
+ } = options;
357
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
358
+ const migrations = await this.loadMigrations();
359
+ const results = [];
360
+ let aborted = false;
361
+ for (let i = 0; i < tenantIds.length && !aborted; i += concurrency) {
362
+ const batch = tenantIds.slice(i, i + concurrency);
363
+ const batchResults = await Promise.all(
364
+ batch.map(async (tenantId) => {
365
+ if (aborted) {
366
+ return this.createSkippedResult(tenantId);
367
+ }
368
+ try {
369
+ onProgress?.(tenantId, "starting");
370
+ const result = await this.migrateTenant(tenantId, migrations, { dryRun, onProgress });
371
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
372
+ return result;
373
+ } catch (error) {
374
+ onProgress?.(tenantId, "failed");
375
+ const action = onError?.(tenantId, error);
376
+ if (action === "abort") {
377
+ aborted = true;
378
+ }
379
+ return this.createErrorResult(tenantId, error);
380
+ }
381
+ })
382
+ );
383
+ results.push(...batchResults);
384
+ }
385
+ if (aborted) {
386
+ const remaining = tenantIds.slice(results.length);
387
+ for (const tenantId of remaining) {
388
+ results.push(this.createSkippedResult(tenantId));
389
+ }
390
+ }
391
+ return this.aggregateResults(results);
392
+ }
393
+ /**
394
+ * Migrate a single tenant
395
+ */
396
+ async migrateTenant(tenantId, migrations, options = {}) {
397
+ const startTime = Date.now();
398
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
399
+ const appliedMigrations = [];
400
+ const pool = await this.createPool(schemaName);
401
+ try {
402
+ await this.migratorConfig.hooks?.beforeTenant?.(tenantId);
403
+ await this.ensureMigrationsTable(pool, schemaName);
404
+ const allMigrations = migrations ?? await this.loadMigrations();
405
+ const applied = await this.getAppliedMigrations(pool, schemaName);
406
+ const appliedSet = new Set(applied.map((m) => m.name));
407
+ const pending = allMigrations.filter((m) => !appliedSet.has(m.name));
408
+ if (options.dryRun) {
409
+ return {
410
+ tenantId,
411
+ schemaName,
412
+ success: true,
413
+ appliedMigrations: pending.map((m) => m.name),
414
+ durationMs: Date.now() - startTime
415
+ };
416
+ }
417
+ for (const migration of pending) {
418
+ const migrationStart = Date.now();
419
+ options.onProgress?.(tenantId, "migrating", migration.name);
420
+ await this.migratorConfig.hooks?.beforeMigration?.(tenantId, migration.name);
421
+ await this.applyMigration(pool, schemaName, migration);
422
+ await this.migratorConfig.hooks?.afterMigration?.(
423
+ tenantId,
424
+ migration.name,
425
+ Date.now() - migrationStart
426
+ );
427
+ appliedMigrations.push(migration.name);
428
+ }
429
+ const result = {
430
+ tenantId,
431
+ schemaName,
432
+ success: true,
433
+ appliedMigrations,
434
+ durationMs: Date.now() - startTime
435
+ };
436
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
437
+ return result;
438
+ } catch (error) {
439
+ const result = {
440
+ tenantId,
441
+ schemaName,
442
+ success: false,
443
+ appliedMigrations,
444
+ error: error.message,
445
+ durationMs: Date.now() - startTime
446
+ };
447
+ await this.migratorConfig.hooks?.afterTenant?.(tenantId, result);
448
+ return result;
449
+ } finally {
450
+ await pool.end();
451
+ }
452
+ }
453
+ /**
454
+ * Migrate specific tenants
455
+ */
456
+ async migrateTenants(tenantIds, options = {}) {
457
+ const migrations = await this.loadMigrations();
458
+ const results = [];
459
+ const { concurrency = 10, onProgress, onError } = options;
460
+ for (let i = 0; i < tenantIds.length; i += concurrency) {
461
+ const batch = tenantIds.slice(i, i + concurrency);
462
+ const batchResults = await Promise.all(
463
+ batch.map(async (tenantId) => {
464
+ try {
465
+ onProgress?.(tenantId, "starting");
466
+ const result = await this.migrateTenant(tenantId, migrations, { dryRun: options.dryRun ?? false, onProgress });
467
+ onProgress?.(tenantId, result.success ? "completed" : "failed");
468
+ return result;
469
+ } catch (error) {
470
+ onProgress?.(tenantId, "failed");
471
+ onError?.(tenantId, error);
472
+ return this.createErrorResult(tenantId, error);
473
+ }
474
+ })
475
+ );
476
+ results.push(...batchResults);
477
+ }
478
+ return this.aggregateResults(results);
479
+ }
480
+ /**
481
+ * Get migration status for all tenants
482
+ */
483
+ async getStatus() {
484
+ const tenantIds = await this.migratorConfig.tenantDiscovery();
485
+ const migrations = await this.loadMigrations();
486
+ const statuses = [];
487
+ for (const tenantId of tenantIds) {
488
+ statuses.push(await this.getTenantStatus(tenantId, migrations));
489
+ }
490
+ return statuses;
491
+ }
492
+ /**
493
+ * Get migration status for a specific tenant
494
+ */
495
+ async getTenantStatus(tenantId, migrations) {
496
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
497
+ const pool = await this.createPool(schemaName);
498
+ try {
499
+ const allMigrations = migrations ?? await this.loadMigrations();
500
+ const tableExists = await this.migrationsTableExists(pool, schemaName);
501
+ if (!tableExists) {
502
+ return {
503
+ tenantId,
504
+ schemaName,
505
+ appliedCount: 0,
506
+ pendingCount: allMigrations.length,
507
+ pendingMigrations: allMigrations.map((m) => m.name),
508
+ status: allMigrations.length > 0 ? "behind" : "ok"
509
+ };
510
+ }
511
+ const applied = await this.getAppliedMigrations(pool, schemaName);
512
+ const appliedSet = new Set(applied.map((m) => m.name));
513
+ const pending = allMigrations.filter((m) => !appliedSet.has(m.name));
514
+ return {
515
+ tenantId,
516
+ schemaName,
517
+ appliedCount: applied.length,
518
+ pendingCount: pending.length,
519
+ pendingMigrations: pending.map((m) => m.name),
520
+ status: pending.length > 0 ? "behind" : "ok"
521
+ };
522
+ } catch (error) {
523
+ return {
524
+ tenantId,
525
+ schemaName,
526
+ appliedCount: 0,
527
+ pendingCount: 0,
528
+ pendingMigrations: [],
529
+ status: "error",
530
+ error: error.message
531
+ };
532
+ } finally {
533
+ await pool.end();
534
+ }
535
+ }
536
+ /**
537
+ * Create a new tenant schema and optionally apply migrations
538
+ */
539
+ async createTenant(tenantId, options = {}) {
540
+ const { migrate = true } = options;
541
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
542
+ const pool = new Pool({
543
+ connectionString: this.tenantConfig.connection.url,
544
+ ...this.tenantConfig.connection.poolConfig
545
+ });
546
+ try {
547
+ await pool.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
548
+ if (migrate) {
549
+ await this.migrateTenant(tenantId);
550
+ }
551
+ } finally {
552
+ await pool.end();
553
+ }
554
+ }
555
+ /**
556
+ * Drop a tenant schema
557
+ */
558
+ async dropTenant(tenantId, options = {}) {
559
+ const { cascade = true } = options;
560
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
561
+ const pool = new Pool({
562
+ connectionString: this.tenantConfig.connection.url,
563
+ ...this.tenantConfig.connection.poolConfig
564
+ });
565
+ try {
566
+ const cascadeSql = cascade ? "CASCADE" : "RESTRICT";
567
+ await pool.query(`DROP SCHEMA IF EXISTS "${schemaName}" ${cascadeSql}`);
568
+ } finally {
569
+ await pool.end();
570
+ }
571
+ }
572
+ /**
573
+ * Check if a tenant schema exists
574
+ */
575
+ async tenantExists(tenantId) {
576
+ const schemaName = this.tenantConfig.isolation.schemaNameTemplate(tenantId);
577
+ const pool = new Pool({
578
+ connectionString: this.tenantConfig.connection.url,
579
+ ...this.tenantConfig.connection.poolConfig
580
+ });
581
+ try {
582
+ const result = await pool.query(
583
+ `SELECT 1 FROM information_schema.schemata WHERE schema_name = $1`,
584
+ [schemaName]
585
+ );
586
+ return result.rowCount !== null && result.rowCount > 0;
587
+ } finally {
588
+ await pool.end();
589
+ }
590
+ }
591
+ /**
592
+ * Load migration files from the migrations folder
593
+ */
594
+ async loadMigrations() {
595
+ const files = await readdir(this.migratorConfig.migrationsFolder);
596
+ const migrations = [];
597
+ for (const file of files) {
598
+ if (!file.endsWith(".sql")) continue;
599
+ const filePath = join(this.migratorConfig.migrationsFolder, file);
600
+ const content = await readFile(filePath, "utf-8");
601
+ const match = file.match(/^(\d+)_/);
602
+ const timestamp = match?.[1] ? parseInt(match[1], 10) : 0;
603
+ migrations.push({
604
+ name: basename(file, ".sql"),
605
+ path: filePath,
606
+ sql: content,
607
+ timestamp
608
+ });
609
+ }
610
+ return migrations.sort((a, b) => a.timestamp - b.timestamp);
611
+ }
612
+ /**
613
+ * Create a pool for a specific schema
614
+ */
615
+ async createPool(schemaName) {
616
+ return new Pool({
617
+ connectionString: this.tenantConfig.connection.url,
618
+ ...this.tenantConfig.connection.poolConfig,
619
+ options: `-c search_path="${schemaName}",public`
620
+ });
621
+ }
622
+ /**
623
+ * Ensure migrations table exists
624
+ */
625
+ async ensureMigrationsTable(pool, schemaName) {
626
+ await pool.query(`
627
+ CREATE TABLE IF NOT EXISTS "${schemaName}"."${this.migrationsTable}" (
628
+ id SERIAL PRIMARY KEY,
629
+ name VARCHAR(255) NOT NULL UNIQUE,
630
+ applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
631
+ )
632
+ `);
633
+ }
634
+ /**
635
+ * Check if migrations table exists
636
+ */
637
+ async migrationsTableExists(pool, schemaName) {
638
+ const result = await pool.query(
639
+ `SELECT 1 FROM information_schema.tables
640
+ WHERE table_schema = $1 AND table_name = $2`,
641
+ [schemaName, this.migrationsTable]
642
+ );
643
+ return result.rowCount !== null && result.rowCount > 0;
644
+ }
645
+ /**
646
+ * Get applied migrations for a schema
647
+ */
648
+ async getAppliedMigrations(pool, schemaName) {
649
+ const result = await pool.query(
650
+ `SELECT id, name, applied_at FROM "${schemaName}"."${this.migrationsTable}" ORDER BY id`
651
+ );
652
+ return result.rows.map((row) => ({
653
+ id: row.id,
654
+ name: row.name,
655
+ appliedAt: row.applied_at
656
+ }));
657
+ }
658
+ /**
659
+ * Apply a migration to a schema
660
+ */
661
+ async applyMigration(pool, schemaName, migration) {
662
+ const client = await pool.connect();
663
+ try {
664
+ await client.query("BEGIN");
665
+ await client.query(migration.sql);
666
+ await client.query(
667
+ `INSERT INTO "${schemaName}"."${this.migrationsTable}" (name) VALUES ($1)`,
668
+ [migration.name]
669
+ );
670
+ await client.query("COMMIT");
671
+ } catch (error) {
672
+ await client.query("ROLLBACK");
673
+ throw error;
674
+ } finally {
675
+ client.release();
676
+ }
677
+ }
678
+ /**
679
+ * Create a skipped result
680
+ */
681
+ createSkippedResult(tenantId) {
682
+ return {
683
+ tenantId,
684
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
685
+ success: false,
686
+ appliedMigrations: [],
687
+ error: "Skipped due to abort",
688
+ durationMs: 0
689
+ };
690
+ }
691
+ /**
692
+ * Create an error result
693
+ */
694
+ createErrorResult(tenantId, error) {
695
+ return {
696
+ tenantId,
697
+ schemaName: this.tenantConfig.isolation.schemaNameTemplate(tenantId),
698
+ success: false,
699
+ appliedMigrations: [],
700
+ error: error.message,
701
+ durationMs: 0
702
+ };
703
+ }
704
+ /**
705
+ * Aggregate migration results
706
+ */
707
+ aggregateResults(results) {
708
+ return {
709
+ total: results.length,
710
+ succeeded: results.filter((r) => r.success).length,
711
+ failed: results.filter((r) => !r.success && r.error !== "Skipped due to abort").length,
712
+ skipped: results.filter((r) => r.error === "Skipped due to abort").length,
713
+ details: results
714
+ };
715
+ }
716
+ };
717
+ function createMigrator(tenantConfig, migratorConfig) {
718
+ return new Migrator(tenantConfig, migratorConfig);
719
+ }
720
+ var CrossSchemaQueryBuilder = class {
721
+ constructor(context) {
722
+ this.context = context;
723
+ }
724
+ fromTable = null;
725
+ joins = [];
726
+ selectFields = {};
727
+ whereCondition = null;
728
+ orderByFields = [];
729
+ limitValue = null;
730
+ offsetValue = null;
731
+ /**
732
+ * Set the main table to query from
733
+ */
734
+ from(source, table) {
735
+ const schemaName = this.getSchemaName(source);
736
+ this.fromTable = { table, source, schemaName };
737
+ return this;
738
+ }
739
+ /**
740
+ * Add an inner join
741
+ */
742
+ innerJoin(source, table, condition) {
743
+ return this.addJoin(source, table, condition, "inner");
744
+ }
745
+ /**
746
+ * Add a left join
747
+ */
748
+ leftJoin(source, table, condition) {
749
+ return this.addJoin(source, table, condition, "left");
750
+ }
751
+ /**
752
+ * Add a right join
753
+ */
754
+ rightJoin(source, table, condition) {
755
+ return this.addJoin(source, table, condition, "right");
756
+ }
757
+ /**
758
+ * Add a full outer join
759
+ */
760
+ fullJoin(source, table, condition) {
761
+ return this.addJoin(source, table, condition, "full");
762
+ }
763
+ /**
764
+ * Select specific fields
765
+ */
766
+ select(fields) {
767
+ this.selectFields = fields;
768
+ return this;
769
+ }
770
+ /**
771
+ * Add a where condition
772
+ */
773
+ where(condition) {
774
+ this.whereCondition = condition;
775
+ return this;
776
+ }
777
+ /**
778
+ * Add order by
779
+ */
780
+ orderBy(...fields) {
781
+ this.orderByFields = fields;
782
+ return this;
783
+ }
784
+ /**
785
+ * Set limit
786
+ */
787
+ limit(value) {
788
+ this.limitValue = value;
789
+ return this;
790
+ }
791
+ /**
792
+ * Set offset
793
+ */
794
+ offset(value) {
795
+ this.offsetValue = value;
796
+ return this;
797
+ }
798
+ /**
799
+ * Execute the query and return typed results
800
+ */
801
+ async execute() {
802
+ if (!this.fromTable) {
803
+ throw new Error("[drizzle-multitenant] No table specified. Use .from() first.");
804
+ }
805
+ const sqlQuery = this.buildSql();
806
+ const result = await this.context.tenantDb.execute(sqlQuery);
807
+ return result.rows;
808
+ }
809
+ /**
810
+ * Build the SQL query
811
+ */
812
+ buildSql() {
813
+ if (!this.fromTable) {
814
+ throw new Error("[drizzle-multitenant] No table specified");
815
+ }
816
+ const parts = [];
817
+ const selectParts = Object.entries(this.selectFields).map(([alias, column]) => {
818
+ const columnName = column.name;
819
+ return sql`${sql.raw(`"${columnName}"`)} as ${sql.raw(`"${alias}"`)}`;
820
+ });
821
+ if (selectParts.length === 0) {
822
+ parts.push(sql`SELECT *`);
823
+ } else {
824
+ parts.push(sql`SELECT ${sql.join(selectParts, sql`, `)}`);
825
+ }
826
+ const fromTableRef = this.getFullTableName(this.fromTable.schemaName, this.fromTable.table);
827
+ parts.push(sql` FROM ${sql.raw(fromTableRef)}`);
828
+ for (const join2 of this.joins) {
829
+ const joinTableRef = this.getFullTableName(join2.schemaName, join2.table);
830
+ const joinType = this.getJoinKeyword(join2.type);
831
+ parts.push(sql` ${sql.raw(joinType)} ${sql.raw(joinTableRef)} ON ${join2.condition}`);
832
+ }
833
+ if (this.whereCondition) {
834
+ parts.push(sql` WHERE ${this.whereCondition}`);
835
+ }
836
+ if (this.orderByFields.length > 0) {
837
+ parts.push(sql` ORDER BY ${sql.join(this.orderByFields, sql`, `)}`);
838
+ }
839
+ if (this.limitValue !== null) {
840
+ parts.push(sql` LIMIT ${sql.raw(this.limitValue.toString())}`);
841
+ }
842
+ if (this.offsetValue !== null) {
843
+ parts.push(sql` OFFSET ${sql.raw(this.offsetValue.toString())}`);
844
+ }
845
+ return sql.join(parts, sql``);
846
+ }
847
+ /**
848
+ * Add a join to the query
849
+ */
850
+ addJoin(source, table, condition, type) {
851
+ const schemaName = this.getSchemaName(source);
852
+ this.joins.push({ table, source, schemaName, condition, type });
853
+ return this;
854
+ }
855
+ /**
856
+ * Get schema name for a source
857
+ */
858
+ getSchemaName(source) {
859
+ if (source === "tenant") {
860
+ return this.context.tenantSchema ?? "tenant";
861
+ }
862
+ return this.context.sharedSchema ?? "public";
863
+ }
864
+ /**
865
+ * Get fully qualified table name
866
+ */
867
+ getFullTableName(schemaName, table) {
868
+ const tableName = getTableName(table);
869
+ return `"${schemaName}"."${tableName}"`;
870
+ }
871
+ /**
872
+ * Get SQL keyword for join type
873
+ */
874
+ getJoinKeyword(type) {
875
+ switch (type) {
876
+ case "inner":
877
+ return "INNER JOIN";
878
+ case "left":
879
+ return "LEFT JOIN";
880
+ case "right":
881
+ return "RIGHT JOIN";
882
+ case "full":
883
+ return "FULL OUTER JOIN";
884
+ }
885
+ }
886
+ };
887
+ function createCrossSchemaQuery(context) {
888
+ return new CrossSchemaQueryBuilder(context);
889
+ }
890
+ async function withSharedLookup(config) {
891
+ const {
892
+ tenantDb,
893
+ tenantTable,
894
+ sharedTable,
895
+ foreignKey,
896
+ sharedKey = "id",
897
+ sharedFields,
898
+ where: whereCondition
899
+ } = config;
900
+ const tenantTableName = getTableName(tenantTable);
901
+ const sharedTableName = getTableName(sharedTable);
902
+ const sharedFieldList = sharedFields.map((field) => `s."${String(field)}"`).join(", ");
903
+ const queryParts = [
904
+ `SELECT t.*, ${sharedFieldList}`,
905
+ `FROM "${tenantTableName}" t`,
906
+ `LEFT JOIN "public"."${sharedTableName}" s ON t."${String(foreignKey)}" = s."${String(sharedKey)}"`
907
+ ];
908
+ if (whereCondition) {
909
+ queryParts.push("WHERE");
910
+ }
911
+ const sqlQuery = sql.raw(queryParts.join(" "));
912
+ const result = await tenantDb.execute(sqlQuery);
913
+ return result.rows;
914
+ }
915
+ async function crossSchemaRaw(db, options) {
916
+ const { tenantSchema, sharedSchema, sql: rawSql } = options;
917
+ const processedSql = rawSql.replace(/\$tenant\./g, `"${tenantSchema}".`).replace(/\$shared\./g, `"${sharedSchema}".`);
918
+ const query = sql.raw(processedSql);
919
+ const result = await db.execute(query);
920
+ return result.rows;
921
+ }
922
+ function buildCrossSchemaSelect(fields, tenantSchema, _sharedSchema) {
923
+ const columns = Object.entries(fields).map(([alias, column]) => {
924
+ const columnName = column.name;
925
+ return `"${columnName}" as "${alias}"`;
926
+ });
927
+ const getSchema = () => {
928
+ return tenantSchema;
929
+ };
930
+ return { columns, getSchema };
931
+ }
932
+
933
+ export { CrossSchemaQueryBuilder, DEFAULT_CONFIG, Migrator, buildCrossSchemaSelect, createCrossSchemaQuery, createMigrator, createTenantContext, createTenantManager, crossSchemaRaw, defineConfig, withSharedLookup };
934
+ //# sourceMappingURL=index.js.map
935
+ //# sourceMappingURL=index.js.map