| 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 | */ |
| 12 | import type * as Arr from "effect/Array" |
| 13 | import * as Clock from "effect/Clock" |
| 14 | import type * as Duration from "effect/Duration" |
| 15 | import * as Effect from "effect/Effect" |
| 16 | import * as Layer from "effect/Layer" |
| 17 | import * 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 | */ |
| 44 | export 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 | |
| 68 | const defaultDatabase = "effect_persistence" |
| 69 | const databaseVersion = 1 |
| 70 | const entriesStoreName = "entries" |
| 71 | const 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 | */ |
| 79 | export const layerIndexedDb = (options?: { |
| 80 | readonly database?: string | undefined |
| 81 | }): Layer.Layer<Persistence.Persistence> => |
| 82 | Persistence.layer.pipe( |
| 83 | Layer.provide(layerBackingIndexedDb(options)) |
| 84 | ) |
| 85 | |
| 86 | const 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 | |
| 110 | interface EntryRow { |
| 111 | readonly storeId: string |
| 112 | readonly id: string |
| 113 | readonly value: object |
| 114 | readonly expires: number | null |
| 115 | } |
| 116 | |
| 117 | const isExpired = (row: EntryRow, now: number): boolean => row.expires !== null && row.expires <= now |
| 118 | |
| 119 | const 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 | |
| 152 | const 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 | |
| 186 | const 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 | |
| 212 | const 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 | |
| 238 | const 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 | |
| 254 | const 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 | |
| 271 | const 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 | |
| 318 | const 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 | }) |