| 1 | import { IndexedDb, IndexedDbDatabase, IndexedDbTable, IndexedDbVersion } from "@effect/platform-browser" |
| 2 | import { afterEach, assert, describe, it } from "@effect/vitest" |
| 3 | import { Effect, Layer, Result, Schema } from "effect" |
| 4 | import { IDBKeyRange, indexedDB } from "fake-indexeddb" |
| 5 | |
| 6 | const databaseName = "db" |
| 7 | |
| 8 | const layerFakeIndexedDb = Layer.succeed( |
| 9 | IndexedDb.IndexedDb, |
| 10 | IndexedDb.make({ indexedDB, IDBKeyRange }) |
| 11 | ) |
| 12 | |
| 13 | const provideMigration = (database: IndexedDbDatabase.Any) => |
| 14 | Effect.provide( |
| 15 | database.layer(databaseName).pipe(Layer.provide(layerFakeIndexedDb)) |
| 16 | ) |
| 17 | |
| 18 | afterEach(() => { |
| 19 | indexedDB.deleteDatabase(databaseName) |
| 20 | }) |
| 21 | |
| 22 | describe("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 | }) |