1/**
2 * Browser-backed `KeyValueStore` layers for client-side Effect programs.
3 *
4 * This module provides browser implementations of the unstable persistence
5 * `KeyValueStore` service. Use {@link layerLocalStorage} for small
6 * origin-scoped values that should survive reloads and browser restarts, use
7 * {@link layerSessionStorage} for tab / page-session state, and use
8 * {@link layerIndexedDb} when the store should be asynchronous and backed by
9 * IndexedDB. The IndexedDB layer requires the browser `IndexedDb` service and
10 * accepts an optional database name.
11 *
12 * @since 4.0.0
13 */
14import * as Effect from "effect/Effect"
15import * as Layer from "effect/Layer"
16import * as KeyValueStore from "effect/persistence/KeyValueStore"
17import { IndexedDb } from "./IndexedDb.ts"
18
19/**
20 * Creates a `KeyValueStore` layer that uses the browser's `localStorage` API and stores values between browser sessions.
21 *
22 * @category layers
23 * @since 4.0.0
24 */
25export const layerLocalStorage: Layer.Layer<KeyValueStore.KeyValueStore> = KeyValueStore.layerStorage(() =>
26 globalThis.localStorage
27)
28
29/**
30 * Creates a `KeyValueStore` layer that uses the browser's `sessionStorage` API and stores values only for the current session.
31 *
32 * @category layers
33 * @since 4.0.0
34 */
35export const layerSessionStorage: Layer.Layer<KeyValueStore.KeyValueStore> = KeyValueStore.layerStorage(() =>
36 globalThis.sessionStorage
37)
38
39/**
40 * Creates a `KeyValueStore` layer backed by IndexedDB.
41 *
42 * **When to use**
43 *
44 * Use when you need persistent asynchronous IndexedDB storage for a browser
45 * `KeyValueStore` instead of the synchronous Web Storage APIs.
46 *
47 * **Details**
48 *
49 * The database name defaults to `"effect_key_value_store"`. The layer requires
50 * the `IndexedDb` service and stores string and `Uint8Array` values in the same
51 * backing object store.
52 *
53 * **Gotchas**
54 *
55 * IndexedDB may be unavailable or blocked by browser settings, private browsing,
56 * quota limits, or restricted contexts. The string and `Uint8Array` accessors do
57 * not coerce values stored with the other representation.
58 *
59 * @see {@link layerLocalStorage} for synchronous persistent Web Storage
60 * @see {@link layerSessionStorage} for synchronous tab-session Web Storage
61 *
62 * @category layers
63 * @since 4.0.0
64 */
65export const layerIndexedDb = (options?: {
66 readonly database?: string | undefined
67}): Layer.Layer<KeyValueStore.KeyValueStore, never, IndexedDb> =>
68 Layer.effect(KeyValueStore.KeyValueStore)(
69 Effect.gen(function*() {
70 const db = yield* Effect.acquireRelease(
71 openDatabase(options?.database ?? "effect_key_value_store"),
72 (db) => Effect.sync(() => db.close())
73 ).pipe(Effect.orDie)
74
75 return KeyValueStore.make({
76 clear: Effect.suspend(() => {
77 return idbWriteRequest(
78 db,
79 { method: "clear", message: "Failed to clear backing store" },
80 (store) => store.clear()
81 )
82 }),
83 get: (key: string) =>
84 Effect.map(
85 Effect.suspend(() => {
86 const store = getKvsEntriesStore(db, "readonly")
87 return idbRequest<{ key: string; value: string } | undefined>({
88 method: "get",
89 message: "Failed to get value from backing store",
90 key
91 }, () => store.get(key))
92 }),
93 (found) => typeof found?.value === "string" ? found.value : undefined
94 ),
95 getUint8Array: (key: string) =>
96 Effect.map(
97 Effect.suspend(() => {
98 const store = getKvsEntriesStore(db, "readonly")
99 return idbRequest<{ key: string; value: Uint8Array } | undefined>({
100 method: "getUint8Array",
101 message: "Failed to get value from backing store",
102 key
103 }, () => store.get(key))
104 }),
105 (found) => found?.value && found.value instanceof Uint8Array ? found.value : undefined
106 ),
107 has: (key: string) =>
108 Effect.map(
109 Effect.suspend(() => {
110 const store = getKvsEntriesStore(db, "readonly")
111 return idbRequest<number>({
112 method: "has",
113 message: "Failed to check key in backing store",
114 key
115 }, () => store.count(key))
116 }),
117 (count) => count > 0
118 ),
119 set: (key: string, value: string | Uint8Array) =>
120 Effect.asVoid(Effect.suspend(() => {
121 return idbWriteRequest(
122 db,
123 { method: "set", message: "Failed to set value in backing store", key },
124 (store) => store.put({ key, value })
125 )
126 })),
127 size: Effect.suspend(() => {
128 const store = getKvsEntriesStore(db, "readonly")
129 return idbRequest<number>(
130 { method: "size", message: "Failed to get backing store size" },
131 () => store.count()
132 )
133 }),
134 remove: (key: string) =>
135 Effect.asVoid(Effect.suspend(() => {
136 return idbWriteRequest(
137 db,
138 { method: "remove", message: "Failed to remove value from backing store", key },
139 (store) => store.delete(key)
140 )
141 }))
142 })
143 })
144 )
145
146const databaseVersion = 1
147const entriesStoreName = "entries"
148const openDatabase = Effect.fnUntraced(function*(database: string) {
149 const idb = (yield* IndexedDb).indexedDB
150 const openRequest = yield* Effect.try({
151 try: () => idb.open(database, databaseVersion),
152 catch: (cause) =>
153 new KeyValueStore.KeyValueStoreError({
154 method: "open",
155 message: "Failed to open backing store database",
156 cause
157 })
158 })
159 openRequest.onupgradeneeded = () => {
160 const db = openRequest.result
161 if (!db.objectStoreNames.contains(entriesStoreName)) {
162 db.createObjectStore(entriesStoreName, { keyPath: "key" })
163 }
164 }
165 return yield* idbRequest({ method: "open", message: "Failed to open backing store database" }, () => openRequest)
166})
167
168const idbRequest = <A>(
169 failArgs: { method: string; message: string; key?: string },
170 evaluate: () => IDBRequest<A>
171): Effect.Effect<A, KeyValueStore.KeyValueStoreError> =>
172 Effect.callback<A, KeyValueStore.KeyValueStoreError>((resume) => {
173 const request = evaluate()
174 if (request.readyState === "done") {
175 return resume(Effect.succeed(request.result))
176 }
177 request.onsuccess = () => {
178 resume(Effect.succeed(request.result))
179 }
180 request.onerror = () =>
181 resume(Effect.fail(
182 new KeyValueStore.KeyValueStoreError({
183 ...failArgs,
184 cause: request.error
185 })
186 ))
187 })
188
189const idbWriteRequest = <A>(
190 db: IDBDatabase,
191 failArgs: { method: string; message: string; key?: string },
192 evaluate: (store: IDBObjectStore) => IDBRequest<A>
193): Effect.Effect<A, KeyValueStore.KeyValueStoreError> =>
194 Effect.callback<A, KeyValueStore.KeyValueStoreError>((resume) => {
195 const transaction = db.transaction(entriesStoreName, "readwrite")
196 const request = evaluate(transaction.objectStore(entriesStoreName))
197 let result: A
198 let done = false
199
200 const fail = (cause: unknown) => {
201 if (done) return
202 done = true
203 resume(Effect.fail(new KeyValueStore.KeyValueStoreError({ ...failArgs, cause })))
204 }
205
206 if (request.readyState === "done") {
207 result = request.result
208 } else {
209 request.onsuccess = () => {
210 result = request.result
211 }
212 request.onerror = () => fail(request.error)
213 }
214
215 transaction.oncomplete = () => {
216 if (done) return
217 done = true
218 resume(Effect.succeed(result!))
219 }
220 transaction.onerror = () => fail(transaction.error)
221 transaction.onabort = () => fail(transaction.error)
222
223 return Effect.sync(() => {
224 if (!done) transaction.abort()
225 })
226 })
227
228const getKvsEntriesStore = (db: IDBDatabase, mode: IDBTransactionMode) => {
229 const transaction = db.transaction(entriesStoreName, mode)
230 return transaction.objectStore(entriesStoreName)
231}