import * as BrowserPersistence from "@effect/platform-browser/BrowserPersistence" import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import { TestClock } from "effect/testing" import * as Persistence from "effect/unstable/persistence/Persistence" import { indexedDB as fakeIndexedDb } from "fake-indexeddb" const defaultDatabase = "effect_persistence" const customDatabase = "effect_persistence_custom" interface EntryRow { readonly storeId: string readonly id: string readonly value: object readonly expires: number | null } let previousIndexedDb: unknown beforeEach(() => { previousIndexedDb = Reflect.get(globalThis, "indexedDB") Reflect.set(globalThis, "indexedDB", fakeIndexedDb) }) afterEach(() => { fakeIndexedDb.deleteDatabase(defaultDatabase) fakeIndexedDb.deleteDatabase(customDatabase) if (previousIndexedDb === undefined) { Reflect.deleteProperty(globalThis, "indexedDB") return } Reflect.set(globalThis, "indexedDB", previousIndexedDb) }) describe.sequential("BrowserPersistence", () => { it.effect("set + get", () => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const store = yield* backing.make("users") yield* store.set("key1", { name: "Alice" }, undefined) const value = yield* store.get("key1") assert.deepStrictEqual(value, { name: "Alice" }) }).pipe( Effect.provide(BrowserPersistence.layerBackingIndexedDb()) )) it.effect("setMany + getMany preserves order, missing keys, and duplicates", () => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const store = yield* backing.make("users") yield* store.setMany([ ["key1", { name: "Alice" }, undefined], ["key2", { name: "Bob" }, undefined], ["key3", { name: "Charlie" }, undefined] ]) const values = yield* store.getMany(["key3", "missing", "key1", "key1", "key2"]) assert.deepStrictEqual(values, [ { name: "Charlie" }, undefined, { name: "Alice" }, { name: "Alice" }, { name: "Bob" } ]) }).pipe( Effect.provide(BrowserPersistence.layerBackingIndexedDb()) )) it.effect("remove", () => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const store = yield* backing.make("users") yield* store.setMany([ ["key1", { name: "Alice" }, undefined], ["key2", { name: "Bob" }, undefined] ]) yield* store.remove("key1") const values = yield* store.getMany(["key1", "key2"]) assert.deepStrictEqual(values, [undefined, { name: "Bob" }]) }).pipe( Effect.provide(BrowserPersistence.layerBackingIndexedDb()) )) it.effect("clear only affects the current storeId", () => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const storeA = yield* backing.make("store-a") const storeB = yield* backing.make("store-b") yield* storeA.set("shared", { owner: "A" }, undefined) yield* storeA.set("only-a", { owner: "A" }, undefined) yield* storeB.set("shared", { owner: "B" }, undefined) yield* storeA.clear assert.deepStrictEqual(yield* storeA.get("shared"), undefined) assert.deepStrictEqual(yield* storeA.get("only-a"), undefined) assert.deepStrictEqual(yield* storeB.get("shared"), { owner: "B" }) }).pipe( Effect.provide(BrowserPersistence.layerBackingIndexedDb()) )) it.effect("TTL expiry performs lazy deletion", () => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const store = yield* backing.make("users") yield* store.set("ttl-key", { name: "expiring" }, Duration.seconds(10)) const rowBefore = yield* getRawEntry(defaultDatabase, "users", "ttl-key").pipe(Effect.orDie) assert.isTrue(rowBefore !== undefined) yield* TestClock.adjust(Duration.seconds(11)) const expired = yield* store.get("ttl-key") assert.deepStrictEqual(expired, undefined) const rowAfter = yield* getRawEntry(defaultDatabase, "users", "ttl-key").pipe(Effect.orDie) assert.deepStrictEqual(rowAfter, undefined) }).pipe( Effect.provide(BrowserPersistence.layerBackingIndexedDb()) )) it.effect("store isolation across storeIds", () => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const storeA = yield* backing.make("store-a") const storeB = yield* backing.make("store-b") yield* storeA.set("same-key", { value: "A" }, undefined) yield* storeB.set("same-key", { value: "B" }, undefined) yield* storeA.remove("same-key") assert.deepStrictEqual(yield* storeA.get("same-key"), undefined) assert.deepStrictEqual(yield* storeB.get("same-key"), { value: "B" }) }).pipe( Effect.provide(BrowserPersistence.layerBackingIndexedDb()) )) it.effect("custom database option", () => Effect.gen(function*() { yield* withStore("users", undefined, (store) => store.set("shared", { database: "default" }, undefined)) yield* withStore("users", { database: customDatabase }, (store) => store.set("shared", { database: "custom" }, undefined)) const fromDefault = yield* withStore("users", undefined, (store) => store.get("shared")) const fromCustom = yield* withStore("users", { database: customDatabase }, (store) => store.get("shared")) assert.deepStrictEqual(fromDefault, { database: "default" }) assert.deepStrictEqual(fromCustom, { database: "custom" }) })) }) const withStore = ( storeId: string, options: { readonly database?: string | undefined } | undefined, f: (store: Persistence.BackingPersistenceStore) => Effect.Effect ) => Effect.gen(function*() { const backing = yield* Persistence.BackingPersistence const store = yield* backing.make(storeId) return yield* f(store) }).pipe(Effect.provide(BrowserPersistence.layerBackingIndexedDb(options))) const withDatabase = ( database: string, f: (db: IDBDatabase) => Effect.Effect ): Effect.Effect => Effect.acquireUseRelease( idbRequest(() => globalThis.indexedDB.open(database, 1)), f, (db) => Effect.sync(() => db.close()) ) const getRawEntry = (database: string, storeId: string, id: string): Effect.Effect => withDatabase( database, (db) => idbRequest(() => db.transaction("entries", "readonly").objectStore("entries").get([storeId, id])) ) const idbRequest = (evaluate: () => IDBRequest): Effect.Effect => Effect.flatMap( Effect.try({ try: evaluate, catch: (cause) => cause }), (request) => Effect.callback((resume) => { if (request.readyState === "done") { resume(Effect.succeed(request.result)) return } request.onsuccess = () => resume(Effect.succeed(request.result)) request.onerror = () => resume(Effect.fail(request.error)) }) )