1import { IndexedDb, IndexedDbDatabase, IndexedDbTable, IndexedDbVersion } from "@effect/platform-browser"
2import { afterEach, assert, describe, it } from "@effect/vitest"
3import { Effect, Layer, Result, Schema } from "effect"
4import { IDBKeyRange, indexedDB } from "fake-indexeddb"
5
6const databaseName = "db"
7
8const layerFakeIndexedDb = Layer.succeed(
9 IndexedDb.IndexedDb,
10 IndexedDb.make({ indexedDB, IDBKeyRange })
11)
12
13const provideMigration = (database: IndexedDbDatabase.Any) =>
14 Effect.provide(
15 database.layer(databaseName).pipe(Layer.provide(layerFakeIndexedDb))
16 )
17
18afterEach(() => {
19 indexedDB.deleteDatabase(databaseName)
20})
21
22describe("IndexedDbDatabase", { concurrent: false }, () => {
23 it.effect("insert and read todos", () => {
24 const Table = IndexedDbTable.make({
25 name: "todo",
26 schema: Schema.Struct({
27 id: Schema.Number,
28 title: Schema.String,
29 completed: Schema.Boolean
30 }),
31 keyPath: "id",
32 indexes: { titleIndex: "title" }
33 })
34
35 const V1 = IndexedDbVersion.make(Table)
36
37 class Db extends IndexedDbDatabase.make(V1, (api) =>
38 Effect.gen(function*() {
39 yield* api.createObjectStore("todo")
40 yield* api.createIndex("todo", "titleIndex")
41 yield* api
42 .from("todo")
43 .insert({ id: 1, title: "test", completed: false })
44 }))
45 {}
46
47 return Effect.gen(function*() {
48 const db = yield* IndexedDbDatabase.IndexedDbDatabase
49 const api = yield* Db.getQueryBuilder
50 let todo = yield* api.from("todo").select()
51
52 const name = yield* api.use((database) => database.name)
53 const version = yield* api.use((database) => database.version)
54 const objectStoreNames = yield* api.use(
55 (database) => database.objectStoreNames
56 )
57 const indexNames = yield* api.use(
58 (database) => database.transaction("todo").objectStore("todo").indexNames
59 )
60 const index = yield* api.use((database) => database.transaction("todo").objectStore("todo").index("titleIndex"))
61
62 assert.equal(name, "db")
63 assert.equal(version, 1)
64 assert.deepStrictEqual(todo, [
65 { id: 1, title: "test", completed: false }
66 ])
67 assert.deepStrictEqual(Array.from(objectStoreNames), ["todo"])
68 assert.deepStrictEqual(Array.from(indexNames), ["titleIndex"])
69 assert.deepStrictEqual(index.keyPath, "title")
70
71 yield* api.from("todo").insert({ id: 2, title: "test2", completed: false })
72
73 yield* db.rebuild
74 todo = yield* api.from("todo").select()
75 assert.deepStrictEqual(todo, [
76 { id: 1, title: "test", completed: false }
77 ])
78 }).pipe(provideMigration(Db))
79 })
80
81 it.effect("transaction", () => {
82 const Table = IndexedDbTable.make({
83 name: "todo",
84 schema: Schema.Struct({
85 id: Schema.Number,
86 title: Schema.String,
87 completed: Schema.Boolean
88 }),
89 keyPath: "id",
90 indexes: { titleIndex: "title" }
91 })
92
93 const Table2 = IndexedDbTable.make({
94 name: "user",
95 schema: Schema.Struct({
96 id: Schema.Number,
97 name: Schema.String
98 }),
99 keyPath: "id"
100 })
101
102 const Db = IndexedDbVersion.make(Table, Table2)
103
104 class Migration extends IndexedDbDatabase.make(Db, (api) =>
105 Effect.gen(function*() {
106 yield* api.createObjectStore("todo")
107 yield* api.createIndex("todo", "titleIndex")
108 yield* api
109 .from("todo")
110 .insert({ id: 1, title: "test", completed: false })
111 }))
112 {}
113
114 return Effect.gen(function*() {
115 const api = yield* Migration.getQueryBuilder
116 yield* api.withTransaction({ tables: ["todo"], mode: "readwrite" })(
117 api.from("todo").insert({ id: 2, title: "test2", completed: false })
118 )
119
120 const todo = yield* api.from("todo").select()
121 assert.deepStrictEqual(todo, [
122 { id: 1, title: "test", completed: false },
123 {
124 id: 2,
125 title: "test2",
126 completed: false
127 }
128 ])
129 }).pipe(provideMigration(Migration))
130 })
131
132 it.effect("migration sequence", () => {
133 const Table1 = IndexedDbTable.make({
134 name: "todo",
135 schema: Schema.Struct({
136 id: Schema.Number,
137 title: Schema.String,
138 completed: Schema.Boolean
139 }),
140 keyPath: "id",
141 indexes: { titleIndex: "title" }
142 })
143
144 const Table2 = IndexedDbTable.make({
145 name: "todo",
146 schema: Schema.Struct({
147 uuid: Schema.String.check(Schema.isUUID(4)),
148 title: Schema.String,
149 completed: Schema.Boolean
150 }),
151 keyPath: "uuid"
152 })
153
154 const V1 = IndexedDbVersion.make(Table1)
155 const V2 = IndexedDbVersion.make(Table2)
156 const uuid = "9535a059-a61f-42e1-a2e0-35ec87203c24"
157
158 class Db extends IndexedDbDatabase.make(V1, (api) =>
159 Effect.gen(function*() {
160 yield* api.createObjectStore("todo")
161 yield* api.createIndex("todo", "titleIndex")
162 yield* api
163 .from("todo")
164 .insert({ id: 1, title: "test", completed: false })
165 })).add(V2, (from, to) =>
166 Effect.gen(function*() {
167 const todo = yield* from.from("todo").select()
168 yield* from.deleteIndex("todo", "titleIndex")
169 yield* from.deleteObjectStore("todo")
170 yield* to.createObjectStore("todo")
171 yield* to.from("todo").insertAll(
172 todo.map((t) => ({
173 uuid,
174 title: t.title,
175 completed: t.completed
176 }))
177 )
178 }))
179 {}
180
181 return Effect.gen(function*() {
182 const api = yield* Db.getQueryBuilder
183 const todo = yield* api.from("todo").select()
184 const name = yield* api.use((database) => database.name)
185 const version = yield* api.use((database) => database.version)
186 const objectStoreNames = yield* api.use(
187 (database) => database.objectStoreNames
188 )
189 const indexNames = yield* api.use(
190 (database) => database.transaction("todo").objectStore("todo").indexNames
191 )
192
193 assert.equal(name, "db")
194 assert.equal(version, 2)
195 assert.deepStrictEqual(todo, [{ uuid, title: "test", completed: false }])
196 assert.deepStrictEqual(Array.from(objectStoreNames), ["todo"])
197 assert.deepStrictEqual(Array.from(indexNames), [])
198 }).pipe(provideMigration(Db))
199 })
200
201 it.effect("delete object store migration", () => {
202 const Table1 = IndexedDbTable.make({
203 name: "todo",
204 schema: Schema.Struct({
205 id: Schema.Number,
206 title: Schema.String,
207 completed: Schema.Boolean
208 }),
209 keyPath: "id"
210 })
211
212 const Table2 = IndexedDbTable.make({
213 name: "user",
214 schema: Schema.Struct({
215 userId: Schema.Number,
216 name: Schema.String,
217 email: Schema.String
218 }),
219 keyPath: "userId"
220 })
221
222 const V1 = IndexedDbVersion.make(Table1)
223 const V2 = IndexedDbVersion.make(Table2, Table1)
224
225 class Migration extends IndexedDbDatabase.make(V1, (api) => api.createObjectStore("todo")).add(
226 V2,
227 Effect.fnUntraced(function*(from, to) {
228 yield* from.deleteObjectStore("todo")
229 yield* to.createObjectStore("user")
230 yield* to.from("user").insert({
231 userId: 1,
232 name: "John Doe",
233 email: "john.doe@example.com"
234 })
235 })
236 ) {}
237
238 return Effect.gen(function*() {
239 const api = yield* Migration.getQueryBuilder
240 const user = yield* api.from("user").select()
241
242 const name = yield* api.use((database) => database.name)
243 const version = yield* api.use((database) => database.version)
244 const objectStoreNames = yield* api.use((database) => database.objectStoreNames)
245 assert.equal(name, "db")
246 assert.equal(version, 2)
247 assert.deepStrictEqual(user, [
248 { userId: 1, name: "John Doe", email: "john.doe@example.com" }
249 ])
250 assert.deepStrictEqual(Array.from(objectStoreNames), ["user"])
251 }).pipe(provideMigration(Migration))
252 })
253
254 it.effect("aborts the versionchange transaction when a migration fails", () => {
255 const Table = IndexedDbTable.make({
256 name: "entries",
257 schema: Schema.Struct({ id: Schema.Number }),
258 keyPath: "id"
259 })
260 const Version = IndexedDbVersion.make(Table)
261 class Database extends IndexedDbDatabase.make(Version, (transaction) =>
262 Effect.andThen(
263 transaction.createObjectStore("entries"),
264 Effect.fail("migration failed")
265 ))
266 {}
267 const open = Effect.callback<IDBDatabase, Error>((resume) => {
268 const request = indexedDB.open(databaseName)
269 request.onerror = () => resume(Effect.fail(request.error ?? new Error("IndexedDB open request failed")))
270 request.onblocked = () => resume(Effect.fail(new Error("IndexedDB open request blocked")))
271 request.onsuccess = () => resume(Effect.succeed(request.result))
272 })
273
274 return Effect.gen(function*() {
275 const migration = yield* Effect.result(
276 Effect.service(IndexedDbDatabase.IndexedDbDatabase).pipe(provideMigration(Database))
277 )
278 assert.isTrue(Result.isFailure(migration))
279
280 const database = yield* open
281 assert.deepStrictEqual(Array.from(database.objectStoreNames), [])
282 database.close()
283 })
284 })
285})