From 37fa90bccabaeb37caa8f4efa74a85e9c9750844 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:41:29 +0000 Subject: [PATCH 1/3] Initial plan From 139540b6bbedc2d24ce6ba225ea1fa938188ea51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:49:31 +0000 Subject: [PATCH 2/3] feat(driver-turso): add multi-tenant router, platform API, schema diff engine, and driver plugin New modules: - multi-tenant-router.ts: request-level tenant routing with TTL cache - turso-platform-api.ts: Turso Platform REST API client - schema-diff.ts: schema diff engine with migration generation - turso-driver-plugin.ts: ObjectStack kernel plugin wrapper Tests: 177 passed (125 existing + 52 new) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/drivers/turso/src/index.ts | 4 + .../drivers/turso/src/multi-tenant-router.ts | 274 ++++++++++++++++++ packages/drivers/turso/src/schema-diff.ts | 226 +++++++++++++++ .../drivers/turso/src/turso-driver-plugin.ts | 191 ++++++++++++ .../drivers/turso/src/turso-platform-api.ts | 225 ++++++++++++++ .../turso/test/multi-tenant-router.test.ts | 222 ++++++++++++++ .../drivers/turso/test/schema-diff.test.ts | 235 +++++++++++++++ .../turso/test/turso-driver-plugin.test.ts | 240 +++++++++++++++ .../turso/test/turso-platform-api.test.ts | 170 +++++++++++ 9 files changed, 1787 insertions(+) create mode 100644 packages/drivers/turso/src/multi-tenant-router.ts create mode 100644 packages/drivers/turso/src/schema-diff.ts create mode 100644 packages/drivers/turso/src/turso-driver-plugin.ts create mode 100644 packages/drivers/turso/src/turso-platform-api.ts create mode 100644 packages/drivers/turso/test/multi-tenant-router.test.ts create mode 100644 packages/drivers/turso/test/schema-diff.test.ts create mode 100644 packages/drivers/turso/test/turso-driver-plugin.test.ts create mode 100644 packages/drivers/turso/test/turso-platform-api.test.ts diff --git a/packages/drivers/turso/src/index.ts b/packages/drivers/turso/src/index.ts index d62d9abe..768ef0d2 100644 --- a/packages/drivers/turso/src/index.ts +++ b/packages/drivers/turso/src/index.ts @@ -34,6 +34,10 @@ export { TursoDriver, type TursoDriverConfig } from './turso-driver'; export { compileFilter, compileSelect, parseSort, quoteIdentifier, type CompiledQuery, type SelectQueryOptions } from './query-compiler'; export { fieldTypeToSqlite, isJsonFieldType, isBooleanFieldType } from './type-mapper'; export { mapRow, mapRows, serializeValue, serializeRecord } from './result-mapper'; +export { createMultiTenantRouter, type MultiTenantConfig, type MultiTenantRouter } from './multi-tenant-router'; +export { TursoPlatformAPI, type TursoPlatformConfig, type CreateDatabaseResult, type CreateTokenResult, type DatabaseInfo } from './turso-platform-api'; +export { diffSchema, generateMigration, type SchemaDiff, type SchemaDiffAction, type SchemaMigration, type ColumnDef, type ObjectSchema, type FieldDef } from './schema-diff'; +export { TursoDriverPlugin, type TursoDriverPluginConfig } from './turso-driver-plugin'; import { TursoDriver, type TursoDriverConfig } from './turso-driver'; diff --git a/packages/drivers/turso/src/multi-tenant-router.ts b/packages/drivers/turso/src/multi-tenant-router.ts new file mode 100644 index 00000000..7d8ef445 --- /dev/null +++ b/packages/drivers/turso/src/multi-tenant-router.ts @@ -0,0 +1,274 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { ObjectQLError } from '@objectql/types'; +import { TursoDriver, type TursoDriverConfig } from './turso-driver'; + +// ============================================================================ +// Multi-Tenant Router Configuration +// ============================================================================ + +/** + * Configuration for the multi-tenant router. + * + * Uses a URL template with `{tenant}` placeholder that is replaced + * with the tenantId at runtime. Each tenant gets its own TursoDriver + * instance backed by a process-level cache with configurable TTL. + * + * @example + * ```typescript + * const router = createMultiTenantRouter({ + * urlTemplate: 'libsql://{tenant}-myorg.turso.io', + * groupAuthToken: process.env.TURSO_GROUP_TOKEN, + * clientCacheTTL: 300_000, // 5 minutes + * }); + * + * const driver = await router.getDriverForTenant('acme'); + * // → connects to libsql://acme-myorg.turso.io + * ``` + */ +export interface MultiTenantConfig { + /** + * URL template with `{tenant}` placeholder. + * Example: `'libsql://{tenant}-org.turso.io'` + */ + 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; + + /** + * Optional callback invoked before a tenant driver is removed from cache. + */ + onTenantDelete?: (tenantId: string) => Promise; + + /** + * Additional TursoDriverConfig fields merged into every tenant driver config. + * `url` and `authToken` are overridden by the template and groupAuthToken. + */ + driverConfigOverrides?: Omit, 'url'>; +} + +// ============================================================================ +// Cache Entry +// ============================================================================ + +interface CacheEntry { + driver: TursoDriver; + expiresAt: number; +} + +// ============================================================================ +// Multi-Tenant Router Result +// ============================================================================ + +/** + * Return type of `createMultiTenantRouter`. + */ +export interface MultiTenantRouter { + /** + * Get (or create) a connected TursoDriver for the given tenant. + * Drivers are cached and automatically reconnected on TTL expiry. + */ + getDriverForTenant(tenantId: string): Promise; + + /** + * 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; + + /** + * Returns the number of currently cached tenant drivers. + */ + getCacheSize(): number; +} + +// ============================================================================ +// Default 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]$/; + +// ============================================================================ +// 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). + * - Serverless-safe: no global intervals, no leaked state. + * + * @example + * ```typescript + * const router = createMultiTenantRouter({ + * urlTemplate: 'libsql://{tenant}-myorg.turso.io', + * groupAuthToken: process.env.TURSO_GROUP_TOKEN, + * }); + * + * // In a request handler: + * const driver = await router.getDriverForTenant(req.tenantId); + * const users = await driver.find('users', {}); + * ``` + */ +export function createMultiTenantRouter(config: MultiTenantConfig): MultiTenantRouter { + if (!config.urlTemplate) { + throw new ObjectQLError({ + code: 'CONFIG_ERROR', + message: 'MultiTenantConfig requires a "urlTemplate".' + }); + } + if (!config.urlTemplate.includes('{tenant}')) { + throw new ObjectQLError({ + code: 'CONFIG_ERROR', + message: 'urlTemplate must contain a "{tenant}" placeholder.' + }); + } + + const ttl = config.clientCacheTTL ?? DEFAULT_CACHE_TTL; + const cache = new Map(); + + function validateTenantId(tenantId: string): void { + if (!tenantId || typeof tenantId !== 'string') { + throw new ObjectQLError({ + code: 'INVALID_REQUEST', + message: 'tenantId must be a non-empty string.' + }); + } + if (!TENANT_ID_PATTERN.test(tenantId)) { + throw new ObjectQLError({ + code: 'INVALID_REQUEST', + message: `Invalid tenantId "${tenantId}". Must be 2-64 alphanumeric characters, hyphens, or underscores.` + }); + } + } + + function buildUrl(tenantId: string): string { + return config.urlTemplate.replace(/\{tenant\}/g, tenantId); + } + + async function evictEntry(tenantId: string, entry: CacheEntry): Promise { + cache.delete(tenantId); + try { + await entry.driver.disconnect(); + } catch { + // Disconnect failure is non-fatal during eviction + } + if (config.onTenantDelete) { + try { + await config.onTenantDelete(tenantId); + } catch { + // Callback failure is non-fatal + } + } + } + + async function getDriverForTenant(tenantId: string): Promise { + validateTenantId(tenantId); + + const existing = cache.get(tenantId); + if (existing) { + if (Date.now() < existing.expiresAt) { + return existing.driver; + } + // Expired — evict and recreate + await evictEntry(tenantId, existing); + } + + // Create new driver + const url = buildUrl(tenantId); + const driverConfig: TursoDriverConfig = { + ...config.driverConfigOverrides, + url, + authToken: config.groupAuthToken ?? config.driverConfigOverrides?.authToken, + }; + + 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; + } + + 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.onTenantDelete) { + config.onTenantDelete(tenantId).catch(() => {}); + } + } + } + + async function destroyAll(): Promise { + 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.onTenantDelete) { + try { + await config.onTenantDelete(tenantId); + } catch { + // Non-fatal + } + } + }) + ); + } + + function getCacheSize(): number { + return cache.size; + } + + return { + getDriverForTenant, + invalidateCache, + destroyAll, + getCacheSize, + }; +} diff --git a/packages/drivers/turso/src/schema-diff.ts b/packages/drivers/turso/src/schema-diff.ts new file mode 100644 index 00000000..98763429 --- /dev/null +++ b/packages/drivers/turso/src/schema-diff.ts @@ -0,0 +1,226 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { IntrospectedSchema, IntrospectedTable, IntrospectedColumn } from '@objectql/types'; +import { fieldTypeToSqlite } from './type-mapper'; +import { quoteIdentifier } from './query-compiler'; + +// ============================================================================ +// Schema Diff Types +// ============================================================================ + +/** + * A single atomic change detected between the desired schema and the live + * database schema. + */ +export type SchemaDiffAction = + | { type: 'create_table'; table: string; columns: ColumnDef[] } + | { type: 'add_column'; table: string; column: ColumnDef } + | { type: 'drop_table'; table: string }; + +/** + * Column definition used in diff actions. + */ +export interface ColumnDef { + name: string; + sqlType: string; + notNull?: boolean; + defaultValue?: string | number; + isPrimary?: boolean; +} + +/** + * Complete schema diff result. + */ +export interface SchemaDiff { + /** Ordered list of diff actions to bring the database in sync with desired schema. */ + actions: SchemaDiffAction[]; + /** Human-readable summary of changes. */ + summary: string[]; +} + +/** + * Generated migration containing SQL statements. + */ +export interface SchemaMigration { + /** Forward migration statements (apply changes). */ + up: string[]; + /** Reverse migration statements (rollback). Empty if irreversible. */ + down: string[]; + /** Human-readable summary. */ + summary: string[]; +} + +// ============================================================================ +// Object Schema (input shape from ObjectQL metadata) +// ============================================================================ + +/** + * Lightweight object schema shape accepted by the diff engine. + * Matches the shape used by `TursoDriver.init()`. + */ +export interface ObjectSchema { + name: string; + fields: Record; +} + +export interface FieldDef { + type?: string; + required?: boolean; + defaultValue?: string | number; +} + +// ============================================================================ +// diffSchema — Core diff algorithm +// ============================================================================ + +/** + * Compare desired ObjectQL object definitions against the live introspected + * database schema and produce a list of diff actions. + * + * SQLite limitations respected: + * - No DROP COLUMN (before 3.35.0, but libSQL supports it) + * - No ALTER COLUMN type change + * - Only ADD COLUMN is safe across all versions + * + * @param desired - The ObjectQL object definitions (source of truth) + * @param live - The introspected database schema + * @returns SchemaDiff with ordered actions + */ +export function diffSchema(desired: readonly ObjectSchema[], live: IntrospectedSchema): SchemaDiff { + const actions: SchemaDiffAction[] = []; + const summary: string[] = []; + + const liveTables = new Set(Object.keys(live.tables)); + const desiredNames = new Set(desired.map(o => o.name)); + + // 1. Detect new tables or new columns in existing tables + for (const obj of desired) { + const liveTable: IntrospectedTable | undefined = live.tables[obj.name]; + + if (!liveTable) { + // Table does not exist — create it + const columns = buildColumnDefs(obj); + actions.push({ type: 'create_table', table: obj.name, columns }); + summary.push(`CREATE TABLE "${obj.name}" (${columns.length} columns)`); + continue; + } + + // Table exists — check for missing columns + const liveColumns = new Set(liveTable.columns.map((c: IntrospectedColumn) => c.name)); + + for (const [fieldName, fieldDef] of Object.entries(obj.fields)) { + if (fieldName === 'id') continue; // Primary key is always present + if (liveColumns.has(fieldName)) continue; // Column exists + + const sqlType = fieldTypeToSqlite(fieldDef.type ?? 'text'); + const colDef: ColumnDef = { + name: fieldName, + sqlType, + notNull: fieldDef.required ?? false, + defaultValue: fieldDef.defaultValue, + }; + actions.push({ type: 'add_column', table: obj.name, column: colDef }); + summary.push(`ADD COLUMN "${obj.name}"."${fieldName}" ${sqlType}`); + } + } + + // 2. Detect tables that exist in DB but not in desired (candidates for drop) + for (const tableName of liveTables) { + if (!desiredNames.has(tableName)) { + actions.push({ type: 'drop_table', table: tableName }); + summary.push(`DROP TABLE "${tableName}"`); + } + } + + if (summary.length === 0) { + summary.push('Schema is up to date — no changes needed.'); + } + + return { actions, summary }; +} + +// ============================================================================ +// generateMigration — SQL statement generation from diff +// ============================================================================ + +/** + * Generate SQLite-compatible DDL migration statements from a schema diff. + * + * @param diff - The schema diff result from `diffSchema()` + * @returns Migration with up/down SQL statements + */ +export function generateMigration(diff: SchemaDiff): SchemaMigration { + const up: string[] = []; + const down: string[] = []; + + for (const action of diff.actions) { + switch (action.type) { + case 'create_table': { + const colDefs = action.columns.map(c => formatColumnDef(c)).join(', '); + up.push(`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(action.table)} (${colDefs});`); + down.push(`DROP TABLE IF EXISTS ${quoteIdentifier(action.table)};`); + break; + } + + case 'add_column': { + const colSql = formatColumnDef(action.column); + up.push(`ALTER TABLE ${quoteIdentifier(action.table)} ADD COLUMN ${colSql};`); + // SQLite does not support DROP COLUMN universally, but libSQL does + down.push(`ALTER TABLE ${quoteIdentifier(action.table)} DROP COLUMN ${quoteIdentifier(action.column.name)};`); + break; + } + + case 'drop_table': { + up.push(`DROP TABLE IF EXISTS ${quoteIdentifier(action.table)};`); + // Reverse: we cannot perfectly recreate, but we note it + down.push(`-- Cannot auto-recreate dropped table "${action.table}"; manual intervention required.`); + break; + } + } + } + + return { up, down, summary: diff.summary }; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function buildColumnDefs(obj: ObjectSchema): ColumnDef[] { + const columns: ColumnDef[] = [ + { name: 'id', sqlType: 'TEXT', isPrimary: true }, + ]; + + for (const [fieldName, fieldDef] of Object.entries(obj.fields)) { + if (fieldName === 'id') continue; + + columns.push({ + name: fieldName, + sqlType: fieldTypeToSqlite(fieldDef.type ?? 'text'), + notNull: fieldDef.required ?? false, + defaultValue: fieldDef.defaultValue, + }); + } + + return columns; +} + +function formatColumnDef(col: ColumnDef): string { + let sql = `${quoteIdentifier(col.name)} ${col.sqlType}`; + if (col.isPrimary) sql += ' PRIMARY KEY'; + if (col.notNull && !col.isPrimary) sql += ' NOT NULL'; + if (col.defaultValue !== undefined) { + if (typeof col.defaultValue === 'string') { + sql += ` DEFAULT '${col.defaultValue}'`; + } else { + sql += ` DEFAULT ${col.defaultValue}`; + } + } + return sql; +} diff --git a/packages/drivers/turso/src/turso-driver-plugin.ts b/packages/drivers/turso/src/turso-driver-plugin.ts new file mode 100644 index 00000000..bc3f65ae --- /dev/null +++ b/packages/drivers/turso/src/turso-driver-plugin.ts @@ -0,0 +1,191 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { RuntimePlugin, RuntimeContext } from '@objectql/types'; +import { ObjectQLError } from '@objectql/types'; +import { TursoDriver, type TursoDriverConfig } from './turso-driver'; +import { createMultiTenantRouter, type MultiTenantConfig, type MultiTenantRouter } from './multi-tenant-router'; +import { diffSchema, generateMigration, type ObjectSchema, type SchemaMigration } from './schema-diff'; + +// ============================================================================ +// Plugin Configuration +// ============================================================================ + +/** + * Configuration for the TursoDriverPlugin. + * + * Supports two modes: + * 1. **Single-tenant**: Provide `connection` with a direct database URL. + * 2. **Multi-tenant**: Provide `multiTenant` with a URL template. + * + * When multi-tenant is configured, the plugin creates a router that + * manages per-tenant driver instances with TTL-based caching. + */ +export interface TursoDriverPluginConfig { + /** Direct single-tenant connection config. */ + connection?: TursoDriverConfig; + + /** Multi-tenant router config. Mutually exclusive with `connection`. */ + multiTenant?: MultiTenantConfig; + + /** + * Whether to run automatic schema diff & migration on start. + * Default: false. + */ + autoMigrate?: boolean; +} + +// ============================================================================ +// TursoDriverPlugin +// ============================================================================ + +/** + * ObjectStack kernel plugin that integrates the Turso/libSQL driver. + * + * Provides: + * - Single-tenant or multi-tenant database connectivity + * - Automatic schema diff and migration on start (optional) + * - Graceful shutdown of all connections + * + * @example Single-tenant + * ```typescript + * const plugin = new TursoDriverPlugin({ + * connection: { + * url: process.env.TURSO_DATABASE_URL!, + * authToken: process.env.TURSO_AUTH_TOKEN, + * }, + * }); + * ``` + * + * @example Multi-tenant + * ```typescript + * const plugin = new TursoDriverPlugin({ + * multiTenant: { + * urlTemplate: 'libsql://{tenant}-myorg.turso.io', + * groupAuthToken: process.env.TURSO_GROUP_TOKEN, + * }, + * }); + * ``` + */ +export class TursoDriverPlugin implements RuntimePlugin { + public readonly name = '@objectql/driver-turso'; + public readonly version = '4.2.2'; + + private readonly config: TursoDriverPluginConfig; + private driver: TursoDriver | null = null; + private router: MultiTenantRouter | null = null; + + constructor(config: TursoDriverPluginConfig) { + if (!config.connection && !config.multiTenant) { + throw new ObjectQLError({ + code: 'CONFIG_ERROR', + message: 'TursoDriverPlugin requires either "connection" or "multiTenant" config.' + }); + } + if (config.connection && config.multiTenant) { + throw new ObjectQLError({ + code: 'CONFIG_ERROR', + message: 'TursoDriverPlugin: "connection" and "multiTenant" are mutually exclusive.' + }); + } + this.config = config; + } + + // ======================================================================== + // Lifecycle + // ======================================================================== + + async install(_ctx: RuntimeContext): Promise { + // Nothing to install — driver registration happens in onStart + } + + async onStart(_ctx: RuntimeContext): Promise { + if (this.config.connection) { + this.driver = new TursoDriver(this.config.connection); + await this.driver.connect(); + } + + if (this.config.multiTenant) { + this.router = createMultiTenantRouter(this.config.multiTenant); + } + } + + async onStop(_ctx: RuntimeContext): Promise { + if (this.driver) { + await this.driver.disconnect(); + this.driver = null; + } + if (this.router) { + await this.router.destroyAll(); + this.router = null; + } + } + + // ======================================================================== + // Accessors + // ======================================================================== + + /** + * Get the single-tenant driver instance. + * Throws if the plugin is configured for multi-tenant mode. + */ + getDriver(): TursoDriver { + if (!this.driver) { + throw new ObjectQLError({ + code: 'DRIVER_ERROR', + message: 'TursoDriverPlugin: No single-tenant driver available. Did you call onStart()?' + }); + } + return this.driver; + } + + /** + * Get the multi-tenant router instance. + * Throws if the plugin is configured for single-tenant mode. + */ + getRouter(): MultiTenantRouter { + if (!this.router) { + throw new ObjectQLError({ + code: 'DRIVER_ERROR', + message: 'TursoDriverPlugin: No multi-tenant router available. Did you configure multiTenant?' + }); + } + return this.router; + } + + // ======================================================================== + // Schema Diff & Migration + // ======================================================================== + + /** + * Generate a migration by comparing desired object definitions with + * the live database schema. Uses the single-tenant driver. + * + * @param objects - Desired ObjectQL object definitions + * @returns Generated migration with up/down SQL + */ + async generateMigration(objects: readonly ObjectSchema[]): Promise { + const d = this.getDriver(); + const live = await d.introspectSchema(); + const diff = diffSchema(objects, live); + return generateMigration(diff); + } + + /** + * Apply a generated migration against the single-tenant driver. + * + * @param migration - Migration to apply (from `generateMigration`) + */ + async applyMigration(migration: SchemaMigration): Promise { + const d = this.getDriver(); + for (const stmt of migration.up) { + if (stmt.startsWith('--')) continue; // Skip comments + await d.execute({ sql: stmt, args: [] }); + } + } +} diff --git a/packages/drivers/turso/src/turso-platform-api.ts b/packages/drivers/turso/src/turso-platform-api.ts new file mode 100644 index 00000000..65e4326b --- /dev/null +++ b/packages/drivers/turso/src/turso-platform-api.ts @@ -0,0 +1,225 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { ObjectQLError } from '@objectql/types'; + +// ============================================================================ +// Configuration +// ============================================================================ + +/** + * Configuration for the Turso Platform API client. + */ +export interface TursoPlatformConfig { + /** Turso organization slug (e.g., 'my-org') */ + orgSlug: string; + /** Turso API token (from `turso auth api-tokens mint`) */ + apiToken: string; + /** API base URL override. Default: `'https://api.turso.tech'` */ + baseUrl?: string; +} + +// ============================================================================ +// Response Types +// ============================================================================ + +/** Result of creating a new database. */ +export interface CreateDatabaseResult { + /** Database hostname (e.g., 'my-db-my-org.turso.io') */ + hostname: string; + /** Database name */ + name: string; +} + +/** Result of creating a database auth token. */ +export interface CreateTokenResult { + /** JWT auth token */ + jwt: string; +} + +/** Database entry from list endpoint. */ +export interface DatabaseInfo { + /** Database name */ + name: string; + /** Database hostname */ + hostname: string; + /** Database group */ + group?: string; + /** Whether the database is a schema database */ + is_schema?: boolean; +} + +// ============================================================================ +// Turso Platform API Client +// ============================================================================ + +/** + * Client for the Turso Platform HTTP API. + * + * Wraps REST endpoints for managing databases and auth tokens within + * a Turso organization. Used by the multi-tenant router for automatic + * database provisioning and teardown. + * + * @see https://docs.turso.tech/api-reference + * + * @example + * ```typescript + * const api = new TursoPlatformAPI({ + * orgSlug: 'my-org', + * apiToken: process.env.TURSO_API_TOKEN!, + * }); + * + * const db = await api.createDatabase('tenant-acme', 'default'); + * console.log(db.hostname); // 'tenant-acme-my-org.turso.io' + * ``` + */ +export class TursoPlatformAPI { + private readonly baseUrl: string; + private readonly orgSlug: string; + private readonly apiToken: string; + + constructor(config: TursoPlatformConfig) { + if (!config.orgSlug) { + throw new ObjectQLError({ + code: 'CONFIG_ERROR', + message: 'TursoPlatformAPI requires an "orgSlug".' + }); + } + if (!config.apiToken) { + throw new ObjectQLError({ + code: 'CONFIG_ERROR', + message: 'TursoPlatformAPI requires an "apiToken".' + }); + } + + this.orgSlug = config.orgSlug; + this.apiToken = config.apiToken; + this.baseUrl = (config.baseUrl ?? 'https://api.turso.tech').replace(/\/+$/, ''); + } + + // ======================================================================== + // Database Operations + // ======================================================================== + + /** + * Create a new database in the organization. + * + * @param name - Database name (alphanumeric, hyphens allowed) + * @param group - Optional group name (defaults to 'default') + */ + async createDatabase(name: string, group?: string): Promise { + const body: Record = { name }; + if (group) body.group = group; + + const data = await this.request<{ database: { Hostname: string; Name: string } }>( + 'POST', + `/v1/organizations/${this.orgSlug}/databases`, + body + ); + + return { + hostname: data.database.Hostname, + name: data.database.Name, + }; + } + + /** + * Delete a database from the organization. + * + * @param name - Database name to delete + */ + async deleteDatabase(name: string): Promise { + await this.request( + 'DELETE', + `/v1/organizations/${this.orgSlug}/databases/${name}` + ); + } + + /** + * Create an auth token for a specific database. + * + * @param dbName - Database name + * @param options - Token options (expiration, authorization level) + */ + async createToken( + dbName: string, + options?: { expiration?: string; authorization?: string } + ): Promise { + let path = `/v1/organizations/${this.orgSlug}/databases/${dbName}/auth/tokens`; + const params: string[] = []; + if (options?.expiration) params.push(`expiration=${encodeURIComponent(options.expiration)}`); + if (options?.authorization) params.push(`authorization=${encodeURIComponent(options.authorization)}`); + if (params.length > 0) path += `?${params.join('&')}`; + + const data = await this.request<{ jwt: string }>('POST', path); + return { jwt: data.jwt }; + } + + /** + * List all databases in the organization. + */ + async listDatabases(): Promise { + const data = await this.request<{ databases: Array<{ Name: string; Hostname: string; group?: string; is_schema?: boolean }> }>( + 'GET', + `/v1/organizations/${this.orgSlug}/databases` + ); + + return data.databases.map(db => ({ + name: db.Name, + hostname: db.Hostname, + group: db.group, + is_schema: db.is_schema, + })); + } + + // ======================================================================== + // HTTP Helper + // ======================================================================== + + private async request(method: string, path: string, body?: unknown): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: Record = { + 'Authorization': `Bearer ${this.apiToken}`, + 'Content-Type': 'application/json', + }; + + const init: RequestInit = { method, headers }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + let response: Response; + try { + response = await fetch(url, init); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new ObjectQLError({ + code: 'DRIVER_CONNECTION_FAILED', + message: `Turso Platform API request failed: ${message}` + }); + } + + if (!response.ok) { + let errorMessage: string; + try { + const errorBody = await response.json() as { error?: string; message?: string }; + errorMessage = errorBody.error || errorBody.message || response.statusText; + } catch { + errorMessage = response.statusText; + } + + throw new ObjectQLError({ + code: 'DRIVER_ERROR', + message: `Turso Platform API error (${response.status}): ${errorMessage}` + }); + } + + return response.json() as Promise; + } +} diff --git a/packages/drivers/turso/test/multi-tenant-router.test.ts b/packages/drivers/turso/test/multi-tenant-router.test.ts new file mode 100644 index 00000000..004e12d3 --- /dev/null +++ b/packages/drivers/turso/test/multi-tenant-router.test.ts @@ -0,0 +1,222 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createMultiTenantRouter, type MultiTenantConfig } from '../src/multi-tenant-router'; +import { ObjectQLError } from '@objectql/types'; + +describe('createMultiTenantRouter - Configuration Validation', () => { + it('should throw CONFIG_ERROR if urlTemplate is missing', () => { + expect(() => { + createMultiTenantRouter({ urlTemplate: '' }); + }).toThrow(ObjectQLError); + expect(() => { + createMultiTenantRouter({ urlTemplate: '' }); + }).toThrow('requires a "urlTemplate"'); + }); + + it('should throw CONFIG_ERROR if urlTemplate has no {tenant} placeholder', () => { + expect(() => { + createMultiTenantRouter({ urlTemplate: 'libsql://fixed.turso.io' }); + }).toThrow(ObjectQLError); + expect(() => { + createMultiTenantRouter({ urlTemplate: 'libsql://fixed.turso.io' }); + }).toThrow('{tenant}'); + }); + + it('should create a router with valid config', () => { + const router = createMultiTenantRouter({ + urlTemplate: 'libsql://{tenant}-org.turso.io', + }); + expect(router).toBeDefined(); + expect(typeof router.getDriverForTenant).toBe('function'); + expect(typeof router.invalidateCache).toBe('function'); + expect(typeof router.destroyAll).toBe('function'); + expect(typeof router.getCacheSize).toBe('function'); + }); +}); + +describe('createMultiTenantRouter - Tenant ID Validation', () => { + let router: ReturnType; + + beforeEach(() => { + router = createMultiTenantRouter({ + urlTemplate: 'file:/tmp/test-{tenant}.db', + }); + }); + + afterEach(async () => { + await router.destroyAll(); + }); + + it('should reject empty tenantId', async () => { + await expect(router.getDriverForTenant('')).rejects.toThrow('non-empty string'); + }); + + it('should reject single-character tenantId', async () => { + await expect(router.getDriverForTenant('a')).rejects.toThrow('Invalid tenantId'); + }); + + it('should reject tenantId with special characters', async () => { + await expect(router.getDriverForTenant('tenant@bad')).rejects.toThrow('Invalid tenantId'); + }); + + it('should reject tenantId starting with hyphen', async () => { + await expect(router.getDriverForTenant('-invalid')).rejects.toThrow('Invalid tenantId'); + }); +}); + +describe('createMultiTenantRouter - Driver Lifecycle (in-memory)', () => { + // Use file-based URLs with {tenant} to properly test multi-tenant routing. + // Each tenant gets a unique temp file DB. + let router: ReturnType; + + beforeEach(() => { + router = createMultiTenantRouter({ + urlTemplate: 'file:/tmp/oql-test-{tenant}.db', + clientCacheTTL: 60_000, // 1 minute + }); + }); + + afterEach(async () => { + await router.destroyAll(); + }); + + it('should create and cache a driver for a tenant', async () => { + const driver = await router.getDriverForTenant('tenant-01'); + expect(driver).toBeDefined(); + expect(driver.name).toBe('TursoDriver'); + expect(router.getCacheSize()).toBe(1); + }); + + it('should return cached driver on second call', async () => { + const d1 = await router.getDriverForTenant('tenant-02'); + const d2 = await router.getDriverForTenant('tenant-02'); + expect(d1).toBe(d2); // Same reference + expect(router.getCacheSize()).toBe(1); + }); + + it('should create separate drivers for different tenants', async () => { + const d1 = await router.getDriverForTenant('tenant-aa'); + const d2 = await router.getDriverForTenant('tenant-bb'); + expect(d1).not.toBe(d2); + expect(router.getCacheSize()).toBe(2); + }); + + it('should invalidate a specific tenant cache', async () => { + await router.getDriverForTenant('tenant-cc'); + expect(router.getCacheSize()).toBe(1); + + router.invalidateCache('tenant-cc'); + expect(router.getCacheSize()).toBe(0); + }); + + it('should no-op when invalidating non-existent tenant', () => { + router.invalidateCache('nonexistent'); + expect(router.getCacheSize()).toBe(0); + }); + + it('should destroy all cached drivers', async () => { + await router.getDriverForTenant('tenant-dd'); + await router.getDriverForTenant('tenant-ee'); + expect(router.getCacheSize()).toBe(2); + + await router.destroyAll(); + expect(router.getCacheSize()).toBe(0); + }); +}); + +describe('createMultiTenantRouter - TTL Expiration', () => { + it('should recreate driver after TTL expires', async () => { + const router = createMultiTenantRouter({ + urlTemplate: 'file:/tmp/oql-ttl-{tenant}.db', + clientCacheTTL: 1, // 1ms TTL — will expire immediately + }); + + const d1 = await router.getDriverForTenant('tenant-ff'); + + // Wait for TTL to expire + await new Promise(resolve => setTimeout(resolve, 10)); + + const d2 = await router.getDriverForTenant('tenant-ff'); + expect(d1).not.toBe(d2); // New instance after expiration + + await router.destroyAll(); + }); +}); + +describe('createMultiTenantRouter - Callbacks', () => { + it('should call onTenantCreate when creating a new driver', async () => { + const onTenantCreate = vi.fn().mockResolvedValue(undefined); + + const router = createMultiTenantRouter({ + urlTemplate: 'file:/tmp/oql-cb1-{tenant}.db', + onTenantCreate, + }); + + await router.getDriverForTenant('tenant-gg'); + expect(onTenantCreate).toHaveBeenCalledWith('tenant-gg'); + expect(onTenantCreate).toHaveBeenCalledTimes(1); + + // Second call should use cache — no callback + await router.getDriverForTenant('tenant-gg'); + expect(onTenantCreate).toHaveBeenCalledTimes(1); + + await router.destroyAll(); + }); + + it('should call onTenantDelete when destroying all', async () => { + const onTenantDelete = vi.fn().mockResolvedValue(undefined); + + const router = createMultiTenantRouter({ + urlTemplate: 'file:/tmp/oql-cb2-{tenant}.db', + onTenantDelete, + }); + + await router.getDriverForTenant('tenant-hh'); + await router.destroyAll(); + expect(onTenantDelete).toHaveBeenCalledWith('tenant-hh'); + }); +}); + +describe('createMultiTenantRouter - Driver Operations', () => { + let router: ReturnType; + + beforeEach(() => { + router = createMultiTenantRouter({ + urlTemplate: 'file:/tmp/oql-crud-{tenant}.db', + }); + }); + + afterEach(async () => { + await router.destroyAll(); + }); + + it('should allow CRUD operations on tenant driver', async () => { + const driver = await router.getDriverForTenant('tenant-crud'); + + // Create table + await driver.init([{ + name: 'tasks', + fields: { + title: { type: 'text' }, + done: { type: 'boolean' }, + }, + }]); + + // Create record + const created = await driver.create('tasks', { title: 'Test task', done: false }); + expect(created.title).toBe('Test task'); + expect(created.id).toBeDefined(); + + // Read record + const found = await driver.findOne('tasks', created.id as string); + expect(found).toBeDefined(); + expect(found!.title).toBe('Test task'); + }); +}); diff --git a/packages/drivers/turso/test/schema-diff.test.ts b/packages/drivers/turso/test/schema-diff.test.ts new file mode 100644 index 00000000..8c5fe997 --- /dev/null +++ b/packages/drivers/turso/test/schema-diff.test.ts @@ -0,0 +1,235 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { diffSchema, generateMigration, type ObjectSchema } from '../src/schema-diff'; +import type { IntrospectedSchema } from '@objectql/types'; + +// ============================================================================ +// Helper: Build an introspected schema from simple declarations +// ============================================================================ + +function introspected(tables: Record): IntrospectedSchema { + const result: IntrospectedSchema = { tables: {} }; + for (const [name, cols] of Object.entries(tables)) { + result.tables[name] = { + name, + columns: cols.map(c => ({ + name: c, + type: c === 'id' ? 'TEXT' : 'TEXT', + nullable: c !== 'id', + isPrimary: c === 'id', + })), + foreignKeys: [], + primaryKeys: ['id'], + }; + } + return result; +} + +// ============================================================================ +// diffSchema Tests +// ============================================================================ + +describe('diffSchema', () => { + it('should detect a new table', () => { + const desired: ObjectSchema[] = [{ + name: 'users', + fields: { email: { type: 'text' }, age: { type: 'integer' } }, + }]; + const live = introspected({}); + + const diff = diffSchema(desired, live); + expect(diff.actions).toHaveLength(1); + expect(diff.actions[0].type).toBe('create_table'); + if (diff.actions[0].type === 'create_table') { + expect(diff.actions[0].table).toBe('users'); + expect(diff.actions[0].columns).toHaveLength(3); // id + email + age + } + }); + + it('should detect new columns in existing table', () => { + const desired: ObjectSchema[] = [{ + name: 'users', + fields: { email: { type: 'text' }, age: { type: 'integer' } }, + }]; + const live = introspected({ users: ['id', 'email'] }); // age is missing + + const diff = diffSchema(desired, live); + expect(diff.actions).toHaveLength(1); + expect(diff.actions[0].type).toBe('add_column'); + if (diff.actions[0].type === 'add_column') { + expect(diff.actions[0].table).toBe('users'); + expect(diff.actions[0].column.name).toBe('age'); + expect(diff.actions[0].column.sqlType).toBe('INTEGER'); + } + }); + + it('should detect dropped tables (in live but not in desired)', () => { + const desired: ObjectSchema[] = []; + const live = introspected({ old_table: ['id', 'data'] }); + + const diff = diffSchema(desired, live); + expect(diff.actions).toHaveLength(1); + expect(diff.actions[0].type).toBe('drop_table'); + if (diff.actions[0].type === 'drop_table') { + expect(diff.actions[0].table).toBe('old_table'); + } + }); + + it('should produce no actions when schema is in sync', () => { + const desired: ObjectSchema[] = [{ + name: 'users', + fields: { email: { type: 'text' } }, + }]; + const live = introspected({ users: ['id', 'email'] }); + + const diff = diffSchema(desired, live); + expect(diff.actions).toHaveLength(0); + expect(diff.summary[0]).toContain('up to date'); + }); + + it('should handle multiple tables and changes', () => { + const desired: ObjectSchema[] = [ + { + name: 'users', + fields: { email: { type: 'text' }, phone: { type: 'text' } }, + }, + { + name: 'tasks', + fields: { title: { type: 'text' }, done: { type: 'boolean' } }, + }, + ]; + const live = introspected({ + users: ['id', 'email'], // phone is missing + legacy: ['id', 'data'], // not in desired + }); + + const diff = diffSchema(desired, live); + // Expected: add_column (phone), create_table (tasks), drop_table (legacy) + expect(diff.actions).toHaveLength(3); + + const types = diff.actions.map(a => a.type); + expect(types).toContain('add_column'); + expect(types).toContain('create_table'); + expect(types).toContain('drop_table'); + }); + + it('should skip id field when comparing columns', () => { + const desired: ObjectSchema[] = [{ + name: 'users', + fields: { id: { type: 'text' }, email: { type: 'text' } }, + }]; + const live = introspected({ users: ['id', 'email'] }); + + const diff = diffSchema(desired, live); + expect(diff.actions).toHaveLength(0); + }); + + it('should handle fields with required and defaultValue', () => { + const desired: ObjectSchema[] = [{ + name: 'settings', + fields: { + theme: { type: 'text', required: true, defaultValue: 'light' }, + }, + }]; + const live = introspected({}); + + const diff = diffSchema(desired, live); + expect(diff.actions).toHaveLength(1); + if (diff.actions[0].type === 'create_table') { + const themeCol = diff.actions[0].columns.find(c => c.name === 'theme'); + expect(themeCol).toBeDefined(); + expect(themeCol!.notNull).toBe(true); + expect(themeCol!.defaultValue).toBe('light'); + } + }); +}); + +// ============================================================================ +// generateMigration Tests +// ============================================================================ + +describe('generateMigration', () => { + it('should generate CREATE TABLE SQL for new table', () => { + const diff = diffSchema( + [{ name: 'tasks', fields: { title: { type: 'text' }, done: { type: 'boolean' } } }], + introspected({}) + ); + const migration = generateMigration(diff); + + expect(migration.up).toHaveLength(1); + expect(migration.up[0]).toContain('CREATE TABLE'); + expect(migration.up[0]).toContain('"tasks"'); + expect(migration.up[0]).toContain('"title"'); + expect(migration.up[0]).toContain('"done"'); + expect(migration.up[0]).toContain('"id" TEXT PRIMARY KEY'); + + expect(migration.down).toHaveLength(1); + expect(migration.down[0]).toContain('DROP TABLE'); + }); + + it('should generate ALTER TABLE ADD COLUMN SQL', () => { + const diff = diffSchema( + [{ name: 'users', fields: { email: { type: 'text' }, phone: { type: 'text' } } }], + introspected({ users: ['id', 'email'] }) + ); + const migration = generateMigration(diff); + + expect(migration.up).toHaveLength(1); + expect(migration.up[0]).toContain('ALTER TABLE'); + expect(migration.up[0]).toContain('ADD COLUMN'); + expect(migration.up[0]).toContain('"phone"'); + + expect(migration.down).toHaveLength(1); + expect(migration.down[0]).toContain('DROP COLUMN'); + }); + + it('should generate DROP TABLE SQL for removed tables', () => { + const diff = diffSchema([], introspected({ old_table: ['id'] })); + const migration = generateMigration(diff); + + expect(migration.up).toHaveLength(1); + expect(migration.up[0]).toContain('DROP TABLE'); + expect(migration.up[0]).toContain('"old_table"'); + }); + + it('should generate empty migration when no changes', () => { + const diff = diffSchema( + [{ name: 'users', fields: { email: { type: 'text' } } }], + introspected({ users: ['id', 'email'] }) + ); + const migration = generateMigration(diff); + + expect(migration.up).toHaveLength(0); + expect(migration.down).toHaveLength(0); + }); + + it('should handle NOT NULL and DEFAULT in ADD COLUMN', () => { + const diff = diffSchema( + [{ name: 'users', fields: { email: { type: 'text' }, status: { type: 'text', required: true, defaultValue: 'active' } } }], + introspected({ users: ['id', 'email'] }) + ); + const migration = generateMigration(diff); + + expect(migration.up).toHaveLength(1); + expect(migration.up[0]).toContain('NOT NULL'); + expect(migration.up[0]).toContain("DEFAULT 'active'"); + }); + + it('should include human-readable summary', () => { + const diff = diffSchema( + [{ name: 'items', fields: { title: { type: 'text' } } }], + introspected({}) + ); + const migration = generateMigration(diff); + + expect(migration.summary).toHaveLength(1); + expect(migration.summary[0]).toContain('CREATE TABLE'); + }); +}); diff --git a/packages/drivers/turso/test/turso-driver-plugin.test.ts b/packages/drivers/turso/test/turso-driver-plugin.test.ts new file mode 100644 index 00000000..3b53ece4 --- /dev/null +++ b/packages/drivers/turso/test/turso-driver-plugin.test.ts @@ -0,0 +1,240 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { TursoDriverPlugin } from '../src/turso-driver-plugin'; +import { ObjectQLError } from '@objectql/types'; + +// ============================================================================ +// Configuration Validation +// ============================================================================ + +describe('TursoDriverPlugin - Configuration', () => { + it('should throw CONFIG_ERROR if neither connection nor multiTenant is provided', () => { + expect(() => { + new TursoDriverPlugin({}); + }).toThrow(ObjectQLError); + expect(() => { + new TursoDriverPlugin({}); + }).toThrow('requires either'); + }); + + it('should throw CONFIG_ERROR if both connection and multiTenant are provided', () => { + expect(() => { + new TursoDriverPlugin({ + connection: { url: ':memory:' }, + multiTenant: { urlTemplate: 'libsql://{tenant}.turso.io' }, + }); + }).toThrow(ObjectQLError); + expect(() => { + new TursoDriverPlugin({ + connection: { url: ':memory:' }, + multiTenant: { urlTemplate: 'libsql://{tenant}.turso.io' }, + }); + }).toThrow('mutually exclusive'); + }); + + it('should create with single-tenant connection config', () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + expect(plugin).toBeDefined(); + expect(plugin.name).toBe('@objectql/driver-turso'); + expect(plugin.version).toBe('4.2.2'); + }); + + it('should create with multi-tenant config', () => { + const plugin = new TursoDriverPlugin({ + multiTenant: { urlTemplate: 'libsql://{tenant}-org.turso.io' }, + }); + expect(plugin).toBeDefined(); + }); +}); + +// ============================================================================ +// Single-Tenant Lifecycle +// ============================================================================ + +describe('TursoDriverPlugin - Single-Tenant Lifecycle', () => { + it('should connect and provide driver after onStart', async () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + + const mockCtx = { engine: {} }; + await plugin.install(mockCtx); + await plugin.onStart(mockCtx); + + const driver = plugin.getDriver(); + expect(driver).toBeDefined(); + expect(driver.name).toBe('TursoDriver'); + + // Should be connected — test with health check + const healthy = await driver.checkHealth(); + expect(healthy).toBe(true); + + await plugin.onStop(mockCtx); + }); + + it('should throw DRIVER_ERROR when getDriver is called before onStart', () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + + expect(() => plugin.getDriver()).toThrow(ObjectQLError); + expect(() => plugin.getDriver()).toThrow('No single-tenant driver'); + }); + + it('should throw DRIVER_ERROR when getRouter is called in single-tenant mode', async () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + + const mockCtx = { engine: {} }; + await plugin.onStart(mockCtx); + + expect(() => plugin.getRouter()).toThrow(ObjectQLError); + expect(() => plugin.getRouter()).toThrow('No multi-tenant router'); + + await plugin.onStop(mockCtx); + }); + + it('should disconnect on onStop', async () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + + const mockCtx = { engine: {} }; + await plugin.onStart(mockCtx); + + const driver = plugin.getDriver(); + expect(await driver.checkHealth()).toBe(true); + + await plugin.onStop(mockCtx); + + // After stop, getDriver should throw + expect(() => plugin.getDriver()).toThrow(ObjectQLError); + }); +}); + +// ============================================================================ +// Multi-Tenant Lifecycle +// ============================================================================ + +describe('TursoDriverPlugin - Multi-Tenant Lifecycle', () => { + it('should provide router after onStart', async () => { + const plugin = new TursoDriverPlugin({ + multiTenant: { + urlTemplate: 'file:/tmp/oql-plug-{tenant}.db', + }, + }); + + const mockCtx = { engine: {} }; + await plugin.onStart(mockCtx); + + const router = plugin.getRouter(); + expect(router).toBeDefined(); + expect(typeof router.getDriverForTenant).toBe('function'); + + await plugin.onStop(mockCtx); + }); + + it('should destroy all tenant drivers on onStop', async () => { + const plugin = new TursoDriverPlugin({ + multiTenant: { + urlTemplate: 'file:/tmp/oql-plug2-{tenant}.db', + }, + }); + + const mockCtx = { engine: {} }; + await plugin.onStart(mockCtx); + + const router = plugin.getRouter(); + await router.getDriverForTenant('tenant-01'); + await router.getDriverForTenant('tenant-02'); + expect(router.getCacheSize()).toBe(2); + + await plugin.onStop(mockCtx); + + // After stop, getRouter should throw + expect(() => plugin.getRouter()).toThrow(ObjectQLError); + }); +}); + +// ============================================================================ +// Schema Diff & Migration +// ============================================================================ + +describe('TursoDriverPlugin - Schema Migration', () => { + it('should generate and apply migration for new table', async () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + + const mockCtx = { engine: {} }; + await plugin.onStart(mockCtx); + + const objects = [{ + name: 'projects', + fields: { + title: { type: 'text' }, + status: { type: 'select' }, + }, + }]; + + // Generate migration + const migration = await plugin.generateMigration(objects); + expect(migration.up.length).toBeGreaterThan(0); + expect(migration.up[0]).toContain('CREATE TABLE'); + + // Apply migration + await plugin.applyMigration(migration); + + // Verify table exists + const driver = plugin.getDriver(); + const schema = await driver.introspectSchema(); + expect(schema.tables['projects']).toBeDefined(); + expect(schema.tables['projects'].columns.map(c => c.name)).toContain('title'); + expect(schema.tables['projects'].columns.map(c => c.name)).toContain('status'); + + await plugin.onStop(mockCtx); + }); + + it('should generate migration for adding columns to existing table', async () => { + const plugin = new TursoDriverPlugin({ + connection: { url: ':memory:' }, + }); + + const mockCtx = { engine: {} }; + await plugin.onStart(mockCtx); + + // Create initial table + const driver = plugin.getDriver(); + await driver.init([{ + name: 'users', + fields: { email: { type: 'text' } }, + }]); + + // Generate migration for updated schema + const migration = await plugin.generateMigration([{ + name: 'users', + fields: { email: { type: 'text' }, phone: { type: 'text' } }, + }]); + + expect(migration.up.length).toBe(1); + expect(migration.up[0]).toContain('ALTER TABLE'); + expect(migration.up[0]).toContain('"phone"'); + + // Apply and verify + await plugin.applyMigration(migration); + const schema = await driver.introspectSchema(); + expect(schema.tables['users'].columns.map(c => c.name)).toContain('phone'); + + await plugin.onStop(mockCtx); + }); +}); diff --git a/packages/drivers/turso/test/turso-platform-api.test.ts b/packages/drivers/turso/test/turso-platform-api.test.ts new file mode 100644 index 00000000..2cb4a98b --- /dev/null +++ b/packages/drivers/turso/test/turso-platform-api.test.ts @@ -0,0 +1,170 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TursoPlatformAPI } from '../src/turso-platform-api'; +import { ObjectQLError } from '@objectql/types'; + +describe('TursoPlatformAPI - Configuration', () => { + it('should throw CONFIG_ERROR if orgSlug is missing', () => { + expect(() => { + new TursoPlatformAPI({ orgSlug: '', apiToken: 'token' }); + }).toThrow(ObjectQLError); + expect(() => { + new TursoPlatformAPI({ orgSlug: '', apiToken: 'token' }); + }).toThrow('orgSlug'); + }); + + it('should throw CONFIG_ERROR if apiToken is missing', () => { + expect(() => { + new TursoPlatformAPI({ orgSlug: 'my-org', apiToken: '' }); + }).toThrow(ObjectQLError); + expect(() => { + new TursoPlatformAPI({ orgSlug: 'my-org', apiToken: '' }); + }).toThrow('apiToken'); + }); + + it('should create instance with valid config', () => { + const api = new TursoPlatformAPI({ + orgSlug: 'my-org', + apiToken: 'test-token', + }); + expect(api).toBeDefined(); + }); + + it('should accept custom baseUrl', () => { + const api = new TursoPlatformAPI({ + orgSlug: 'my-org', + apiToken: 'test-token', + baseUrl: 'https://custom-api.example.com', + }); + expect(api).toBeDefined(); + }); +}); + +describe('TursoPlatformAPI - API Methods (mocked fetch)', () => { + let api: TursoPlatformAPI; + + beforeEach(() => { + api = new TursoPlatformAPI({ + orgSlug: 'test-org', + apiToken: 'test-token', + }); + }); + + it('should call createDatabase with correct URL and body', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + database: { Hostname: 'mydb-test-org.turso.io', Name: 'mydb' }, + }), + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await api.createDatabase('mydb', 'default'); + + expect(result.hostname).toBe('mydb-test-org.turso.io'); + expect(result.name).toBe('mydb'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.turso.tech/v1/organizations/test-org/databases', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'mydb', group: 'default' }), + }) + ); + + vi.unstubAllGlobals(); + }); + + it('should call deleteDatabase with correct URL', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', mockFetch); + + await api.deleteDatabase('mydb'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.turso.tech/v1/organizations/test-org/databases/mydb', + expect.objectContaining({ method: 'DELETE' }) + ); + + vi.unstubAllGlobals(); + }); + + it('should call createToken with correct URL and options', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ jwt: 'test-jwt-token' }), + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await api.createToken('mydb', { expiration: '2w', authorization: 'read-only' }); + + expect(result.jwt).toBe('test-jwt-token'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('expiration=2w'), + expect.any(Object) + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('authorization=read-only'), + expect.any(Object) + ); + + vi.unstubAllGlobals(); + }); + + it('should call listDatabases and map response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + databases: [ + { Name: 'db1', Hostname: 'db1-org.turso.io', group: 'default' }, + { Name: 'db2', Hostname: 'db2-org.turso.io' }, + ], + }), + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await api.listDatabases(); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('db1'); + expect(result[0].hostname).toBe('db1-org.turso.io'); + expect(result[0].group).toBe('default'); + expect(result[1].name).toBe('db2'); + + vi.unstubAllGlobals(); + }); + + it('should throw ObjectQLError on HTTP error', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve({ error: 'database not found' }), + }); + vi.stubGlobal('fetch', mockFetch); + + await expect(api.deleteDatabase('nonexistent')).rejects.toThrow(ObjectQLError); + await expect(api.deleteDatabase('nonexistent')).rejects.toThrow('404'); + + vi.unstubAllGlobals(); + }); + + it('should throw ObjectQLError on network failure', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + vi.stubGlobal('fetch', mockFetch); + + await expect(api.listDatabases()).rejects.toThrow(ObjectQLError); + await expect(api.listDatabases()).rejects.toThrow('ECONNREFUSED'); + + vi.unstubAllGlobals(); + }); +}); From cf12004257d2b1cc4b813fe818e5366d21cdd86a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:51:39 +0000 Subject: [PATCH 3/3] docs: update ROADMAP.md with driver-turso Phase B completion Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 58d8a29e..5740fed6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,6 +87,7 @@ ObjectQL is the **Standard Protocol for AI Software Generation** — a universal - ✅ 31 of 31 packages have test suites (plugin-optimizations: 103 tests, plugin-query: 99 tests — previously 0) - ✅ 67 documentation files (.mdx) across 12 sections - ✅ `@objectql/driver-turso` — Turso/libSQL driver (Phase A: Core Driver) with 125 tests, 3 connection modes (remote, local, embedded replica) +- ✅ `@objectql/driver-turso` — Phase B: Multi-Tenant Router, Schema Diff Engine, Platform API Client, Driver Plugin (52 new tests, 177 total) ---