-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathmulti-tenant.ts
More file actions
289 lines (248 loc) · 9.16 KB
/
Copy pathmulti-tenant.ts
File metadata and controls
289 lines (248 loc) · 9.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Multi-Tenant Router for TursoDriver
*
* Manages per-tenant TursoDriver instances with TTL-based caching.
* Uses a URL template with `{tenant}` placeholder that is replaced
* with the tenantId (UUID) at runtime.
*
* **UUID-Based Tenant Naming:**
* - Tenant IDs are UUIDs, not organization slugs
* - Ensures database URLs remain stable even if organization names change
* - Example: `550e8400-e29b-41d4-a716-446655440000.turso.io`
*
* **Serverless-safe:** no global intervals, no leaked state. Expired
* entries are evicted lazily on next access.
*/
import { TursoDriver, type TursoDriverConfig } from './turso-driver.js';
// ── Configuration ────────────────────────────────────────────────────────────
/**
* Configuration for the multi-tenant router.
*
* **UUID-Based Tenant Naming:**
* The `{tenant}` placeholder is replaced with a UUID, not an organization slug.
* This ensures database URLs remain stable even if organization names change.
*
* @example
* ```typescript
* const router = createMultiTenantRouter({
* // UUID-based URL template
* urlTemplate: 'libsql://{tenant}.turso.io',
* groupAuthToken: process.env.TURSO_GROUP_TOKEN,
* clientCacheTTL: 300_000, // 5 minutes
* });
*
* // Tenant ID is a UUID
* const driver = await router.getDriverForTenant('550e8400-e29b-41d4-a716-446655440000');
* ```
*/
export interface MultiTenantConfig {
/**
* URL template with `{tenant}` placeholder (replaced with UUID at runtime).
*
* Examples:
* - Remote: `'libsql://{tenant}.turso.io'`
* - Local: `'file:./data/{tenant}.db'`
*
* **Important:** Use UUID for tenant ID, not organization slug.
*/
urlTemplate: string;
/**
* Shared auth token for the Turso group (used for all tenant databases).
* Individual tenant tokens can be provided via `driverConfigOverrides`.
*/
groupAuthToken?: string;
/**
* Cache TTL in milliseconds. Cached drivers are evicted after this period.
* Default: 300_000 (5 minutes).
*/
clientCacheTTL?: number;
/**
* Optional callback invoked when a new tenant driver is created.
* Useful for provisioning tenant databases via the Turso Platform API.
*/
onTenantCreate?: (tenantId: string) => Promise<void>;
/**
* Optional callback invoked before a tenant driver is removed from cache.
*/
onTenantEvict?: (tenantId: string) => Promise<void>;
/**
* Additional TursoDriverConfig fields merged into every tenant driver config.
* `url` is overridden by the template. String fields like `syncUrl` support
* `{tenant}` placeholders which are interpolated automatically.
*/
driverConfigOverrides?: Omit<Partial<TursoDriverConfig>, 'url'>;
}
// ── Router Interface ─────────────────────────────────────────────────────────
/**
* Return type of `createMultiTenantRouter`.
*/
export interface MultiTenantRouter {
/** Get (or create) a connected TursoDriver for the given tenant. */
getDriverForTenant(tenantId: string): Promise<TursoDriver>;
/** Immediately invalidate and disconnect a cached tenant driver. */
invalidateCache(tenantId: string): void;
/** Disconnect and destroy all cached tenant drivers. Call on process shutdown. */
destroyAll(): Promise<void>;
/** Returns the number of currently cached tenant drivers. */
getCacheSize(): number;
}
// ── Constants ────────────────────────────────────────────────────────────────
const DEFAULT_CACHE_TTL = 300_000; // 5 minutes
const TENANT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}[a-zA-Z0-9]$/;
// ── Cache Entry ──────────────────────────────────────────────────────────────
interface CacheEntry {
driver: TursoDriver;
expiresAt: number;
}
// ── Factory ──────────────────────────────────────────────────────────────────
/**
* Creates a multi-tenant router that manages per-tenant TursoDriver instances.
*
* - `urlTemplate` must contain `{tenant}` which is replaced with the tenantId.
* - Drivers are lazily created and cached in a process-level Map.
* - Expired entries are evicted on next access (lazy expiration).
* - Concurrent calls for the same tenant share a single in-flight creation.
* - Serverless-safe: no global intervals, no leaked state.
*
* @example
* ```typescript
* const router = createMultiTenantRouter({
* urlTemplate: 'file:./data/{tenant}.db',
* });
*
* const driver = await router.getDriverForTenant('acme');
* const users = await driver.find('users', {});
* ```
*/
export function createMultiTenantRouter(config: MultiTenantConfig): MultiTenantRouter {
if (!config.urlTemplate) {
throw new Error('MultiTenantConfig requires a "urlTemplate".');
}
if (!config.urlTemplate.includes('{tenant}')) {
throw new Error('urlTemplate must contain a "{tenant}" placeholder.');
}
const ttl = config.clientCacheTTL ?? DEFAULT_CACHE_TTL;
const cache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<TursoDriver>>();
function validateTenantId(tenantId: string): void {
if (!tenantId || typeof tenantId !== 'string') {
throw new Error('tenantId must be a non-empty string.');
}
if (!TENANT_ID_PATTERN.test(tenantId)) {
throw new Error(
`Invalid tenantId "${tenantId}". Must be 2-64 alphanumeric characters, hyphens, or underscores.`,
);
}
}
/**
* Replace `{tenant}` placeholders in a string value.
*/
function interpolateTenant(template: string, tenantId: string): string {
return template.replace(/\{tenant\}/g, tenantId);
}
async function evictEntry(tenantId: string, entry: CacheEntry): Promise<void> {
cache.delete(tenantId);
try {
await entry.driver.disconnect();
} catch {
// Disconnect failure is non-fatal during eviction
}
if (config.onTenantEvict) {
try {
await config.onTenantEvict(tenantId);
} catch {
// Callback failure is non-fatal
}
}
}
/**
* Internal driver creation — called once per tenant, guarded by inflight map.
*/
async function createDriverForTenant(tenantId: string): Promise<TursoDriver> {
// Evict expired entry if present
const existing = cache.get(tenantId);
if (existing) {
await evictEntry(tenantId, existing);
}
// Build config with {tenant} interpolated in all string fields
const url = interpolateTenant(config.urlTemplate, tenantId);
const overrides = config.driverConfigOverrides ?? {};
const driverConfig: TursoDriverConfig = {
...overrides,
url,
authToken: config.groupAuthToken ?? overrides.authToken,
// Interpolate {tenant} in syncUrl if present
syncUrl: overrides.syncUrl ? interpolateTenant(overrides.syncUrl, tenantId) : undefined,
};
const driver = new TursoDriver(driverConfig);
if (config.onTenantCreate) {
await config.onTenantCreate(tenantId);
}
await driver.connect();
cache.set(tenantId, {
driver,
expiresAt: Date.now() + ttl,
});
return driver;
}
async function getDriverForTenant(tenantId: string): Promise<TursoDriver> {
validateTenantId(tenantId);
// Return cached driver if still valid
const existing = cache.get(tenantId);
if (existing && Date.now() < existing.expiresAt) {
return existing.driver;
}
// Return in-flight creation if one exists (prevents concurrent duplicates)
const pending = inflight.get(tenantId);
if (pending) {
return pending;
}
// Create new driver with in-flight guard
const promise = createDriverForTenant(tenantId).finally(() => {
inflight.delete(tenantId);
});
inflight.set(tenantId, promise);
return promise;
}
function invalidateCache(tenantId: string): void {
const entry = cache.get(tenantId);
if (entry) {
cache.delete(tenantId);
// Fire-and-forget disconnect
entry.driver.disconnect().catch(() => {});
if (config.onTenantEvict) {
config.onTenantEvict(tenantId).catch(() => {});
}
}
}
async function destroyAll(): Promise<void> {
const entries = Array.from(cache.entries());
cache.clear();
await Promise.allSettled(
entries.map(async ([tenantId, entry]) => {
try {
await entry.driver.disconnect();
} catch {
// Non-fatal
}
if (config.onTenantEvict) {
try {
await config.onTenantEvict(tenantId);
} catch {
// Non-fatal
}
}
}),
);
}
function getCacheSize(): number {
return cache.size;
}
return {
getDriverForTenant,
invalidateCache,
destroyAll,
getCacheSize,
};
}