diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f5aeecb..69e4299c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`@objectstack/driver-turso` plugin** — Migrated and standardized the Turso/libSQL driver from + `@objectql/driver-turso` into `packages/plugins/driver-turso/`. The driver **extends** `SqlDriver` + from `@objectstack/driver-sql` — all CRUD, schema, filter, aggregation, and introspection logic + is inherited with zero code duplication. Turso-specific features include: three connection modes + (local file, in-memory, embedded replica), `@libsql/client` sync mechanism for embedded replicas, + multi-tenant router with TTL-based driver caching, and enhanced capability flags (FTS5, JSON1, + CTE, savepoints, indexes). Includes 53 unit tests. Factory function `createTursoDriver()` and + plugin manifest for kernel integration. +- **Multi-tenant routing** (`createMultiTenantRouter`) — Database-per-tenant architecture with + automatic driver lifecycle management, tenant ID validation, configurable TTL cache, and + `onTenantCreate`/`onTenantEvict` lifecycle callbacks. Serverless-safe (no global intervals). + +### Changed +- **`@objectstack/driver-sql` — Protected extensibility** — Changed `private` to `protected` for + all internal properties and methods (`knex`, `config`, `jsonFields`, `booleanFields`, + `tablesWithTimestamps`, `isSqlite`, `isPostgres`, `isMysql`, `getBuilder`, `applyFilters`, + `applyFilterCondition`, `mapSortField`, `mapAggregateFunc`, `buildWindowFunction`, + `createColumn`, `ensureDatabaseExists`, `createDatabase`, `isJsonField`, `formatInput`, + `formatOutput`, `introspectColumns`, `introspectForeignKeys`, `introspectPrimaryKeys`, + `introspectUniqueConstraints`). Enables clean subclassing for driver variants (Turso, D1, etc.) + without code duplication. + +### Fixed +- **`@objectstack/driver-sql` — `count()` returns NaN for zero results** — Fixed `count()` method + using `||` (logical OR) instead of `??` (nullish coalescing) to read the count value. When the + actual count was `0`, `row.count || row['count(*)']` evaluated to `Number(undefined)` = `NaN` + because `0` is falsy. Now uses `row.count ?? row['count(*)'] ?? 0` for correct zero handling. + ### Changed - **Unified Data Driver Contract (`IDataDriver`)** — Resolved the split between `DriverInterface` (core, minimal ~13 methods) and `IDataDriver` (spec, comprehensive 28 methods). `IDataDriver` diff --git a/ROADMAP.md b/ROADMAP.md index 08e7facd6..0ec2986be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,6 +44,8 @@ the ecosystem for enterprise workloads. | Microkernel (ObjectKernel / LiteKernel) | ✅ | `@objectstack/core` | | Data Engine (ObjectQL) | ✅ | `@objectstack/objectql` | | In-Memory Driver | ✅ | `@objectstack/driver-memory` | +| SQL Driver (PostgreSQL, MySQL, SQLite) | ✅ | `@objectstack/driver-sql` | +| Turso/libSQL Driver (Edge SQLite) | ✅ | `@objectstack/driver-turso` | | Metadata Service | ✅ | `@objectstack/metadata` | | REST API Server | ✅ | `@objectstack/rest` | | Client SDK (TypeScript) | ✅ | `@objectstack/client` | diff --git a/packages/plugins/driver-sql/src/sql-driver-queryast.test.ts b/packages/plugins/driver-sql/src/sql-driver-queryast.test.ts index c791fbb56..df51ee937 100644 --- a/packages/plugins/driver-sql/src/sql-driver-queryast.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-queryast.test.ts @@ -144,7 +144,7 @@ describe('SqlDriver (QueryAST Format)', () => { const results = await driver.find('products', { fields: ['name'], limit: 2, - orderBy: [['price', 'asc']], + orderBy: [{ field: 'price', order: 'asc' }], } as any); expect(results.length).toBe(2); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 89907dfc6..bc2410726 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -65,8 +65,8 @@ export type SqlDriverConfig = Knex.Config; */ export class SqlDriver implements IDataDriver { // IDataDriver metadata - public readonly name = 'com.objectstack.driver.sql'; - public readonly version = '1.0.0'; + public readonly name: string = 'com.objectstack.driver.sql'; + public readonly version: string = '1.0.0'; public readonly supports = { // Basic CRUD Operations create: true, @@ -113,26 +113,26 @@ export class SqlDriver implements IDataDriver { queryCache: false, }; - private knex: Knex; - private config: Knex.Config; - private jsonFields: Record = {}; - private booleanFields: Record = {}; - private tablesWithTimestamps: Set = new Set(); + protected knex: Knex; + protected config: Knex.Config; + protected jsonFields: Record = {}; + protected booleanFields: Record = {}; + protected tablesWithTimestamps: Set = new Set(); /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */ - private get isSqlite(): boolean { + protected get isSqlite(): boolean { const c = (this.config as any).client; return c === 'sqlite3' || c === 'better-sqlite3'; } /** Whether the underlying database is PostgreSQL. */ - private get isPostgres(): boolean { + protected get isPostgres(): boolean { const c = (this.config as any).client; return c === 'pg' || c === 'postgresql'; } /** Whether the underlying database is MySQL. */ - private get isMysql(): boolean { + protected get isMysql(): boolean { const c = (this.config as any).client; return c === 'mysql' || c === 'mysql2'; } @@ -185,10 +185,8 @@ export class SqlDriver implements IDataDriver { // ORDER BY if (query.orderBy && Array.isArray(query.orderBy)) { for (const item of query.orderBy) { - const field = item.field || item[0]; - const dir = item.order || item[1] || 'asc'; - if (field) { - builder.orderBy(this.mapSortField(field), dir); + if (item.field) { + builder.orderBy(this.mapSortField(item.field), item.order || 'asc'); } } } @@ -364,7 +362,7 @@ export class SqlDriver implements IDataDriver { const result = await builder.count<{ count: number }[]>('* as count'); if (result && result.length > 0) { const row: any = result[0]; - return Number(row.count || row['count(*)']); + return Number(row.count ?? row['count(*)'] ?? 0); } return 0; } @@ -702,7 +700,7 @@ export class SqlDriver implements IDataDriver { return this.knex; } - private getBuilder(object: string, options?: DriverOptions) { + protected getBuilder(object: string, options?: DriverOptions) { let builder = this.knex(object); if (options?.transaction) { builder = builder.transacting(options.transaction as Knex.Transaction); @@ -712,7 +710,7 @@ export class SqlDriver implements IDataDriver { // ── Filter helpers ────────────────────────────────────────────────────────── - private applyFilters(builder: Knex.QueryBuilder, filters: any) { + protected applyFilters(builder: Knex.QueryBuilder, filters: any) { if (!filters) return; if (!Array.isArray(filters) && typeof filters === 'object') { @@ -793,7 +791,7 @@ export class SqlDriver implements IDataDriver { } } - private applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and') { + protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and') { if (!condition || typeof condition !== 'object') return; for (const [key, value] of Object.entries(condition)) { @@ -864,13 +862,13 @@ export class SqlDriver implements IDataDriver { // ── Field mapping ─────────────────────────────────────────────────────────── - private mapSortField(field: string): string { + protected mapSortField(field: string): string { if (field === 'createdAt') return 'created_at'; if (field === 'updatedAt') return 'updated_at'; return field; } - private mapAggregateFunc(func: string): string { + protected mapAggregateFunc(func: string): string { switch (func) { case 'count': return 'count'; @@ -889,7 +887,7 @@ export class SqlDriver implements IDataDriver { // ── Window function builder ───────────────────────────────────────────────── - private buildWindowFunction(spec: any): string { + protected buildWindowFunction(spec: any): string { const func = spec.function.toUpperCase(); let sql = `${func}()`; @@ -917,7 +915,7 @@ export class SqlDriver implements IDataDriver { // ── Column creation helper ────────────────────────────────────────────────── - private createColumn(table: Knex.CreateTableBuilder, name: string, field: any) { + protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any) { if (field.multiple) { table.json(name); return; @@ -996,7 +994,7 @@ export class SqlDriver implements IDataDriver { // ── Database helpers ──────────────────────────────────────────────────────── - private async ensureDatabaseExists() { + protected async ensureDatabaseExists() { if (!this.isPostgres) return; try { @@ -1010,7 +1008,7 @@ export class SqlDriver implements IDataDriver { } } - private async createDatabase() { + protected async createDatabase() { const config = this.config as any; const connection = config.connection; let dbName = ''; @@ -1034,13 +1032,13 @@ export class SqlDriver implements IDataDriver { } } - private isJsonField(type: string, field: any): boolean { + protected isJsonField(type: string, field: any): boolean { return ['json', 'object', 'array', 'image', 'file', 'avatar', 'location'].includes(type) || field.multiple; } // ── SQLite serialisation ──────────────────────────────────────────────────── - private formatInput(object: string, data: any): any { + protected formatInput(object: string, data: any): any { if (!this.isSqlite) return data; const fields = this.jsonFields[object]; @@ -1055,7 +1053,7 @@ export class SqlDriver implements IDataDriver { return copy; } - private formatOutput(object: string, data: any): any { + protected formatOutput(object: string, data: any): any { if (!data) return data; if (this.isSqlite) { @@ -1087,7 +1085,7 @@ export class SqlDriver implements IDataDriver { // ── Introspection internals ───────────────────────────────────────────────── - private async introspectColumns(tableName: string): Promise { + protected async introspectColumns(tableName: string): Promise { const columnInfo = await this.knex(tableName).columnInfo(); const columns: IntrospectedColumn[] = []; @@ -1119,7 +1117,7 @@ export class SqlDriver implements IDataDriver { return columns; } - private async introspectForeignKeys(tableName: string): Promise { + protected async introspectForeignKeys(tableName: string): Promise { const foreignKeys: IntrospectedForeignKey[] = []; try { @@ -1205,7 +1203,7 @@ export class SqlDriver implements IDataDriver { return foreignKeys; } - private async introspectPrimaryKeys(tableName: string): Promise { + protected async introspectPrimaryKeys(tableName: string): Promise { const primaryKeys: string[] = []; try { @@ -1265,7 +1263,7 @@ export class SqlDriver implements IDataDriver { return primaryKeys; } - private async introspectUniqueConstraints(tableName: string): Promise { + protected async introspectUniqueConstraints(tableName: string): Promise { const uniqueColumns: string[] = []; try { diff --git a/packages/plugins/driver-turso/README.md b/packages/plugins/driver-turso/README.md new file mode 100644 index 000000000..38edc55d3 --- /dev/null +++ b/packages/plugins/driver-turso/README.md @@ -0,0 +1,194 @@ +# @objectstack/driver-turso + +Turso/libSQL driver for ObjectStack — edge-first SQLite with embedded replicas and database-per-tenant multi-tenancy. + +## Architecture + +`TursoDriver` **extends** `SqlDriver` from `@objectstack/driver-sql`. All CRUD operations, schema management, filtering, aggregation, window functions, introspection, and transactions are **inherited** — zero duplicated query/schema code. + +``` +TursoDriver extends SqlDriver (via Knex + better-sqlite3) +├── Inherited: find, findOne, create, update, delete, count, upsert +├── Inherited: bulkCreate, bulkUpdate, bulkDelete, updateMany, deleteMany +├── Inherited: syncSchema, dropTable, introspectSchema +├── Inherited: aggregate, distinct, findWithWindowFunctions +├── Inherited: beginTransaction, commit, rollback +├── Inherited: applyFilters (MongoDB-style + array-style) +├── Override: name, version, supports (Turso-specific capabilities) +├── Override: connect / disconnect (libSQL client lifecycle) +├── Added: sync() — Embedded replica sync via @libsql/client +├── Added: Multi-tenant router with TTL cache +└── Added: TursoDriverConfig (url, authToken, syncUrl, encryptionKey) +``` + +## Installation + +```bash +pnpm add @objectstack/driver-turso +``` + +## Connection Modes + +### Local File (Embedded SQLite) + +```typescript +import { TursoDriver } from '@objectstack/driver-turso'; + +const driver = new TursoDriver({ + url: 'file:./data/app.db', +}); +await driver.connect(); +``` + +### In-Memory (Testing) + +```typescript +const driver = new TursoDriver({ + url: ':memory:', +}); +await driver.connect(); +``` + +### Embedded Replica (Hybrid) + +Local SQLite file + automatic sync from Turso cloud: + +```typescript +const driver = new TursoDriver({ + url: 'file:./data/replica.db', + syncUrl: 'libsql://my-db-orgname.turso.io', + authToken: process.env.TURSO_AUTH_TOKEN, + sync: { + intervalSeconds: 60, // sync every 60 seconds + onConnect: true, // sync on initial connect + }, +}); +await driver.connect(); + +// Manual sync +await driver.sync(); +``` + +> **Note:** Remote-only URLs (`url: 'libsql://...'`) without `syncUrl` are +> not supported and will throw. Always use a local `file:` or `:memory:` URL +> as the primary store. For remote persistence, use embedded replica mode with `syncUrl`. + +## Multi-Tenant Routing + +Database-per-tenant architecture with automatic driver caching: + +```typescript +import { createMultiTenantRouter } from '@objectstack/driver-turso'; + +const router = createMultiTenantRouter({ + urlTemplate: 'file:./data/{tenant}.db', + clientCacheTTL: 300_000, // 5 minutes + onTenantCreate: async (tenantId) => { + console.log(`Provisioned database for tenant: ${tenantId}`); + }, +}); + +// In a request handler: +const driver = await router.getDriverForTenant('acme'); +const users = await driver.find('users', { where: { active: true } }); + +// Cleanup on shutdown +await router.destroyAll(); +``` + +### Multi-Tenant with Embedded Replicas + +Both `urlTemplate` and `driverConfigOverrides.syncUrl` support `{tenant}` placeholder interpolation: + +```typescript +const router = createMultiTenantRouter({ + urlTemplate: 'file:./data/{tenant}-replica.db', + groupAuthToken: process.env.TURSO_GROUP_TOKEN, + driverConfigOverrides: { + syncUrl: 'libsql://{tenant}-myorg.turso.io', + sync: { intervalSeconds: 30 }, + }, +}); +``` + +### Concurrency Safety + +Concurrent `getDriverForTenant()` calls for the same tenant are deduplicated — only one driver is created, and all callers share the same instance. + +## Configuration + +```typescript +interface TursoDriverConfig { + /** Database URL for the local store (file: or :memory:) */ + url: string; + + /** JWT auth token for the remote Turso database (used with syncUrl) */ + authToken?: string; + + /** + * AES-256 encryption key for local database file. + * Only effective in embedded replica mode (requires syncUrl). + */ + encryptionKey?: string; + + /** + * Maximum concurrent requests to the remote database. + * Only effective in embedded replica mode (requires syncUrl). + * Default: 20 + */ + concurrency?: number; + + /** Remote sync URL for embedded replica mode (libsql:// or https://) */ + syncUrl?: string; + + /** Sync configuration (requires syncUrl) */ + sync?: { + intervalSeconds?: number; // Default: 60 + onConnect?: boolean; // Default: true + }; + + /** + * Operation timeout in milliseconds for remote operations. + * Only effective in embedded replica mode (requires syncUrl). + */ + timeout?: number; +} +``` + +## Capabilities + +TursoDriver declares enhanced capabilities beyond the base SqlDriver: + +| Capability | SqlDriver | TursoDriver | +|:---|:---:|:---:| +| FTS5 Full-Text Search | ❌ | ✅ | +| JSON1 Query | ❌ | ✅ | +| Common Table Expressions | ❌ | ✅ | +| Savepoints | ❌ | ✅ | +| Indexes | ❌ | ✅ | +| Connection Pooling | ✅ | ❌ (concurrency limits) | +| Embedded Replica Sync | — | ✅ | +| Multi-Tenant Routing | — | ✅ | + +## Plugin Registration + +```typescript +import tursoPlugin from '@objectstack/driver-turso'; + +// Via plugin system +await kernel.enablePlugin(tursoPlugin, { + url: 'file:./data/app.db', +}); +``` + +## Testing + +```bash +pnpm test # Run all tests +``` + +Tests run against in-memory SQLite (`:memory:`) — no external services required. + +## License + +Apache-2.0 — Copyright (c) 2025 ObjectStack diff --git a/packages/plugins/driver-turso/package.json b/packages/plugins/driver-turso/package.json new file mode 100644 index 000000000..f922f44cc --- /dev/null +++ b/packages/plugins/driver-turso/package.json @@ -0,0 +1,44 @@ +{ + "name": "@objectstack/driver-turso", + "version": "3.2.9", + "license": "Apache-2.0", + "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas and multi-tenancy", + "keywords": [ + "objectstack", + "driver", + "turso", + "libsql", + "sqlite", + "edge", + "serverless", + "embedded-replica", + "multi-tenant" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "dev": "tsc -w", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/driver-sql": "workspace:*", + "@objectstack/spec": "workspace:*", + "@libsql/client": "^0.17.2", + "better-sqlite3": "^11.9.1", + "nanoid": "^3.3.11" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.0.0", + "vitest": "^4.1.0" + } +} diff --git a/packages/plugins/driver-turso/src/index.ts b/packages/plugins/driver-turso/src/index.ts new file mode 100644 index 000000000..7eef4b8f8 --- /dev/null +++ b/packages/plugins/driver-turso/src/index.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/driver-turso + * + * Turso/libSQL driver for ObjectStack — edge-first, globally distributed + * SQLite with embedded replicas and database-per-tenant multi-tenancy. + * + * Extends `@objectstack/driver-sql` (SqlDriver) and inherits all CRUD, + * schema management, filtering, aggregation, and introspection logic. + * Only Turso-specific features (connection modes, sync, multi-tenant) + * are implemented here — zero duplicated query/schema code. + * + * Supports three connection modes: + * 1. Local (Embedded): `url: 'file:./data/local.db'` + * 2. In-Memory (Testing): `url: ':memory:'` + * 3. Embedded Replica (Hybrid): `url` + `syncUrl` + * + * @example + * ```typescript + * import { TursoDriver } from '@objectstack/driver-turso'; + * + * const driver = new TursoDriver({ + * url: 'file:./data/app.db', + * }); + * await driver.connect(); + * ``` + */ + +import { TursoDriver } from './turso-driver.js'; + +export { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; + +export { + createMultiTenantRouter, + type MultiTenantConfig, + type MultiTenantRouter, +} from './multi-tenant.js'; + +/** + * Factory function to create a TursoDriver instance. + * + * @param config - Turso driver configuration + * @returns A new TursoDriver instance (not yet connected) + * + * @example + * ```typescript + * import { createTursoDriver } from '@objectstack/driver-turso'; + * + * // Local file + * const driver = createTursoDriver({ url: 'file:./data/app.db' }); + * + * // In-memory (testing) + * const driver = createTursoDriver({ url: ':memory:' }); + * + * // Embedded replica + * const driver = createTursoDriver({ + * url: 'file:./data/replica.db', + * syncUrl: 'libsql://my-db-orgname.turso.io', + * authToken: process.env.TURSO_AUTH_TOKEN, + * sync: { intervalSeconds: 60, onConnect: true }, + * }); + * + * await driver.connect(); + * ``` + */ +export function createTursoDriver(config: import('./turso-driver.js').TursoDriverConfig): TursoDriver { + return new TursoDriver(config); +} + +export default { + id: 'com.objectstack.driver.turso', + version: '1.0.0', + + onEnable: async (context: any) => { + const { logger, config, drivers } = context; + logger.info('[Turso Driver] Initializing...'); + + if (drivers) { + const driver = new TursoDriver(config); + drivers.register(driver); + logger.info(`[Turso Driver] Registered driver: ${driver.name}`); + } else { + logger.warn('[Turso Driver] No driver registry found in context.'); + } + }, +}; diff --git a/packages/plugins/driver-turso/src/multi-tenant.test.ts b/packages/plugins/driver-turso/src/multi-tenant.test.ts new file mode 100644 index 000000000..3f17aaa0f --- /dev/null +++ b/packages/plugins/driver-turso/src/multi-tenant.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterEach } from 'vitest'; +import { createMultiTenantRouter, type MultiTenantRouter } from '../src/multi-tenant.js'; +import { TursoDriver } from '../src/turso-driver.js'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Build a unique temp path template for test isolation. */ +function buildTenantUrlTemplate(label: string): string { + const rand = Math.random().toString(36).slice(2, 8); + return `file:${join(tmpdir(), `test-turso-${label}-${rand}-{tenant}.db`)}`; +} + +describe('Multi-Tenant Router', () => { + let router: MultiTenantRouter | null = null; + + afterEach(async () => { + if (router) { + await router.destroyAll(); + router = null; + } + }); + + // ── Configuration Validation ─────────────────────────────────────────── + + it('should throw if urlTemplate is missing', () => { + expect(() => createMultiTenantRouter({ urlTemplate: '' })).toThrow(); + }); + + it('should throw if urlTemplate has no {tenant} placeholder', () => { + expect(() => createMultiTenantRouter({ urlTemplate: ':memory:' })).toThrow('{tenant}'); + }); + + // ── Tenant Driver Creation ───────────────────────────────────────────── + + it('should create a TursoDriver for a tenant', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('mt'), + }); + + const driver = await router.getDriverForTenant('acme'); + expect(driver).toBeInstanceOf(TursoDriver); + expect(driver).toBeInstanceOf(SqlDriver); + expect(driver.name).toBe('com.objectstack.driver.turso'); + }); + + it('should cache drivers and return same instance', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('cache'), + }); + + const driver1 = await router.getDriverForTenant('acme'); + const driver2 = await router.getDriverForTenant('acme'); + expect(driver1).toBe(driver2); + }); + + it('should create separate drivers per tenant', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('sep'), + }); + + const driver1 = await router.getDriverForTenant('tenant-a'); + const driver2 = await router.getDriverForTenant('tenant-b'); + expect(driver1).not.toBe(driver2); + expect(router.getCacheSize()).toBe(2); + }); + + // ── Tenant ID Validation ─────────────────────────────────────────────── + + it('should reject empty tenantId', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('val'), + }); + + await expect(router.getDriverForTenant('')).rejects.toThrow(); + }); + + it('should reject invalid tenantId with special characters', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('val2'), + }); + + await expect(router.getDriverForTenant('a')).rejects.toThrow(); + await expect(router.getDriverForTenant('../escape')).rejects.toThrow(); + }); + + it('should accept valid tenantId', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('accept'), + }); + + const driver = await router.getDriverForTenant('valid-tenant'); + expect(driver).toBeDefined(); + }); + + // ── Cache Management ─────────────────────────────────────────────────── + + it('should report cache size', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('size'), + }); + + expect(router.getCacheSize()).toBe(0); + await router.getDriverForTenant('tenant-1'); + expect(router.getCacheSize()).toBe(1); + await router.getDriverForTenant('tenant-2'); + expect(router.getCacheSize()).toBe(2); + }); + + it('should invalidate cache for a tenant', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('inv'), + }); + + await router.getDriverForTenant('to-invalidate'); + expect(router.getCacheSize()).toBe(1); + + router.invalidateCache('to-invalidate'); + expect(router.getCacheSize()).toBe(0); + }); + + it('should destroy all cached drivers', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('destroy'), + }); + + await router.getDriverForTenant('destroy-a'); + await router.getDriverForTenant('destroy-b'); + expect(router.getCacheSize()).toBe(2); + + await router.destroyAll(); + expect(router.getCacheSize()).toBe(0); + }); + + // ── Lifecycle Callbacks ──────────────────────────────────────────────── + + it('should call onTenantCreate when a new tenant is provisioned', async () => { + const created: string[] = []; + + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('cb'), + onTenantCreate: async (tenantId) => { + created.push(tenantId); + }, + }); + + await router.getDriverForTenant('callback-test'); + expect(created).toEqual(['callback-test']); + + // Second call should use cache, not trigger callback + await router.getDriverForTenant('callback-test'); + expect(created.length).toBe(1); + }); + + it('should call onTenantEvict when a tenant is invalidated', async () => { + const evicted: string[] = []; + + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('evict'), + onTenantEvict: async (tenantId) => { + evicted.push(tenantId); + }, + }); + + await router.getDriverForTenant('evict-test'); + router.invalidateCache('evict-test'); + // Allow async callback to complete + await new Promise((r) => setTimeout(r, 50)); + expect(evicted).toEqual(['evict-test']); + }); + + // ── Concurrent Access Guard ──────────────────────────────────────────── + + it('should deduplicate concurrent getDriverForTenant calls', async () => { + let createCount = 0; + + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('conc'), + onTenantCreate: async () => { + createCount++; + }, + }); + + // Fire multiple concurrent calls for the same tenant + const [d1, d2, d3] = await Promise.all([ + router.getDriverForTenant('same-tenant'), + router.getDriverForTenant('same-tenant'), + router.getDriverForTenant('same-tenant'), + ]); + + // All calls should return the same driver instance + expect(d1).toBe(d2); + expect(d2).toBe(d3); + + // onTenantCreate should only be called once + expect(createCount).toBe(1); + expect(router.getCacheSize()).toBe(1); + }); + + // ── CRUD through Multi-Tenant Driver ─────────────────────────────────── + + it('should perform CRUD operations through a tenant driver', async () => { + router = createMultiTenantRouter({ + urlTemplate: buildTenantUrlTemplate('crud'), + }); + + const driver = await router.getDriverForTenant('crud-test'); + + // Sync schema + await driver.syncSchema('tasks', { + name: 'tasks', + fields: { + title: { type: 'string' }, + done: { type: 'boolean' }, + }, + }); + + // Create + const task = await driver.create('tasks', { title: 'Test Task', done: false }); + expect(task.title).toBe('Test Task'); + expect(task.id).toBeDefined(); + + // Read + const found = await driver.find('tasks', {}); + expect(found.length).toBe(1); + + // Update + await driver.update('tasks', task.id as string, { done: true }); + const updated = await driver.findOne('tasks', task.id as any); + // SQLite stores boolean as 0/1, formatOutput converts back + expect(updated!.done).toBeTruthy(); + + // Delete + const deleted = await driver.delete('tasks', task.id as string); + expect(deleted).toBe(true); + + const count = await driver.count('tasks'); + expect(count).toBe(0); + }); +}); diff --git a/packages/plugins/driver-turso/src/multi-tenant.ts b/packages/plugins/driver-turso/src/multi-tenant.ts new file mode 100644 index 000000000..bfe762d7d --- /dev/null +++ b/packages/plugins/driver-turso/src/multi-tenant.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Multi-Tenant Router for TursoDriver + * + * Manages per-tenant TursoDriver instances with TTL-based caching. + * Uses a URL template with `{tenant}` placeholder that is replaced + * with the tenantId at runtime. + * + * Serverless-safe: no global intervals, no leaked state. Expired + * entries are evicted lazily on next access. + */ + +import { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; + +// ── Configuration ──────────────────────────────────────────────────────────── + +/** + * Configuration for the multi-tenant router. + * + * @example + * ```typescript + * const router = createMultiTenantRouter({ + * urlTemplate: 'file:./data/{tenant}.db', + * clientCacheTTL: 300_000, // 5 minutes + * }); + * + * const driver = await router.getDriverForTenant('acme'); + * ``` + */ +export interface MultiTenantConfig { + /** + * URL template with `{tenant}` placeholder. + * Example: `'file:./data/{tenant}.db'` + */ + 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. + */ + onTenantEvict?: (tenantId: string) => Promise; + + /** + * Additional TursoDriverConfig fields merged into every tenant driver config. + * `url` is overridden by the template. String fields like `syncUrl` support + * `{tenant}` placeholders which are interpolated automatically. + */ + driverConfigOverrides?: Omit, 'url'>; +} + +// ── Router Interface ───────────────────────────────────────────────────────── + +/** + * Return type of `createMultiTenantRouter`. + */ +export interface MultiTenantRouter { + /** Get (or create) a connected TursoDriver for the given tenant. */ + 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; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const DEFAULT_CACHE_TTL = 300_000; // 5 minutes +const TENANT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}[a-zA-Z0-9]$/; + +// ── Cache Entry ────────────────────────────────────────────────────────────── + +interface CacheEntry { + driver: TursoDriver; + expiresAt: number; +} + +// ── Factory ────────────────────────────────────────────────────────────────── + +/** + * Creates a multi-tenant router that manages per-tenant TursoDriver instances. + * + * - `urlTemplate` must contain `{tenant}` which is replaced with the tenantId. + * - Drivers are lazily created and cached in a process-level Map. + * - Expired entries are evicted on next access (lazy expiration). + * - Concurrent calls for the same tenant share a single in-flight creation. + * - Serverless-safe: no global intervals, no leaked state. + * + * @example + * ```typescript + * const router = createMultiTenantRouter({ + * urlTemplate: 'file:./data/{tenant}.db', + * }); + * + * const driver = await router.getDriverForTenant('acme'); + * const users = await driver.find('users', {}); + * ``` + */ +export function createMultiTenantRouter(config: MultiTenantConfig): MultiTenantRouter { + if (!config.urlTemplate) { + throw new Error('MultiTenantConfig requires a "urlTemplate".'); + } + if (!config.urlTemplate.includes('{tenant}')) { + throw new Error('urlTemplate must contain a "{tenant}" placeholder.'); + } + + const ttl = config.clientCacheTTL ?? DEFAULT_CACHE_TTL; + const cache = new Map(); + const inflight = new Map>(); + + function validateTenantId(tenantId: string): void { + if (!tenantId || typeof tenantId !== 'string') { + throw new Error('tenantId must be a non-empty string.'); + } + if (!TENANT_ID_PATTERN.test(tenantId)) { + throw new Error( + `Invalid tenantId "${tenantId}". Must be 2-64 alphanumeric characters, hyphens, or underscores.`, + ); + } + } + + /** + * Replace `{tenant}` placeholders in a string value. + */ + function interpolateTenant(template: string, tenantId: string): string { + return template.replace(/\{tenant\}/g, tenantId); + } + + async function evictEntry(tenantId: string, entry: CacheEntry): Promise { + cache.delete(tenantId); + try { + await entry.driver.disconnect(); + } catch { + // Disconnect failure is non-fatal during eviction + } + if (config.onTenantEvict) { + try { + await config.onTenantEvict(tenantId); + } catch { + // Callback failure is non-fatal + } + } + } + + /** + * Internal driver creation — called once per tenant, guarded by inflight map. + */ + async function createDriverForTenant(tenantId: string): Promise { + // Evict expired entry if present + const existing = cache.get(tenantId); + if (existing) { + await evictEntry(tenantId, existing); + } + + // Build config with {tenant} interpolated in all string fields + const url = interpolateTenant(config.urlTemplate, tenantId); + const overrides = config.driverConfigOverrides ?? {}; + const driverConfig: TursoDriverConfig = { + ...overrides, + url, + authToken: config.groupAuthToken ?? overrides.authToken, + // Interpolate {tenant} in syncUrl if present + syncUrl: overrides.syncUrl ? interpolateTenant(overrides.syncUrl, tenantId) : undefined, + }; + + const driver = new TursoDriver(driverConfig); + + if (config.onTenantCreate) { + await config.onTenantCreate(tenantId); + } + + await driver.connect(); + + cache.set(tenantId, { + driver, + expiresAt: Date.now() + ttl, + }); + + return driver; + } + + async function getDriverForTenant(tenantId: string): Promise { + validateTenantId(tenantId); + + // Return cached driver if still valid + const existing = cache.get(tenantId); + if (existing && Date.now() < existing.expiresAt) { + return existing.driver; + } + + // Return in-flight creation if one exists (prevents concurrent duplicates) + const pending = inflight.get(tenantId); + if (pending) { + return pending; + } + + // Create new driver with in-flight guard + const promise = createDriverForTenant(tenantId).finally(() => { + inflight.delete(tenantId); + }); + inflight.set(tenantId, promise); + + return promise; + } + + function invalidateCache(tenantId: string): void { + const entry = cache.get(tenantId); + if (entry) { + cache.delete(tenantId); + // Fire-and-forget disconnect + entry.driver.disconnect().catch(() => {}); + if (config.onTenantEvict) { + config.onTenantEvict(tenantId).catch(() => {}); + } + } + } + + async function destroyAll(): Promise { + const entries = Array.from(cache.entries()); + cache.clear(); + + await Promise.allSettled( + entries.map(async ([tenantId, entry]) => { + try { + await entry.driver.disconnect(); + } catch { + // Non-fatal + } + if (config.onTenantEvict) { + try { + await config.onTenantEvict(tenantId); + } catch { + // Non-fatal + } + } + }), + ); + } + + function getCacheSize(): number { + return cache.size; + } + + return { + getDriverForTenant, + invalidateCache, + destroyAll, + getCacheSize, + }; +} diff --git a/packages/plugins/driver-turso/src/turso-driver.test.ts b/packages/plugins/driver-turso/src/turso-driver.test.ts new file mode 100644 index 000000000..3af04b4c1 --- /dev/null +++ b/packages/plugins/driver-turso/src/turso-driver.test.ts @@ -0,0 +1,400 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TursoDriver } from '../src/turso-driver.js'; +import { SqlDriver } from '@objectstack/driver-sql'; + +// ── TursoDriver Core ───────────────────────────────────────────────────────── + +describe('TursoDriver (SQLite Integration)', () => { + let driver: TursoDriver; + + beforeEach(async () => { + driver = new TursoDriver({ url: ':memory:' }); + + // Access the inherited Knex instance for test setup + const k = (driver as any).knex; + + await k.schema.createTable('users', (t: any) => { + t.string('id').primary(); + t.string('name'); + t.integer('age'); + }); + + await k('users').insert([ + { id: '1', name: 'Alice', age: 25 }, + { id: '2', name: 'Bob', age: 17 }, + { id: '3', name: 'Charlie', age: 30 }, + { id: '4', name: 'Dave', age: 17 }, + ]); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + // ── Instantiation & Metadata ───────────────────────────────────────────── + + it('should be instantiable', () => { + expect(driver).toBeDefined(); + expect(driver).toBeInstanceOf(TursoDriver); + }); + + it('should extend SqlDriver', () => { + expect(driver).toBeInstanceOf(SqlDriver); + }); + + it('should have turso-specific name and version', () => { + expect(driver.name).toBe('com.objectstack.driver.turso'); + expect(driver.version).toBe('1.0.0'); + }); + + it('should expose turso-specific capabilities', () => { + expect(driver.supports.fullTextSearch).toBe(true); + expect(driver.supports.jsonQuery).toBe(true); + expect(driver.supports.queryCTE).toBe(true); + expect(driver.supports.savepoints).toBe(true); + expect(driver.supports.indexes).toBe(true); + expect(driver.supports.connectionPooling).toBe(false); + }); + + it('should expose turso config', () => { + const config = driver.getTursoConfig(); + expect(config.url).toBe(':memory:'); + }); + + // ── CRUD (inherited from SqlDriver) ────────────────────────────────────── + + it('should find records with filters', async () => { + const results = await driver.find('users', { + fields: ['name', 'age'], + where: { age: { $gt: 18 } }, + orderBy: [{ field: 'name', order: 'asc' }], + }); + + expect(results.length).toBe(2); + expect(results.map((r: any) => r.name)).toEqual(['Alice', 'Charlie']); + }); + + it('should apply $or filters', async () => { + const results = await driver.find('users', { + where: { + $or: [{ age: 17 }, { age: { $gt: 29 } }], + }, + }); + const names = results.map((r: any) => r.name).sort(); + expect(names).toEqual(['Bob', 'Charlie', 'Dave']); + }); + + it('should find one record by id', async () => { + const [alice] = await driver.find('users', { where: { name: 'Alice' } }); + expect(alice).toBeDefined(); + + const fetched = await driver.findOne('users', alice.id as any); + expect(fetched).toBeDefined(); + expect(fetched!.name).toBe('Alice'); + }); + + it('should create a record', async () => { + await driver.create('users', { name: 'Eve', age: 22 }); + + const [eve] = await driver.find('users', { where: { name: 'Eve' } }); + expect(eve).toBeDefined(); + expect(eve.age).toBe(22); + }); + + it('should auto-generate id on create', async () => { + const created = await driver.create('users', { name: 'Frank', age: 35 }); + expect(created.id).toBeDefined(); + expect(typeof created.id).toBe('string'); + expect((created.id as string).length).toBeGreaterThan(0); + }); + + it('should update a record', async () => { + const [bob] = await driver.find('users', { where: { name: 'Bob' } }); + await driver.update('users', bob.id as string, { age: 18 }); + + const updated = await driver.findOne('users', bob.id as any); + expect(updated!.age).toBe(18); + }); + + it('should delete a record', async () => { + const [charlie] = await driver.find('users', { where: { name: 'Charlie' } }); + const result = await driver.delete('users', charlie.id as string); + expect(result).toBe(true); + + const deleted = await driver.findOne('users', charlie.id as any); + expect(deleted).toBeNull(); + }); + + it('should count records', async () => { + const count = await driver.count('users', { where: { age: 17 } } as any); + expect(count).toBe(2); + }); + + it('should count all records', async () => { + const count = await driver.count('users'); + expect(count).toBe(4); + }); + + // ── Upsert ─────────────────────────────────────────────────────────────── + + it('should upsert (insert) a new record', async () => { + const result = await driver.upsert('users', { id: 'new-1', name: 'Grace', age: 28 }); + expect(result.name).toBe('Grace'); + + const count = await driver.count('users'); + expect(count).toBe(5); + }); + + it('should upsert (update) an existing record', async () => { + await driver.upsert('users', { id: '1', name: 'Alice Updated', age: 26 }); + + const updated = await driver.findOne('users', '1' as any); + expect(updated!.name).toBe('Alice Updated'); + expect(updated!.age).toBe(26); + }); + + // ── Bulk Operations ────────────────────────────────────────────────────── + + it('should bulk create records', async () => { + const data = [ + { id: 'b1', name: 'Bulk1', age: 10 }, + { id: 'b2', name: 'Bulk2', age: 20 }, + ]; + const result = await driver.bulkCreate('users', data); + expect(result.length).toBe(2); + }); + + it('should bulk update records', async () => { + const updates = [ + { id: '1', data: { age: 99 } }, + { id: '2', data: { age: 88 } }, + ]; + const result = await driver.bulkUpdate('users', updates); + expect(result.length).toBe(2); + expect(result[0].age).toBe(99); + expect(result[1].age).toBe(88); + }); + + it('should bulk delete records', async () => { + await driver.bulkDelete('users', ['1', '2']); + const count = await driver.count('users'); + expect(count).toBe(2); + }); + + // ── Transactions ───────────────────────────────────────────────────────── + + it('should support transactions with commit', async () => { + const trx = await driver.beginTransaction(); + await driver.create('users', { name: 'TrxUser', age: 40 }, { transaction: trx }); + await driver.commit(trx); + + const found = await driver.find('users', { where: { name: 'TrxUser' } }); + expect(found.length).toBe(1); + }); + + it('should support transactions with rollback', async () => { + const trx = await driver.beginTransaction(); + await driver.create('users', { name: 'RollbackUser', age: 41 }, { transaction: trx }); + await driver.rollback(trx); + + const found = await driver.find('users', { where: { name: 'RollbackUser' } }); + expect(found.length).toBe(0); + }); + + // ── Schema Sync (inherited) ────────────────────────────────────────────── + + it('should sync schema and create tables', async () => { + await driver.syncSchema('products', { + name: 'products', + fields: { + title: { type: 'string' }, + price: { type: 'float' }, + active: { type: 'boolean' }, + metadata: { type: 'json' }, + }, + }); + + const created = await driver.create('products', { + title: 'Widget', + price: 9.99, + active: true, + metadata: { category: 'tools' }, + }); + + expect(created.title).toBe('Widget'); + expect(created.price).toBe(9.99); + }); + + // ── Raw Execution ──────────────────────────────────────────────────────── + + it('should execute raw SQL', async () => { + const result = await driver.execute('SELECT COUNT(*) as count FROM users'); + expect(result).toBeDefined(); + }); + + // ── Health Check ───────────────────────────────────────────────────────── + + it('should report healthy connection', async () => { + const healthy = await driver.checkHealth(); + expect(healthy).toBe(true); + }); + + // ── Pagination ─────────────────────────────────────────────────────────── + + it('should support limit and offset', async () => { + const results = await driver.find('users', { + orderBy: [{ field: 'name', order: 'asc' }], + limit: 2, + offset: 1, + }); + expect(results.length).toBe(2); + expect(results[0].name).toBe('Bob'); + expect(results[1].name).toBe('Charlie'); + }); + + // ── findStream (inherited) ─────────────────────────────────────────────── + + it('should stream records via findStream', async () => { + const records: any[] = []; + for await (const record of driver.findStream('users', {})) { + records.push(record); + } + expect(records.length).toBe(4); + }); + + // ── updateMany / deleteMany ────────────────────────────────────────────── + + it('should updateMany records matching a query', async () => { + const count = await driver.updateMany!('users', { where: { age: 17 } }, { age: 18 }); + expect(count).toBe(2); + + const updated = await driver.find('users', { where: { age: 18 } }); + expect(updated.length).toBe(2); + }); + + it('should deleteMany records matching a query', async () => { + const count = await driver.deleteMany!('users', { where: { age: 17 } }); + expect(count).toBe(2); + + const remaining = await driver.count('users'); + expect(remaining).toBe(2); + }); + + // ── Sorting ────────────────────────────────────────────────────────────── + + it('should sort results', async () => { + const results = await driver.find('users', { + orderBy: [{ field: 'age', order: 'desc' }], + }); + expect(results[0].name).toBe('Charlie'); + expect(results[results.length - 1].age).toBe(17); + }); + + // ── Edge Cases ─────────────────────────────────────────────────────────── + + it('should return empty array for no matches', async () => { + const results = await driver.find('users', { where: { age: 999 } }); + expect(results).toEqual([]); + }); + + it('should return null for findOne with no match', async () => { + const result = await driver.findOne('users', { where: { name: 'NonExistent' } }); + expect(result).toBeNull(); + }); + + it('should return false when deleting non-existent record', async () => { + const result = await driver.delete('users', 'non-existent-id'); + expect(result).toBe(false); + }); +}); + +// ── Sync Configuration ─────────────────────────────────────────────────────── + +describe('TursoDriver Sync Configuration', () => { + it('should report sync not enabled for memory mode', () => { + const driver = new TursoDriver({ url: ':memory:' }); + expect(driver.isSyncEnabled()).toBe(false); + }); + + it('should return null libsql client when sync not configured', () => { + const driver = new TursoDriver({ url: ':memory:' }); + expect(driver.getLibsqlClient()).toBeNull(); + }); + + it('should handle sync() gracefully when not configured', async () => { + const driver = new TursoDriver({ url: ':memory:' }); + // Should not throw + await driver.sync(); + }); +}); + +// ── URL Parsing & Validation ───────────────────────────────────────────────── + +describe('TursoDriver URL Parsing', () => { + it('should parse file: URL correctly', () => { + const driver = new TursoDriver({ url: 'file:./data/test.db' }); + expect(driver.getTursoConfig().url).toBe('file:./data/test.db'); + }); + + it('should handle :memory: URL', () => { + const driver = new TursoDriver({ url: ':memory:' }); + expect(driver.getTursoConfig().url).toBe(':memory:'); + }); + + it('should throw for remote-only URL without syncUrl', () => { + expect(() => new TursoDriver({ + url: 'libsql://test-db.turso.io', + authToken: 'test-token', + })).toThrow('not supported without "syncUrl"'); + }); + + it('should accept remote URL when syncUrl is provided', () => { + // Should not throw — embedded replica mode + const driver = new TursoDriver({ + url: 'libsql://test-db.turso.io', + syncUrl: 'libsql://test-db.turso.io', + authToken: 'test-token', + }); + expect(driver.getTursoConfig().syncUrl).toBe('libsql://test-db.turso.io'); + }); +}); + +// ── Capabilities ───────────────────────────────────────────────────────────── + +describe('TursoDriver Capabilities', () => { + it('should declare all required IDataDriver capabilities', () => { + const driver = new TursoDriver({ url: ':memory:' }); + const caps = driver.supports; + + // CRUD + expect(caps.create).toBe(true); + expect(caps.read).toBe(true); + expect(caps.update).toBe(true); + expect(caps.delete).toBe(true); + + // Bulk + expect(caps.bulkCreate).toBe(true); + expect(caps.bulkUpdate).toBe(true); + expect(caps.bulkDelete).toBe(true); + + // Transactions + expect(caps.transactions).toBe(true); + expect(caps.savepoints).toBe(true); + + // Query + expect(caps.queryFilters).toBe(true); + expect(caps.queryAggregations).toBe(true); + expect(caps.querySorting).toBe(true); + expect(caps.queryPagination).toBe(true); + + // Turso-specific + expect(caps.fullTextSearch).toBe(true); + expect(caps.jsonQuery).toBe(true); + expect(caps.queryCTE).toBe(true); + + // Schema + expect(caps.schemaSync).toBe(true); + }); +}); diff --git a/packages/plugins/driver-turso/src/turso-driver.ts b/packages/plugins/driver-turso/src/turso-driver.ts new file mode 100644 index 000000000..5e352865a --- /dev/null +++ b/packages/plugins/driver-turso/src/turso-driver.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Turso/libSQL Driver for ObjectStack + * + * Extends SqlDriver to provide Turso-specific capabilities: + * - libSQL connection modes (local file, in-memory, embedded replica) + * - Embedded replica sync mechanism via @libsql/client + * - Turso-specific capability flags (FTS5, JSON1, CTE, savepoints) + * + * All CRUD, schema, query, filter, and introspection logic is inherited + * from SqlDriver. TursoDriver only overrides connection lifecycle and + * adds Turso-specific extension points. + */ + +import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql'; +import type { Client } from '@libsql/client'; + +// ── Configuration Types ────────────────────────────────────────────────────── + +/** + * Turso driver configuration. + * + * Supports the following connection modes: + * 1. **Local (Embedded):** `url: 'file:./data/local.db'` + * 2. **In-memory (Ephemeral):** `url: ':memory:'` + * 3. **Embedded Replica (Hybrid):** `url` (local file or `:memory:`) + + * `syncUrl` (remote `libsql://` / `https://` Turso endpoint) + * + * In all modes, the primary query engine runs against a local SQLite + * database (via SqlDriver / Knex + better-sqlite3). In embedded replica + * mode, `syncUrl` and `authToken` configure synchronization with a remote + * Turso database via `@libsql/client`. + * + * **Note:** A bare remote-only URL (`url: 'libsql://...'`) without + * `syncUrl` is NOT supported and will throw during `connect()`. + */ +export interface TursoDriverConfig { + /** Database URL for the local store (`file:` path or `:memory:`) */ + url: string; + + /** JWT auth token for the remote Turso database (used with `syncUrl`) */ + authToken?: string; + + /** + * AES-256 encryption key for the local database file. + * Only effective in embedded replica mode (requires `syncUrl`). + */ + encryptionKey?: string; + + /** + * Maximum concurrent requests to the remote database. + * Only effective in embedded replica mode (requires `syncUrl`). + * Default: 20 + */ + concurrency?: number; + + /** Remote sync URL for embedded replica mode (`libsql://` or `https://`) */ + syncUrl?: string; + + /** Sync configuration for embedded replica mode (requires `syncUrl`) */ + sync?: { + /** Periodic sync interval in seconds (0 = manual only). Default: 60 */ + intervalSeconds?: number; + /** Sync immediately on connect. Default: true */ + onConnect?: boolean; + }; + + /** + * Operation timeout in milliseconds for remote operations. + * Only effective in embedded replica mode (requires `syncUrl`). + */ + timeout?: number; +} + +// ── Turso Driver ───────────────────────────────────────────────────────────── + +/** + * Turso/libSQL Driver for ObjectStack. + * + * Extends SqlDriver to add Turso-specific connection management and + * embedded replica sync. All CRUD, schema, filtering, aggregation, + * and introspection are inherited from SqlDriver — zero duplicated logic. + * + * @example Local mode + * ```typescript + * const driver = new TursoDriver({ url: 'file:./data/app.db' }); + * await driver.connect(); + * ``` + * + * @example In-memory mode (testing) + * ```typescript + * const driver = new TursoDriver({ url: ':memory:' }); + * await driver.connect(); + * ``` + * + * @example Embedded replica mode + * ```typescript + * const driver = new TursoDriver({ + * url: 'file:./data/replica.db', + * syncUrl: 'libsql://my-db-orgname.turso.io', + * authToken: process.env.TURSO_AUTH_TOKEN, + * sync: { intervalSeconds: 60, onConnect: true }, + * }); + * await driver.connect(); + * ``` + */ +export class TursoDriver extends SqlDriver { + // IDataDriver metadata + public override readonly name: string = 'com.objectstack.driver.turso'; + public override readonly version: string = '1.0.0'; + + public override readonly supports = { + // Basic CRUD Operations + create: true, + read: true, + update: true, + delete: true, + + // Bulk Operations + bulkCreate: true, + bulkUpdate: true, + bulkDelete: true, + + // Transaction & Connection Management + transactions: true, + savepoints: true, + + // Query Operations + queryFilters: true, + queryAggregations: true, + querySorting: true, + queryPagination: true, + queryWindowFunctions: true, + querySubqueries: true, + queryCTE: true, + joins: true, + + // Advanced Features — Turso/libSQL native capabilities + fullTextSearch: true, // FTS5 + jsonQuery: true, // JSON1 extension + geospatialQuery: false, + streaming: false, + jsonFields: true, + arrayFields: true, + vectorSearch: false, + + // Schema Management + schemaSync: true, + migrations: false, + indexes: true, + + // Performance & Optimization + connectionPooling: false, // Turso uses concurrency limits, not connection pools + preparedStatements: true, + queryCache: false, + }; + + private tursoConfig: TursoDriverConfig; + private libsqlClient: Client | null = null; + private syncIntervalId: ReturnType | null = null; + + constructor(config: TursoDriverConfig) { + const knexConfig = TursoDriver.toKnexConfig(config); + super(knexConfig); + this.tursoConfig = config; + } + + /** + * Convert TursoDriverConfig to a Knex-compatible SqlDriverConfig. + * Extracts the file path from the URL for local/embedded modes. + * + * @throws Error if the URL is a remote-only URL without syncUrl + */ + private static toKnexConfig(config: TursoDriverConfig): SqlDriverConfig { + if (config.url === ':memory:') { + return { + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }; + } + + if (config.url.startsWith('file:')) { + return { + client: 'better-sqlite3', + connection: { filename: config.url.replace(/^file:/, '') }, + useNullAsDefault: true, + }; + } + + // Remote-only URL (libsql://, https://) — not supported as standalone + if (!config.syncUrl) { + throw new Error( + `TursoDriver: Remote-only URL "${config.url}" is not supported without "syncUrl". ` + + 'Use a local URL (file: or :memory:) with "syncUrl" for embedded replica mode, ' + + 'or use a local/in-memory URL for standalone mode.', + ); + } + + // Remote URL with syncUrl — use :memory: as the local Knex backend + // The actual remote sync is handled by @libsql/client + return { + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }; + } + + /** + * Get the Turso-specific configuration. + */ + getTursoConfig(): Readonly { + return this.tursoConfig; + } + + // =================================== + // Lifecycle (Turso-specific overrides) + // =================================== + + /** + * Connect the driver and optionally initialize embedded replica sync. + * + * 1. Initializes the Knex/better-sqlite3 connection (via SqlDriver.connect) + * 2. If syncUrl is configured, creates a @libsql/client for sync operations + * 3. Triggers initial sync if configured + * 4. Starts periodic sync interval if configured + */ + override async connect(): Promise { + await super.connect(); + + // Initialize libSQL client for embedded replica sync + if (this.tursoConfig.syncUrl) { + const { createClient } = await import('@libsql/client'); + this.libsqlClient = createClient({ + url: this.tursoConfig.url, + authToken: this.tursoConfig.authToken, + encryptionKey: this.tursoConfig.encryptionKey, + syncUrl: this.tursoConfig.syncUrl, + concurrency: this.tursoConfig.concurrency, + }); + + // Sync on connect if configured (default: true) + if (this.tursoConfig.sync?.onConnect !== false) { + await this.sync(); + } + + // Start periodic sync if configured + const interval = this.tursoConfig.sync?.intervalSeconds; + if (interval && interval > 0) { + this.syncIntervalId = setInterval(() => { + this.sync().catch(() => { + /* background sync failure is non-fatal */ + }); + }, interval * 1000); + } + } + } + + /** + * Disconnect the driver, clean up sync intervals, and close libSQL client. + */ + override async disconnect(): Promise { + if (this.syncIntervalId) { + clearInterval(this.syncIntervalId); + this.syncIntervalId = null; + } + + if (this.libsqlClient) { + this.libsqlClient.close(); + this.libsqlClient = null; + } + + await super.disconnect(); + } + + // =================================== + // Turso-specific: Embedded Replica Sync + // =================================== + + /** + * Trigger manual sync of the embedded replica with the remote primary. + * No-op if no syncUrl is configured or libSQL client is not initialized. + */ + async sync(): Promise { + if (this.libsqlClient && this.tursoConfig.syncUrl) { + await this.libsqlClient.sync(); + } + } + + /** + * Check if embedded replica sync is configured and active. + */ + isSyncEnabled(): boolean { + return !!this.tursoConfig.syncUrl && this.libsqlClient !== null; + } + + /** + * Get the underlying @libsql/client instance (if available). + * Used for advanced operations like direct remote queries. + */ + getLibsqlClient(): Client | null { + return this.libsqlClient; + } +} diff --git a/packages/plugins/driver-turso/tsconfig.json b/packages/plugins/driver-turso/tsconfig.json new file mode 100644 index 000000000..e20db184d --- /dev/null +++ b/packages/plugins/driver-turso/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cc8ec004..e7821f92c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -763,6 +763,37 @@ importers: specifier: ^4.1.0 version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.4)(msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + packages/plugins/driver-turso: + dependencies: + '@libsql/client': + specifier: ^0.17.2 + version: 0.17.2 + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../driver-sql + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + better-sqlite3: + specifier: ^11.9.1 + version: 11.10.0 + nanoid: + specifier: ^3.3.11 + version: 3.3.11 + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.4)(msw@2.12.13(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + packages/plugins/plugin-audit: dependencies: '@objectstack/core': @@ -792,7 +823,7 @@ importers: version: link:../../spec better-auth: specifier: ^1.5.5 - version: 1.5.5(762af877ac957f740d2f8d5e00374a63) + version: 1.5.5(20f5c332311f6c72b51781baa72405a6) devDependencies: '@objectstack/cli': specifier: workspace:* @@ -2181,6 +2212,63 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@libsql/client@0.17.2': + resolution: {integrity: sha512-0aw0S3iQMHvOxfRt5j1atoCCPMT3gjsB2PS8/uxSM1DcDn39xqz6RlgSMxtP8I3JsxIXAFuw7S41baLEw0Zi+Q==} + + '@libsql/core@0.17.2': + resolution: {integrity: sha512-L8qv12HZ/jRBcETVR3rscP0uHNxh+K3EABSde6scCw7zfOdiLqO3MAkJaeE1WovPsjXzsN/JBoZED4+7EZVT3g==} + + '@libsql/darwin-arm64@0.5.28': + resolution: {integrity: sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.28': + resolution: {integrity: sha512-m1hGkQm8A+CjZmR9D5G3zi36na7GXGJomsMbHwOFiCUYPjqRReD5KZ2HZ/qEAV6U/66xPdDDCuqDB8MzNhiwxA==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.9.0': + resolution: {integrity: sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.28': + resolution: {integrity: sha512-D22yQotJkLcYxrwYP9ukoqbpA5hK7pHmho9jagCM/ij7UwjWJPAY2d2SmEndpJs/SueaGy1xuiUQFec4R7VebQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.28': + resolution: {integrity: sha512-Z/aSb2WzZm7TYn/FEqefoN2sJoDhMtCjV8aHw55ibck6mdLLPGMYXxTyWn5U/OZbqD+wiM7eUgdsG20uEzxEoQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.28': + resolution: {integrity: sha512-gQGJgmUBdk3qm8rDwvFujzTWipLE4ZNP9fgcdVabVBFmD38wLOU5aZ4F3BHrL1ZWdvsrC8mrtnCTKEGuYHDZIw==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.28': + resolution: {integrity: sha512-zLlgKyG96DKJ4skFtubHbWuWRUW8YpcjHVyKyJJDIp2USPQKLXfB+rT06OSQIS90Bm3dbfU+9rAlNX0ua0cSvw==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.28': + resolution: {integrity: sha512-ra+fk6FmTl8ma4opxcTJ8JIt3KrSr+TrFCJtgccfg+7HDdGiE5Ys6jIJMqYuYG61Mv40z3lPZxRivBK5sP9o/w==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.28': + resolution: {integrity: sha512-XXl7lHsZEY8szhfMWoe0tFzKXv52nlDt0kckMmtYb97AkKB0bIcxbgx5zTHGyoXLMMhLvEo33OR7NHvjdDyvjw==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.28': + resolution: {integrity: sha512-KLB4TQKkRdki9Ugbz+X986a1F7IaZUZbPuTfPNFi7slTT+biSw0b/LPJ0tCk7EHyo5QmN8tZ1XLZwI7GgUBsfA==} + cpu: [x64] + os: [win32] + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -2208,6 +2296,9 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@nestjs/common@11.1.17': resolution: {integrity: sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==} peerDependencies: @@ -4299,6 +4390,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4321,6 +4415,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -4399,6 +4497,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4762,6 +4864,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-type@21.3.2: resolution: {integrity: sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==} engines: {node: '>=20'} @@ -4799,6 +4905,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -5323,6 +5433,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5410,6 +5523,11 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + libsql@0.5.28: + resolution: {integrity: sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -6048,9 +6166,27 @@ packages: node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} @@ -6419,6 +6555,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -7069,6 +7208,9 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -7431,6 +7573,13 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -7452,6 +7601,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7682,12 +7834,12 @@ snapshots: nanostores: 1.2.0 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))': + '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 optionalDependencies: - drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.12)': dependencies: @@ -8374,6 +8526,68 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@libsql/client@0.17.2': + dependencies: + '@libsql/core': 0.17.2 + '@libsql/hrana-client': 0.9.0 + js-base64: 3.7.8 + libsql: 0.5.28 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + + '@libsql/core@0.17.2': + dependencies: + js-base64: 3.7.8 + + '@libsql/darwin-arm64@0.5.28': + optional: true + + '@libsql/darwin-x64@0.5.28': + optional: true + + '@libsql/hrana-client@0.9.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + cross-fetch: 4.1.0 + js-base64: 3.7.8 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.28': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.28': + optional: true + + '@libsql/linux-arm64-gnu@0.5.28': + optional: true + + '@libsql/linux-arm64-musl@0.5.28': + optional: true + + '@libsql/linux-x64-gnu@0.5.28': + optional: true + + '@libsql/linux-x64-musl@0.5.28': + optional: true + + '@libsql/win32-x64-msvc@0.5.28': + optional: true + '@lukeed/csprng@1.1.0': {} '@manypkg/find-root@1.1.0': @@ -8448,6 +8662,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@neon-rs/load@0.0.4': {} + '@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.2 @@ -10102,10 +10318,10 @@ snapshots: baseline-browser-mapping@2.10.8: {} - better-auth@1.5.5(762af877ac957f740d2f8d5e00374a63): + better-auth@1.5.5(20f5c332311f6c72b51781baa72405a6): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))) + '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))) '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.12) '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1) '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(mongodb@7.1.0) @@ -10125,7 +10341,7 @@ snapshots: '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.50.3)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) better-sqlite3: 11.10.0 - drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) mongodb: 7.1.0 mysql2: 3.15.3 next: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10415,6 +10631,12 @@ snapshots: create-require@1.1.1: {} + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10439,6 +10661,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + dateformat@4.6.3: {} debug@4.3.4: @@ -10490,6 +10714,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -10530,9 +10756,10 @@ snapshots: dotenv@16.6.1: {} - drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)): + drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.2)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@11.10.0)(knex@3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3))(kysely@0.28.12)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)): optionalDependencies: '@electric-sql/pglite': 0.3.15 + '@libsql/client': 0.17.2 '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.14)(better-sqlite3@11.10.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 11.10.0 knex: 3.1.0(better-sqlite3@11.10.0)(mysql2@3.15.3) @@ -10890,6 +11117,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-type@21.3.2: dependencies: '@tokenizer/inflate': 0.4.1 @@ -10950,6 +11182,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -11533,6 +11769,8 @@ snapshots: joycon@3.1.1: {} + js-base64@3.7.8: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -11624,6 +11862,21 @@ snapshots: leven@3.1.0: {} + libsql@0.5.28: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.28 + '@libsql/darwin-x64': 0.5.28 + '@libsql/linux-arm-gnueabihf': 0.5.28 + '@libsql/linux-arm-musleabihf': 0.5.28 + '@libsql/linux-arm64-gnu': 0.5.28 + '@libsql/linux-arm64-musl': 0.5.28 + '@libsql/linux-x64-gnu': 0.5.28 + '@libsql/linux-x64-musl': 0.5.28 + '@libsql/win32-x64-msvc': 0.5.28 + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -12438,9 +12691,21 @@ snapshots: node-addon-api@4.3.0: optional: true + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: optional: true + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-mock-http@1.0.4: {} node-releases@2.0.27: {} @@ -12745,6 +13010,8 @@ snapshots: process-warning@5.0.0: {} + promise-limit@2.7.0: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -13563,6 +13830,8 @@ snapshots: dependencies: tldts: 7.0.26 + tr46@0.0.3: {} + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -13870,6 +14139,10 @@ snapshots: web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -13885,6 +14158,11 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0