@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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @veloxts/orm
2
2
 
3
+ ## 0.6.31
4
+
5
+ ### Patch Changes
6
+
7
+ - npm must use concurrently for run dev script
8
+ - Updated dependencies
9
+ - @veloxts/core@0.6.31
10
+
11
+ ## 0.6.30
12
+
13
+ ### Patch Changes
14
+
15
+ - disable source maps for published packages
16
+ - Updated dependencies
17
+ - @veloxts/core@0.6.30
18
+
19
+ ## 0.6.29
20
+
21
+ ### Patch Changes
22
+
23
+ - add multi-tenancy and PostgreSQL support - test and lint fix
24
+ - Updated dependencies
25
+ - @veloxts/core@0.6.29
26
+
27
+ ## 0.6.28
28
+
29
+ ### Patch Changes
30
+
31
+ - add multi-tenancy support and postgresql database
32
+ - Updated dependencies
33
+ - @veloxts/core@0.6.28
34
+
3
35
  ## 0.6.27
4
36
 
5
37
  ### Patch Changes
package/GUIDE.md CHANGED
@@ -43,7 +43,7 @@ const db = new PrismaClient({ adapter });
43
43
  ## Using in Procedures
44
44
 
45
45
  ```typescript
46
- export const userProcedures = defineProcedures('users', {
46
+ export const userProcedures = procedures('users', {
47
47
  getUser: procedure()
48
48
  .input(z.object({ id: z.string().uuid() }))
49
49
  .query(async ({ input, ctx }) => {
@@ -68,6 +68,107 @@ npx prisma migrate deploy # Apply migrations
68
68
  velox migrate # VeloxTS CLI shortcut
69
69
  ```
70
70
 
71
+ ## Multi-Tenancy (Schema-per-Tenant)
72
+
73
+ For SaaS applications requiring tenant isolation, import from `@veloxts/orm/tenant`:
74
+
75
+ ```typescript
76
+ import {
77
+ createTenantClientPool,
78
+ createTenantSchemaManager,
79
+ createTenantProvisioner,
80
+ createTenant,
81
+ } from '@veloxts/orm/tenant';
82
+ ```
83
+
84
+ ### Setup
85
+
86
+ ```typescript
87
+ // 1. Schema manager (DDL operations)
88
+ const schemaManager = createTenantSchemaManager({
89
+ databaseUrl: process.env.DATABASE_URL!,
90
+ schemaPrefix: 'tenant_', // PostgreSQL schema prefix
91
+ });
92
+
93
+ // 2. Client pool (manages PrismaClient per tenant)
94
+ const clientPool = createTenantClientPool({
95
+ baseDatabaseUrl: process.env.DATABASE_URL!,
96
+ createClient: (schemaName) => {
97
+ const url = `${process.env.DATABASE_URL}?schema=${schemaName}`;
98
+ const adapter = new PrismaPg({ connectionString: url });
99
+ return new PrismaClient({ adapter });
100
+ },
101
+ maxClients: 50, // LRU eviction when exceeded
102
+ });
103
+
104
+ // 3. Tenant middleware namespace
105
+ const tenant = createTenant({
106
+ loadTenant: (id) => publicDb.tenant.findUnique({ where: { id } }),
107
+ clientPool,
108
+ publicClient: publicDb, // For shared data in 'public' schema
109
+ });
110
+ ```
111
+
112
+ ### Using in Procedures
113
+
114
+ ```typescript
115
+ const getUsers = procedure()
116
+ .use(auth.requireAuth())
117
+ .use(tenant.middleware()) // Adds ctx.tenant, ctx.db (scoped)
118
+ .query(({ ctx }) => ctx.db.user.findMany());
119
+ ```
120
+
121
+ The middleware:
122
+ 1. Extracts `tenantId` from JWT claims (`ctx.auth.token.tenantId`)
123
+ 2. Loads tenant from public schema
124
+ 3. Validates tenant status (must be `active`)
125
+ 4. Gets tenant-scoped database client from pool
126
+ 5. Adds `ctx.tenant`, `ctx.db`, and optionally `ctx.publicDb`
127
+
128
+ ### Provisioning Tenants
129
+
130
+ ```typescript
131
+ const provisioner = createTenantProvisioner({
132
+ schemaManager,
133
+ publicClient: publicDb,
134
+ clientPool,
135
+ });
136
+
137
+ // Create new tenant (creates schema + runs migrations)
138
+ const result = await provisioner.provision({
139
+ slug: 'acme-corp',
140
+ name: 'Acme Corporation',
141
+ });
142
+ // result.tenant.schemaName === 'tenant_acme_corp'
143
+
144
+ // Migrate all tenant schemas
145
+ await provisioner.migrateAll();
146
+ ```
147
+
148
+ ### Architecture
149
+
150
+ ```
151
+ PostgreSQL Database
152
+ ├── public schema (shared)
153
+ │ └── tenants table
154
+ └── tenant_acme_corp schema (isolated)
155
+ ├── users
156
+ ├── posts
157
+ └── ...
158
+ ```
159
+
160
+ ### JWT Integration
161
+
162
+ Add `tenantId` to your JWT payload when generating tokens:
163
+
164
+ ```typescript
165
+ const tokens = await jwt.generateTokens({
166
+ sub: user.id,
167
+ email: user.email,
168
+ tenantId: user.tenantId, // Include tenant ID
169
+ });
170
+ ```
171
+
71
172
  ## Learn More
72
173
 
73
174
  See [@veloxts/velox](https://www.npmjs.com/package/@veloxts/velox) for complete documentation.
package/dist/client.d.ts CHANGED
@@ -111,4 +111,3 @@ export interface Database<TClient extends DatabaseClient> {
111
111
  * ```
112
112
  */
113
113
  export declare function createDatabase<TClient extends DatabaseClient>(config: DatabaseWrapperConfig<TClient>): Database<TClient>;
114
- //# sourceMappingURL=client.d.ts.map
package/dist/client.js CHANGED
@@ -153,4 +153,3 @@ export function createDatabase(config) {
153
153
  };
154
154
  return database;
155
155
  }
156
- //# sourceMappingURL=client.js.map
package/dist/index.d.ts CHANGED
@@ -111,4 +111,3 @@ export {
111
111
  * ```
112
112
  */
113
113
  databasePlugin, } from './plugin.js';
114
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -87,4 +87,3 @@ export {
87
87
  * ```
88
88
  */
89
89
  databasePlugin, } from './plugin.js';
90
- //# sourceMappingURL=index.js.map
package/dist/plugin.d.ts CHANGED
@@ -114,4 +114,3 @@ declare module '@veloxts/core' {
114
114
  * ```
115
115
  */
116
116
  export declare function databasePlugin<TClient extends DatabaseClient>(config: OrmPluginConfig<TClient>): import("@veloxts/core").VeloxPlugin<import("fastify").FastifyPluginOptions>;
117
- //# sourceMappingURL=plugin.d.ts.map
package/dist/plugin.js CHANGED
@@ -135,4 +135,3 @@ export function databasePlugin(config) {
135
135
  },
136
136
  });
137
137
  }
138
- //# sourceMappingURL=plugin.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Tenant client pool for managing PrismaClient instances per schema
3
+ *
4
+ * Features:
5
+ * - LRU eviction when pool reaches capacity
6
+ * - Idle timeout cleanup
7
+ * - Connection lifecycle management
8
+ */
9
+ import type { DatabaseClient } from '../types.js';
10
+ import type { TenantClientPool as ITenantClientPool, TenantClientPoolConfig } from './types.js';
11
+ /**
12
+ * Tenant client pool implementation
13
+ *
14
+ * Manages a pool of PrismaClient instances, one per tenant schema.
15
+ * Uses LRU eviction when the pool reaches maximum capacity.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const pool = createTenantClientPool({
20
+ * baseDatabaseUrl: process.env.DATABASE_URL!,
21
+ * createClient: (schemaName) => {
22
+ * const url = `${process.env.DATABASE_URL}?schema=${schemaName}`;
23
+ * const adapter = new PrismaPg({ connectionString: url });
24
+ * return new PrismaClient({ adapter });
25
+ * },
26
+ * maxClients: 50,
27
+ * });
28
+ *
29
+ * const client = await pool.getClient('tenant_acme');
30
+ * // Use client...
31
+ * pool.releaseClient('tenant_acme');
32
+ * ```
33
+ */
34
+ export declare function createTenantClientPool<TClient extends DatabaseClient>(config: TenantClientPoolConfig<TClient>): ITenantClientPool<TClient>;
35
+ /**
36
+ * Type alias for the client pool
37
+ */
38
+ export type { ITenantClientPool as TenantClientPool };
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Tenant client pool for managing PrismaClient instances per schema
3
+ *
4
+ * Features:
5
+ * - LRU eviction when pool reaches capacity
6
+ * - Idle timeout cleanup
7
+ * - Connection lifecycle management
8
+ */
9
+ import { ClientCreateError, ClientDisconnectError, ClientPoolExhaustedError } from './errors.js';
10
+ /**
11
+ * Default configuration values
12
+ */
13
+ const DEFAULTS = {
14
+ maxClients: 50,
15
+ idleTimeoutMs: 5 * 60 * 1000, // 5 minutes
16
+ cleanupIntervalMs: 60 * 1000, // 1 minute
17
+ };
18
+ /**
19
+ * Tenant client pool implementation
20
+ *
21
+ * Manages a pool of PrismaClient instances, one per tenant schema.
22
+ * Uses LRU eviction when the pool reaches maximum capacity.
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const pool = createTenantClientPool({
27
+ * baseDatabaseUrl: process.env.DATABASE_URL!,
28
+ * createClient: (schemaName) => {
29
+ * const url = `${process.env.DATABASE_URL}?schema=${schemaName}`;
30
+ * const adapter = new PrismaPg({ connectionString: url });
31
+ * return new PrismaClient({ adapter });
32
+ * },
33
+ * maxClients: 50,
34
+ * });
35
+ *
36
+ * const client = await pool.getClient('tenant_acme');
37
+ * // Use client...
38
+ * pool.releaseClient('tenant_acme');
39
+ * ```
40
+ */
41
+ export function createTenantClientPool(config) {
42
+ const maxClients = config.maxClients ?? DEFAULTS.maxClients;
43
+ const idleTimeoutMs = config.idleTimeoutMs ?? DEFAULTS.idleTimeoutMs;
44
+ // Pool state
45
+ const clients = new Map();
46
+ let totalCreated = 0;
47
+ let totalEvicted = 0;
48
+ let cleanupTimer = null;
49
+ /**
50
+ * Start the idle cleanup timer
51
+ */
52
+ function startCleanupTimer() {
53
+ if (cleanupTimer)
54
+ return;
55
+ cleanupTimer = setInterval(() => {
56
+ void cleanupIdleClients();
57
+ }, DEFAULTS.cleanupIntervalMs);
58
+ // Don't block process exit
59
+ if (cleanupTimer.unref) {
60
+ cleanupTimer.unref();
61
+ }
62
+ }
63
+ /**
64
+ * Stop the cleanup timer
65
+ */
66
+ function stopCleanupTimer() {
67
+ if (cleanupTimer) {
68
+ clearInterval(cleanupTimer);
69
+ cleanupTimer = null;
70
+ }
71
+ }
72
+ /**
73
+ * Cleanup clients that have been idle too long
74
+ */
75
+ async function cleanupIdleClients() {
76
+ const now = Date.now();
77
+ const toEvict = [];
78
+ for (const [schemaName, cached] of clients) {
79
+ if (now - cached.lastAccessedAt > idleTimeoutMs) {
80
+ toEvict.push(schemaName);
81
+ }
82
+ }
83
+ for (const schemaName of toEvict) {
84
+ await evictClient(schemaName);
85
+ }
86
+ }
87
+ /**
88
+ * Evict a client from the pool
89
+ */
90
+ async function evictClient(schemaName) {
91
+ const cached = clients.get(schemaName);
92
+ if (!cached)
93
+ return;
94
+ clients.delete(schemaName);
95
+ totalEvicted++;
96
+ try {
97
+ await cached.client.$disconnect();
98
+ }
99
+ catch (error) {
100
+ // Log but don't throw - eviction should be best-effort
101
+ console.warn(`[TenantClientPool] Failed to disconnect client for ${schemaName}:`, error);
102
+ }
103
+ }
104
+ /**
105
+ * Find and evict the least recently used client
106
+ */
107
+ async function evictLRU() {
108
+ let oldest = null;
109
+ for (const [schemaName, cached] of clients) {
110
+ if (!oldest || cached.lastAccessedAt < oldest.lastAccessedAt) {
111
+ oldest = { schemaName, lastAccessedAt: cached.lastAccessedAt };
112
+ }
113
+ }
114
+ if (oldest) {
115
+ await evictClient(oldest.schemaName);
116
+ }
117
+ }
118
+ /**
119
+ * Create a new client for a schema
120
+ */
121
+ async function createClient(schemaName) {
122
+ try {
123
+ const client = config.createClient(schemaName);
124
+ // Connect the client
125
+ await client.$connect();
126
+ return client;
127
+ }
128
+ catch (error) {
129
+ throw new ClientCreateError(schemaName, error instanceof Error ? error : new Error(String(error)));
130
+ }
131
+ }
132
+ // Start cleanup timer
133
+ startCleanupTimer();
134
+ return {
135
+ /**
136
+ * Get or create a client for a tenant schema
137
+ */
138
+ async getClient(schemaName) {
139
+ // Check if client exists in pool
140
+ const cached = clients.get(schemaName);
141
+ if (cached) {
142
+ // Update last accessed time (LRU tracking)
143
+ cached.lastAccessedAt = Date.now();
144
+ return cached.client;
145
+ }
146
+ // Check if pool is at capacity
147
+ if (clients.size >= maxClients) {
148
+ // Try to evict idle clients first
149
+ await cleanupIdleClients();
150
+ // If still at capacity, evict LRU
151
+ if (clients.size >= maxClients) {
152
+ await evictLRU();
153
+ }
154
+ // If still at capacity (shouldn't happen), throw
155
+ if (clients.size >= maxClients) {
156
+ throw new ClientPoolExhaustedError(maxClients);
157
+ }
158
+ }
159
+ // Create new client
160
+ const client = await createClient(schemaName);
161
+ const now = Date.now();
162
+ clients.set(schemaName, {
163
+ client,
164
+ schemaName,
165
+ lastAccessedAt: now,
166
+ createdAt: now,
167
+ });
168
+ totalCreated++;
169
+ return client;
170
+ },
171
+ /**
172
+ * Release a client back to the pool
173
+ *
174
+ * Note: This doesn't actually remove the client, it just marks
175
+ * it as available for LRU eviction if needed.
176
+ */
177
+ releaseClient(schemaName) {
178
+ const cached = clients.get(schemaName);
179
+ if (cached) {
180
+ cached.lastAccessedAt = Date.now();
181
+ }
182
+ },
183
+ /**
184
+ * Check if a client exists in the pool without creating it
185
+ * Useful for health checks
186
+ */
187
+ hasClient(schemaName) {
188
+ return clients.has(schemaName);
189
+ },
190
+ /**
191
+ * Stop the cleanup timer without disconnecting clients
192
+ *
193
+ * Use this when you need to stop the timer but keep clients connected.
194
+ * For full cleanup, use `disconnectAll()` instead.
195
+ */
196
+ close() {
197
+ stopCleanupTimer();
198
+ },
199
+ /**
200
+ * Disconnect all clients and clear the pool
201
+ *
202
+ * IMPORTANT: This also stops the cleanup timer.
203
+ * Always call this method during application shutdown.
204
+ */
205
+ async disconnectAll() {
206
+ stopCleanupTimer();
207
+ const errors = [];
208
+ for (const [schemaName, cached] of clients) {
209
+ try {
210
+ await cached.client.$disconnect();
211
+ }
212
+ catch (error) {
213
+ errors.push(new ClientDisconnectError(schemaName, error instanceof Error ? error : new Error(String(error))));
214
+ }
215
+ }
216
+ clients.clear();
217
+ if (errors.length > 0) {
218
+ throw new AggregateError(errors, 'Failed to disconnect some clients');
219
+ }
220
+ },
221
+ /**
222
+ * Get current pool statistics
223
+ */
224
+ getStats() {
225
+ return {
226
+ activeClients: clients.size,
227
+ maxClients,
228
+ totalCreated,
229
+ totalEvicted,
230
+ };
231
+ },
232
+ };
233
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tenant-specific error classes for @veloxts/orm/tenant
3
+ */
4
+ /**
5
+ * Base error class for tenant-related errors
6
+ */
7
+ export declare class TenantError extends Error {
8
+ readonly code: TenantErrorCode;
9
+ readonly tenantId?: string;
10
+ readonly schemaName?: string;
11
+ constructor(message: string, code: TenantErrorCode, options?: {
12
+ tenantId?: string;
13
+ schemaName?: string;
14
+ cause?: Error;
15
+ });
16
+ }
17
+ /**
18
+ * Error codes for tenant operations
19
+ */
20
+ export type TenantErrorCode = 'TENANT_NOT_FOUND' | 'TENANT_SUSPENDED' | 'TENANT_PENDING' | 'TENANT_MIGRATING' | 'TENANT_ID_MISSING' | 'TENANT_ACCESS_DENIED' | 'SCHEMA_CREATE_FAILED' | 'SCHEMA_DELETE_FAILED' | 'SCHEMA_MIGRATE_FAILED' | 'SCHEMA_NOT_FOUND' | 'SCHEMA_LIST_FAILED' | 'SCHEMA_ALREADY_EXISTS' | 'CLIENT_POOL_EXHAUSTED' | 'CLIENT_CREATE_FAILED' | 'CLIENT_DISCONNECT_FAILED' | 'INVALID_SLUG' | 'PROVISION_FAILED' | 'DEPROVISION_FAILED';
21
+ /**
22
+ * Tenant not found in database
23
+ */
24
+ export declare class TenantNotFoundError extends TenantError {
25
+ constructor(tenantId: string);
26
+ }
27
+ /**
28
+ * Tenant is suspended and cannot be accessed
29
+ */
30
+ export declare class TenantSuspendedError extends TenantError {
31
+ constructor(tenantId: string);
32
+ }
33
+ /**
34
+ * Tenant is pending activation
35
+ */
36
+ export declare class TenantPendingError extends TenantError {
37
+ constructor(tenantId: string);
38
+ }
39
+ /**
40
+ * Tenant is currently being migrated
41
+ */
42
+ export declare class TenantMigratingError extends TenantError {
43
+ constructor(tenantId: string);
44
+ }
45
+ /**
46
+ * Tenant ID missing from request context
47
+ */
48
+ export declare class TenantIdMissingError extends TenantError {
49
+ constructor();
50
+ }
51
+ /**
52
+ * User does not have access to the requested tenant
53
+ *
54
+ * SECURITY: This error is thrown when tenant access verification fails.
55
+ * It prevents tenant isolation bypass attacks where a user might try
56
+ * to access a tenant they don't belong to by manipulating JWT claims.
57
+ */
58
+ export declare class TenantAccessDeniedError extends TenantError {
59
+ readonly userId?: string;
60
+ constructor(tenantId: string, userId?: string);
61
+ }
62
+ /**
63
+ * Schema creation failed
64
+ */
65
+ export declare class SchemaCreateError extends TenantError {
66
+ constructor(schemaName: string, cause?: Error);
67
+ }
68
+ /**
69
+ * Schema deletion failed
70
+ */
71
+ export declare class SchemaDeleteError extends TenantError {
72
+ constructor(schemaName: string, cause?: Error);
73
+ }
74
+ /**
75
+ * Schema migration failed
76
+ */
77
+ export declare class SchemaMigrateError extends TenantError {
78
+ constructor(schemaName: string, cause?: Error);
79
+ }
80
+ /**
81
+ * Schema not found
82
+ */
83
+ export declare class SchemaNotFoundError extends TenantError {
84
+ constructor(schemaName: string);
85
+ }
86
+ /**
87
+ * Schema list operation failed
88
+ */
89
+ export declare class SchemaListError extends TenantError {
90
+ constructor(cause?: Error);
91
+ }
92
+ /**
93
+ * Schema already exists
94
+ */
95
+ export declare class SchemaAlreadyExistsError extends TenantError {
96
+ constructor(schemaName: string);
97
+ }
98
+ /**
99
+ * Client pool has reached maximum capacity
100
+ */
101
+ export declare class ClientPoolExhaustedError extends TenantError {
102
+ constructor(maxClients: number);
103
+ }
104
+ /**
105
+ * Failed to create database client
106
+ */
107
+ export declare class ClientCreateError extends TenantError {
108
+ constructor(schemaName: string, cause?: Error);
109
+ }
110
+ /**
111
+ * Failed to disconnect database client
112
+ */
113
+ export declare class ClientDisconnectError extends TenantError {
114
+ constructor(schemaName: string, cause?: Error);
115
+ }
116
+ /**
117
+ * Invalid tenant slug format
118
+ */
119
+ export declare class InvalidSlugError extends TenantError {
120
+ constructor(slug: string, reason: string);
121
+ }
122
+ /**
123
+ * Tenant provisioning failed
124
+ */
125
+ export declare class ProvisionError extends TenantError {
126
+ constructor(slug: string, cause?: Error);
127
+ }
128
+ /**
129
+ * Tenant deprovisioning failed
130
+ */
131
+ export declare class DeprovisionError extends TenantError {
132
+ constructor(tenantId: string, cause?: Error);
133
+ }
134
+ /**
135
+ * Type guard to check if an error is a TenantError
136
+ */
137
+ export declare function isTenantError(error: unknown): error is TenantError;
138
+ /**
139
+ * Get error based on tenant status
140
+ */
141
+ export declare function getTenantStatusError(tenantId: string, status: string): TenantError | null;