From 8fafcdd5dbf535bbd99ea1c348325035cef9aa64 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:03:03 +0000 Subject: [PATCH 1/2] feat: add flexible driver configuration for organization databases - Add DriverConfig discriminated union supporting 5 driver types - Support Turso, Memory, SQL, SQLite, and Custom drivers - Update TenantDatabase schema to use driverConfig instead of hardcoded Turso fields - Update TenantProvisioningService with driver-specific provisioning methods - Add DriverFactory for runtime driver instance management - Update TenantContextService with getDriverForOrganization() method - Update sys_tenant_database object schema to store driver_config - Enable flexible deployment scenarios (dev/test/prod/enterprise) Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/ad2ea41d-bf29-42ea-af00-fcc7d6deea5f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../service-tenant/src/driver-factory.ts | 255 ++++++++++++++++ packages/services/service-tenant/src/index.ts | 1 + .../src/objects/sys-tenant-database.object.ts | 38 +-- .../service-tenant/src/tenant-context.ts | 124 +++++++- .../service-tenant/src/tenant-provisioning.ts | 278 ++++++++++++++---- packages/spec/src/cloud/tenant.zod.ts | 133 +++++++-- 6 files changed, 714 insertions(+), 115 deletions(-) create mode 100644 packages/services/service-tenant/src/driver-factory.ts diff --git a/packages/services/service-tenant/src/driver-factory.ts b/packages/services/service-tenant/src/driver-factory.ts new file mode 100644 index 000000000..1012ee7cd --- /dev/null +++ b/packages/services/service-tenant/src/driver-factory.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IDataDriver } from '@objectstack/spec'; +import type { DriverConfig } from '@objectstack/spec/cloud'; + +/** + * Driver Factory Configuration + */ +export interface DriverFactoryConfig { + /** + * Available driver instances keyed by driver name + * Example: { 'turso': tursoDriver, 'memory': memoryDriver } + */ + drivers?: Map; + + /** + * Driver class constructors for dynamic instantiation + * Example: { 'turso': TursoDriver, 'memory': InMemoryDriver } + */ + driverConstructors?: Map IDataDriver>; +} + +/** + * Driver Factory + * + * Manages driver instances for multi-tenant deployments. + * Each organization's database uses a specific driver configuration, + * and the factory creates/caches driver instances on demand. + * + * Features: + * - Instance caching to avoid creating duplicate drivers + * - Support for pre-configured drivers + * - Dynamic driver instantiation + * - Type-safe driver configuration + */ +export class DriverFactory { + private driverCache = new Map(); + private config: DriverFactoryConfig; + + constructor(config: DriverFactoryConfig = {}) { + this.config = config; + + // Pre-populate cache with provided drivers + if (config.drivers) { + for (const [key, driver] of config.drivers) { + this.driverCache.set(key, driver); + } + } + } + + /** + * Create or retrieve a driver instance based on configuration + * + * @param driverConfig - Driver configuration from TenantDatabase + * @returns Driver instance ready to use + */ + async create(driverConfig: DriverConfig): Promise { + const cacheKey = this.getCacheKey(driverConfig); + + // Check cache first + if (this.driverCache.has(cacheKey)) { + return this.driverCache.get(cacheKey)!; + } + + // Create new driver instance + const driver = await this.instantiateDriver(driverConfig); + + // Cache for future use + this.driverCache.set(cacheKey, driver); + + return driver; + } + + /** + * Instantiate a new driver based on configuration + */ + private async instantiateDriver(driverConfig: DriverConfig): Promise { + switch (driverConfig.driver) { + case 'turso': + return this.createTursoDriver(driverConfig); + + case 'memory': + return this.createMemoryDriver(driverConfig); + + case 'sql': + return this.createSQLDriver(driverConfig); + + case 'sqlite': + return this.createSQLiteDriver(driverConfig); + + case 'custom': + return this.createCustomDriver(driverConfig); + + default: + throw new Error(`Unsupported driver type: ${(driverConfig as any).driver}`); + } + } + + /** + * Create Turso driver instance + */ + private async createTursoDriver(config: DriverConfig): Promise { + if (config.driver !== 'turso') { + throw new Error('Invalid driver config for Turso'); + } + + // Check if constructor is available + const TursoDriverConstructor = this.config.driverConstructors?.get('turso'); + if (!TursoDriverConstructor) { + throw new Error( + 'Turso driver constructor not registered. Register it via DriverFactory config.', + ); + } + + return new TursoDriverConstructor({ + url: config.databaseUrl, + authToken: config.authToken, + syncUrl: config.syncUrl, + }); + } + + /** + * Create Memory driver instance + */ + private async createMemoryDriver(config: DriverConfig): Promise { + if (config.driver !== 'memory') { + throw new Error('Invalid driver config for Memory'); + } + + const MemoryDriverConstructor = this.config.driverConstructors?.get('memory'); + if (!MemoryDriverConstructor) { + throw new Error( + 'Memory driver constructor not registered. Register it via DriverFactory config.', + ); + } + + return new MemoryDriverConstructor({ + persistent: config.persistent, + dataFile: config.dataFile, + }); + } + + /** + * Create SQL driver instance + */ + private async createSQLDriver(config: DriverConfig): Promise { + if (config.driver !== 'sql') { + throw new Error('Invalid driver config for SQL'); + } + + const SQLDriverConstructor = this.config.driverConstructors?.get('sql'); + if (!SQLDriverConstructor) { + throw new Error( + 'SQL driver constructor not registered. Register it via DriverFactory config.', + ); + } + + return new SQLDriverConstructor({ + dialect: config.dialect, + host: config.host, + port: config.port, + database: config.database, + username: config.username, + password: config.password, + ssl: config.ssl, + pool: config.pool, + }); + } + + /** + * Create SQLite driver instance + */ + private async createSQLiteDriver(config: DriverConfig): Promise { + if (config.driver !== 'sqlite') { + throw new Error('Invalid driver config for SQLite'); + } + + const SQLiteDriverConstructor = this.config.driverConstructors?.get('sqlite'); + if (!SQLiteDriverConstructor) { + throw new Error( + 'SQLite driver constructor not registered. Register it via DriverFactory config.', + ); + } + + return new SQLiteDriverConstructor({ + filename: config.filename, + readonly: config.readonly, + }); + } + + /** + * Create Custom driver instance + */ + private async createCustomDriver(config: DriverConfig): Promise { + if (config.driver !== 'custom') { + throw new Error('Invalid driver config for Custom'); + } + + const CustomDriverConstructor = this.config.driverConstructors?.get(config.driverName); + if (!CustomDriverConstructor) { + throw new Error( + `Custom driver '${config.driverName}' constructor not registered. Register it via DriverFactory config.`, + ); + } + + return new CustomDriverConstructor(config.config); + } + + /** + * Generate cache key from driver configuration + */ + private getCacheKey(driverConfig: DriverConfig): string { + // Create a unique key based on driver type and critical connection params + switch (driverConfig.driver) { + case 'turso': + return `turso:${driverConfig.databaseUrl}`; + + case 'memory': + return `memory:${driverConfig.dataFile || 'ephemeral'}`; + + case 'sql': + return `sql:${driverConfig.dialect}:${driverConfig.host}:${driverConfig.port}:${driverConfig.database}`; + + case 'sqlite': + return `sqlite:${driverConfig.filename}`; + + case 'custom': + return `custom:${driverConfig.driverName}:${JSON.stringify(driverConfig.config)}`; + + default: + return `unknown:${JSON.stringify(driverConfig)}`; + } + } + + /** + * Clear driver cache (useful for testing) + */ + clearCache(): void { + this.driverCache.clear(); + } + + /** + * Remove specific driver from cache + */ + invalidateDriver(cacheKey: string): void { + this.driverCache.delete(cacheKey); + } + + /** + * Get number of cached drivers + */ + getCacheSize(): number { + return this.driverCache.size; + } +} diff --git a/packages/services/service-tenant/src/index.ts b/packages/services/service-tenant/src/index.ts index 793ce7b2e..386e66fcf 100644 --- a/packages/services/service-tenant/src/index.ts +++ b/packages/services/service-tenant/src/index.ts @@ -5,4 +5,5 @@ export * from './tenant-plugin.js'; export * from './tenant-provisioning.js'; export * from './turso-platform-client.js'; export * from './tenant-schema-initializer.js'; +export * from './driver-factory.js'; export * from './objects/index.js'; diff --git a/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts b/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts index 535e7dd38..8992cfc72 100644 --- a/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts +++ b/packages/services/service-tenant/src/objects/sys-tenant-database.object.ts @@ -5,8 +5,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * sys_tenant_database — Global Tenant Registry Object * - * Stores tenant database information in the global control plane. - * Each tenant has its own isolated Turso database with UUID-based naming. + * Stores tenant database configuration in the global control plane. + * Each tenant can use different database drivers (Turso, Memory, SQL, SQLite, Custom). * * @namespace sys */ @@ -17,9 +17,9 @@ export const SysTenantDatabase = ObjectSchema.create({ pluralLabel: 'Tenant Databases', icon: 'database', isSystem: true, - description: 'Tenant database registry for multi-tenant architecture', - titleFormat: '{database_name}', - compactLayout: ['database_name', 'organization_id', 'status', 'plan'], + description: 'Tenant database registry with flexible driver configuration', + titleFormat: '{id}', + compactLayout: ['id', 'organization_id', 'status', 'plan'], fields: { id: Field.text({ @@ -47,24 +47,10 @@ export const SysTenantDatabase = ObjectSchema.create({ description: 'Foreign key to sys_organization', }), - database_name: Field.text({ - label: 'Database Name', + driver_config: Field.textarea({ + label: 'Driver Configuration', required: true, - maxLength: 255, - description: 'UUID-based database name (immutable)', - }), - - database_url: Field.url({ - label: 'Database URL', - required: true, - description: 'Full database connection URL (e.g., libsql://{uuid}.turso.io)', - }), - - auth_token: Field.text({ - label: 'Auth Token', - required: true, - maxLength: 2000, - description: 'Encrypted database-specific auth token', + description: 'JSON-serialized driver configuration (type: turso|memory|sql|sqlite|custom)', }), status: Field.picklist({ @@ -80,13 +66,6 @@ export const SysTenantDatabase = ObjectSchema.create({ defaultValue: 'provisioning', }), - region: Field.text({ - label: 'Region', - required: true, - maxLength: 100, - description: 'Deployment region (e.g., us-east-1, eu-west-1)', - }), - plan: Field.picklist({ label: 'Plan', required: true, @@ -121,7 +100,6 @@ export const SysTenantDatabase = ObjectSchema.create({ }, indexes: [ - { fields: ['database_name'], unique: true }, { fields: ['organization_id'] }, { fields: ['status'] }, { fields: ['plan'] }, diff --git a/packages/services/service-tenant/src/tenant-context.ts b/packages/services/service-tenant/src/tenant-context.ts index 999870de0..13b0ad3ba 100644 --- a/packages/services/service-tenant/src/tenant-context.ts +++ b/packages/services/service-tenant/src/tenant-context.ts @@ -4,25 +4,50 @@ import type { TenantContext, TenantIdentificationSource, TenantRoutingConfig, + TenantDatabase, } from '@objectstack/spec/cloud'; +import type { IDataDriver } from '@objectstack/spec'; +import { DriverFactory, type DriverFactoryConfig } from './driver-factory.js'; + +/** + * Tenant Context Service Configuration + */ +export interface TenantContextServiceConfig extends TenantRoutingConfig { + /** + * Driver factory for creating driver instances + */ + driverFactory?: DriverFactory; + + /** + * Driver factory configuration (if driverFactory not provided) + */ + driverFactoryConfig?: DriverFactoryConfig; + + /** + * Control plane driver for querying tenant database records + */ + controlPlaneDriver?: IDataDriver; +} /** * Tenant Context Service * * Manages tenant identification and context resolution from HTTP requests. - * Supports multiple identification strategies: - * - Subdomain extraction (e.g., acme.objectstack.app) - * - Custom domain mapping - * - HTTP headers (X-Tenant-ID) - * - JWT claims (organizationId) - * - Session data + * Supports multiple identification strategies and runtime driver resolution. */ export class TenantContextService { - private config: TenantRoutingConfig; + private config: TenantContextServiceConfig; private tenantCache = new Map(); + private tenantDbCache = new Map(); + private driverFactory: DriverFactory; - constructor(config: TenantRoutingConfig) { + constructor(config: TenantContextServiceConfig) { this.config = config; + + // Initialize driver factory + this.driverFactory = + config.driverFactory || + new DriverFactory(config.driverFactoryConfig || {}); } /** @@ -208,6 +233,75 @@ export class TenantContextService { return context; } + /** + * Get driver instance for a specific organization + * + * @param organizationId - Organization ID + * @returns Driver instance configured for the organization + */ + async getDriverForOrganization(organizationId: string): Promise { + // Get tenant database configuration + const tenantDb = await this.getTenantDatabase(organizationId); + + if (!tenantDb) { + throw new Error(`No tenant database found for organization: ${organizationId}`); + } + + // Create or retrieve driver instance + return this.driverFactory.create(tenantDb.driverConfig); + } + + /** + * Get tenant database configuration by organization ID + */ + private async getTenantDatabase(organizationId: string): Promise { + // Check cache first + const cached = this.tenantDbCache.get(organizationId); + if (cached) { + return cached; + } + + // Query control plane database + if (!this.config.controlPlaneDriver) { + return null; + } + + try { + const results = await this.config.controlPlaneDriver.find('tenant_database', { + filter: { organization_id: organizationId }, + limit: 1, + }); + + if (results.records.length === 0) { + return null; + } + + const record = results.records[0]; + const tenantDb: TenantDatabase = { + id: record.id as string, + organizationId: record.organization_id as string, + driverConfig: typeof record.driver_config === 'string' + ? JSON.parse(record.driver_config) + : record.driver_config, + status: record.status as any, + plan: record.plan as any, + storageLimitMb: record.storage_limit_mb as number, + createdAt: record.created_at as string, + updatedAt: record.updated_at as string, + lastAccessedAt: record.last_accessed_at as string | undefined, + metadata: record.metadata, + }; + + // Cache the result + this.tenantDbCache.set(organizationId, tenantDb); + + return tenantDb; + } catch (error) { + console.error(`Failed to fetch tenant database for org ${organizationId}:`, error); + return null; + } + } + /** * Clear tenant cache */ @@ -215,10 +309,24 @@ export class TenantContextService { this.tenantCache.clear(); } + /** + * Clear tenant database cache + */ + clearDbCache(): void { + this.tenantDbCache.clear(); + } + /** * Invalidate specific tenant from cache */ invalidateTenant(tenantId: string): void { this.tenantCache.delete(tenantId); } + + /** + * Invalidate specific tenant database from cache + */ + invalidateTenantDb(organizationId: string): void { + this.tenantDbCache.delete(organizationId); + } } diff --git a/packages/services/service-tenant/src/tenant-provisioning.ts b/packages/services/service-tenant/src/tenant-provisioning.ts index c040156f9..5a6b0173b 100644 --- a/packages/services/service-tenant/src/tenant-provisioning.ts +++ b/packages/services/service-tenant/src/tenant-provisioning.ts @@ -73,14 +73,14 @@ export class TenantProvisioningService { /** * Provision a new tenant database * - * Production flow: - * 1. Call Turso Platform API to create database - * 2. Generate tenant-specific auth token - * 3. Store tenant record in global control plane database - * 4. Initialize tenant database with base schema - * 5. Apply any pre-installed packages + * Supports multiple driver types: + * - Turso: Cloud-native production deployment + * - Memory: Development and testing (data lost on restart) + * - SQL: Enterprise PostgreSQL/MySQL/etc. + * - SQLite: Local file-based storage + * - Custom: Custom driver implementation * - * @param request - Provisioning request + * @param request - Provisioning request with driver configuration * @returns Provisioning result with tenant database info */ async provisionTenant(request: ProvisionTenantRequest): Promise { @@ -89,24 +89,82 @@ export class TenantProvisioningService { // Generate UUID for tenant database const tenantId = randomUUID(); - const databaseName = tenantId; // UUID-based naming - // Determine region - const region = request.region || this.config.defaultRegion || 'us-east-1'; + // Provision based on driver type + let tenant: TenantDatabase; + + switch (request.driverConfig.driver) { + case 'turso': + tenant = await this.provisionTursoDatabase(tenantId, request, warnings); + break; + + case 'memory': + tenant = await this.provisionMemoryDriver(tenantId, request, warnings); + break; + + case 'sql': + tenant = await this.provisionSQLDatabase(tenantId, request, warnings); + break; + + case 'sqlite': + tenant = await this.provisionSQLiteDatabase(tenantId, request, warnings); + break; + + case 'custom': + tenant = await this.provisionCustomDriver(tenantId, request, warnings); + break; + + default: + throw new Error(`Unsupported driver type: ${(request.driverConfig as any).driver}`); + } + + // Store tenant record in global control plane + if (this.config.controlPlaneDriver) { + try { + await this.storeTenantRecord(tenant); + } catch (error) { + warnings.push( + `Failed to store tenant record in control plane: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + warnings.push('Control plane driver not configured - tenant record not persisted'); + } + + const durationMs = Date.now() - startTime; + + return { + tenant, + durationMs, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + /** + * Provision Turso database + */ + private async provisionTursoDatabase( + tenantId: string, + request: ProvisionTenantRequest, + warnings: string[], + ): Promise { + const config = request.driverConfig; + if (config.driver !== 'turso') { + throw new Error('Invalid driver config for Turso provisioning'); + } + + const databaseName = tenantId; // UUID-based naming let databaseUrl: string; let authToken: string; if (this.tursoClient) { // Production mode: Use Turso Platform API try { - // Step 1: Create database via Platform API const createDbResponse = await this.tursoClient.createDatabase({ name: databaseName, group: this.config.databaseGroup, }); - // Step 2: Generate database-specific auth token const tokenResponse = await this.tursoClient.createDatabaseToken(databaseName, { authorization: 'full-access', }); @@ -115,70 +173,190 @@ export class TenantProvisioningService { authToken = this.encryptAuthToken(tokenResponse.jwt); } catch (error) { throw new Error( - `Failed to provision tenant database: ${error instanceof Error ? error.message : String(error)}`, + `Failed to provision Turso database: ${error instanceof Error ? error.message : String(error)}`, ); } } else { - // Development/Mock mode: Generate placeholder values + // Development/Mock mode databaseUrl = `libsql://${databaseName}.turso.io`; authToken = this.encryptAuthToken(`mock-token-${tenantId}`); warnings.push('Running in mock mode - Turso Platform API credentials not configured'); } - // Step 3: Create tenant database record - const tenant: TenantDatabase = { + return { id: tenantId, organizationId: request.organizationId, - databaseName, - databaseUrl, - authToken, + driverConfig: { + driver: 'turso', + databaseUrl, + authToken, + region: config.region, + syncUrl: config.syncUrl, + }, status: 'active', - region, plan: request.plan || 'free', storageLimitMb: request.storageLimitMb || this.config.defaultStorageLimitMb || 1024, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: request.metadata, }; + } - // Step 4: Store tenant record in global control plane - if (this.config.controlPlaneDriver) { - try { - await this.config.controlPlaneDriver.create('tenant_database', { - id: tenant.id, - organization_id: tenant.organizationId, - database_name: tenant.databaseName, - database_url: tenant.databaseUrl, - auth_token: tenant.authToken, - status: tenant.status, - region: tenant.region, - plan: tenant.plan, - storage_limit_mb: tenant.storageLimitMb, - created_at: tenant.createdAt, - updated_at: tenant.updatedAt, - metadata: tenant.metadata, - }); - } catch (error) { - warnings.push( - `Failed to store tenant record in control plane: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } else { - warnings.push('Control plane driver not configured - tenant record not persisted'); + /** + * Provision Memory driver (for development/testing) + */ + private async provisionMemoryDriver( + tenantId: string, + request: ProvisionTenantRequest, + warnings: string[], + ): Promise { + const config = request.driverConfig; + if (config.driver !== 'memory') { + throw new Error('Invalid driver config for Memory provisioning'); } - // Step 5: Initialize tenant database with base schema - // TODO: This will be implemented when we have the schema initialization service + warnings.push('Memory driver: Data will be lost on restart unless persistence is enabled'); - const durationMs = Date.now() - startTime; + return { + id: tenantId, + organizationId: request.organizationId, + driverConfig: { + driver: 'memory', + persistent: config.persistent, + dataFile: config.dataFile, + }, + status: 'active', + plan: request.plan || 'free', + storageLimitMb: request.storageLimitMb || 512, // Lower default for memory + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: request.metadata, + }; + } + + /** + * Provision SQL database (PostgreSQL, MySQL, etc.) + */ + private async provisionSQLDatabase( + tenantId: string, + request: ProvisionTenantRequest, + warnings: string[], + ): Promise { + const config = request.driverConfig; + if (config.driver !== 'sql') { + throw new Error('Invalid driver config for SQL provisioning'); + } + + // In production, you might create a new database via SQL admin connection + // For now, we assume the database already exists or will be created externally return { - tenant, - durationMs, - warnings: warnings.length > 0 ? warnings : undefined, + id: tenantId, + organizationId: request.organizationId, + driverConfig: { + driver: 'sql', + dialect: config.dialect, + host: config.host, + port: config.port, + database: config.database, + username: config.username, + password: config.password, + ssl: config.ssl, + pool: config.pool, + }, + status: 'active', + plan: request.plan || 'enterprise', + storageLimitMb: request.storageLimitMb || 10240, // 10GB default for SQL + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: request.metadata, }; } + /** + * Provision SQLite database + */ + private async provisionSQLiteDatabase( + tenantId: string, + request: ProvisionTenantRequest, + warnings: string[], + ): Promise { + const config = request.driverConfig; + if (config.driver !== 'sqlite') { + throw new Error('Invalid driver config for SQLite provisioning'); + } + + return { + id: tenantId, + organizationId: request.organizationId, + driverConfig: { + driver: 'sqlite', + filename: config.filename, + readonly: config.readonly, + }, + status: 'active', + plan: request.plan || 'free', + storageLimitMb: request.storageLimitMb || 2048, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: request.metadata, + }; + } + + /** + * Provision Custom driver + */ + private async provisionCustomDriver( + tenantId: string, + request: ProvisionTenantRequest, + warnings: string[], + ): Promise { + const config = request.driverConfig; + if (config.driver !== 'custom') { + throw new Error('Invalid driver config for Custom provisioning'); + } + + warnings.push(`Using custom driver: ${config.driverName}`); + + return { + id: tenantId, + organizationId: request.organizationId, + driverConfig: { + driver: 'custom', + driverName: config.driverName, + config: config.config, + }, + status: 'active', + plan: request.plan || 'custom', + storageLimitMb: request.storageLimitMb || 5120, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: request.metadata, + }; + } + + /** + * Store tenant record in control plane database + */ + private async storeTenantRecord(tenant: TenantDatabase): Promise { + if (!this.config.controlPlaneDriver) { + return; + } + + await this.config.controlPlaneDriver.create('tenant_database', { + id: tenant.id, + organization_id: tenant.organizationId, + driver_config: JSON.stringify(tenant.driverConfig), + status: tenant.status, + plan: tenant.plan, + storage_limit_mb: tenant.storageLimitMb, + created_at: tenant.createdAt, + updated_at: tenant.updatedAt, + last_accessed_at: tenant.lastAccessedAt, + metadata: tenant.metadata, + }); + } + /** * Suspend a tenant database * diff --git a/packages/spec/src/cloud/tenant.zod.ts b/packages/spec/src/cloud/tenant.zod.ts index b2d9cabcc..d0b27eded 100644 --- a/packages/spec/src/cloud/tenant.zod.ts +++ b/packages/spec/src/cloud/tenant.zod.ts @@ -7,12 +7,13 @@ import { z } from 'zod'; * * Defines the schema for managing multi-tenant architecture with: * - Global control plane: Single database for auth, org management, tenant registry - * - Tenant data plane: Isolated databases per organization (UUID-based naming) + * - Tenant data plane: Isolated databases per organization with flexible driver selection * * Design decisions: - * - Database naming: {uuid}.turso.io (not org-slug, since slugs can be modified) - * - Each tenant has its own Turso database for complete data isolation + * - Database naming: UUID-based (not org-slug, since slugs can be modified) + * - Each organization can choose its own database driver (Turso, Memory, SQL, etc.) * - Global database stores user auth, organizations, and tenant metadata + * - Supports development (memory), production (Turso), and enterprise (SQL) scenarios */ /** @@ -41,11 +42,102 @@ export const TenantPlanSchema = z.enum([ export type TenantPlan = z.infer; +/** + * Organization Database Driver Type + * + * Specifies which driver implementation to use for the organization's data storage. + */ +export const OrganizationDatabaseDriverSchema = z.enum([ + 'turso', // Turso/LibSQL driver (cloud-native, edge-ready) + 'memory', // In-memory driver (dev/test only, data lost on restart) + 'sql', // Generic SQL driver (PostgreSQL, MySQL, MariaDB, MSSQL) + 'sqlite', // SQLite file-based driver (local development) + 'custom', // Custom driver implementation +]); + +export type OrganizationDatabaseDriver = z.infer; + +/** + * Driver Configuration Schemas + * + * Each driver type has its own configuration structure. + * Uses discriminated union for type-safe driver config. + */ + +/** Turso Driver Configuration */ +export const TursoDriverConfigSchema = z.object({ + driver: z.literal('turso'), + databaseUrl: z.string().url().describe('Turso database URL'), + authToken: z.string().describe('Turso auth token'), + region: z.string().optional().describe('Deployment region'), + syncUrl: z.string().url().optional().describe('Sync URL for embedded replicas'), +}); + +/** Memory Driver Configuration */ +export const MemoryDriverConfigSchema = z.object({ + driver: z.literal('memory'), + persistent: z.boolean().default(false).describe('Enable persistence to disk'), + dataFile: z.string().optional().describe('File path for persistent storage'), +}); + +/** SQL Driver Configuration */ +export const SQLDriverConfigSchema = z.object({ + driver: z.literal('sql'), + dialect: z.enum(['postgresql', 'mysql', 'mariadb', 'mssql']).describe('SQL dialect'), + host: z.string().describe('Database host'), + port: z.number().int().positive().describe('Database port'), + database: z.string().describe('Database name'), + username: z.string().describe('Database username'), + password: z.string().describe('Database password'), + ssl: z.boolean().optional().describe('Enable SSL connection'), + pool: z.object({ + min: z.number().int().optional(), + max: z.number().int().optional(), + }).optional().describe('Connection pool configuration'), +}); + +/** SQLite Driver Configuration */ +export const SQLiteDriverConfigSchema = z.object({ + driver: z.literal('sqlite'), + filename: z.string().describe('SQLite database file path'), + readonly: z.boolean().optional().describe('Open database in readonly mode'), +}); + +/** Custom Driver Configuration */ +export const CustomDriverConfigSchema = z.object({ + driver: z.literal('custom'), + driverName: z.string().describe('Custom driver identifier'), + config: z.record(z.string(), z.unknown()).describe('Driver-specific configuration'), +}); + +/** + * Driver Configuration (Discriminated Union) + * + * Type-safe union of all supported driver configurations. + * The 'driver' field discriminates which config structure to use. + */ +export const DriverConfigSchema = z.discriminatedUnion('driver', [ + TursoDriverConfigSchema, + MemoryDriverConfigSchema, + SQLDriverConfigSchema, + SQLiteDriverConfigSchema, + CustomDriverConfigSchema, +]); + +export type DriverConfig = z.infer; +export type TursoDriverConfig = z.infer; +export type MemoryDriverConfig = z.infer; +export type SQLDriverConfig = z.infer; +export type SQLiteDriverConfig = z.infer; +export type CustomDriverConfig = z.infer; + /** * Tenant Database Registry Entry * - * Tracks each tenant's dedicated database instance. + * Tracks each tenant's database configuration. * Stored in the global control plane database. + * + * Now supports multiple driver types instead of hardcoded Turso configuration. */ export const TenantDatabaseSchema = z.object({ /** @@ -59,33 +151,18 @@ export const TenantDatabaseSchema = z.object({ organizationId: z.string().describe('Organization ID (foreign key to sys_organization)'), /** - * Database name (UUID-based for immutability) - * Example: "550e8400-e29b-41d4-a716-446655440000" + * Driver Configuration + * + * Specifies which database driver to use and its configuration. + * Replaces hardcoded Turso fields for flexibility. */ - databaseName: z.string().describe('Database name (UUID-based)'), - - /** - * Full database URL - * Example: "libsql://550e8400-e29b-41d4-a716-446655440000.turso.io" - */ - databaseUrl: z.string().url().describe('Full database URL'), - - /** - * Encrypted tenant-specific auth token - */ - authToken: z.string().describe('Encrypted tenant-specific auth token'), + driverConfig: DriverConfigSchema.describe('Database driver configuration'), /** * Database provisioning and runtime status */ status: TenantDatabaseStatusSchema.default('provisioning').describe('Database status'), - /** - * Deployment region - * Example: "us-east-1", "eu-west-1", "ap-southeast-1" - */ - region: z.string().describe('Deployment region'), - /** * Tenant plan tier */ @@ -299,7 +376,7 @@ export type TenantRoutingConfig = z.infer; /** * Tenant Provisioning Request * - * Request to provision a new tenant database + * Request to provision a new organization database with flexible driver selection. */ export const ProvisionTenantRequestSchema = z.object({ /** @@ -308,9 +385,11 @@ export const ProvisionTenantRequestSchema = z.object({ organizationId: z.string().describe('Organization ID'), /** - * Deployment region preference + * Driver Configuration + * + * Specifies which database driver to use and its configuration. */ - region: z.string().optional().describe('Deployment region preference'), + driverConfig: DriverConfigSchema.describe('Database driver configuration'), /** * Tenant plan tier From be93315299ffa4e9363e1f87de8281bf875ef295 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:04:31 +0000 Subject: [PATCH 2/2] docs: add comprehensive driver configuration guide and tests - Add multi-driver.test.ts with tests for all 5 driver types - Add driver-factory.test.ts with cache key tests - Add DRIVER_CONFIG.md with complete usage guide - Document migration path from hardcoded Turso - Include examples for dev/staging/prod scenarios - Add type safety and troubleshooting sections Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/ad2ea41d-bf29-42ea-af00-fcc7d6deea5f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../services/service-tenant/DRIVER_CONFIG.md | 349 ++++++++++++++++++ .../service-tenant/src/driver-factory.test.ts | 114 ++++++ .../service-tenant/src/multi-driver.test.ts | 149 ++++++++ 3 files changed, 612 insertions(+) create mode 100644 packages/services/service-tenant/DRIVER_CONFIG.md create mode 100644 packages/services/service-tenant/src/driver-factory.test.ts create mode 100644 packages/services/service-tenant/src/multi-driver.test.ts diff --git a/packages/services/service-tenant/DRIVER_CONFIG.md b/packages/services/service-tenant/DRIVER_CONFIG.md new file mode 100644 index 000000000..23c86748c --- /dev/null +++ b/packages/services/service-tenant/DRIVER_CONFIG.md @@ -0,0 +1,349 @@ +# Organization Database Driver Configuration + +## Overview + +Organizations in ObjectStack can now choose their own database driver, enabling flexible deployment scenarios: + +- **Development/Testing**: Use `memory` driver for fast, ephemeral data +- **Production Cloud**: Use `turso` driver for edge-ready cloud deployment +- **Enterprise On-Premise**: Use `sql` driver with PostgreSQL/MySQL/etc. +- **Local Development**: Use `sqlite` driver for file-based storage +- **Custom Solutions**: Use `custom` driver for proprietary implementations + +## Supported Drivers + +### 1. Turso Driver (Production Cloud) + +```typescript +const driverConfig = { + driver: 'turso', + databaseUrl: 'libsql://uuid.turso.io', + authToken: process.env.TURSO_AUTH_TOKEN!, + region: 'us-east-1', // optional + syncUrl: 'libsql://sync.turso.io', // optional for embedded replicas +}; +``` + +**Use Cases:** +- Production SaaS deployments +- Edge-ready applications +- Global distribution + +### 2. Memory Driver (Development/Testing) + +```typescript +const driverConfig = { + driver: 'memory', + persistent: false, // data lost on restart + dataFile: '/tmp/org-dev.db', // optional for persistence +}; +``` + +**Use Cases:** +- Unit testing +- Integration testing +- Rapid prototyping +- Metadata schema development + +### 3. SQL Driver (Enterprise) + +```typescript +const driverConfig = { + driver: 'sql', + dialect: 'postgresql', // or 'mysql', 'mariadb', 'mssql' + host: 'localhost', + port: 5432, + database: 'org_enterprise_001', + username: 'app_user', + password: process.env.DB_PASSWORD!, + ssl: true, + pool: { + min: 2, + max: 10, + }, +}; +``` + +**Use Cases:** +- Enterprise on-premise deployments +- Regulatory/compliance requirements +- Existing PostgreSQL/MySQL infrastructure + +### 4. SQLite Driver (Local Development) + +```typescript +const driverConfig = { + driver: 'sqlite', + filename: '/data/org-local.db', + readonly: false, +}; +``` + +**Use Cases:** +- Local development +- Embedded applications +- Portable database files + +### 5. Custom Driver + +```typescript +const driverConfig = { + driver: 'custom', + driverName: 'my-custom-driver', + config: { + endpoint: 'https://api.custom-db.com', + apiKey: process.env.CUSTOM_API_KEY!, + // ... driver-specific config + }, +}; +``` + +**Use Cases:** +- Proprietary database systems +- Third-party integrations +- Specialized storage solutions + +## Usage Examples + +### Provision Organization with Driver + +```typescript +import { TenantProvisioningService } from '@objectstack/service-tenant'; + +const provisioningService = new TenantProvisioningService({ + controlPlaneDriver: globalDriver, + defaultStorageLimitMb: 1024, +}); + +// Production: Turso driver +const result = await provisioningService.provisionTenant({ + organizationId: 'org-prod-001', + driverConfig: { + driver: 'turso', + databaseUrl: 'libsql://550e8400.turso.io', + authToken: process.env.TURSO_TOKEN!, + }, + plan: 'pro', + storageLimitMb: 5120, +}); + +console.log('Tenant provisioned:', result.tenant.id); +``` + +### Get Driver for Organization + +```typescript +import { TenantContextService } from '@objectstack/service-tenant'; + +const tenantContext = new TenantContextService({ + enabled: true, + controlPlaneDriver: globalDriver, + driverFactoryConfig: { + driverConstructors: new Map([ + ['turso', TursoDriver], + ['memory', InMemoryDriver], + ['sql', SQLDriver], + ]), + }, +}); + +// Get driver instance for an organization +const driver = await tenantContext.getDriverForOrganization('org-001'); + +// Use driver for data operations +const users = await driver.find('user', { + filter: { status: 'active' }, +}); +``` + +### Driver Factory Setup + +```typescript +import { DriverFactory } from '@objectstack/service-tenant'; +import { TursoDriver } from '@objectstack/driver-turso'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SQLDriver } from '@objectstack/driver-sql'; + +const factory = new DriverFactory({ + driverConstructors: new Map([ + ['turso', TursoDriver], + ['memory', InMemoryDriver], + ['sql', SQLDriver], + ]), +}); + +// Create driver from config +const driver = await factory.create(driverConfig); +``` + +## Migration Guide + +### From Hardcoded Turso to Flexible Drivers + +**Before:** +```typescript +const tenant = { + id: 'uuid', + organizationId: 'org-001', + databaseName: 'uuid', + databaseUrl: 'libsql://uuid.turso.io', + authToken: 'encrypted-token', + region: 'us-east-1', + // ... +}; +``` + +**After:** +```typescript +const tenant = { + id: 'uuid', + organizationId: 'org-001', + driverConfig: { + driver: 'turso', + databaseUrl: 'libsql://uuid.turso.io', + authToken: 'encrypted-token', + region: 'us-east-1', + }, + // ... +}; +``` + +### Updating Control Plane Schema + +The `sys_tenant_database` object now uses `driver_config` instead of separate fields: + +```sql +-- Old schema (deprecated) +database_name TEXT +database_url TEXT +auth_token TEXT +region TEXT + +-- New schema +driver_config TEXT -- JSON-serialized DriverConfig +``` + +## Best Practices + +### 1. Development Setup +```typescript +// Use memory driver for fast tests +const devConfig = { + driver: 'memory', + persistent: false, +}; +``` + +### 2. Staging Environment +```typescript +// Use SQLite for staging +const stagingConfig = { + driver: 'sqlite', + filename: '/data/staging.db', +}; +``` + +### 3. Production Multi-Region +```typescript +// Use Turso with regional endpoints +const prodConfig = { + driver: 'turso', + databaseUrl: 'libsql://org-us-east.turso.io', + authToken: process.env.TURSO_TOKEN!, + region: 'us-east-1', +}; +``` + +### 4. Enterprise Compliance +```typescript +// Use on-premise PostgreSQL +const enterpriseConfig = { + driver: 'sql', + dialect: 'postgresql', + host: 'internal-db.company.com', + database: 'org_data', + ssl: true, +}; +``` + +## Architecture Benefits + +1. **Flexibility**: Choose the right storage for each use case +2. **Development Speed**: Use memory driver for rapid iteration +3. **Cost Optimization**: Scale storage based on needs +4. **Compliance**: Meet data residency requirements +5. **Future-Proof**: Easy to add new driver types + +## Type Safety + +All driver configurations are type-safe using discriminated unions: + +```typescript +type DriverConfig = + | TursoDriverConfig + | MemoryDriverConfig + | SQLDriverConfig + | SQLiteDriverConfig + | CustomDriverConfig; + +// TypeScript knows which fields are available +if (config.driver === 'turso') { + console.log(config.databaseUrl); // ✅ OK + console.log(config.host); // ❌ Error: Property 'host' does not exist +} +``` + +## Testing + +```typescript +import { describe, it, expect } from 'vitest'; +import { TenantProvisioningService } from '@objectstack/service-tenant'; + +describe('Multi-Driver Support', () => { + it('should provision with memory driver', async () => { + const service = new TenantProvisioningService(); + + const result = await service.provisionTenant({ + organizationId: 'test-org', + driverConfig: { + driver: 'memory', + persistent: false, + }, + }); + + expect(result.tenant.driverConfig.driver).toBe('memory'); + }); +}); +``` + +## Troubleshooting + +### Driver Constructor Not Registered + +```typescript +// Error: "Turso driver constructor not registered" + +// Solution: Register constructor in factory +const factory = new DriverFactory({ + driverConstructors: new Map([ + ['turso', TursoDriver], // Add this + ]), +}); +``` + +### Cache Issues + +```typescript +// Clear driver cache if needed +factory.clearCache(); + +// Or invalidate specific driver +factory.invalidateDriver('turso:libsql://uuid.turso.io'); +``` + +## Further Reading + +- [Turso Driver Documentation](../driver-turso/README.md) +- [Memory Driver Documentation](../driver-memory/README.md) +- [SQL Driver Documentation](../driver-sql/README.md) +- [Multi-Tenant Architecture](../../docs/MULTI_TENANT.md) diff --git a/packages/services/service-tenant/src/driver-factory.test.ts b/packages/services/service-tenant/src/driver-factory.test.ts new file mode 100644 index 000000000..2e0081321 --- /dev/null +++ b/packages/services/service-tenant/src/driver-factory.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DriverFactory } from './driver-factory'; +import type { DriverConfig } from '@objectstack/spec/cloud'; + +describe('DriverFactory', () => { + let factory: DriverFactory; + + beforeEach(() => { + factory = new DriverFactory(); + }); + + it('should generate consistent cache keys for same config', () => { + const config1: DriverConfig = { + driver: 'turso', + databaseUrl: 'libsql://test.turso.io', + authToken: 'token-123', + }; + + const config2: DriverConfig = { + driver: 'turso', + databaseUrl: 'libsql://test.turso.io', + authToken: 'token-123', + }; + + const key1 = (factory as any).getCacheKey(config1); + const key2 = (factory as any).getCacheKey(config2); + + expect(key1).toBe(key2); + expect(key1).toBe('turso:libsql://test.turso.io'); + }); + + it('should generate different cache keys for different configs', () => { + const config1: DriverConfig = { + driver: 'turso', + databaseUrl: 'libsql://test1.turso.io', + authToken: 'token-123', + }; + + const config2: DriverConfig = { + driver: 'turso', + databaseUrl: 'libsql://test2.turso.io', + authToken: 'token-456', + }; + + const key1 = (factory as any).getCacheKey(config1); + const key2 = (factory as any).getCacheKey(config2); + + expect(key1).not.toBe(key2); + }); + + it('should generate cache key for SQL config', () => { + const config: DriverConfig = { + driver: 'sql', + dialect: 'postgresql', + host: 'localhost', + port: 5432, + database: 'testdb', + username: 'user', + password: 'pass', + }; + + const key = (factory as any).getCacheKey(config); + expect(key).toBe('sql:postgresql:localhost:5432:testdb'); + }); + + it('should generate cache key for Memory config', () => { + const config: DriverConfig = { + driver: 'memory', + persistent: true, + dataFile: '/data/memory.db', + }; + + const key = (factory as any).getCacheKey(config); + expect(key).toBe('memory:/data/memory.db'); + }); + + it('should generate cache key for ephemeral Memory config', () => { + const config: DriverConfig = { + driver: 'memory', + persistent: false, + }; + + const key = (factory as any).getCacheKey(config); + expect(key).toBe('memory:ephemeral'); + }); + + it('should generate cache key for SQLite config', () => { + const config: DriverConfig = { + driver: 'sqlite', + filename: '/data/tenant.db', + }; + + const key = (factory as any).getCacheKey(config); + expect(key).toBe('sqlite:/data/tenant.db'); + }); + + it('should generate cache key for Custom config', () => { + const config: DriverConfig = { + driver: 'custom', + driverName: 'my-driver', + config: { endpoint: 'https://api.example.com' }, + }; + + const key = (factory as any).getCacheKey(config); + expect(key).toContain('custom:my-driver:'); + }); + + it('should clear cache', () => { + factory.clearCache(); + expect(factory.getCacheSize()).toBe(0); + }); +}); diff --git a/packages/services/service-tenant/src/multi-driver.test.ts b/packages/services/service-tenant/src/multi-driver.test.ts new file mode 100644 index 000000000..a915808a8 --- /dev/null +++ b/packages/services/service-tenant/src/multi-driver.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { TenantProvisioningService } from './tenant-provisioning'; +import type { ProvisionTenantRequest } from '@objectstack/spec/cloud'; + +describe('TenantProvisioningService - Multi-Driver Support', () => { + it('should provision tenant with Turso driver', async () => { + const service = new TenantProvisioningService({ + defaultStorageLimitMb: 1024, + }); + + const request: ProvisionTenantRequest = { + organizationId: 'org-turso-001', + driverConfig: { + driver: 'turso', + databaseUrl: 'libsql://test.turso.io', + authToken: 'test-token', + region: 'us-east-1', + }, + plan: 'pro', + }; + + const result = await service.provisionTenant(request); + + expect(result.tenant).toBeDefined(); + expect(result.tenant.driverConfig.driver).toBe('turso'); + expect(result.tenant.organizationId).toBe('org-turso-001'); + expect(result.tenant.status).toBe('active'); + }); + + it('should provision tenant with Memory driver', async () => { + const service = new TenantProvisioningService({ + defaultStorageLimitMb: 512, + }); + + const request: ProvisionTenantRequest = { + organizationId: 'org-memory-001', + driverConfig: { + driver: 'memory', + persistent: false, + }, + plan: 'free', + }; + + const result = await service.provisionTenant(request); + + expect(result.tenant).toBeDefined(); + expect(result.tenant.driverConfig.driver).toBe('memory'); + expect(result.tenant.storageLimitMb).toBe(512); + expect(result.warnings).toContain('Memory driver: Data will be lost on restart unless persistence is enabled'); + }); + + it('should provision tenant with SQL driver', async () => { + const service = new TenantProvisioningService(); + + const request: ProvisionTenantRequest = { + organizationId: 'org-sql-001', + driverConfig: { + driver: 'sql', + dialect: 'postgresql', + host: 'localhost', + port: 5432, + database: 'tenant_db', + username: 'postgres', + password: 'secret', + ssl: true, + }, + plan: 'enterprise', + }; + + const result = await service.provisionTenant(request); + + expect(result.tenant).toBeDefined(); + expect(result.tenant.driverConfig.driver).toBe('sql'); + if (result.tenant.driverConfig.driver === 'sql') { + expect(result.tenant.driverConfig.dialect).toBe('postgresql'); + expect(result.tenant.driverConfig.host).toBe('localhost'); + } + expect(result.tenant.plan).toBe('enterprise'); + }); + + it('should provision tenant with SQLite driver', async () => { + const service = new TenantProvisioningService(); + + const request: ProvisionTenantRequest = { + organizationId: 'org-sqlite-001', + driverConfig: { + driver: 'sqlite', + filename: '/data/tenant-001.db', + }, + plan: 'free', + }; + + const result = await service.provisionTenant(request); + + expect(result.tenant).toBeDefined(); + expect(result.tenant.driverConfig.driver).toBe('sqlite'); + if (result.tenant.driverConfig.driver === 'sqlite') { + expect(result.tenant.driverConfig.filename).toBe('/data/tenant-001.db'); + } + }); + + it('should provision tenant with Custom driver', async () => { + const service = new TenantProvisioningService(); + + const request: ProvisionTenantRequest = { + organizationId: 'org-custom-001', + driverConfig: { + driver: 'custom', + driverName: 'my-custom-driver', + config: { + endpoint: 'https://api.example.com', + apiKey: 'secret', + }, + }, + plan: 'custom', + }; + + const result = await service.provisionTenant(request); + + expect(result.tenant).toBeDefined(); + expect(result.tenant.driverConfig.driver).toBe('custom'); + if (result.tenant.driverConfig.driver === 'custom') { + expect(result.tenant.driverConfig.driverName).toBe('my-custom-driver'); + expect(result.tenant.driverConfig.config).toEqual({ + endpoint: 'https://api.example.com', + apiKey: 'secret', + }); + } + expect(result.warnings).toContain('Using custom driver: my-custom-driver'); + }); + + it('should include provisioning duration in response', async () => { + const service = new TenantProvisioningService(); + + const request: ProvisionTenantRequest = { + organizationId: 'org-test', + driverConfig: { + driver: 'memory', + persistent: false, + }, + }; + + const result = await service.provisionTenant(request); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); +});