1/**
2 * IndexedDB-backed persistence layers for browser Effect programs.
3 *
4 * This module provides a low-level `BackingPersistence` layer and a higher-level
5 * `Persistence` layer that store object values in IndexedDB. Values are stored
6 * by persistence store id and key, and reads check TTL expiration before
7 * returning stored data. The database name can be customized and defaults to
8 * `"effect_persistence"`.
9 *
10 * @since 4.0.0
11 */
12import type * as Arr from "effect/Array"
13import * as Clock from "effect/Clock"
14import type * as Duration from "effect/Duration"
15import * as Effect from "effect/Effect"
16import * as Layer from "effect/Layer"
17import * as Persistence from "effect/persistence/Persistence"
18
19/**
20 * Creates a `BackingPersistence` layer backed by IndexedDB, optionally using the provided database name.
21 *
22 * **When to use**
23 *
24 * Use when composing persistence manually and the lower-level
25 * `BackingPersistence` service should be backed by browser IndexedDB.
26 *
27 * **Details**
28 *
29 * The database name defaults to `"effect_persistence"`. Entries are stored by
30 * persistence store id and key in a shared object store, and TTL expiration is
31 * checked when values are read.
32 *
33 * **Gotchas**
34 *
35 * Opening the database is defected during layer acquisition if IndexedDB is
36 * unavailable or cannot be opened. Store operations report `PersistenceError`
37 * for IndexedDB request, transaction, quota, and structured-clone failures.
38 *
39 * @see {@link layerIndexedDb} for providing the higher-level `Persistence` service
40 *
41 * @category layers
42 * @since 4.0.0
43 */
44export const layerBackingIndexedDb = (options?: {
45 readonly database?: string | undefined
46}): Layer.Layer<Persistence.BackingPersistence> =>
47 Layer.effect(Persistence.BackingPersistence)(Effect.gen(function*() {
48 const db = yield* Effect.acquireRelease(
49 openDatabase(options?.database ?? defaultDatabase),
50 (db) => Effect.sync(() => db.close())
51 ).pipe(Effect.orDie)
52
53 return Persistence.BackingPersistence.of({
54 make: Effect.fnUntraced(function*(storeId) {
55 const clock = yield* Clock.Clock
56 return {
57 get: (key) => get(db, clock, storeId, key),
58 getMany: (keys) => getMany(db, clock, storeId, keys),
59 set: (key, value, ttl) => set(db, clock, storeId, key, value, ttl),
60 setMany: (entries) => setMany(db, clock, storeId, entries),
61 remove: (key) => remove(db, storeId, key),
62 clear: clear(db, storeId)
63 }
64 })
65 })
66 }))
67
68const defaultDatabase = "effect_persistence"
69const databaseVersion = 1
70const entriesStoreName = "entries"
71const storeIdIndexName = "storeId"
72
73/**
74 * Creates a `Persistence` layer backed by IndexedDB, optionally using the provided database name.
75 *
76 * @category layers
77 * @since 4.0.0
78 */
79export const layerIndexedDb = (options?: {
80 readonly database?: string | undefined
81}): Layer.Layer<Persistence.Persistence> =>
82 Persistence.layer.pipe(
83 Layer.provide(layerBackingIndexedDb(options))
84 )
85
86const openDatabase = (database: string): Effect.Effect<IDBDatabase, Persistence.PersistenceError> =>
87 Effect.gen(function*() {
88 const openRequest = yield* Effect.try({
89 try: () => globalThis.indexedDB.open(database, databaseVersion),
90 catch: (cause) =>
91 new Persistence.PersistenceError({
92 message: "Failed to open backing store database",
93 cause
94 })
95 })
96
97 openRequest.onupgradeneeded = () => {
98 const db = openRequest.result
99 const entries = db.objectStoreNames.contains(entriesStoreName)
100 ? openRequest.transaction?.objectStore(entriesStoreName)
101 : db.createObjectStore(entriesStoreName, { keyPath: ["storeId", "id"] })
102 if (entries && !entries.indexNames.contains(storeIdIndexName)) {
103 entries.createIndex(storeIdIndexName, storeIdIndexName, { unique: false })
104 }
105 }
106
107 return yield* idbRequest("Failed to open backing store database", () => openRequest)
108 })
109
110interface EntryRow {
111 readonly storeId: string
112 readonly id: string
113 readonly value: object
114 readonly expires: number | null
115}
116
117const isExpired = (row: EntryRow, now: number): boolean => row.expires !== null && row.expires <= now
118
119const get = (
120 db: IDBDatabase,
121 clock: Clock.Clock,
122 storeId: string,
123 key: string
124): Effect.Effect<object | undefined, Persistence.PersistenceError> =>
125 withEntriesTransaction<object | undefined>(
126 db,
127 "readwrite",
128 `Failed to get key ${key} from backing store`,
129 (
130 entries,
131 setResult,
132 fail
133 ) => {
134 const now = clock.currentTimeMillisUnsafe()
135 const id: [string, string] = [storeId, key]
136 const request = entries.get(id)
137 request.onerror = () => fail(request.error)
138 request.onsuccess = () => {
139 const row = request.result as EntryRow | undefined
140 if (!row || !isExpired(row, now)) {
141 setResult(row?.value)
142 return
143 }
144
145 const deleteRequest = entries.delete(id)
146 deleteRequest.onerror = () => fail(deleteRequest.error)
147 deleteRequest.onsuccess = () => setResult(undefined)
148 }
149 }
150 )
151
152const getMany = (
153 db: IDBDatabase,
154 clock: Clock.Clock,
155 storeId: string,
156 keys: Arr.NonEmptyArray<string>
157): Effect.Effect<Arr.NonEmptyArray<object | undefined>, Persistence.PersistenceError> =>
158 withEntriesTransaction(
159 db,
160 "readwrite",
161 "Failed to getMany from backing store",
162 (entries, setResult, fail) => {
163 const now = clock.currentTimeMillisUnsafe()
164 const results = new Array<object | undefined>(keys.length)
165 setResult(results as any)
166
167 for (let i = 0; i < keys.length; i++) {
168 const key = keys[i]
169 const keyPath = [storeId, key]
170 const request = entries.get(keyPath)
171 request.onerror = () => fail(request.error)
172 request.onsuccess = () => {
173 const row = request.result as EntryRow | undefined
174 if (!row) return
175 else if (!isExpired(row, now)) {
176 results[i] = row.value
177 return
178 }
179 const deleteRequest = entries.delete(keyPath)
180 deleteRequest.onerror = () => fail(deleteRequest.error)
181 }
182 }
183 }
184 )
185
186const set = (
187 db: IDBDatabase,
188 clock: Clock.Clock,
189 storeId: string,
190 key: string,
191 value: object,
192 ttl: Duration.Duration | undefined
193): Effect.Effect<void, Persistence.PersistenceError> =>
194 withEntriesTransaction(
195 db,
196 "readwrite",
197 `Failed to set key ${key} in backing store`,
198 (entries, setResult, fail) => {
199 const request = entries.put(
200 {
201 storeId,
202 id: key,
203 value,
204 expires: Persistence.unsafeTtlToExpires(clock, ttl)
205 } satisfies EntryRow
206 )
207 request.onerror = () => fail(request.error)
208 request.onsuccess = () => setResult(undefined)
209 }
210 )
211
212const setMany = (
213 db: IDBDatabase,
214 clock: Clock.Clock,
215 storeId: string,
216 entries: Arr.NonEmptyArray<readonly [key: string, value: object, ttl: Duration.Duration | undefined]>
217): Effect.Effect<void, Persistence.PersistenceError> =>
218 withEntriesTransaction(
219 db,
220 "readwrite",
221 "Failed to setMany in backing store",
222 (store, setResult, fail) => {
223 for (const [key, value, ttl] of entries) {
224 const request = store.put(
225 {
226 storeId,
227 id: key,
228 value,
229 expires: Persistence.unsafeTtlToExpires(clock, ttl)
230 } satisfies EntryRow
231 )
232 request.onerror = () => fail(request.error)
233 request.onsuccess = () => setResult(undefined)
234 }
235 }
236 )
237
238const remove = (
239 db: IDBDatabase,
240 storeId: string,
241 key: string
242): Effect.Effect<void, Persistence.PersistenceError> =>
243 withEntriesTransaction(
244 db,
245 "readwrite",
246 `Failed to remove key ${key} from backing store`,
247 (entries, setResult, fail) => {
248 const request = entries.delete([storeId, key])
249 request.onerror = () => fail(request.error)
250 request.onsuccess = () => setResult(undefined)
251 }
252 )
253
254const clear = (db: IDBDatabase, storeId: string): Effect.Effect<void, Persistence.PersistenceError> =>
255 withEntriesTransaction(db, "readwrite", "Failed to clear backing store", (entries, setResult, fail) => {
256 const index = entries.index(storeIdIndexName)
257 const cursorRequest = index.openCursor(storeId)
258 cursorRequest.onerror = () => fail(cursorRequest.error)
259 cursorRequest.onsuccess = () => {
260 const cursor = cursorRequest.result
261 if (!cursor) {
262 setResult(undefined)
263 return
264 }
265 const deleteRequest = cursor.delete()
266 deleteRequest.onerror = () => fail(deleteRequest.error)
267 deleteRequest.onsuccess = () => cursor.continue()
268 }
269 })
270
271const withEntriesTransaction = <A>(
272 db: IDBDatabase,
273 mode: IDBTransactionMode,
274 message: string,
275 run: (
276 entries: IDBObjectStore,
277 onResult: (result: A) => void,
278 fail: (cause: unknown) => void
279 ) => void
280): Effect.Effect<A, Persistence.PersistenceError> =>
281 Effect.callback<A, Persistence.PersistenceError>((resume) => {
282 const tx = db.transaction(entriesStoreName, mode)
283 const entries = tx.objectStore(entriesStoreName)
284
285 let result: A | undefined
286 let setResult = false
287 let done = false
288
289 const fail = (cause: unknown) => {
290 done = true
291 resume(Effect.fail(new Persistence.PersistenceError({ message, cause })))
292 }
293
294 tx.oncomplete = () => {
295 done = true
296 if (setResult) resume(Effect.succeed(result!))
297 }
298 tx.onerror = () => {
299 done = true
300 fail(tx.error)
301 }
302 tx.onabort = () => {
303 done = true
304 fail(tx.error)
305 }
306
307 run(entries, (next) => {
308 if (done) return resume(Effect.succeed(next))
309 setResult = true
310 result = next
311 }, fail)
312
313 return Effect.sync(() => {
314 tx.abort()
315 })
316 })
317
318const idbRequest = <A>(
319 message: string,
320 evaluate: () => IDBRequest<A>
321): Effect.Effect<A, Persistence.PersistenceError> =>
322 Effect.callback<A, Persistence.PersistenceError>((resume) => {
323 const request = evaluate()
324 const fail = (cause: unknown) => {
325 resume(Effect.fail(new Persistence.PersistenceError({ message, cause })))
326 }
327 if (request.readyState === "done") {
328 resume(Effect.succeed(request.result))
329 }
330 request.onsuccess = () => {
331 resume(Effect.succeed(request.result))
332 }
333 request.onerror = () => fail(request.error)
334 })