@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.
- package/CHANGELOG.md +32 -0
- package/GUIDE.md +102 -1
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/plugin.d.ts +0 -1
- package/dist/plugin.js +0 -1
- package/dist/tenant/client-pool.d.ts +38 -0
- package/dist/tenant/client-pool.js +233 -0
- package/dist/tenant/errors.d.ts +141 -0
- package/dist/tenant/errors.js +220 -0
- package/dist/tenant/index.d.ts +58 -0
- package/dist/tenant/index.js +85 -0
- package/dist/tenant/middleware.d.ts +81 -0
- package/dist/tenant/middleware.js +162 -0
- package/dist/tenant/schema/manager.d.ts +37 -0
- package/dist/tenant/schema/manager.js +334 -0
- package/dist/tenant/schema/provisioner.d.ts +32 -0
- package/dist/tenant/schema/provisioner.js +281 -0
- package/dist/tenant/types.d.ts +431 -0
- package/dist/tenant/types.js +35 -0
- package/dist/types.d.ts +0 -1
- package/dist/types.js +0 -1
- package/package.json +10 -2
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/plugin.d.ts.map +0 -1
- package/dist/plugin.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
|
@@ -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
|
+
}
|