/** * Browser-backed `KeyValueStore` layers for client-side Effect programs. * * This module provides browser implementations of the unstable persistence * `KeyValueStore` service. Use {@link layerLocalStorage} for small * origin-scoped values that should survive reloads and browser restarts, use * {@link layerSessionStorage} for tab / page-session state, and use * {@link layerIndexedDb} when the store should be asynchronous and backed by * IndexedDB. The IndexedDB layer requires the browser `IndexedDb` service and * accepts an optional database name. * * @since 4.0.0 */ import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore" import { IndexedDb } from "./IndexedDb.ts" /** * Creates a `KeyValueStore` layer that uses the browser's `localStorage` API and stores values between browser sessions. * * @category layers * @since 4.0.0 */ export const layerLocalStorage: Layer.Layer = KeyValueStore.layerStorage(() => globalThis.localStorage ) /** * Creates a `KeyValueStore` layer that uses the browser's `sessionStorage` API and stores values only for the current session. * * @category layers * @since 4.0.0 */ export const layerSessionStorage: Layer.Layer = KeyValueStore.layerStorage(() => globalThis.sessionStorage ) /** * Creates a `KeyValueStore` layer backed by IndexedDB. * * **When to use** * * Use when you need persistent asynchronous IndexedDB storage for a browser * `KeyValueStore` instead of the synchronous Web Storage APIs. * * **Details** * * The database name defaults to `"effect_key_value_store"`. The layer requires * the `IndexedDb` service and stores string and `Uint8Array` values in the same * backing object store. * * **Gotchas** * * IndexedDB may be unavailable or blocked by browser settings, private browsing, * quota limits, or restricted contexts. The string and `Uint8Array` accessors do * not coerce values stored with the other representation. * * @see {@link layerLocalStorage} for synchronous persistent Web Storage * @see {@link layerSessionStorage} for synchronous tab-session Web Storage * * @category layers * @since 4.0.0 */ export const layerIndexedDb = (options?: { readonly database?: string | undefined }): Layer.Layer => Layer.effect(KeyValueStore.KeyValueStore)( Effect.gen(function*() { const db = yield* Effect.acquireRelease( openDatabase(options?.database ?? "effect_key_value_store"), (db) => Effect.sync(() => db.close()) ).pipe(Effect.orDie) return KeyValueStore.make({ clear: Effect.suspend(() => { const store = getKvsEntriesStore(db, "readwrite") return idbRequest({ method: "clear", message: "Failed to clear backing store" }, () => store.clear()) }), get: (key: string) => Effect.map( Effect.suspend(() => { const store = getKvsEntriesStore(db, "readonly") return idbRequest<{ key: string; value: string } | undefined>({ method: "get", message: "Failed to get value from backing store", key }, () => store.get(key)) }), (found) => typeof found?.value === "string" ? found.value : undefined ), getUint8Array: (key: string) => Effect.map( Effect.suspend(() => { const store = getKvsEntriesStore(db, "readonly") return idbRequest<{ key: string; value: Uint8Array } | undefined>({ method: "getUint8Array", message: "Failed to get value from backing store", key }, () => store.get(key)) }), (found) => found?.value && found.value instanceof Uint8Array ? found.value : undefined ), set: (key: string, value: string | Uint8Array) => Effect.asVoid(Effect.suspend(() => { const store = getKvsEntriesStore(db, "readwrite") return idbRequest( { method: "set", message: "Failed to set value in backing store", key }, () => store.put({ key, value }) ) })), size: Effect.suspend(() => { const store = getKvsEntriesStore(db, "readonly") return idbRequest( { method: "size", message: "Failed to get backing store size" }, () => store.count() ) }), remove: (key: string) => Effect.asVoid(Effect.suspend(() => { const store = getKvsEntriesStore(db, "readwrite") return idbRequest( { method: "remove", message: "Failed to remove value from backing store", key }, () => store.delete(key) ) })) }) }) ) const databaseVersion = 1 const entriesStoreName = "entries" const openDatabase = Effect.fnUntraced(function*(database: string) { const idb = (yield* IndexedDb).indexedDB const openRequest = yield* Effect.try({ try: () => idb.open(database, databaseVersion), catch: (cause) => new KeyValueStore.KeyValueStoreError({ method: "open", message: "Failed to open backing store database", cause }) }) openRequest.onupgradeneeded = () => { const db = openRequest.result if (!db.objectStoreNames.contains(entriesStoreName)) { db.createObjectStore(entriesStoreName, { keyPath: "key" }) } } return yield* idbRequest({ method: "open", message: "Failed to open backing store database" }, () => openRequest) }) const idbRequest = ( failArgs: { method: string; message: string; key?: string }, evaluate: () => IDBRequest ): Effect.Effect => Effect.callback((resume) => { const request = evaluate() if (request.readyState === "done") { return resume(Effect.succeed(request.result)) } request.onsuccess = () => { resume(Effect.succeed(request.result)) } request.onerror = () => resume(Effect.fail( new KeyValueStore.KeyValueStoreError({ ...failArgs, cause: request.error }) )) }) const getKvsEntriesStore = (db: IDBDatabase, mode: IDBTransactionMode) => { const transaction = db.transaction(entriesStoreName, mode) return transaction.objectStore(entriesStoreName) }