import { Data, Effect, Exit, Option, Schema as S } from "effect"; /** Supported primitive value types for Durable Object SQL APIs. */ export type SqlStorageValue = globalThis.SqlStorageValue; /** Error type used when a storage operation throws or rejects. */ export class StorageOperationError extends Data.TaggedError("StorageOperationError")<{ readonly operation: string; readonly cause: unknown; }> {} type StorageEffect = Effect.Effect; /** * Effect wrapper for Cloudflare SQL cursor operations. */ export interface SqlCursor> { next(): StorageEffect<{ done?: false; value: T } | { done: true; value?: never }>; toArray(): StorageEffect>; one(): StorageEffect; raw>(): StorageEffect>; readonly columnNames: Array; readonly rowsRead: StorageEffect; readonly rowsWritten: StorageEffect; } /** * Effect wrapper for Durable Object SQLite APIs. */ export interface SqlStorage { /** Executes a SQL statement and returns a typed cursor. */ exec>( query: string, ...bindings: Array ): StorageEffect>; readonly databaseSize: number; } /** * Schema pair used by `SyncKvStorage.schema(...)`. */ export interface SyncKvDefinition { readonly key: S.Codec; readonly value: S.Codec; } /** * Sync KV wrapper that transparently encodes keys/values through Effect Schema. */ export interface SchemaBackedSyncKvStorage { get(key: Key): Effect.Effect, unknown>; put(key: Key, value: Value): Effect.Effect; delete(key: Key): Effect.Effect; list(options?: globalThis.SyncKvListOptions): Effect.Effect, unknown>; } /** * Effect wrapper for synchronous KV attached to Durable Object SQLite storage. */ export interface SyncKvStorage { get(key: string): StorageEffect; put(key: string, value: T): StorageEffect; delete(key: string): StorageEffect; list(options?: globalThis.SyncKvListOptions): StorageEffect>; schema( definition: SyncKvDefinition, ): SchemaBackedSyncKvStorage; } /** * Effect wrapper around Cloudflare transaction callbacks. */ export interface DurableObjectTransaction { get( key: string, options?: globalThis.DurableObjectGetOptions, ): StorageEffect; get( keys: Array, options?: globalThis.DurableObjectGetOptions, ): StorageEffect>; list(options?: globalThis.DurableObjectListOptions): StorageEffect>; put(key: string, value: T, options?: globalThis.DurableObjectPutOptions): StorageEffect; put( entries: Record, options?: globalThis.DurableObjectPutOptions, ): StorageEffect; delete(key: string, options?: globalThis.DurableObjectPutOptions): StorageEffect; delete(keys: Array, options?: globalThis.DurableObjectPutOptions): StorageEffect; rollback(): StorageEffect; getAlarm(options?: globalThis.DurableObjectGetAlarmOptions): StorageEffect; setAlarm( scheduledTime: number | Date, options?: globalThis.DurableObjectSetAlarmOptions, ): StorageEffect; deleteAlarm(options?: globalThis.DurableObjectSetAlarmOptions): StorageEffect; } /** * Effect wrapper around Cloudflare Durable Object storage. * * @example * ```ts * const program = Effect.gen(function* () { * const state = yield* DurableObjectState; * yield* state.storage.put("counter", 1); * const value = yield* state.storage.get("counter"); * return value; * }); * ``` */ export interface DurableObjectStorage { get( key: string, options?: globalThis.DurableObjectGetOptions, ): StorageEffect; put(key: string, value: T, options?: globalThis.DurableObjectPutOptions): StorageEffect; delete(key: string, options?: globalThis.DurableObjectPutOptions): StorageEffect; /** * Deletes all stored data. On compatibility dates before 2026-02-24, Cloudflare * documents that active alarms must be deleted separately with `deleteAlarm()`. */ deleteAll(options?: globalThis.DurableObjectPutOptions): StorageEffect; getAlarm(options?: globalThis.DurableObjectGetAlarmOptions): StorageEffect; setAlarm( scheduledTime: number | Date, options?: globalThis.DurableObjectSetAlarmOptions, ): StorageEffect; deleteAlarm(options?: globalThis.DurableObjectSetAlarmOptions): StorageEffect; /** * SQLite-backed Durable Object storage only. * * The callback Effect must complete synchronously. If it requires asynchronous * work, use `transaction` instead so Cloudflare can run the async transaction * callback under the platform's transaction contract. */ transactionSync( closure: () => Effect.Effect, ): Effect.Effect; /** * Runs an async transaction and exposes a typed transaction wrapper. */ transaction( closure: (txn: DurableObjectTransaction) => Effect.Effect, ): Effect.Effect; /** Flushes pending writes to disk. */ sync(): StorageEffect; /** SQLite-backed Durable Object storage point-in-time recovery API only. */ getCurrentBookmark(): StorageEffect; /** SQLite-backed Durable Object storage point-in-time recovery API only. */ onNextSessionRestoreBookmark(bookmark: string): StorageEffect; readonly sql: SqlStorage; readonly kv: SyncKvStorage; } const storageError = (operation: string, cause: unknown) => new StorageOperationError({ operation, cause }); const tryStorageSync = (operation: string, evaluate: () => A): StorageEffect => Effect.try({ try: evaluate, catch: (cause) => storageError(operation, cause), }); const tryStoragePromise = (operation: string, evaluate: () => Promise): StorageEffect => Effect.tryPromise({ try: evaluate, catch: (cause) => storageError(operation, cause), }); const fromSqlCursor = >( cursor: globalThis.SqlStorageCursor, ): SqlCursor => ({ next: () => tryStorageSync("sql.next", () => cursor.next()), toArray: () => tryStorageSync("sql.toArray", () => cursor.toArray()), one: () => tryStorageSync("sql.one", () => cursor.one()), raw: >() => tryStorageSync("sql.raw", () => cursor.raw()), get columnNames() { return cursor.columnNames; }, rowsRead: tryStorageSync("sql.rowsRead", () => cursor.rowsRead), rowsWritten: tryStorageSync("sql.rowsWritten", () => cursor.rowsWritten), }); const fromSqlStorage = (sql: globalThis.SqlStorage): SqlStorage => ({ exec: >( query: string, ...bindings: Array ) => tryStorageSync("sql.exec", () => fromSqlCursor(sql.exec(query, ...bindings))), get databaseSize() { return sql.databaseSize; }, }); const schemaBackedSyncKvStorage = ( kv: globalThis.SyncKvStorage, definition: SyncKvDefinition, ): SchemaBackedSyncKvStorage => { const encodeKey = S.encodeEffect(definition.key); const decodeKey = S.decodeUnknownEffect(definition.key); const encodeValue = S.encodeEffect(definition.value); const decodeValue = S.decodeUnknownEffect(definition.value); return { get: (key) => Effect.gen(function* () { const encodedKey = yield* encodeKey(key); const encodedValue = yield* tryStorageSync("kv.get", () => kv.get(encodedKey), ); if (encodedValue === undefined) { return Option.none(); } return yield* decodeValue(encodedValue).pipe(Effect.map(Option.some)); }), put: (key, value) => Effect.gen(function* () { const encodedKey = yield* encodeKey(key); const encodedValue = yield* encodeValue(value); yield* tryStorageSync("kv.put", () => kv.put(encodedKey, encodedValue)); }), delete: (key) => Effect.gen(function* () { const encodedKey = yield* encodeKey(key); return yield* tryStorageSync("kv.delete", () => kv.delete(encodedKey)); }), list: (options) => Effect.gen(function* () { const entries = Array.from( yield* tryStorageSync("kv.list", () => kv.list(options)), ); const decoded: Array<[Key, Value]> = []; for (const [key, value] of entries) { decoded.push([yield* decodeKey(key), yield* decodeValue(value)]); } return decoded; }), }; }; const fromSyncKvStorage = (kv: globalThis.SyncKvStorage): SyncKvStorage => ({ get: (key: string) => tryStorageSync("kv.get", () => kv.get(key)), put: (key: string, value: T) => tryStorageSync("kv.put", () => kv.put(key, value)), delete: (key: string) => tryStorageSync("kv.delete", () => kv.delete(key)), list: (options?: globalThis.SyncKvListOptions) => tryStorageSync("kv.list", () => Array.from(kv.list(options))), schema: (definition: SyncKvDefinition) => schemaBackedSyncKvStorage(kv, definition), }); const fromDurableObjectTransaction = ( txn: globalThis.DurableObjectTransaction, ): DurableObjectTransaction => ({ get: ( keyOrKeys: string | Array, options?: globalThis.DurableObjectGetOptions, ) => Array.isArray(keyOrKeys) ? tryStoragePromise("transaction.get", () => txn.get(keyOrKeys, options)) : tryStoragePromise("transaction.get", () => txn.get(keyOrKeys, options)), list: (options?: globalThis.DurableObjectListOptions) => tryStoragePromise("transaction.list", () => txn.list(options)), put: ( keyOrEntries: string | Record, valueOrOptions?: T | globalThis.DurableObjectPutOptions, maybeOptions?: globalThis.DurableObjectPutOptions, ) => tryStoragePromise("transaction.put", () => typeof keyOrEntries === "string" ? txn.put(keyOrEntries, valueOrOptions as T, maybeOptions) : txn.put(keyOrEntries, valueOrOptions as globalThis.DurableObjectPutOptions | undefined), ), delete: (keyOrKeys: string | Array, options?: globalThis.DurableObjectPutOptions) => Array.isArray(keyOrKeys) ? tryStoragePromise("transaction.delete", () => txn.delete(keyOrKeys, options)) : tryStoragePromise("transaction.delete", () => txn.delete(keyOrKeys, options)), rollback: () => tryStorageSync("transaction.rollback", () => txn.rollback()), getAlarm: (options?: globalThis.DurableObjectGetAlarmOptions) => tryStoragePromise("transaction.getAlarm", () => txn.getAlarm(options)), setAlarm: (scheduledTime: number | Date, options?: globalThis.DurableObjectSetAlarmOptions) => tryStoragePromise("transaction.setAlarm", () => txn.setAlarm(scheduledTime, options)), deleteAlarm: (options?: globalThis.DurableObjectSetAlarmOptions) => tryStoragePromise("transaction.deleteAlarm", () => txn.deleteAlarm(options)), }) as DurableObjectTransaction; /** * Wraps native Cloudflare storage APIs as Effect-returning helpers. */ export const fromDurableObjectStorage = ( storage: globalThis.DurableObjectStorage, ): DurableObjectStorage => ({ get: (key: string, options?: globalThis.DurableObjectGetOptions) => tryStoragePromise("get", () => storage.get(key, options)), put: (key: string, value: T, options?: globalThis.DurableObjectPutOptions) => tryStoragePromise("put", () => storage.put(key, value, options)), delete: (key: string, options?: globalThis.DurableObjectPutOptions) => tryStoragePromise("delete", () => storage.delete(key, options)), deleteAll: (options?: globalThis.DurableObjectPutOptions) => tryStoragePromise("deleteAll", () => storage.deleteAll(options)), getAlarm: (options?: globalThis.DurableObjectGetAlarmOptions) => tryStoragePromise("getAlarm", () => storage.getAlarm(options)), setAlarm: (scheduledTime: number | Date, options?: globalThis.DurableObjectSetAlarmOptions) => tryStoragePromise("setAlarm", () => storage.setAlarm(scheduledTime, options)), deleteAlarm: (options?: globalThis.DurableObjectSetAlarmOptions) => tryStoragePromise("deleteAlarm", () => storage.deleteAlarm(options)), transactionSync: (closure: () => Effect.Effect) => Effect.context().pipe( Effect.flatMap((context) => Effect.suspend(() => { try { return Effect.succeed( storage.transactionSync(() => { const exit = Effect.runSyncExitWith(context)(closure()); if (Exit.isSuccess(exit)) { return exit.value; } throw exit; }), ); } catch (cause) { if (Exit.isExit(cause) && Exit.isFailure(cause)) { return Effect.failCause(cause.cause) as Effect.Effect< A, E | StorageOperationError, R >; } return Effect.fail(storageError("transactionSync", cause)); } }), ), ), transaction: (closure: (txn: DurableObjectTransaction) => Effect.Effect) => Effect.context().pipe( Effect.flatMap((context) => Effect.callback((resume) => { void storage .transaction(async (txn) => { const exit = await Effect.runPromiseExitWith(context)( closure(fromDurableObjectTransaction(txn)), ); if (Exit.isSuccess(exit)) { return exit.value; } throw exit; }) .then( (value) => resume(Effect.succeed(value)), (cause) => { if (Exit.isExit(cause) && Exit.isFailure(cause)) { resume( Effect.failCause(cause.cause) as Effect.Effect, ); } else { resume(Effect.fail(storageError("transaction", cause))); } }, ); }), ), ), sync: () => tryStoragePromise("sync", () => storage.sync()), getCurrentBookmark: () => tryStoragePromise("getCurrentBookmark", () => storage.getCurrentBookmark()), onNextSessionRestoreBookmark: (bookmark: string) => tryStoragePromise("onNextSessionRestoreBookmark", () => storage.onNextSessionRestoreBookmark(bookmark), ), sql: fromSqlStorage(storage.sql), kv: fromSyncKvStorage(storage.kv), });