diff --git a/packages/libsql-client/README.md b/packages/libsql-client/README.md index 11fcd602..03ef9b27 100644 --- a/packages/libsql-client/README.md +++ b/packages/libsql-client/README.md @@ -105,6 +105,130 @@ await turso.execute({ | [encryption](examples/encryption) | Creates and uses an encrypted SQLite database, demonstrating setup and data operations. | | [ollama](examples/ollama) | Similarity search with Ollama and Mistral. | +## Attaching Databases + +libSQL supports attaching multiple SQLite databases to a single connection, allowing cross-database queries using schema prefixes. + +### Config-Based Attachment (Static) + +For databases that exist at client creation time: + +```typescript +import { createClient } from "@libsql/client"; + +const client = createClient({ + url: "file:main.db", + attach: [{ alias: "analytics", path: "file:analytics.db?mode=ro" }], +}); + +// Query main database +await client.execute("SELECT * FROM users"); + +// Query attached database +await client.execute("SELECT * FROM analytics.events"); + +// Cross-database JOIN +await client.execute(` + SELECT u.name, COUNT(e.id) as event_count + FROM users u + LEFT JOIN analytics.events e ON u.id = e.user_id + GROUP BY u.id +`); +``` + +### Explicit Attachment (Dynamic) + +For databases that don't exist at client creation time: + +```typescript +const client = createClient({ url: "file:main.db" }); + +// Later, when database becomes available +await client.attach("obs", "file:observability.db?mode=ro"); + +// Query newly attached database +await client.execute("SELECT * FROM obs.traces"); + +// Detach when no longer needed +await client.detach("obs"); +``` + +### Read-Only Attachments + +Use the `file:` URI scheme with `?mode=ro` parameter to attach databases in read-only mode. This prevents write lock conflicts when another connection is writing to the attached database: + +```typescript +// Config-based +const client = createClient({ + url: "file:main.db", + attach: [{ alias: "analytics", path: "file:analytics.db?mode=ro" }], +}); + +// Explicit +await client.attach("obs", "file:observability.db?mode=ro"); +``` + +**When to use read-only mode:** + +- Attached database has a dedicated writer connection +- You only need to read from the attached database +- Prevents `SQLITE_BUSY` errors from lock contention + +### Persistence Across Transactions + +Both config and explicit attachments automatically persist across connection recycling (e.g., after `transaction()`): + +```typescript +const client = createClient({ + url: "file:main.db", + attach: [{ alias: "analytics", path: "analytics.db" }], +}); + +await client.attach("obs", "observability.db"); + +// Both work before transaction +await client.execute("SELECT * FROM analytics.events"); +await client.execute("SELECT * FROM obs.traces"); + +// Create transaction (may recycle connection internally) +const tx = await client.transaction(); +await tx.execute("INSERT INTO main_table VALUES (1)"); +await tx.commit(); + +// Both still work after transaction ✅ +await client.execute("SELECT * FROM analytics.events"); +await client.execute("SELECT * FROM obs.traces"); +``` + +This fixes a bug where ATTACH statements were lost after transactions in previous versions. + +### API Methods + +```typescript +interface Client { + /** + * Attach a database at runtime. + * Persists across transaction() and connection recycling. + */ + attach(alias: string, path: string): Promise; + + /** + * Detach a previously attached database. + * Detachment persists across transaction() and connection recycling. + */ + detach(alias: string): Promise; +} +``` + +### Notes + +- Attached databases use schema prefixes: `analytics.table_name` +- Config attachments applied on client creation +- Explicit attachments applied when `attach()` is called +- Both types re-applied automatically after connection recycling +- Failed attachments (e.g., missing file) log warnings but don't crash +- Duplicate aliases throw `ATTACH_DUPLICATE` error + ## Documentation Visit our [official documentation](https://docs.turso.tech/sdk/ts). diff --git a/packages/libsql-client/src/__tests__/attach-config.test.ts b/packages/libsql-client/src/__tests__/attach-config.test.ts new file mode 100644 index 00000000..d748abb3 --- /dev/null +++ b/packages/libsql-client/src/__tests__/attach-config.test.ts @@ -0,0 +1,541 @@ +/** + * Test suite for config-based ATTACH DATABASE API + explicit methods + * + * Validates that databases attached via config.attach and client.attach() + * persist across connection recycling (e.g., after transaction()). + * + * @see https://github.com/tursodatabase/libsql-client-ts/issues/XXX + */ + +import { expect } from "@jest/globals"; +import { createClient } from "../sqlite3.js"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +// Test context +let tmpDir: string; + +function getTempDbPath(name: string): string { + return path.join(tmpDir, name); +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "libsql-attach-config-test-"), + ); +}); + +afterAll(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// ============================================================================ +// Config-Based Attachment Tests +// ============================================================================ + +/** + * Test 1: Basic Config-Based ATTACH + */ +test("Config-based ATTACH works on client creation", async () => { + const mainPath = getTempDbPath("test-config-main.db"); + const attachedPath = getTempDbPath("test-config-attached.db"); + + // Setup: Create attached database + const attachedClient = createClient({ url: `file:${attachedPath}` }); + await attachedClient.execute( + "CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)", + ); + await attachedClient.execute( + "INSERT INTO test_data (id, value) VALUES (1, 'hello')", + ); + attachedClient.close(); + + // Test: Create client with ATTACH config + const mainClient = createClient({ + url: `file:${mainPath}`, + attach: [{ alias: "attached", path: attachedPath }], + }); + + // Verify: Can query attached database immediately + const rows = await mainClient.execute("SELECT * FROM attached.test_data"); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0]).toMatchObject({ id: 1, value: "hello" }); + + mainClient.close(); +}); + +/** + * Test 2: Config ATTACH Persists After transaction() + * + * Core bug fix validation: ATTACH must survive connection recycling. + */ +test("Config ATTACH persists after transaction (FIX VALIDATION)", async () => { + const mainPath = getTempDbPath("test-persist-main.db"); + const attachedPath = getTempDbPath("test-persist-attached.db"); + + // Setup: Create main database + const mainClient = createClient({ + url: `file:${mainPath}`, + attach: [{ alias: "attached", path: attachedPath }], + }); + + await mainClient.execute( + "CREATE TABLE main_table (id INTEGER PRIMARY KEY)", + ); + + // Setup: Create attached database + const attachedSetup = createClient({ url: `file:${attachedPath}` }); + await attachedSetup.execute( + "CREATE TABLE attached_table (id INTEGER PRIMARY KEY, data TEXT)", + ); + await attachedSetup.execute( + "INSERT INTO attached_table (id, data) VALUES (42, 'test')", + ); + attachedSetup.close(); + + // Verify: ATTACH works BEFORE transaction + const beforeTx = await mainClient.execute( + "SELECT * FROM attached.attached_table", + ); + expect(beforeTx.rows).toHaveLength(1); + expect(beforeTx.rows[0]).toMatchObject({ id: 42, data: "test" }); + + // Action: Create transaction (triggers connection recycling) + const tx = await mainClient.transaction(); + await tx.execute("INSERT INTO main_table (id) VALUES (1)"); + await tx.commit(); + + // Verify: ATTACH still works AFTER transaction (FIX!) + const afterTx = await mainClient.execute( + "SELECT * FROM attached.attached_table", + ); + expect(afterTx.rows).toHaveLength(1); + expect(afterTx.rows[0]).toMatchObject({ id: 42, data: "test" }); + + mainClient.close(); +}); + +/** + * Test 3: Multiple Config ATTACH Statements + */ +test("Multiple config ATTACH statements work", async () => { + const mainPath = getTempDbPath("test-multiple-main.db"); + + // Setup: Create three attached databases + const configs = []; + for (let i = 1; i <= 3; i++) { + const attachedPath = getTempDbPath(`test-multiple-attached${i}.db`); + + const attachedClient = createClient({ url: `file:${attachedPath}` }); + await attachedClient.execute(`CREATE TABLE data${i} (value INTEGER)`); + await attachedClient.execute( + `INSERT INTO data${i} (value) VALUES (${i * 100})`, + ); + attachedClient.close(); + + configs.push({ alias: `db${i}`, path: attachedPath }); + } + + // Test: Create client with multiple attachments + const mainClient = createClient({ + url: `file:${mainPath}`, + attach: configs, + }); + + // Transaction (triggers connection recycling) + const tx = await mainClient.transaction(); + await tx.execute("SELECT 1"); + await tx.commit(); + + // Verify: All attachments persist + const r1 = await mainClient.execute("SELECT * FROM db1.data1"); + const r2 = await mainClient.execute("SELECT * FROM db2.data2"); + const r3 = await mainClient.execute("SELECT * FROM db3.data3"); + + expect(r1.rows[0]).toMatchObject({ value: 100 }); + expect(r2.rows[0]).toMatchObject({ value: 200 }); + expect(r3.rows[0]).toMatchObject({ value: 300 }); + + mainClient.close(); +}); + +/** + * Test 4: Read-Only Mode via URI Parameter + * + * CRITICAL: Tests file: URI with ?mode=ro parameter. + */ +test("Read-only ATTACH via mode=ro URI parameter", async () => { + const mainPath = getTempDbPath("test-readonly-main.db"); + const sharedPath = getTempDbPath("test-readonly-shared.db"); + + // Setup: Create shared database with writer + const writerClient = createClient({ url: `file:${sharedPath}` }); + await writerClient.execute( + "CREATE TABLE shared_data (id INTEGER PRIMARY KEY, value TEXT)", + ); + await writerClient.execute( + "INSERT INTO shared_data (id, value) VALUES (1, 'initial')", + ); + + // Test: Attach in read-only mode + const readerClient = createClient({ + url: `file:${mainPath}`, + attach: [{ alias: "shared", path: `file:${sharedPath}?mode=ro` }], + }); + + // Verify: Can read from attached database + const readResult = await readerClient.execute( + "SELECT * FROM shared.shared_data", + ); + expect(readResult.rows).toHaveLength(1); + expect(readResult.rows[0]).toMatchObject({ id: 1, value: "initial" }); + + // Verify: Writer can still write (no lock conflict) + await writerClient.execute( + "INSERT INTO shared_data (id, value) VALUES (2, 'concurrent')", + ); + + // Verify: Reader sees updated data + const tx = await readerClient.transaction(); + await tx.commit(); + + const updatedRead = await readerClient.execute( + "SELECT COUNT(*) as count FROM shared.shared_data", + ); + expect(updatedRead.rows[0]).toMatchObject({ count: 2 }); + + // Verify: Reader cannot write to read-only attached database + await expect( + readerClient.execute( + "INSERT INTO shared.shared_data (id, value) VALUES (3, 'fail')", + ), + ).rejects.toThrow( + /readonly database|attempt to write a readonly database/i, + ); + + writerClient.close(); + readerClient.close(); +}); + +/** + * Test 5: Cross-Database JOIN + */ +test("Cross-database JOIN works with config ATTACH", async () => { + const warehousePath = getTempDbPath("test-join-warehouse.db"); + const analyticsPath = getTempDbPath("test-join-analytics.db"); + + const warehouseClient = createClient({ + url: `file:${warehousePath}`, + attach: [{ alias: "analytics", path: analyticsPath }], + }); + + await warehouseClient.execute(` + CREATE TABLE orders ( + order_id INTEGER PRIMARY KEY, + customer_id INTEGER, + total REAL + ) + `); + await warehouseClient.execute( + "INSERT INTO orders (order_id, customer_id, total) VALUES (1, 100, 50.00)", + ); + + const analyticsSetup = createClient({ url: `file:${analyticsPath}` }); + await analyticsSetup.execute(` + CREATE TABLE customer_metrics ( + customer_id INTEGER PRIMARY KEY, + lifetime_value REAL + ) + `); + await analyticsSetup.execute( + "INSERT INTO customer_metrics (customer_id, lifetime_value) VALUES (100, 500.00)", + ); + analyticsSetup.close(); + + const tx = await warehouseClient.transaction(); + await tx.execute( + "INSERT INTO orders (order_id, customer_id, total) VALUES (2, 100, 75.00)", + ); + await tx.commit(); + + const result = await warehouseClient.execute(` + SELECT + o.order_id, + o.total, + m.lifetime_value + FROM orders o + JOIN analytics.customer_metrics m ON o.customer_id = m.customer_id + WHERE o.order_id = 1 + `); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ + order_id: 1, + total: 50.0, + lifetime_value: 500.0, + }); + + warehouseClient.close(); +}); + +// ============================================================================ +// Explicit attach() Method Tests +// ============================================================================ + +/** + * Test 7: Explicit attach() Method Works and Persists + * + * CRITICAL: Tests runtime attachment for databases created after client init. + */ +test("Explicit attach() method works and persists", async () => { + const mainPath = getTempDbPath("test-explicit-main.db"); + const laterPath = getTempDbPath("test-explicit-later.db"); + + // Create client WITHOUT attachment + const mainClient = createClient({ url: `file:${mainPath}` }); + + // Create database that "appears later" + const laterClient = createClient({ url: `file:${laterPath}` }); + await laterClient.execute( + "CREATE TABLE late_data (id INTEGER, value TEXT)", + ); + await laterClient.execute("INSERT INTO late_data VALUES (1, 'late')"); + laterClient.close(); + + // Attach explicitly after client creation + await mainClient.attach("later", laterPath); + + // Verify attachment works + const rows1 = await mainClient.execute("SELECT * FROM later.late_data"); + expect(rows1.rows[0]).toMatchObject({ id: 1, value: "late" }); + + // Transaction (triggers connection recycling) + const tx = await mainClient.transaction(); + await tx.commit(); + + // Verify attachment PERSISTS after transaction + const rows2 = await mainClient.execute("SELECT * FROM later.late_data"); + expect(rows2.rows[0]).toMatchObject({ id: 1, value: "late" }); + + mainClient.close(); +}); + +/** + * Test 8: Explicit attach() with Read-Only Mode + */ +test("Explicit attach() with mode=ro works", async () => { + const mainPath = getTempDbPath("test-explicit-ro-main.db"); + const sharedPath = getTempDbPath("test-explicit-ro-shared.db"); + + const writerClient = createClient({ url: `file:${sharedPath}` }); + await writerClient.execute("CREATE TABLE shared (id INTEGER, data TEXT)"); + await writerClient.execute("INSERT INTO shared VALUES (1, 'data')"); + + const readerClient = createClient({ url: `file:${mainPath}` }); + + // Explicit attach in read-only mode + await readerClient.attach("shared", `file:${sharedPath}?mode=ro`); + + // Can read + const rows = await readerClient.execute("SELECT * FROM shared.shared"); + expect(rows.rows[0]).toMatchObject({ id: 1, data: "data" }); + + // Cannot write + await expect( + readerClient.execute("INSERT INTO shared.shared VALUES (2, 'fail')"), + ).rejects.toThrow(/readonly/i); + + writerClient.close(); + readerClient.close(); +}); + +/** + * Test 9: Explicit detach() Method Works + */ +test("Explicit detach() method works", async () => { + const mainPath = getTempDbPath("test-detach-main.db"); + const attachedPath = getTempDbPath("test-detach-attached.db"); + + const mainClient = createClient({ url: `file:${mainPath}` }); + + const attachedClient = createClient({ url: `file:${attachedPath}` }); + await attachedClient.execute("CREATE TABLE data (id INTEGER)"); + attachedClient.close(); + + // Attach then detach + await mainClient.attach("attached", attachedPath); + const rows1 = await mainClient.execute("SELECT * FROM attached.data"); + expect(rows1.rows).toHaveLength(0); + + await mainClient.detach("attached"); + + // Verify detached + await expect( + mainClient.execute("SELECT * FROM attached.data"), + ).rejects.toThrow(/no such table/i); + + // Transaction (should NOT re-attach) + const tx = await mainClient.transaction(); + await tx.commit(); + + await expect( + mainClient.execute("SELECT * FROM attached.data"), + ).rejects.toThrow(/no such table/i); + + mainClient.close(); +}); + +/** + * Test 10: attach() with Duplicate Alias Throws Error + */ +test("attach() with duplicate alias throws error", async () => { + const mainPath = getTempDbPath("test-duplicate-main.db"); + const path1 = getTempDbPath("test-duplicate-1.db"); + const path2 = getTempDbPath("test-duplicate-2.db"); + + const client = createClient({ url: `file:${mainPath}` }); + + for (const path of [path1, path2]) { + const c = createClient({ url: `file:${path}` }); + await c.execute("CREATE TABLE data (id INTEGER)"); + c.close(); + } + + // First attach succeeds + await client.attach("db", path1); + + // Second attach with same alias fails + await expect(client.attach("db", path2)).rejects.toThrow( + /already attached/i, + ); + + client.close(); +}); + +/** + * Test 11: Config + Explicit attach() Both Persist + */ +test("Config and explicit attachments both persist", async () => { + const mainPath = getTempDbPath("test-both-main.db"); + const configPath = getTempDbPath("test-both-config.db"); + const explicitPath = getTempDbPath("test-both-explicit.db"); + + // Setup config database + const configSetup = createClient({ url: `file:${configPath}` }); + await configSetup.execute("CREATE TABLE config_data (id INTEGER)"); + await configSetup.execute("INSERT INTO config_data VALUES (1)"); + configSetup.close(); + + // Setup explicit database + const explicitSetup = createClient({ url: `file:${explicitPath}` }); + await explicitSetup.execute("CREATE TABLE explicit_data (id INTEGER)"); + await explicitSetup.execute("INSERT INTO explicit_data VALUES (2)"); + explicitSetup.close(); + + // Create client with config attachment + const mainClient = createClient({ + url: `file:${mainPath}`, + attach: [{ alias: "config_db", path: configPath }], + }); + + // Add explicit attachment + await mainClient.attach("explicit_db", explicitPath); + + // Both work before transaction + const r1 = await mainClient.execute("SELECT * FROM config_db.config_data"); + const r2 = await mainClient.execute( + "SELECT * FROM explicit_db.explicit_data", + ); + expect(r1.rows[0]).toMatchObject({ id: 1 }); + expect(r2.rows[0]).toMatchObject({ id: 2 }); + + // Transaction + const tx = await mainClient.transaction(); + await tx.commit(); + + // Both still work after transaction + const r3 = await mainClient.execute("SELECT * FROM config_db.config_data"); + const r4 = await mainClient.execute( + "SELECT * FROM explicit_db.explicit_data", + ); + expect(r3.rows[0]).toMatchObject({ id: 1 }); + expect(r4.rows[0]).toMatchObject({ id: 2 }); + + mainClient.close(); +}); + +/** + * Test 12: Multiple Transactions with Config + Explicit + */ +test("Config and explicit attachments persist across multiple transactions", async () => { + const mainPath = getTempDbPath("test-multi-tx-main.db"); + const configPath = getTempDbPath("test-multi-tx-config.db"); + const explicitPath = getTempDbPath("test-multi-tx-explicit.db"); + + const configSetup = createClient({ url: `file:${configPath}` }); + await configSetup.execute("CREATE TABLE data (id INTEGER)"); + await configSetup.execute("INSERT INTO data VALUES (100)"); + configSetup.close(); + + const explicitSetup = createClient({ url: `file:${explicitPath}` }); + await explicitSetup.execute("CREATE TABLE data (id INTEGER)"); + await explicitSetup.execute("INSERT INTO data VALUES (200)"); + explicitSetup.close(); + + const mainClient = createClient({ + url: `file:${mainPath}`, + attach: [{ alias: "config_db", path: configPath }], + }); + + await mainClient.attach("explicit_db", explicitPath); + + // Multiple transactions + for (let i = 0; i < 3; i++) { + const tx = await mainClient.transaction(); + await tx.commit(); + + // Both still work after each transaction + const r1 = await mainClient.execute("SELECT * FROM config_db.data"); + const r2 = await mainClient.execute("SELECT * FROM explicit_db.data"); + expect(r1.rows[0]).toMatchObject({ id: 100 }); + expect(r2.rows[0]).toMatchObject({ id: 200 }); + } + + mainClient.close(); +}); + +/** + * Test 13: Empty attach Config Array + */ +test("Empty attach config array works", async () => { + const mainPath = getTempDbPath("test-empty-attach-main.db"); + + const mainClient = createClient({ + url: `file:${mainPath}`, + attach: [], + }); + + await mainClient.execute("CREATE TABLE test (id INTEGER)"); + const rows = await mainClient.execute("SELECT * FROM test"); + expect(rows.rows).toHaveLength(0); + + mainClient.close(); +}); + +/** + * Test 14: Omitted attach Config (Backward Compatible) + */ +test("Omitting attach config works (backward compatible)", async () => { + const mainPath = getTempDbPath("test-no-attach-main.db"); + + const mainClient = createClient({ url: `file:${mainPath}` }); + + await mainClient.execute("CREATE TABLE test (id INTEGER)"); + const rows = await mainClient.execute("SELECT * FROM test"); + expect(rows.rows).toHaveLength(0); + + mainClient.close(); +}); diff --git a/packages/libsql-client/src/http.ts b/packages/libsql-client/src/http.ts index 05769fdf..525f9c21 100644 --- a/packages/libsql-client/src/http.ts +++ b/packages/libsql-client/src/http.ts @@ -276,6 +276,20 @@ export class HttpClient implements Client { ); } + async attach(_alias: string, _path: string): Promise { + throw new LibsqlError( + "ATTACH DATABASE is not supported for remote HTTP connections. Use local file: URLs instead.", + "ATTACH_NOT_SUPPORTED", + ); + } + + async detach(_alias: string): Promise { + throw new LibsqlError( + "DETACH DATABASE is not supported for remote HTTP connections. Use local file: URLs instead.", + "DETACH_NOT_SUPPORTED", + ); + } + close(): void { this.#client.close(); } diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index 93e91f1e..82c7fea4 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -95,7 +95,7 @@ export function _createClient(config: ExpandedConfig): Client { config.intMode, ); - return new Sqlite3Client(path, options, db, config.intMode); + return new Sqlite3Client(path, options, db, config.intMode, config.attach); } export class Sqlite3Client implements Client { @@ -103,6 +103,7 @@ export class Sqlite3Client implements Client { #options: Database.Options; #db: Database.Database | null; #intMode: IntMode; + #attachConfig: import("@libsql/core/api").AttachConfig[]; closed: boolean; protocol: "file"; @@ -112,13 +113,39 @@ export class Sqlite3Client implements Client { options: Database.Options, db: Database.Database, intMode: IntMode, + attachConfig?: import("@libsql/core/api").AttachConfig[], ) { this.#path = path; this.#options = options; this.#db = db; this.#intMode = intMode; + this.#attachConfig = attachConfig || []; this.closed = false; this.protocol = "file"; + + // Apply initial attachments to the initial connection + this.#applyAttachments(db); + } + + /** + * Apply configured ATTACH statements to a database connection. + * Called on initial connection and after connection recycling. + */ + #applyAttachments(db: Database.Database): void { + for (const { alias, path } of this.#attachConfig) { + try { + // Use native prepare/run to avoid recursion through execute() + const attachSql = `ATTACH DATABASE '${path}' AS ${alias}`; + const stmt = db.prepare(attachSql); + stmt.run(); + } catch (err) { + // Log but don't throw - attached database might not exist yet + // This allows graceful degradation during setup + console.warn( + `Failed to attach database '${alias}' from '${path}': ${err}`, + ); + } + } } async execute( @@ -230,6 +257,104 @@ export class Sqlite3Client implements Client { } finally { this.#db = new Database(this.#path, this.#options); this.closed = false; + + // Re-apply attachments after reconnect + this.#applyAttachments(this.#db); + } + } + + /** + * Attach a database at runtime. + * + * The attachment persists across connection recycling (e.g., after transaction()). + * Use this for databases that don't exist at client creation time. + * + * @param alias - Schema prefix for queries (e.g., 'obs' → 'obs.table_name') + * @param path - Database path, supports file: URI with ?mode=ro + * + * @throws LibsqlError if alias is already attached or attachment fails + * + * @example + * ```typescript + * // Attach when database becomes available + * await client.attach('obs', 'file:observability.db?mode=ro'); + * + * // Query attached database + * await client.execute('SELECT * FROM obs.mastra_traces'); + * + * // Attachment persists after transaction + * const tx = await client.transaction(); + * await tx.commit(); + * await client.execute('SELECT * FROM obs.mastra_traces'); // Still works ✅ + * ``` + */ + async attach(alias: string, path: string): Promise { + this.#checkNotClosed(); + + // Check for duplicate alias + if (this.#attachConfig.some((a) => a.alias === alias)) { + throw new LibsqlError( + `Database with alias '${alias}' is already attached`, + "ATTACH_DUPLICATE", + ); + } + + // Add to persistent config (will survive transaction()) + this.#attachConfig.push({ alias, path }); + + // Apply to current connection + const db = this.#getDb(); + try { + const attachSql = `ATTACH DATABASE '${path}' AS ${alias}`; + const stmt = db.prepare(attachSql); + stmt.run(); + } catch (err) { + // Rollback config change on failure + this.#attachConfig = this.#attachConfig.filter( + (a) => a.alias !== alias, + ); + throw new LibsqlError( + `Failed to attach database '${alias}' from '${path}': ${(err as Error).message}`, + "ATTACH_FAILED", + undefined, + err as Error, + ); + } + } + + /** + * Detach a previously attached database. + * + * The detachment persists across connection recycling. The database + * will not be re-attached on subsequent connections. + * + * @param alias - Schema alias to detach + * + * @example + * ```typescript + * await client.detach('obs'); + * + * // After detach, queries fail + * await client.execute('SELECT * FROM obs.traces'); // Error: no such table + * ``` + */ + async detach(alias: string): Promise { + this.#checkNotClosed(); + + // Remove from persistent config (won't re-attach on reconnection) + this.#attachConfig = this.#attachConfig.filter( + (a) => a.alias !== alias, + ); + + // Detach from current connection + const db = this.#getDb(); + try { + const detachSql = `DETACH DATABASE ${alias}`; + const stmt = db.prepare(detachSql); + stmt.run(); + } catch (err) { + // Ignore errors (already detached is fine) + console.warn(`Failed to detach database '${alias}': ${err}`); } } @@ -251,6 +376,9 @@ export class Sqlite3Client implements Client { #getDb(): Database.Database { if (this.#db === null) { this.#db = new Database(this.#path, this.#options); + + // Re-apply all attachments (config + explicit) to new connection + this.#applyAttachments(this.#db); } return this.#db; } diff --git a/packages/libsql-client/src/ws.ts b/packages/libsql-client/src/ws.ts index e17fbb3c..5844a64b 100644 --- a/packages/libsql-client/src/ws.ts +++ b/packages/libsql-client/src/ws.ts @@ -447,6 +447,20 @@ export class WsClient implements Client { } } + async attach(_alias: string, _path: string): Promise { + throw new LibsqlError( + "ATTACH DATABASE is not supported for remote WebSocket connections. Use local file: URLs instead.", + "ATTACH_NOT_SUPPORTED", + ); + } + + async detach(_alias: string): Promise { + throw new LibsqlError( + "DETACH DATABASE is not supported for remote WebSocket connections. Use local file: URLs instead.", + "DETACH_NOT_SUPPORTED", + ); + } + close(): void { this.#connState.client.close(); this.closed = true; diff --git a/packages/libsql-client/tsconfig.base.json b/packages/libsql-client/tsconfig.base.json index e6d67e71..781ed0a4 100644 --- a/packages/libsql-client/tsconfig.base.json +++ b/packages/libsql-client/tsconfig.base.json @@ -6,7 +6,8 @@ "esModuleInterop": true, "isolatedModules": true, "rootDir": "src/", - "strict": true + "strict": true, + "skipLibCheck": true }, "include": ["src/"], "exclude": ["**/__tests__"] diff --git a/packages/libsql-core/src/api.ts b/packages/libsql-core/src/api.ts index 04899d12..224bc0c2 100644 --- a/packages/libsql-core/src/api.ts +++ b/packages/libsql-core/src/api.ts @@ -59,6 +59,45 @@ export interface Config { * number to increase the concurrency limit or set it to 0 to disable concurrency limits completely. */ concurrency?: number | undefined; + + /** + * Databases to attach on client creation. + * + * Attachments are automatically re-applied when the client creates + * new connections (e.g., after transaction()), ensuring ATTACH + * statements persist across connection recycling. + * + * For databases that don't exist at creation time, use client.attach() + * method instead. + * + * @example + * ```typescript + * createClient({ + * url: 'file:main.db', + * attach: [ + * { alias: 'analytics', path: 'file:analytics.db?mode=ro' } + * ] + * }) + * ``` + */ + attach?: AttachConfig[]; +} + +/** Configuration for attaching databases. See {@link Config.attach}. */ +export interface AttachConfig { + /** Schema alias for the attached database */ + alias: string; + + /** + * Path to database file. + * Supports file: URIs with query parameters: + * - 'path/to/db.db' (read-write, default) + * - 'file:path/to/db.db?mode=ro' (read-only, recommended for readers) + * + * Read-only mode prevents write lock conflicts when another connection + * is writing to the attached database. + */ + path: string; } /** Representation of integers from database as JavaScript values. See {@link Config.intMode}. */ @@ -233,6 +272,51 @@ export interface Client { sync(): Promise; + /** + * Attach a database at runtime. + * + * The attachment persists across connection recycling (e.g., after transaction()). + * Use this for databases that don't exist at client creation time. + * + * @param alias - Schema prefix for queries (e.g., 'obs' → 'obs.table_name') + * @param path - Database path, supports file: URI with ?mode=ro + * + * @throws LibsqlError if alias is already attached or attachment fails + * + * @example + * ```typescript + * // Attach when database becomes available + * await client.attach('obs', 'file:observability.db?mode=ro'); + * + * // Query attached database + * await client.execute('SELECT * FROM obs.mastra_traces'); + * + * // Attachment persists after transaction + * const tx = await client.transaction(); + * await tx.commit(); + * await client.execute('SELECT * FROM obs.mastra_traces'); // Still works ✅ + * ``` + */ + attach(alias: string, path: string): Promise; + + /** + * Detach a previously attached database. + * + * The detachment persists across connection recycling. The database + * will not be re-attached on subsequent connections. + * + * @param alias - Schema alias to detach + * + * @example + * ```typescript + * await client.detach('obs'); + * + * // After detach, queries fail + * await client.execute('SELECT * FROM obs.traces'); // Error: no such table + * ``` + */ + detach(alias: string): Promise; + /** Close the client and release resources. * * This method closes the client (aborting any operations that are currently in progress) and releases any diff --git a/packages/libsql-core/src/config.ts b/packages/libsql-core/src/config.ts index 72cc4beb..5ad77252 100644 --- a/packages/libsql-core/src/config.ts +++ b/packages/libsql-core/src/config.ts @@ -18,6 +18,7 @@ export interface ExpandedConfig { intMode: IntMode; fetch: Function | undefined; concurrency: number; + attach: import("./api.js").AttachConfig[] | undefined; } export type ExpandedScheme = "wss" | "ws" | "https" | "http" | "file"; @@ -182,6 +183,7 @@ export function expandConfig( authToken: undefined, encryptionKey: undefined, authority: undefined, + attach: config.attach, }; } @@ -199,5 +201,6 @@ export function expandConfig( readYourWrites: config.readYourWrites, offline: config.offline, fetch: config.fetch, + attach: config.attach, }; } diff --git a/packages/libsql-core/tsconfig.base.json b/packages/libsql-core/tsconfig.base.json index e6d67e71..781ed0a4 100644 --- a/packages/libsql-core/tsconfig.base.json +++ b/packages/libsql-core/tsconfig.base.json @@ -6,7 +6,8 @@ "esModuleInterop": true, "isolatedModules": true, "rootDir": "src/", - "strict": true + "strict": true, + "skipLibCheck": true }, "include": ["src/"], "exclude": ["**/__tests__"]