mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
245 lines
9.2 KiB
TypeScript
245 lines
9.2 KiB
TypeScript
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 type { NativeTransactionSqlClient } from "./drizzle/effect-sqlite/session.js"
|
|
import { Sqlite } from "./sqlite.js"
|
|
|
|
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 detect this and route through withTransaction.
|
|
transactionStatements: false,
|
|
} as const,
|
|
) satisfies NativeTransactionSqlClient
|
|
|
|
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))
|
|
|
|
export const sqliteLayer = (config: Config) => {
|
|
const native = nativeLayer(config)
|
|
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
|
|
}
|