1import * as Arr from "effect/Array";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import {
5 BackingPersistence,
6 PersistenceError,
7 type BackingPersistenceStore,
8} from "effect/persistence/Persistence";
9import { RuntimeContext } from "../../RuntimeContext.ts";
10import { DurableObjectState } from "./DurableObjectState.ts";
11
12/**
13 * A `BackingPersistence` layer (Effect AI persistence module) backed
14 * by the surrounding Durable Object's `state.storage`. Drop-in storage
15 * for `Persistence.layerResultPersisted({ storeId })` so chat history,
16 * cached AI responses, or any other persisted state lives in the DO
17 * SQLite store with `${storeId}:` key namespacing.
18 *
19 * Multiple `storeId`s can coexist within a single Durable Object —
20 * keys are namespaced with a `${storeId}:` prefix so they don't
21 * collide.
22 *
23 * :::caution
24 * TTL is currently ignored; Durable Object storage has no native TTL.
25 * If you need expiry, layer a TTL-aware backing store on top, or run
26 * a periodic `clear`.
27 * :::
28 *
29 *
30 * ### Wiring it into a chat-backing DO
31 * **Example:** Persisted chat history per DO instance
32 * `Persistence.layerResultPersisted({ storeId })` is the seam Effect
33 * AI exposes for cached/replayable AI calls. Layer
34 * `DurableObjectChatPersistence` underneath and every entry is stored
35 * in the DO's `state.storage` under the `alchemy.chat:` prefix.
36 * ```typescript
37 * import * as Cloudflare from "alchemy/Cloudflare";
38 * import * as Effect from "effect/Effect";
39 * import * as Layer from "effect/Layer";
40 * import { Chat, LanguageModel } from "effect/ai";
41 * import { Persistence } from "effect/persistence";
42 *
43 * export default class ChatBackend extends Cloudflare.DurableObject<ChatBackend>()(
44 * "ChatBackend",
45 * Effect.gen(function* () {
46 * return Effect.gen(function* () {
47 * const persistence = yield* Persistence.layerResultPersisted({
48 * storeId: "alchemy.chat",
49 * }).pipe(Layer.provide(Cloudflare.AI.DurableObjectChatPersistence));
50 *
51 * return {
52 * send: (threadId: string, prompt: string) =>
53 * LanguageModel.generateText({ prompt }).pipe(Effect.provide(persistence)),
54 * };
55 * });
56 * }),
57 * ) {}
58 * ```
59 *
60 * ### Multiple stores in the same DO
61 * **Example:** Separate `storeId`s coexist
62 * Different `storeId`s namespace their keys with `${storeId}:`, so
63 * one DO can keep, say, chat history *and* an audit log in separate
64 * stores without colliding.
65 * ```typescript
66 * const aiPersistence = yield* Persistence.layerResultPersisted({
67 * storeId: "alchemy.chat",
68 * }).pipe(Layer.provide(Cloudflare.AI.DurableObjectChatPersistence));
69 *
70 * const auditPersistence = yield* Persistence.layerResultPersisted({
71 * storeId: "alchemy.audit",
72 * }).pipe(Layer.provide(Cloudflare.AI.DurableObjectChatPersistence));
73 * ```
74 *
75 * @binding
76 * @product Workers
77 * @category Workers & Compute
78 */
79export const DurableObjectChatPersistence = Layer.effect(BackingPersistence)(
80 Effect.gen(function* () {
81 const state = yield* DurableObjectState;
82 const storage = state.storage;
83
84 const wrapErr = (op: string, key?: string) => (cause: unknown) =>
85 new PersistenceError({
86 message: `Failed to ${op}${key !== undefined ? ` key ${key}` : ""} in DurableObject storage`,
87 cause,
88 });
89
90 return BackingPersistence.of({
91 make: (storeId) =>
92 Effect.sync(() => {
93 const prefixed = (k: string) => `${storeId}:${k}`;
94 return {
95 get: (key) =>
96 storage
97 .get<object>(prefixed(key))
98 .pipe(
99 Effect.mapError(wrapErr("get", key)),
100 Effect.provide(RuntimeContext.phantom),
101 ),
102 getMany: (keys) =>
103 storage.get<object>(keys.map(prefixed)).pipe(
104 Effect.mapError(wrapErr("getMany")),
105 Effect.map(
106 (map) =>
107 keys.map((k) => map.get(prefixed(k))) as Arr.NonEmptyArray<
108 object | undefined
109 >,
110 ),
111 Effect.provide(RuntimeContext.phantom),
112 ),
113 set: (key, value, _ttl) =>
114 storage
115 .put(prefixed(key), value)
116 .pipe(
117 Effect.mapError(wrapErr("set", key)),
118 Effect.provide(RuntimeContext.phantom),
119 ),
120 setMany: (entries) =>
121 storage
122 .put(
123 Object.fromEntries(entries.map(([k, v]) => [prefixed(k), v])),
124 )
125 .pipe(
126 Effect.mapError(wrapErr("setMany")),
127 Effect.provide(RuntimeContext.phantom),
128 ),
129 remove: (key) =>
130 storage
131 .delete(prefixed(key))
132 .pipe(
133 Effect.asVoid,
134 Effect.mapError(wrapErr("remove", key)),
135 Effect.provide(RuntimeContext.phantom),
136 ),
137 clear: storage.list({ prefix: `${storeId}:` }).pipe(
138 Effect.flatMap((map) => {
139 const ks = [...map.keys()];
140 if (ks.length === 0) return Effect.void;
141 return Effect.asVoid(storage.delete(ks));
142 }),
143 Effect.mapError(wrapErr("clear")),
144 Effect.provide(RuntimeContext.phantom),
145 ),
146 } satisfies BackingPersistenceStore;
147 }),
148 });
149 }),
150);