diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index b656642..a3cd436 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -1592,6 +1592,79 @@ describe("transaction()", () => { } }); +describe("timeout option (local files)", () => { + const dbPath = `/tmp/libsql-client-timeout-test-${process.pid}.db`; + const dbUrl = `file:${dbPath}`; + + afterEach(async () => { + const fs = await import("node:fs"); + for (const suffix of ["", "-wal", "-shm"]) { + fs.rmSync(dbPath + suffix, { force: true }); + } + }); + + test("timeout sets busy_timeout on the connection", async () => { + const c = createClient({ url: dbUrl, timeout: 5000 }); + try { + const rs = await c.execute("PRAGMA busy_timeout"); + expect(rs.rows[0]["timeout"]).toStrictEqual(5000); + } finally { + c.close(); + } + }); + + // see issue https://github.com/tursodatabase/libsql-client-ts/issues/288 + test("timeout survives connections created after transaction()", async () => { + const c = createClient({ url: dbUrl, timeout: 5000 }); + try { + const txn = await c.transaction("write"); + await txn.execute("CREATE TABLE t (a)"); + await txn.commit(); + + // transaction() hands the client's connection to the transaction + // and lazily opens a new one; the timeout must apply to it too. + const rs = await c.execute("PRAGMA busy_timeout"); + expect(rs.rows[0]["timeout"]).toStrictEqual(5000); + } finally { + c.close(); + } + }); + + test("locked database waits for timeout instead of failing immediately", async () => { + const setup = createClient({ url: dbUrl }); + await setup.execute("CREATE TABLE t (a)"); + setup.close(); + + const lockHolder = createClient({ url: dbUrl }); + const withTimeout = createClient({ url: dbUrl, timeout: 500 }); + const withoutTimeout = createClient({ url: dbUrl }); + try { + const lock = await lockHolder.transaction("write"); + await lock.execute("INSERT INTO t VALUES (1)"); + + // Without a timeout, a locked write fails immediately. + let start = Date.now(); + await expect( + withoutTimeout.execute("INSERT INTO t VALUES (2)"), + ).rejects.toBeLibsqlError("SQLITE_BUSY"); + expect(Date.now() - start).toBeLessThan(250); + + // With a timeout, the busy handler waits before giving up. + start = Date.now(); + await expect( + withTimeout.execute("INSERT INTO t VALUES (3)"), + ).rejects.toBeLibsqlError("SQLITE_BUSY"); + expect(Date.now() - start).toBeGreaterThanOrEqual(450); + + await lock.rollback(); + } finally { + lockHolder.close(); + withTimeout.close(); + withoutTimeout.close(); + } + }); +}); + (isFile ? test : test.skip)("raw error codes", async () => { const c = createClient(config); try { diff --git a/packages/libsql-client/src/__tests__/config.test.ts b/packages/libsql-client/src/__tests__/config.test.ts index e617f73..30fd41f 100644 --- a/packages/libsql-client/src/__tests__/config.test.ts +++ b/packages/libsql-client/src/__tests__/config.test.ts @@ -197,6 +197,18 @@ describe("expandConfig - parsing of valid arguments", () => { concurrency: 20, }, }, + { + name: "timeout", + config: { url: "file:local.db", timeout: 5000 }, + expanded: { + scheme: "file", + tls: true, + intMode: "number", + path: "local.db", + concurrency: 20, + timeout: 5000, + }, + }, { name: "override auth token", config: { diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index b8a9ad8..e3dbc4e 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -86,6 +86,7 @@ export function _createClient(config: ExpandedConfig): Client { syncPeriod: config.syncInterval, readYourWrites: config.readYourWrites, offline: config.offline, + timeout: config.timeout, }; const db = new Database(path, options); diff --git a/packages/libsql-core/src/api.ts b/packages/libsql-core/src/api.ts index 471eb22..c0f5c42 100644 --- a/packages/libsql-core/src/api.ts +++ b/packages/libsql-core/src/api.ts @@ -62,6 +62,17 @@ export interface Config { * number to increase the concurrency limit or set it to 0 to disable concurrency limits completely. */ concurrency?: number | undefined; + + /** Busy timeout in milliseconds for local `file:` databases. + * + * When another connection holds a lock on the database file, operations wait up to this many + * milliseconds before failing with `SQLITE_BUSY`. By default, operations fail immediately. + * + * This option is applied to every connection the client opens, including connections created + * internally after `transaction()`. It only takes effect for local SQLite databases; remote + * clients ignore it. + */ + timeout?: number; } /** Representation of integers from database as JavaScript values. See {@link Config.intMode}. */ diff --git a/packages/libsql-core/src/config.ts b/packages/libsql-core/src/config.ts index 31d259d..44dd7bf 100644 --- a/packages/libsql-core/src/config.ts +++ b/packages/libsql-core/src/config.ts @@ -19,6 +19,7 @@ export interface ExpandedConfig { intMode: IntMode; fetch: Function | undefined; concurrency: number; + timeout: number | undefined; } export type ExpandedScheme = "wss" | "ws" | "https" | "http" | "file"; @@ -180,6 +181,7 @@ export function expandConfig( readYourWrites: config.readYourWrites, offline: config.offline, fetch: config.fetch, + timeout: config.timeout, authToken: undefined, encryptionKey: undefined, remoteEncryptionKey: undefined, @@ -202,5 +204,6 @@ export function expandConfig( readYourWrites: config.readYourWrites, offline: config.offline, fetch: config.fetch, + timeout: config.timeout, }; }