@veloxts/orm 0.6.27 → 0.6.31

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,334 @@
1
+ /**
2
+ * Tenant schema manager for PostgreSQL schema lifecycle operations
3
+ *
4
+ * Handles:
5
+ * - Creating tenant schemas
6
+ * - Running Prisma migrations per schema
7
+ * - Listing and deleting schemas
8
+ *
9
+ * SECURITY:
10
+ * - All SQL queries use parameterized queries via pg library
11
+ * - Prisma migrations use execFile (no shell) with validated paths
12
+ * - Input validation prevents injection attacks
13
+ */
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import pg from 'pg';
17
+ import format from 'pg-format';
18
+ import { InvalidSlugError, SchemaCreateError, SchemaDeleteError, SchemaListError, SchemaMigrateError, SchemaNotFoundError, } from '../errors.js';
19
+ const { Client } = pg;
20
+ const execFileAsync = promisify(execFile);
21
+ /**
22
+ * Default configuration
23
+ */
24
+ const DEFAULTS = {
25
+ schemaPrefix: 'tenant_',
26
+ prismaSchemaPath: './prisma/schema.prisma',
27
+ };
28
+ /**
29
+ * Regex for validating schema names (PostgreSQL identifier rules)
30
+ * Must start with letter or underscore, contain only alphanumeric and underscores
31
+ */
32
+ const SCHEMA_NAME_REGEX = /^[a-z_][a-z0-9_]*$/i;
33
+ /**
34
+ * Maximum schema name length (PostgreSQL limit is 63)
35
+ */
36
+ const MAX_SCHEMA_NAME_LENGTH = 63;
37
+ /**
38
+ * Reserved PostgreSQL schema names that cannot be used
39
+ */
40
+ const RESERVED_SCHEMAS = new Set([
41
+ 'public',
42
+ 'pg_catalog',
43
+ 'pg_toast',
44
+ 'pg_temp',
45
+ 'information_schema',
46
+ ]);
47
+ /**
48
+ * Validate database URL format and check for injection patterns
49
+ */
50
+ function validateDatabaseUrl(url) {
51
+ try {
52
+ const parsed = new URL(url);
53
+ // Only allow postgresql:// protocol
54
+ if (parsed.protocol !== 'postgresql:' && parsed.protocol !== 'postgres:') {
55
+ throw new Error('Invalid database protocol');
56
+ }
57
+ // Check for shell metacharacters
58
+ const DANGEROUS_CHARS = /[;|&$`<>(){}[\]!]/;
59
+ if (DANGEROUS_CHARS.test(url)) {
60
+ throw new Error('Database URL contains dangerous characters');
61
+ }
62
+ }
63
+ catch {
64
+ throw new Error('Invalid database URL format');
65
+ }
66
+ }
67
+ /**
68
+ * Validate Prisma schema path to prevent path traversal
69
+ */
70
+ function validatePrismaSchemaPath(path) {
71
+ // Check for path traversal
72
+ if (path.includes('..') || path.includes('\0')) {
73
+ throw new Error('Invalid Prisma schema path: path traversal detected');
74
+ }
75
+ // Check for shell metacharacters
76
+ const DANGEROUS_CHARS = /[;|&$`<>(){}[\]!'"]/;
77
+ if (DANGEROUS_CHARS.test(path)) {
78
+ throw new Error('Invalid Prisma schema path: dangerous characters detected');
79
+ }
80
+ // Must end with .prisma
81
+ if (!path.endsWith('.prisma')) {
82
+ throw new Error('Invalid Prisma schema path: must end with .prisma');
83
+ }
84
+ }
85
+ /**
86
+ * Sanitize error messages to prevent credential leakage
87
+ */
88
+ function sanitizeError(error) {
89
+ let message = error.message;
90
+ // Remove connection strings
91
+ message = message.replace(/postgresql:\/\/[^@]+@[^\s"']+/gi, 'postgresql://***:***@***/***');
92
+ // Remove passwords
93
+ message = message.replace(/password[=:]\s*['"]?[^'"\s]+/gi, 'password=***');
94
+ return new Error(message);
95
+ }
96
+ /**
97
+ * Create a tenant schema manager
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const schemaManager = createTenantSchemaManager({
102
+ * databaseUrl: process.env.DATABASE_URL!,
103
+ * schemaPrefix: 'tenant_',
104
+ * });
105
+ *
106
+ * // Create a new schema
107
+ * const result = await schemaManager.createSchema('acme-corp');
108
+ * // result.schemaName === 'tenant_acme_corp'
109
+ *
110
+ * // Run migrations
111
+ * await schemaManager.migrateSchema('tenant_acme_corp');
112
+ * ```
113
+ */
114
+ export function createTenantSchemaManager(config) {
115
+ // Validate configuration
116
+ validateDatabaseUrl(config.databaseUrl);
117
+ const schemaPrefix = config.schemaPrefix ?? DEFAULTS.schemaPrefix;
118
+ const prismaSchemaPath = config.prismaSchemaPath ?? DEFAULTS.prismaSchemaPath;
119
+ validatePrismaSchemaPath(prismaSchemaPath);
120
+ /**
121
+ * Create a PostgreSQL client connection
122
+ */
123
+ async function createClient() {
124
+ const client = new Client({ connectionString: config.databaseUrl });
125
+ await client.connect();
126
+ return client;
127
+ }
128
+ /**
129
+ * Execute a parameterized SQL query safely
130
+ */
131
+ async function executeSql(sql, params = []) {
132
+ const client = await createClient();
133
+ try {
134
+ const result = await client.query(sql, params);
135
+ return result.rows;
136
+ }
137
+ catch (error) {
138
+ throw sanitizeError(error instanceof Error ? error : new Error(String(error)));
139
+ }
140
+ finally {
141
+ await client.end();
142
+ }
143
+ }
144
+ /**
145
+ * Sanitize a slug to create a valid schema name
146
+ */
147
+ function slugToSchemaName(slug) {
148
+ // Convert to lowercase and replace hyphens with underscores
149
+ const sanitized = slug.toLowerCase().replace(/-/g, '_');
150
+ // Remove any characters that aren't alphanumeric or underscore
151
+ const cleaned = sanitized.replace(/[^a-z0-9_]/g, '');
152
+ // Ensure it starts with a letter or underscore
153
+ const normalized = /^[a-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
154
+ return `${schemaPrefix}${normalized}`;
155
+ }
156
+ /**
157
+ * Validate a slug with strict security checks
158
+ */
159
+ function validateSlug(slug) {
160
+ if (!slug || slug.trim().length === 0) {
161
+ throw new InvalidSlugError(slug, 'slug cannot be empty');
162
+ }
163
+ if (slug.length > 50) {
164
+ throw new InvalidSlugError(slug, 'slug cannot exceed 50 characters');
165
+ }
166
+ // Strict whitelist validation
167
+ const VALID_SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
168
+ if (!VALID_SLUG_REGEX.test(slug)) {
169
+ throw new InvalidSlugError(slug, 'slug must contain only lowercase letters, numbers, and hyphens');
170
+ }
171
+ // Check for dangerous patterns
172
+ const DANGEROUS_PATTERNS = [
173
+ /[;|&$`<>]/, // Shell metacharacters
174
+ /['"`]/, // SQL quotes
175
+ /\0/, // Null bytes
176
+ /\.\./, // Path traversal
177
+ /[\\/]/, // Path separators
178
+ ];
179
+ for (const pattern of DANGEROUS_PATTERNS) {
180
+ if (pattern.test(slug)) {
181
+ throw new InvalidSlugError(slug, 'slug contains forbidden characters');
182
+ }
183
+ }
184
+ const schemaName = slugToSchemaName(slug);
185
+ if (!SCHEMA_NAME_REGEX.test(schemaName)) {
186
+ throw new InvalidSlugError(slug, 'results in invalid schema name');
187
+ }
188
+ if (schemaName.length > MAX_SCHEMA_NAME_LENGTH) {
189
+ throw new InvalidSlugError(slug, `results in schema name exceeding ${MAX_SCHEMA_NAME_LENGTH} characters`);
190
+ }
191
+ if (RESERVED_SCHEMAS.has(schemaName.toLowerCase())) {
192
+ throw new InvalidSlugError(slug, 'results in reserved schema name');
193
+ }
194
+ }
195
+ /**
196
+ * Validate a schema name
197
+ */
198
+ function validateSchemaName(schemaName) {
199
+ if (!SCHEMA_NAME_REGEX.test(schemaName)) {
200
+ throw new Error(`Invalid schema name: ${schemaName}`);
201
+ }
202
+ if (schemaName.length > MAX_SCHEMA_NAME_LENGTH) {
203
+ throw new Error(`Schema name too long: ${schemaName}`);
204
+ }
205
+ if (RESERVED_SCHEMAS.has(schemaName.toLowerCase())) {
206
+ throw new Error(`Cannot use reserved schema name: ${schemaName}`);
207
+ }
208
+ // Additional security checks
209
+ const DANGEROUS_PATTERNS = [/[;|&$`<>]/, /['"`]/, /\0/, /\.\./, /[\\/]/];
210
+ for (const pattern of DANGEROUS_PATTERNS) {
211
+ if (pattern.test(schemaName)) {
212
+ throw new Error(`Schema name contains forbidden characters: ${schemaName}`);
213
+ }
214
+ }
215
+ }
216
+ return {
217
+ /**
218
+ * Create a new PostgreSQL schema for a tenant
219
+ */
220
+ async createSchema(slug) {
221
+ validateSlug(slug);
222
+ const schemaName = slugToSchemaName(slug);
223
+ try {
224
+ // Check if schema already exists using parameterized query
225
+ const exists = await this.schemaExists(schemaName);
226
+ if (exists) {
227
+ return { schemaName, created: false };
228
+ }
229
+ // Create the schema using pg-format for safe identifier quoting
230
+ // %I is the identifier placeholder that properly escapes schema names
231
+ const createSchemaSql = format('CREATE SCHEMA %I', schemaName);
232
+ await executeSql(createSchemaSql);
233
+ return { schemaName, created: true };
234
+ }
235
+ catch (error) {
236
+ throw new SchemaCreateError(schemaName, error instanceof Error ? error : new Error(String(error)));
237
+ }
238
+ },
239
+ /**
240
+ * Run Prisma migrations on a tenant schema
241
+ *
242
+ * SECURITY: Uses execFile (not exec) to prevent command injection
243
+ */
244
+ async migrateSchema(schemaName) {
245
+ validateSchemaName(schemaName);
246
+ try {
247
+ // Set the schema in the database URL
248
+ const url = new URL(config.databaseUrl);
249
+ url.searchParams.set('schema', schemaName);
250
+ const schemaUrl = url.toString();
251
+ // Run prisma migrate deploy using execFile (no shell interpretation)
252
+ // This prevents command injection via the schemaUrl or prismaSchemaPath
253
+ const { stdout } = await execFileAsync('npx', ['prisma', 'migrate', 'deploy', `--schema=${prismaSchemaPath}`], {
254
+ env: { ...process.env, DATABASE_URL: schemaUrl },
255
+ timeout: 120000, // 2 minute timeout for migrations
256
+ });
257
+ // Parse migration count from output
258
+ const match = stdout.match(/(\d+) migration[s]? applied/i);
259
+ const migrationsApplied = match ? Number.parseInt(match[1], 10) : 0;
260
+ return { schemaName, migrationsApplied };
261
+ }
262
+ catch (error) {
263
+ throw new SchemaMigrateError(schemaName, sanitizeError(error instanceof Error ? error : new Error(String(error))));
264
+ }
265
+ },
266
+ /**
267
+ * Delete a PostgreSQL schema (DANGEROUS - drops all data)
268
+ */
269
+ async deleteSchema(schemaName) {
270
+ validateSchemaName(schemaName);
271
+ // Extra safety check - don't allow deleting public schema
272
+ if (schemaName.toLowerCase() === 'public') {
273
+ throw new Error('Cannot delete public schema');
274
+ }
275
+ try {
276
+ const exists = await this.schemaExists(schemaName);
277
+ if (!exists) {
278
+ throw new SchemaNotFoundError(schemaName);
279
+ }
280
+ // CASCADE drops all objects in the schema
281
+ // Using pg-format for safe identifier quoting
282
+ const dropSchemaSql = format('DROP SCHEMA %I CASCADE', schemaName);
283
+ await executeSql(dropSchemaSql);
284
+ }
285
+ catch (error) {
286
+ if (error instanceof SchemaNotFoundError) {
287
+ throw error;
288
+ }
289
+ throw new SchemaDeleteError(schemaName, error instanceof Error ? error : new Error(String(error)));
290
+ }
291
+ },
292
+ /**
293
+ * List all tenant schemas
294
+ *
295
+ * @throws {SchemaListError} When database query fails
296
+ */
297
+ async listSchemas() {
298
+ try {
299
+ // Use parameterized query with LIKE pattern
300
+ const result = await executeSql(`SELECT schema_name
301
+ FROM information_schema.schemata
302
+ WHERE schema_name LIKE $1
303
+ ORDER BY schema_name`, [`${schemaPrefix}%`]);
304
+ return result.map((row) => row.schema_name);
305
+ }
306
+ catch (error) {
307
+ throw new SchemaListError(error instanceof Error ? error : new Error(String(error)));
308
+ }
309
+ },
310
+ /**
311
+ * Check if a schema exists
312
+ *
313
+ * @throws {Error} When database query fails (schema name validation errors, connection issues)
314
+ */
315
+ async schemaExists(schemaName) {
316
+ validateSchemaName(schemaName);
317
+ // Use parameterized query - errors propagate to caller
318
+ const result = await executeSql(`SELECT EXISTS(
319
+ SELECT 1 FROM information_schema.schemata
320
+ WHERE schema_name = $1
321
+ ) as exists`, [schemaName]);
322
+ return result[0]?.exists ?? false;
323
+ },
324
+ };
325
+ }
326
+ /**
327
+ * Utility to convert a slug to a schema name without creating a manager
328
+ */
329
+ export function slugToSchemaName(slug, prefix = 'tenant_') {
330
+ const sanitized = slug.toLowerCase().replace(/-/g, '_');
331
+ const cleaned = sanitized.replace(/[^a-z0-9_]/g, '');
332
+ const normalized = /^[a-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
333
+ return `${prefix}${normalized}`;
334
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Tenant provisioner for creating and managing tenant lifecycle
3
+ *
4
+ * Orchestrates:
5
+ * - Creating tenant records in public schema
6
+ * - Creating PostgreSQL schemas
7
+ * - Running migrations
8
+ * - Cleanup on failure
9
+ */
10
+ import type { DatabaseClient } from '../../types.js';
11
+ import type { TenantProvisioner as ITenantProvisioner, TenantProvisionerConfig } from '../types.js';
12
+ /**
13
+ * Create a tenant provisioner
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const provisioner = createTenantProvisioner({
18
+ * schemaManager,
19
+ * publicClient: publicDb,
20
+ * clientPool,
21
+ * });
22
+ *
23
+ * // Provision a new tenant
24
+ * const result = await provisioner.provision({
25
+ * slug: 'acme-corp',
26
+ * name: 'Acme Corporation',
27
+ * });
28
+ *
29
+ * console.log(result.tenant.schemaName); // 'tenant_acme_corp'
30
+ * ```
31
+ */
32
+ export declare function createTenantProvisioner<TClient extends DatabaseClient>(config: TenantProvisionerConfig<TClient>): ITenantProvisioner;
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Tenant provisioner for creating and managing tenant lifecycle
3
+ *
4
+ * Orchestrates:
5
+ * - Creating tenant records in public schema
6
+ * - Creating PostgreSQL schemas
7
+ * - Running migrations
8
+ * - Cleanup on failure
9
+ */
10
+ import { DeprovisionError, InvalidSlugError, ProvisionError, TenantNotFoundError, } from '../errors.js';
11
+ import { slugToSchemaName } from './manager.js';
12
+ /**
13
+ * Type guard to check if client has tenant model delegate
14
+ */
15
+ function hasTenantModel(client) {
16
+ return client.tenant !== undefined && typeof client.tenant.findUnique === 'function';
17
+ }
18
+ /**
19
+ * Type guard to check if client has raw query methods
20
+ */
21
+ function hasRawQueryMethods(client) {
22
+ return typeof client.$queryRaw === 'function' && typeof client.$executeRaw === 'function';
23
+ }
24
+ /**
25
+ * Create a tenant provisioner
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const provisioner = createTenantProvisioner({
30
+ * schemaManager,
31
+ * publicClient: publicDb,
32
+ * clientPool,
33
+ * });
34
+ *
35
+ * // Provision a new tenant
36
+ * const result = await provisioner.provision({
37
+ * slug: 'acme-corp',
38
+ * name: 'Acme Corporation',
39
+ * });
40
+ *
41
+ * console.log(result.tenant.schemaName); // 'tenant_acme_corp'
42
+ * ```
43
+ */
44
+ export function createTenantProvisioner(config) {
45
+ const { schemaManager, publicClient, clientPool } = config;
46
+ /**
47
+ * Validate provision input
48
+ */
49
+ function validateInput(input) {
50
+ if (!input.slug || input.slug.trim().length === 0) {
51
+ throw new InvalidSlugError(input.slug || '', 'slug is required');
52
+ }
53
+ if (!input.name || input.name.trim().length === 0) {
54
+ throw new ProvisionError(input.slug, new Error('name is required'));
55
+ }
56
+ // Basic slug validation
57
+ if (!/^[a-z0-9-]+$/i.test(input.slug)) {
58
+ throw new InvalidSlugError(input.slug, 'slug must contain only letters, numbers, and hyphens');
59
+ }
60
+ if (input.slug.length > 50) {
61
+ throw new InvalidSlugError(input.slug, 'slug cannot exceed 50 characters');
62
+ }
63
+ }
64
+ /**
65
+ * Check if a tenant with this slug already exists
66
+ */
67
+ async function tenantExists(slug) {
68
+ if (hasTenantModel(publicClient)) {
69
+ const existing = await publicClient.tenant.findUnique({ where: { slug } });
70
+ return existing !== null;
71
+ }
72
+ if (hasRawQueryMethods(publicClient)) {
73
+ const result = await publicClient.$queryRaw `
74
+ SELECT EXISTS(SELECT 1 FROM tenants WHERE slug = ${slug}) as exists
75
+ `;
76
+ return result[0]?.exists ?? false;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Create tenant record in public schema
82
+ */
83
+ async function createTenantRecord(input, schemaName) {
84
+ const now = new Date();
85
+ if (hasTenantModel(publicClient)) {
86
+ return publicClient.tenant.create({
87
+ data: {
88
+ slug: input.slug,
89
+ name: input.name,
90
+ schemaName,
91
+ status: 'pending',
92
+ },
93
+ });
94
+ }
95
+ if (hasRawQueryMethods(publicClient)) {
96
+ const id = crypto.randomUUID();
97
+ await publicClient.$executeRaw `
98
+ INSERT INTO tenants (id, slug, name, schema_name, status, created_at, updated_at)
99
+ VALUES (${id}, ${input.slug}, ${input.name}, ${schemaName}, 'pending', ${now}, ${now})
100
+ `;
101
+ return {
102
+ id,
103
+ slug: input.slug,
104
+ name: input.name,
105
+ schemaName,
106
+ status: 'pending',
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ };
110
+ }
111
+ throw new Error('Unable to create tenant record: no compatible method found');
112
+ }
113
+ /**
114
+ * Update tenant status
115
+ */
116
+ async function updateTenantStatus(tenantId, status) {
117
+ if (hasTenantModel(publicClient)) {
118
+ await publicClient.tenant.update({
119
+ where: { id: tenantId },
120
+ data: { status },
121
+ });
122
+ return;
123
+ }
124
+ if (hasRawQueryMethods(publicClient)) {
125
+ await publicClient.$executeRaw `
126
+ UPDATE tenants SET status = ${status}, updated_at = ${new Date()} WHERE id = ${tenantId}
127
+ `;
128
+ return;
129
+ }
130
+ throw new Error('Unable to update tenant status: no compatible method found');
131
+ }
132
+ /**
133
+ * Delete tenant record
134
+ */
135
+ async function deleteTenantRecord(tenantId) {
136
+ if (hasTenantModel(publicClient)) {
137
+ await publicClient.tenant.delete({ where: { id: tenantId } });
138
+ return;
139
+ }
140
+ if (hasRawQueryMethods(publicClient)) {
141
+ await publicClient.$executeRaw `DELETE FROM tenants WHERE id = ${tenantId}`;
142
+ return;
143
+ }
144
+ throw new Error('Unable to delete tenant record: no compatible method found');
145
+ }
146
+ /**
147
+ * Get tenant by ID
148
+ */
149
+ async function getTenant(tenantId) {
150
+ if (hasTenantModel(publicClient)) {
151
+ return publicClient.tenant.findUnique({ where: { id: tenantId } });
152
+ }
153
+ if (hasRawQueryMethods(publicClient)) {
154
+ const result = await publicClient.$queryRaw `
155
+ SELECT * FROM tenants WHERE id = ${tenantId} LIMIT 1
156
+ `;
157
+ return result[0] ?? null;
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Get all tenants
163
+ */
164
+ async function getAllTenants() {
165
+ if (hasTenantModel(publicClient)) {
166
+ return publicClient.tenant.findMany();
167
+ }
168
+ if (hasRawQueryMethods(publicClient)) {
169
+ return publicClient.$queryRaw `SELECT * FROM tenants`;
170
+ }
171
+ return [];
172
+ }
173
+ return {
174
+ /**
175
+ * Provision a new tenant
176
+ */
177
+ async provision(input) {
178
+ validateInput(input);
179
+ const schemaName = slugToSchemaName(input.slug);
180
+ let tenant = null;
181
+ let schemaCreated = false;
182
+ try {
183
+ // Check for existing tenant
184
+ const exists = await tenantExists(input.slug);
185
+ if (exists) {
186
+ throw new Error(`Tenant with slug '${input.slug}' already exists`);
187
+ }
188
+ // 1. Create tenant record (status: pending)
189
+ tenant = await createTenantRecord(input, schemaName);
190
+ // 2. Create PostgreSQL schema
191
+ const schemaResult = await schemaManager.createSchema(input.slug);
192
+ schemaCreated = schemaResult.created;
193
+ // 3. Update status to migrating
194
+ await updateTenantStatus(tenant.id, 'migrating');
195
+ // 4. Run migrations
196
+ const migrateResult = await schemaManager.migrateSchema(schemaName);
197
+ // 5. Test connection via client pool
198
+ await clientPool.getClient(schemaName);
199
+ clientPool.releaseClient(schemaName);
200
+ // 6. Update status to active
201
+ await updateTenantStatus(tenant.id, 'active');
202
+ return {
203
+ tenant: { ...tenant, status: 'active' },
204
+ schemaCreated,
205
+ migrationsApplied: migrateResult.migrationsApplied,
206
+ };
207
+ }
208
+ catch (error) {
209
+ // Rollback on failure
210
+ if (tenant) {
211
+ try {
212
+ await deleteTenantRecord(tenant.id);
213
+ }
214
+ catch {
215
+ // Ignore cleanup errors
216
+ }
217
+ }
218
+ if (schemaCreated) {
219
+ try {
220
+ await schemaManager.deleteSchema(schemaName);
221
+ }
222
+ catch {
223
+ // Ignore cleanup errors
224
+ }
225
+ }
226
+ throw new ProvisionError(input.slug, error instanceof Error ? error : new Error(String(error)));
227
+ }
228
+ },
229
+ /**
230
+ * Deprovision a tenant (delete schema and record)
231
+ */
232
+ async deprovision(tenantId) {
233
+ const tenant = await getTenant(tenantId);
234
+ if (!tenant) {
235
+ throw new TenantNotFoundError(tenantId);
236
+ }
237
+ try {
238
+ // 1. Update status to suspended first
239
+ await updateTenantStatus(tenantId, 'suspended');
240
+ // 2. Delete the schema
241
+ try {
242
+ await schemaManager.deleteSchema(tenant.schemaName);
243
+ }
244
+ catch {
245
+ // Schema might not exist - continue with record deletion
246
+ }
247
+ // 3. Delete the tenant record
248
+ await deleteTenantRecord(tenantId);
249
+ }
250
+ catch (error) {
251
+ if (error instanceof TenantNotFoundError) {
252
+ throw error;
253
+ }
254
+ throw new DeprovisionError(tenantId, error instanceof Error ? error : new Error(String(error)));
255
+ }
256
+ },
257
+ /**
258
+ * Migrate all tenant schemas
259
+ */
260
+ async migrateAll() {
261
+ const tenants = await getAllTenants();
262
+ const results = [];
263
+ for (const tenant of tenants) {
264
+ if (tenant.status !== 'active' && tenant.status !== 'pending') {
265
+ continue; // Skip suspended/migrating tenants
266
+ }
267
+ try {
268
+ await updateTenantStatus(tenant.id, 'migrating');
269
+ const result = await schemaManager.migrateSchema(tenant.schemaName);
270
+ results.push(result);
271
+ await updateTenantStatus(tenant.id, 'active');
272
+ }
273
+ catch (error) {
274
+ console.error(`[TenantProvisioner] Failed to migrate ${tenant.schemaName}:`, error);
275
+ // Continue with other tenants
276
+ }
277
+ }
278
+ return results;
279
+ },
280
+ };
281
+ }