|
|
@@ -0,0 +1,254 @@
|
|
|
+import { drizzle } from "drizzle-orm/durable-sqlite"
|
|
|
+import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
|
|
+import { identity } from "effect/Function"
|
|
|
+import { Reactivity } from "effect/unstable/reactivity"
|
|
|
+import { SqlClient, Statement } from "effect/unstable/sql"
|
|
|
+import type { Connection } from "effect/unstable/sql/SqlConnection"
|
|
|
+import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
|
|
+import { Sqlite } from "./sqlite"
|
|
|
+
|
|
|
+const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
|
|
+
|
|
|
+const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
|
|
|
+type TypeId = typeof TypeId
|
|
|
+
|
|
|
+// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
|
|
|
+// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
|
|
|
+// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
|
|
|
+export const supportsTuningPragmas = false
|
|
|
+
|
|
|
+// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
|
|
|
+// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
|
|
|
+// allowlisted for migrations that must relax checking inside a transaction.
|
|
|
+export const supportsForeignKeyToggle = false
|
|
|
+
|
|
|
+// Minimal structural types for the Durable Object storage API so this adapter
|
|
|
+// does not depend on @cloudflare/workers-types (whose ambient globals conflict
|
|
|
+// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
|
|
|
+type SqlStorageValue = ArrayBuffer | string | number | null
|
|
|
+
|
|
|
+interface SqlStorageCursor {
|
|
|
+ readonly columnNames: Array<string>
|
|
|
+ raw(): IterableIterator<Array<SqlStorageValue>>
|
|
|
+ toArray(): Array<Record<string, SqlStorageValue>>
|
|
|
+}
|
|
|
+
|
|
|
+export interface SqlStorage {
|
|
|
+ exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
|
|
|
+}
|
|
|
+
|
|
|
+export interface DurableObjectStorage {
|
|
|
+ readonly sql: SqlStorage
|
|
|
+ transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
|
|
|
+ transactionSync<T>(closure: () => T): T
|
|
|
+}
|
|
|
+
|
|
|
+interface SqliteClient extends SqlClient.SqlClient {
|
|
|
+ readonly [TypeId]: TypeId
|
|
|
+ readonly config: Config
|
|
|
+ readonly updateValues: never
|
|
|
+}
|
|
|
+
|
|
|
+interface Config {
|
|
|
+ readonly storage: DurableObjectStorage
|
|
|
+ readonly spanAttributes?: Record<string, unknown>
|
|
|
+ readonly transformResultNames?: (str: string) => string
|
|
|
+ readonly transformQueryNames?: (str: string) => string
|
|
|
+}
|
|
|
+
|
|
|
+// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
|
|
|
+// transaction SQL can never run. withTransaction is replaced below with a
|
|
|
+// DurableObjectStorage.transaction-backed implementation; this service only
|
|
|
+// tracks the active transaction connection for statements and nesting checks.
|
|
|
+const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
|
|
|
+ "@opencode-ai/core/database/SqliteWorkerdTransaction",
|
|
|
+)
|
|
|
+
|
|
|
+const transactionError = (message: string) =>
|
|
|
+ new SqlError({
|
|
|
+ reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
|
|
|
+ })
|
|
|
+
|
|
|
+const makeWithTransaction =
|
|
|
+ (
|
|
|
+ storage: DurableObjectStorage,
|
|
|
+ connection: Connection,
|
|
|
+ semaphore: Semaphore.Semaphore,
|
|
|
+ ): SqlClient.SqlClient["withTransaction"] =>
|
|
|
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
|
|
|
+ Effect.withFiber((fiber) => {
|
|
|
+ const services = fiber.context
|
|
|
+ if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
|
|
|
+ return Effect.fail(
|
|
|
+ transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
|
|
|
+ )
|
|
|
+ const effectWithTxn = Effect.provideContext(
|
|
|
+ effect,
|
|
|
+ Context.add(services, WorkerdTransaction, [connection, 0] as const),
|
|
|
+ )
|
|
|
+ return semaphore.withPermits(1)(
|
|
|
+ Effect.callback((resume) => {
|
|
|
+ let interrupted = false
|
|
|
+ const promise = storage
|
|
|
+ .transaction(
|
|
|
+ (txn) =>
|
|
|
+ new Promise<void>((resolve) => {
|
|
|
+ if (interrupted) return resolve()
|
|
|
+ resume(
|
|
|
+ Effect.onExit(effectWithTxn, (exit) => {
|
|
|
+ if (Exit.isFailure(exit)) txn.rollback()
|
|
|
+ resolve()
|
|
|
+ // wait for the transaction to complete
|
|
|
+ return Effect.promise(() => promise)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ .catch((cause) =>
|
|
|
+ resume(
|
|
|
+ Effect.fail(
|
|
|
+ new SqlError({
|
|
|
+ reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return Effect.suspend(() => {
|
|
|
+ interrupted = true
|
|
|
+ return Effect.promise(() => promise)
|
|
|
+ })
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+const make = (options: Config) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const native = (yield* Sqlite.Native) as DurableObjectStorage
|
|
|
+
|
|
|
+ const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
|
|
+ const transformRows = options.transformResultNames
|
|
|
+ ? Statement.defaultTransforms(options.transformResultNames).array
|
|
|
+ : undefined
|
|
|
+
|
|
|
+ // SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
|
|
|
+ // mode and always returns integers as numbers. Blobs come back as
|
|
|
+ // ArrayBuffer and are normalized to Uint8Array to match the other adapters.
|
|
|
+ function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
|
|
|
+ const cursor = native.sql.exec(query, ...params)
|
|
|
+ const columns = cursor.columnNames
|
|
|
+ for (const row of cursor.raw()) {
|
|
|
+ const record: Record<string, unknown> = {}
|
|
|
+ for (let i = 0; i < columns.length; i++) {
|
|
|
+ const value = row[i]
|
|
|
+ record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
|
|
|
+ }
|
|
|
+ yield record
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
|
|
+ Effect.try({
|
|
|
+ try: () => Array.from(runIterator(query, params)),
|
|
|
+ catch: (cause) =>
|
|
|
+ new SqlError({
|
|
|
+ reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
|
|
+ }),
|
|
|
+ })
|
|
|
+
|
|
|
+ const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
|
|
+ Effect.try({
|
|
|
+ try: () =>
|
|
|
+ Array.from(native.sql.exec(query, ...params).raw(), (row) =>
|
|
|
+ row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
|
|
|
+ ),
|
|
|
+ catch: (cause) =>
|
|
|
+ new SqlError({
|
|
|
+ reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
|
|
+ }),
|
|
|
+ })
|
|
|
+
|
|
|
+ const connection = identity<Connection>({
|
|
|
+ execute(query, params, transformRows) {
|
|
|
+ return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
|
|
+ },
|
|
|
+ executeRaw(query, params) {
|
|
|
+ return run(query, params)
|
|
|
+ },
|
|
|
+ executeValues(query, params) {
|
|
|
+ return runValues(query, params)
|
|
|
+ },
|
|
|
+ executeValuesUnprepared(query, params) {
|
|
|
+ return runValues(query, params)
|
|
|
+ },
|
|
|
+ executeUnprepared(query, params, transformRows) {
|
|
|
+ return this.execute(query, params, transformRows)
|
|
|
+ },
|
|
|
+ executeStream() {
|
|
|
+ return Stream.die("executeStream not implemented")
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ const semaphore = yield* Semaphore.make(1)
|
|
|
+ const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
|
|
+ const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
|
|
+ const fiber = Fiber.getCurrent()!
|
|
|
+ const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
|
|
+ return Effect.as(
|
|
|
+ Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
|
|
+ connection,
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ const client = Object.assign(
|
|
|
+ (yield* SqlClient.make({
|
|
|
+ acquirer,
|
|
|
+ compiler,
|
|
|
+ transactionAcquirer,
|
|
|
+ transactionService: WorkerdTransaction,
|
|
|
+ spanAttributes: [
|
|
|
+ ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
|
|
+ [ATTR_DB_SYSTEM_NAME, "sqlite"],
|
|
|
+ ],
|
|
|
+ transformRows,
|
|
|
+ })) as SqliteClient,
|
|
|
+ {
|
|
|
+ [TypeId]: TypeId,
|
|
|
+ config: options,
|
|
|
+ withTransaction: makeWithTransaction(native, connection, semaphore),
|
|
|
+ // Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
|
|
|
+ // as the drizzle session must route through withTransaction instead.
|
|
|
+ transactionStatements: false,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ return client
|
|
|
+ })
|
|
|
+
|
|
|
+// Defends against the shared path-based Database.layer, which passes a
|
|
|
+// filename instead of storage when resolved under the workerd condition.
|
|
|
+const nativeLayer = (config: Config) =>
|
|
|
+ config.storage
|
|
|
+ ? Layer.succeed(Sqlite.Native, config.storage)
|
|
|
+ : Layer.effect(
|
|
|
+ Sqlite.Native,
|
|
|
+ Effect.die(
|
|
|
+ "workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
|
|
|
+
|
|
|
+const drizzleLayer = Layer.effect(
|
|
|
+ Sqlite.Drizzle,
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const native = (yield* Sqlite.Native) as DurableObjectStorage
|
|
|
+ return drizzle(native) as unknown as Sqlite.DrizzleClient
|
|
|
+ }),
|
|
|
+)
|
|
|
+
|
|
|
+export const sqliteLayer = (config: Config) => {
|
|
|
+ const native = nativeLayer(config)
|
|
|
+ return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
|
|
+ Layer.provide(Reactivity.layer),
|
|
|
+ )
|
|
|
+}
|