diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index ee84bc06..4fd299a1 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -24,9 +24,13 @@ "vitest": "^4.0.1" }, "peerDependencies": { + "@tursodatabase/serverless": "^0.2.4", "just-bash": ">=2.0.0" }, "peerDependenciesMeta": { + "@tursodatabase/serverless": { + "optional": true + }, "just-bash": { "optional": true } @@ -1006,6 +1010,14 @@ "win32" ] }, + "node_modules/@tursodatabase/serverless": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@tursodatabase/serverless/-/serverless-0.2.4.tgz", + "integrity": "sha512-kqf3FINvTGx8N1iH/4e1Dkbr/om5q9qitshzvljtQXwZFQIsVP/mDE76mGg9U2dg/ElOZKFKtT1VXDgd39Dr1w==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1047,7 +1059,6 @@ "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1081,7 +1092,6 @@ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.16", "@vitest/mocker": "4.0.16", @@ -1203,7 +1213,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -1592,7 +1601,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1619,6 +1627,7 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -1638,6 +1647,7 @@ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -1656,6 +1666,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -1941,7 +1952,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2017,7 +2027,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 860fb259..edc6708e 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -29,6 +29,10 @@ "./cloudflare": { "import": "./dist/integrations/cloudflare/index.js", "types": "./dist/integrations/cloudflare/index.d.ts" + }, + "./serverless": { + "import": "./dist/integrations/serverless/index.js", + "types": "./dist/integrations/serverless/index.d.ts" } }, "keywords": [ @@ -56,11 +60,15 @@ "vitest": "^4.0.1" }, "peerDependencies": { + "@tursodatabase/serverless": "^0.2.4", "just-bash": ">=2.0.0" }, "peerDependenciesMeta": { "just-bash": { "optional": true + }, + "@tursodatabase/serverless": { + "optional": true } }, "dependencies": { diff --git a/sdk/typescript/src/integrations/serverless/adapter.ts b/sdk/typescript/src/integrations/serverless/adapter.ts new file mode 100644 index 00000000..a89b98c8 --- /dev/null +++ b/sdk/typescript/src/integrations/serverless/adapter.ts @@ -0,0 +1,196 @@ +/** + * Adapter that wraps @tursodatabase/serverless Connection to match + * the DatabasePromise interface used by AgentFS internals. + * + * The core challenge: DatabasePromise.prepare() is synchronous and returns + * a Statement immediately, but serverless Connection.prepare() is async + * (it needs to fetch column metadata over HTTP). + * + * Solution: return a LazyStatement that defers the actual prepare() call + * until run()/get()/all() is called — those are already async, so the + * deferral is invisible to callers. + * + * Note on parameter passing: DatabasePromise's Statement uses rest params + * (run(...args)), while serverless Statement uses a single array param + * (run(args?)). The adapter collects rest params and forwards them as a + * single array — this is correct and tested. + */ + +import type { Connection, Statement as ServerlessStatement } from "@tursodatabase/serverless"; +import type { DatabasePromise } from "@tursodatabase/database-common"; + +/** + * A statement that defers the async prepare() call until execution. + * + * DatabasePromise.prepare() must return synchronously, but the serverless + * driver's prepare() is async. LazyStatement bridges this by storing the + * SQL and only calling conn.prepare() when run/get/all is invoked. + * + * The statement promise is cached for performance — options (raw, pluck, + * safeIntegers) are applied at execution time on the resolved statement. + */ +class LazyStatement { + private conn: Connection; + private sql: string; + private stmtPromise: Promise | null = null; + private _raw = false; + private _pluck = false; + private _safeIntegers = false; + + constructor(conn: Connection, sql: string) { + this.conn = conn; + this.sql = sql; + } + + private async getStmt(): Promise { + if (!this.stmtPromise) { + this.stmtPromise = this.conn.prepare(this.sql); + } + const stmt = await this.stmtPromise; + // Apply options at execution time so they reflect the current state + stmt.raw(this._raw); + stmt.pluck(this._pluck); + stmt.safeIntegers(this._safeIntegers); + return stmt; + } + + raw(raw?: boolean): this { + this._raw = raw !== false; + return this; + } + + pluck(pluck?: boolean): this { + this._pluck = pluck !== false; + return this; + } + + safeIntegers(toggle?: boolean): this { + this._safeIntegers = toggle !== false; + return this; + } + + columns(): any[] { + throw new Error("columns() requires an async prepare — not supported synchronously in serverless mode"); + } + + get source(): void { + return undefined; + } + + get reader(): void { + return undefined; + } + + get database(): any { + return undefined; + } + + // Note: DatabasePromise Statement uses rest params (...args), + // but serverless Statement uses a single array param (args?). + // We collect rest params and forward as a single array. + + async run(...args: any[]): Promise<{ changes: number; lastInsertRowid: number }> { + const stmt = await this.getStmt(); + return stmt.run(args); + } + + async get(...args: any[]): Promise { + const stmt = await this.getStmt(); + return stmt.get(args); + } + + async all(...args: any[]): Promise { + const stmt = await this.getStmt(); + return stmt.all(args); + } + + async *iterate(...args: any[]): AsyncGenerator { + const stmt = await this.getStmt(); + yield* stmt.iterate(args); + } + + bind(..._args: any[]): this { + throw new Error("bind() is not supported in serverless mode — pass parameters to run/get/all instead"); + } + + interrupt(): void {} + + close(): void {} +} + +function notSupported(name: string): () => never { + return () => { + throw new Error(`${name}() is not supported in serverless mode`); + }; +} + +/** + * Wraps a @tursodatabase/serverless Connection to match + * the DatabasePromise interface expected by AgentFS.openWith(). + * + * @example + * ```typescript + * import { connect } from "@tursodatabase/serverless"; + * import { AgentFS } from "agentfs-sdk"; + * import { createServerlessAdapter } from "agentfs-sdk/serverless"; + * + * const conn = connect({ + * url: "http://localhost:8080", + * }); + * + * const db = createServerlessAdapter(conn); + * const agent = await AgentFS.openWith(db); + * ``` + */ +export function createServerlessAdapter(conn: Connection): DatabasePromise { + return { + name: "serverless", + readonly: false, + open: true, + memory: false, + inTransaction: false, + + async connect(): Promise { + // Validate the connection by running a simple query + await conn.execute("SELECT 1"); + }, + + prepare(sql: string): LazyStatement { + return new LazyStatement(conn, sql); + }, + + // DatabasePromise.transaction() returns a wrapper function that + // executes fn inside a transaction when called. The serverless + // driver's transaction() has the same shape. + transaction(fn: (...args: any[]) => Promise) { + return (...bindParameters: any[]) => { + return conn.transaction(async () => { + return fn(...bindParameters); + }); + }; + }, + + async exec(sql: string): Promise { + await conn.exec(sql); + }, + + pragma(): Promise { + throw new Error("pragma() is not supported in serverless mode — pragmas are not available over HTTP"); + }, + + backup: notSupported("backup"), + serialize: notSupported("serialize"), + function: notSupported("function"), + aggregate: notSupported("aggregate"), + table: notSupported("table"), + loadExtension: notSupported("loadExtension"), + maxWriteReplicationIndex: notSupported("maxWriteReplicationIndex"), + interrupt: notSupported("interrupt"), + + defaultSafeIntegers(_toggle?: boolean) {}, + + async close(): Promise { + await conn.close(); + }, + } as unknown as DatabasePromise; +} diff --git a/sdk/typescript/src/integrations/serverless/index.ts b/sdk/typescript/src/integrations/serverless/index.ts new file mode 100644 index 00000000..3419257e --- /dev/null +++ b/sdk/typescript/src/integrations/serverless/index.ts @@ -0,0 +1,32 @@ +/** + * Serverless integration for AgentFS. + * + * Provides an adapter that wraps `@tursodatabase/serverless` Connection + * to be compatible with AgentFS's DatabasePromise interface, enabling + * AgentFS to work with remote Turso databases over HTTP. + * + * @example + * ```typescript + * import { connect } from "@tursodatabase/serverless"; + * import { AgentFS } from "agentfs-sdk"; + * import { createServerlessAdapter } from "agentfs-sdk/serverless"; + * + * const conn = connect({ + * url: process.env.TURSO_DATABASE_URL!, + * authToken: process.env.TURSO_AUTH_TOKEN, + * }); + * + * const db = createServerlessAdapter(conn); + * const agent = await AgentFS.openWith(db); + * + * await agent.fs.writeFile("/hello.txt", "Hello from Turso Cloud!"); + * const content = await agent.fs.readFile("/hello.txt", "utf8"); + * console.log(content); + * + * await agent.close(); + * ``` + * + * @see https://github.com/tursodatabase/agentfs/issues/156 + */ + +export { createServerlessAdapter } from "./adapter.js"; diff --git a/sdk/typescript/tests/serverless.test.ts b/sdk/typescript/tests/serverless.test.ts new file mode 100644 index 00000000..c25a08d1 --- /dev/null +++ b/sdk/typescript/tests/serverless.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { connect } from "@tursodatabase/serverless"; +import { createServerlessAdapter } from "../src/integrations/serverless/index.js"; +import { AgentFS } from "../src/index_node.js"; + +const SQLD_URL = process.env.SQLD_URL || "http://localhost:8080"; + +// Check if sqld is reachable before running tests +async function isSqldAvailable(): Promise { + try { + const resp = await fetch(`${SQLD_URL}/version`); + return resp.ok; + } catch { + return false; + } +} + +const sqldAvailable = await isSqldAvailable(); + +describe.skipIf(!sqldAvailable)("Serverless Integration", () => { + let agent: Awaited>; + + beforeAll(async () => { + const conn = connect({ url: SQLD_URL }); + const db = createServerlessAdapter(conn); + agent = await AgentFS.openWith(db); + }); + + afterAll(async () => { + try { + await agent.fs.rm("/test-serverless", { recursive: true, force: true }); + } catch {} + await agent.close(); + }); + + it("should write and read a file", async () => { + await agent.fs.writeFile("/test-serverless/hello.txt", "Hello from serverless!"); + const content = await agent.fs.readFile("/test-serverless/hello.txt", "utf8"); + expect(content).toBe("Hello from serverless!"); + }); + + it("should list directory contents", async () => { + await agent.fs.writeFile("/test-serverless/a.txt", "a"); + await agent.fs.writeFile("/test-serverless/b.txt", "b"); + const entries = await agent.fs.readdir("/test-serverless"); + expect(entries).toContain("a.txt"); + expect(entries).toContain("b.txt"); + }); + + it("should stat a file", async () => { + await agent.fs.writeFile("/test-serverless/stat-test.txt", "test content"); + const stats = await agent.fs.stat("/test-serverless/stat-test.txt"); + expect(stats.isFile()).toBe(true); + expect(stats.isDirectory()).toBe(false); + expect(stats.size).toBe(12); + }); + + it("should use kv store", async () => { + await agent.kv.set("test-key", { value: 42 }); + const result = await agent.kv.get("test-key"); + expect(result).toEqual({ value: 42 }); + }); + + it("should delete a file", async () => { + await agent.fs.writeFile("/test-serverless/delete-me.txt", "bye"); + await agent.fs.unlink("/test-serverless/delete-me.txt"); + await expect(agent.fs.stat("/test-serverless/delete-me.txt")).rejects.toThrow(); + }); +});