1import * as BrowserPersistence from "@effect/platform-browser/BrowserPersistence"
2import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"
3import * as Duration from "effect/Duration"
4import * as Effect from "effect/Effect"
5import * as Persistence from "effect/persistence/Persistence"
6import { TestClock } from "effect/testing"
7import { indexedDB as fakeIndexedDb } from "fake-indexeddb"
8
9const defaultDatabase = "effect_persistence"
10const customDatabase = "effect_persistence_custom"
11
12interface EntryRow {
13 readonly storeId: string
14 readonly id: string
15 readonly value: object
16 readonly expires: number | null
17}
18
19let previousIndexedDb: unknown
20
21beforeEach(() => {
22 previousIndexedDb = Reflect.get(globalThis, "indexedDB")
23 Reflect.set(globalThis, "indexedDB", fakeIndexedDb)
24})
25
26afterEach(() => {
27 fakeIndexedDb.deleteDatabase(defaultDatabase)
28 fakeIndexedDb.deleteDatabase(customDatabase)
29 if (previousIndexedDb === undefined) {
30 Reflect.deleteProperty(globalThis, "indexedDB")
31 return
32 }
33 Reflect.set(globalThis, "indexedDB", previousIndexedDb)
34})
35
36describe("BrowserPersistence", { concurrent: false }, () => {
37 it.effect("set + get", () =>
38 Effect.gen(function*() {
39 const backing = yield* Persistence.BackingPersistence
40 const store = yield* backing.make("users")
41
42 yield* store.set("key1", { name: "Alice" }, undefined)
43
44 const value = yield* store.get("key1")
45 assert.deepStrictEqual(value, { name: "Alice" })
46 }).pipe(
47 Effect.provide(BrowserPersistence.layerBackingIndexedDb())
48 ))
49
50 it.effect("setMany + getMany preserves order, missing keys, and duplicates", () =>
51 Effect.gen(function*() {
52 const backing = yield* Persistence.BackingPersistence
53 const store = yield* backing.make("users")
54
55 yield* store.setMany([
56 ["key1", { name: "Alice" }, undefined],
57 ["key2", { name: "Bob" }, undefined],
58 ["key3", { name: "Charlie" }, undefined]
59 ])
60
61 const values = yield* store.getMany(["key3", "missing", "key1", "key1", "key2"])
62 assert.deepStrictEqual(values, [
63 { name: "Charlie" },
64 undefined,
65 { name: "Alice" },
66 { name: "Alice" },
67 { name: "Bob" }
68 ])
69 }).pipe(
70 Effect.provide(BrowserPersistence.layerBackingIndexedDb())
71 ))
72
73 it.effect("remove", () =>
74 Effect.gen(function*() {
75 const backing = yield* Persistence.BackingPersistence
76 const store = yield* backing.make("users")
77
78 yield* store.setMany([
79 ["key1", { name: "Alice" }, undefined],
80 ["key2", { name: "Bob" }, undefined]
81 ])
82
83 yield* store.remove("key1")
84
85 const values = yield* store.getMany(["key1", "key2"])
86 assert.deepStrictEqual(values, [undefined, { name: "Bob" }])
87 }).pipe(
88 Effect.provide(BrowserPersistence.layerBackingIndexedDb())
89 ))
90
91 it.effect("clear only affects the current storeId", () =>
92 Effect.gen(function*() {
93 const backing = yield* Persistence.BackingPersistence
94 const storeA = yield* backing.make("store-a")
95 const storeB = yield* backing.make("store-b")
96
97 yield* storeA.set("shared", { owner: "A" }, undefined)
98 yield* storeA.set("only-a", { owner: "A" }, undefined)
99 yield* storeB.set("shared", { owner: "B" }, undefined)
100
101 yield* storeA.clear
102
103 assert.deepStrictEqual(yield* storeA.get("shared"), undefined)
104 assert.deepStrictEqual(yield* storeA.get("only-a"), undefined)
105 assert.deepStrictEqual(yield* storeB.get("shared"), { owner: "B" })
106 }).pipe(
107 Effect.provide(BrowserPersistence.layerBackingIndexedDb())
108 ))
109
110 it.effect("TTL expiry performs lazy deletion", () =>
111 Effect.gen(function*() {
112 const backing = yield* Persistence.BackingPersistence
113 const store = yield* backing.make("users")
114
115 yield* store.set("ttl-key", { name: "expiring" }, Duration.seconds(10))
116
117 const rowBefore = yield* getRawEntry(defaultDatabase, "users", "ttl-key").pipe(Effect.orDie)
118 assert.isTrue(rowBefore !== undefined)
119
120 yield* TestClock.adjust(Duration.seconds(11))
121
122 const expired = yield* store.get("ttl-key")
123 assert.deepStrictEqual(expired, undefined)
124
125 const rowAfter = yield* getRawEntry(defaultDatabase, "users", "ttl-key").pipe(Effect.orDie)
126 assert.deepStrictEqual(rowAfter, undefined)
127 }).pipe(
128 Effect.provide(BrowserPersistence.layerBackingIndexedDb())
129 ))
130
131 it.effect("store isolation across storeIds", () =>
132 Effect.gen(function*() {
133 const backing = yield* Persistence.BackingPersistence
134 const storeA = yield* backing.make("store-a")
135 const storeB = yield* backing.make("store-b")
136
137 yield* storeA.set("same-key", { value: "A" }, undefined)
138 yield* storeB.set("same-key", { value: "B" }, undefined)
139
140 yield* storeA.remove("same-key")
141
142 assert.deepStrictEqual(yield* storeA.get("same-key"), undefined)
143 assert.deepStrictEqual(yield* storeB.get("same-key"), { value: "B" })
144 }).pipe(
145 Effect.provide(BrowserPersistence.layerBackingIndexedDb())
146 ))
147
148 it.effect("custom database option", () =>
149 Effect.gen(function*() {
150 yield* withStore("users", undefined, (store) => store.set("shared", { database: "default" }, undefined))
151
152 yield* withStore("users", { database: customDatabase }, (store) =>
153 store.set("shared", { database: "custom" }, undefined))
154
155 const fromDefault = yield* withStore("users", undefined, (store) =>
156 store.get("shared"))
157 const fromCustom = yield* withStore("users", { database: customDatabase }, (store) =>
158 store.get("shared"))
159
160 assert.deepStrictEqual(fromDefault, { database: "default" })
161 assert.deepStrictEqual(fromCustom, { database: "custom" })
162 }))
163})
164
165const withStore = <A>(
166 storeId: string,
167 options: {
168 readonly database?: string | undefined
169 } | undefined,
170 f: (store: Persistence.BackingPersistenceStore) => Effect.Effect<A, Persistence.PersistenceError>
171) =>
172 Effect.gen(function*() {
173 const backing = yield* Persistence.BackingPersistence
174 const store = yield* backing.make(storeId)
175 return yield* f(store)
176 }).pipe(Effect.provide(BrowserPersistence.layerBackingIndexedDb(options)))
177
178const withDatabase = <A>(
179 database: string,
180 f: (db: IDBDatabase) => Effect.Effect<A, unknown>
181): Effect.Effect<A, unknown> =>
182 Effect.acquireUseRelease(
183 idbRequest(() => globalThis.indexedDB.open(database, 1)),
184 f,
185 (db) => Effect.sync(() => db.close())
186 )
187
188const getRawEntry = (database: string, storeId: string, id: string): Effect.Effect<EntryRow | undefined, unknown> =>
189 withDatabase(
190 database,
191 (db) => idbRequest(() => db.transaction("entries", "readonly").objectStore("entries").get([storeId, id]))
192 )
193
194const idbRequest = <A>(evaluate: () => IDBRequest<A>): Effect.Effect<A, unknown> =>
195 Effect.flatMap(
196 Effect.try({
197 try: evaluate,
198 catch: (cause) => cause
199 }),
200 (request) =>
201 Effect.callback<A, unknown>((resume) => {
202 if (request.readyState === "done") {
203 resume(Effect.succeed(request.result))
204 return
205 }
206 request.onsuccess = () => resume(Effect.succeed(request.result))
207 request.onerror = () => resume(Effect.fail(request.error))
208 })
209 )