1/**
2 * Builds and opens typed IndexedDB databases from versioned schema migrations.
3 *
4 * This module turns an `IndexedDbVersion` migration chain into an
5 * `IndexedDbDatabase` layer. The layer opens the browser database, runs any
6 * pending upgrade migrations, provides a query builder for the current schema,
7 * and exposes a `rebuild` effect that deletes and reopens the database.
8 * Migration transactions can create or delete object stores and indexes, and
9 * database failures are represented as `IndexedDbDatabaseError` values.
10 *
11 * @since 4.0.0
12 */
13import * as Context from "effect/Context"
14import * as Data from "effect/Data"
15import * as Effect from "effect/Effect"
16import * as Effectable from "effect/Effectable"
17import * as Fiber from "effect/Fiber"
18import * as Layer from "effect/Layer"
19import * as MutableRef from "effect/MutableRef"
20import * as Reactivity from "effect/reactivity/Reactivity"
21import * as Semaphore from "effect/Semaphore"
22import * as IndexedDb from "./IndexedDb.ts"
23import * as IndexedDbQueryBuilder from "./IndexedDbQueryBuilder.ts"
24import type * as IndexedDbTable from "./IndexedDbTable.ts"
25import type * as IndexedDbVersion from "./IndexedDbVersion.ts"
26
27const TypeId = "~@effect/platform-browser/IndexedDbDatabase"
28const ErrorTypeId = "~@effect/platform-browser/IndexedDbDatabase/IndexedDbDatabaseError"
29
30const SchemaProto = {
31 [TypeId]: {
32 _A: (_: never) => _
33 },
34 get getQueryBuilder() {
35 const self = this as unknown as IndexedDbSchema<any, any, any>
36 return IndexedDbDatabase.useSync(({ database, IDBKeyRange, reactivity }) =>
37 IndexedDbQueryBuilder.make({
38 database,
39 IDBKeyRange,
40 tables: self.version.tables,
41 reactivity
42 })
43 )
44 },
45 ...Effectable.Prototype<IndexedDbSchema<any, any, any>>({
46 label: "IndexedDbSchema",
47 evaluate() {
48 return this.getQueryBuilder
49 }
50 }),
51 add<Version extends IndexedDbVersion.AnyWithProps>(
52 this: IndexedDbSchema<any, any, any>,
53 version: Version,
54 migrate: (
55 fromQuery: Transaction<any>,
56 toQuery: Transaction<Version>
57 ) => Effect.Effect<void, Error>
58 ) {
59 return makeMigration({
60 fromVersion: this.version,
61 version,
62 migrate,
63 previous: this
64 })
65 },
66 layer(this: IndexedDbSchema<any, any, any>, databaseName: string) {
67 return layer(databaseName, this)
68 }
69}
70
71/**
72 * String union describing the failure categories for IndexedDB database opening, migration, and schema operations.
73 *
74 * @category errors
75 * @since 4.0.0
76 */
77export type ErrorReason =
78 | "TransactionError"
79 | "MissingTable"
80 | "OpenError"
81 | "UpgradeError"
82 | "Aborted"
83 | "Blocked"
84 | "MissingIndex"
85
86/**
87 * Tagged error for IndexedDB database operations, carrying a database error reason and the original cause.
88 *
89 * @category errors
90 * @since 4.0.0
91 */
92export class IndexedDbDatabaseError extends Data.TaggedError(
93 "IndexedDbDatabaseError"
94)<{
95 reason: ErrorReason
96 cause: unknown
97}> {
98 /**
99 * Marks this value as an IndexedDB database error for runtime guards.
100 *
101 * @since 4.0.0
102 */
103 readonly [ErrorTypeId]: typeof ErrorTypeId = ErrorTypeId
104 override readonly message = this.reason
105}
106
107/**
108 * Service tag for an open IndexedDB database, its `IDBKeyRange` constructor, reactivity service, and rebuild effect.
109 *
110 * **When to use**
111 *
112 * Use when you need access to the live database service after an
113 * `IndexedDbSchema` layer has been provided, especially for `rebuild` or
114 * lower-level database primitives.
115 *
116 * **Details**
117 *
118 * `database` is a mutable reference to the current `IDBDatabase`. `IDBKeyRange`
119 * and `reactivity` are shared with query builders created from the schema.
120 *
121 * **Gotchas**
122 *
123 * `rebuild` closes and deletes the browser database, then reopens it and reruns
124 * migrations. Records not recreated by migrations are removed.
125 *
126 * @see {@link IndexedDb.IndexedDb} for the lower-level browser IndexedDB primitives
127 * @see {@link make} for creating a schema that provides this service as a layer
128 *
129 * @category services
130 * @since 4.0.0
131 */
132export class IndexedDbDatabase extends Context.Service<
133 IndexedDbDatabase,
134 {
135 readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
136 readonly IDBKeyRange: typeof globalThis.IDBKeyRange
137 readonly reactivity: Reactivity.Reactivity
138 readonly rebuild: Effect.Effect<void, IndexedDbDatabaseError>
139 }
140>()(TypeId) {}
141
142/**
143 * Describes an IndexedDB schema version and its migrations, and acts as an effect that yields a query builder for the target version.
144 *
145 * @category models
146 * @since 4.0.0
147 */
148export interface IndexedDbSchema<
149 in out FromVersion extends IndexedDbVersion.AnyWithProps,
150 in out ToVersion extends IndexedDbVersion.AnyWithProps,
151 out Error = never
152> extends
153 Effect.Effect<
154 IndexedDbQueryBuilder.IndexedDbQueryBuilder<ToVersion>,
155 never,
156 IndexedDbDatabase
157 >
158{
159 new(_: never): {}
160
161 readonly previous: [FromVersion] extends [never] ? undefined
162 : IndexedDbSchema<never, FromVersion, Error>
163 readonly fromVersion: FromVersion
164 readonly version: ToVersion
165
166 readonly migrate: [FromVersion] extends [never] ? (query: Transaction<ToVersion>) => Effect.Effect<void, Error>
167 : (
168 fromQuery: Transaction<FromVersion>,
169 toQuery: Transaction<ToVersion>
170 ) => Effect.Effect<void, Error>
171
172 readonly add: <Version extends IndexedDbVersion.AnyWithProps, MigrationError>(
173 version: Version,
174 migrate: (
175 fromQuery: Transaction<ToVersion>,
176 toQuery: Transaction<Version>
177 ) => Effect.Effect<void, MigrationError>
178 ) => IndexedDbSchema<ToVersion, Version, MigrationError | Error>
179
180 readonly getQueryBuilder: Effect.Effect<
181 IndexedDbQueryBuilder.IndexedDbQueryBuilder<ToVersion>,
182 never,
183 IndexedDbDatabase
184 >
185
186 readonly layer: (
187 databaseName: string
188 ) => Layer.Layer<
189 IndexedDbDatabase,
190 IndexedDbDatabaseError,
191 IndexedDb.IndexedDb
192 >
193}
194
195/**
196 * Query builder available during a database migration, extended with object-store and index management helpers for the active `IDBTransaction`.
197 *
198 * @category models
199 * @since 4.0.0
200 */
201export interface Transaction<
202 Source extends IndexedDbVersion.AnyWithProps = never
203> extends Omit<IndexedDbQueryBuilder.IndexedDbQueryBuilder<Source>, "transaction"> {
204 readonly transaction: globalThis.IDBTransaction
205
206 readonly createObjectStore: <
207 A extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
208 >(
209 table: A
210 ) => Effect.Effect<globalThis.IDBObjectStore, IndexedDbDatabaseError>
211
212 readonly deleteObjectStore: <
213 A extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
214 >(
215 table: A
216 ) => Effect.Effect<void, IndexedDbDatabaseError>
217
218 readonly createIndex: <
219 Name extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
220 >(
221 table: Name,
222 indexName: IndexFromTableName<Source, Name>,
223 options?: IDBIndexParameters
224 ) => Effect.Effect<globalThis.IDBIndex, IndexedDbDatabaseError>
225
226 readonly deleteIndex: <
227 Name extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
228 >(
229 table: Name,
230 indexName: IndexFromTableName<Source, Name>
231 ) => Effect.Effect<void, IndexedDbDatabaseError>
232}
233
234/**
235 * Extracts the string-literal index names defined by an `IndexedDbTable`.
236 *
237 * @category utility types
238 * @since 4.0.0
239 */
240export type IndexFromTable<Table extends IndexedDbTable.AnyWithProps> = IsStringLiteral<
241 Extract<keyof IndexedDbTable.Indexes<Table>, string>
242> extends true ? Extract<keyof IndexedDbTable.Indexes<Table>, string>
243 : never
244
245/**
246 * Extracts the valid index names for a table name within an IndexedDB version.
247 *
248 * @category utility types
249 * @since 4.0.0
250 */
251export type IndexFromTableName<
252 Version extends IndexedDbVersion.AnyWithProps,
253 Table extends string
254> = IndexFromTable<
255 IndexedDbTable.WithName<IndexedDbVersion.Tables<Version>, Table>
256>
257
258/**
259 * Type-erased IndexedDB schema shape used when traversing schema migration chains.
260 *
261 * @category models
262 * @since 4.0.0
263 */
264export interface Any {
265 readonly previous?: Any | undefined
266 readonly layer: (
267 databaseName: string
268 ) => Layer.Layer<
269 IndexedDbDatabase,
270 IndexedDbDatabaseError,
271 IndexedDb.IndexedDb
272 >
273}
274
275/**
276 * Type-erased `IndexedDbSchema` covering any source version, target version, and migration error type.
277 *
278 * @category models
279 * @since 4.0.0
280 */
281export type AnySchema = IndexedDbSchema<
282 IndexedDbVersion.AnyWithProps,
283 IndexedDbVersion.AnyWithProps,
284 any
285>
286
287/**
288 * Creates the initial `IndexedDbSchema` from a version and an initialization migration run during database upgrade.
289 *
290 * @category constructors
291 * @since 4.0.0
292 */
293export const make = <
294 InitialVersion extends IndexedDbVersion.AnyWithProps,
295 Error
296>(
297 initialVersion: InitialVersion,
298 init: (toQuery: Transaction<InitialVersion>) => Effect.Effect<void, Error>
299): IndexedDbSchema<never, InitialVersion, Error> => {
300 // oxlint-disable-next-line typescript/no-extraneous-class
301 function Initial() {}
302 Object.setPrototypeOf(Initial, SchemaProto)
303 ;(Initial as any).version = initialVersion
304 ;(Initial as any).migrate = init
305 return Initial as any
306}
307
308const makeMigration = <
309 FromVersion extends IndexedDbVersion.AnyWithProps,
310 ToVersion extends IndexedDbVersion.AnyWithProps,
311 Error
312>(options: {
313 readonly previous:
314 | IndexedDbSchema<FromVersion, ToVersion, Error>
315 | IndexedDbSchema<never, FromVersion, Error>
316 readonly fromVersion: FromVersion
317 readonly version: ToVersion
318 readonly migrate: (
319 fromQuery: Transaction<FromVersion>,
320 toQuery: Transaction<ToVersion>
321 ) => Effect.Effect<void, Error>
322}): IndexedDbSchema<FromVersion, ToVersion, Error> => {
323 // oxlint-disable-next-line typescript/no-extraneous-class
324 function Migration() {}
325 Object.setPrototypeOf(Migration, SchemaProto)
326 ;(Migration as any).previous = options.previous
327 ;(Migration as any).fromVersion = options.fromVersion
328 ;(Migration as any).version = options.version
329 ;(Migration as any).migrate = options.migrate
330
331 return Migration as any
332}
333
334const layer = <DatabaseName extends string>(
335 databaseName: DatabaseName,
336 migration: Any
337) =>
338 Layer.effect(
339 IndexedDbDatabase,
340 Effect.gen(function*() {
341 const { IDBKeyRange, indexedDB } = yield* IndexedDb.IndexedDb
342 const reactivity = yield* Reactivity.Reactivity
343 const context = yield* Effect.context()
344 const runForkWith = Effect.runForkWith(context)
345
346 let oldVersion = 0
347 const migrations: Array<Any> = []
348 let current = migration
349 while (current) {
350 migrations.unshift(current)
351 current = (current as unknown as AnySchema).previous as any
352 }
353
354 const version = migrations.length
355 const database = MutableRef.make<globalThis.IDBDatabase>(null as any)
356
357 const open = Effect.callback<
358 void,
359 IndexedDbDatabaseError
360 >((resume) => {
361 const request = indexedDB.open(databaseName, version)
362
363 request.onblocked = (event) => {
364 resume(
365 Effect.fail(
366 new IndexedDbDatabaseError({
367 reason: "Blocked",
368 cause: event
369 })
370 )
371 )
372 }
373
374 request.onerror = (event) => {
375 const idbRequest = event.target as IDBRequest<IDBDatabase>
376
377 resume(
378 Effect.fail(
379 new IndexedDbDatabaseError({
380 reason: "OpenError",
381 cause: idbRequest.error
382 })
383 )
384 )
385 }
386
387 let fiber: Fiber.Fiber<void, IndexedDbDatabaseError> | undefined
388 request.onupgradeneeded = (event) => {
389 const idbRequest = event.target as IDBRequest<IDBDatabase>
390 const db = idbRequest.result
391 const transaction = idbRequest.transaction
392 oldVersion = event.oldVersion
393
394 MutableRef.set(database, db)
395
396 if (transaction === null) {
397 return resume(
398 Effect.fail(
399 new IndexedDbDatabaseError({
400 reason: "TransactionError",
401 cause: null
402 })
403 )
404 )
405 }
406
407 transaction.onabort = (event) => {
408 resume(
409 Effect.fail(
410 new IndexedDbDatabaseError({
411 reason: "Aborted",
412 cause: event
413 })
414 )
415 )
416 }
417
418 transaction.onerror = (event) => {
419 resume(
420 Effect.fail(
421 new IndexedDbDatabaseError({
422 reason: "TransactionError",
423 cause: event
424 })
425 )
426 )
427 }
428
429 const effect = Effect.forEach(
430 migrations.slice(oldVersion),
431 (untypedMigration) => {
432 if (untypedMigration.previous === undefined) {
433 const migration = untypedMigration as any as AnySchema
434 const api = makeTransactionProto({
435 database,
436 IDBKeyRange,
437 tables: migration.version.tables,
438 transaction,
439 reactivity
440 })
441 return (migration as any).migrate(api) as Effect.Effect<
442 void,
443 IndexedDbDatabaseError
444 >
445 } else if (untypedMigration.previous) {
446 const migration = untypedMigration as any as AnySchema
447 const fromApi = makeTransactionProto({
448 database,
449 IDBKeyRange,
450 tables: migration.fromVersion.tables,
451 transaction,
452 reactivity
453 })
454 const toApi = makeTransactionProto({
455 database,
456 IDBKeyRange,
457 tables: migration.version.tables,
458 transaction,
459 reactivity
460 })
461 return migration.migrate(fromApi, toApi) as Effect.Effect<
462 void,
463 IndexedDbDatabaseError
464 >
465 }
466
467 return Effect.die(new Error("Invalid migration"))
468 },
469 { discard: true }
470 ).pipe(
471 Effect.mapError(
472 (cause) =>
473 new IndexedDbDatabaseError({
474 reason: "UpgradeError",
475 cause
476 })
477 ),
478 Effect.provideService(IndexedDbQueryBuilder.IndexedDbTransaction, transaction)
479 )
480 fiber = runForkWith(effect)
481 fiber.addObserver((exit) => {
482 if (exit._tag === "Failure") {
483 transaction.abort()
484 resume(exit)
485 }
486 })
487 fiber.currentDispatcher.flush()
488 }
489
490 request.onsuccess = (event) => {
491 const idbRequest = event.target as IDBRequest<IDBDatabase>
492 const db = idbRequest.result
493 MutableRef.set(database, db)
494 if (fiber) {
495 // ensure migration errors are propagated
496 resume(Effect.asVoid(Fiber.join(fiber)))
497 } else {
498 resume(Effect.void)
499 }
500 }
501 })
502
503 yield* Effect.addFinalizer(() => {
504 database.current?.close()
505 return Effect.void
506 })
507 yield* open
508
509 const rebuildLock = Semaphore.makeUnsafe(1).withPermit
510 const rebuild = Effect.callback<void, IndexedDbDatabaseError>((resume) => {
511 database.current?.close()
512 const request = indexedDB.deleteDatabase(databaseName)
513 request.onerror = (_) => {
514 resume(
515 Effect.fail(
516 new IndexedDbDatabaseError({
517 reason: "OpenError",
518 cause: request.error
519 })
520 )
521 )
522 }
523 request.onsuccess = () => {
524 resume(Effect.void)
525 }
526 }).pipe(
527 Effect.flatMap(() => open),
528 rebuildLock
529 )
530
531 return IndexedDbDatabase.of({ database, IDBKeyRange, rebuild, reactivity })
532 })
533 ).pipe(
534 Layer.provide(Reactivity.layer)
535 )
536
537// -----------------------------------------------------------------------------
538// Internal
539// -----------------------------------------------------------------------------
540
541type IsStringLiteral<T> = T extends string ? string extends T ? false
542 : true
543 : false
544
545const makeTransactionProto = <Source extends IndexedDbVersion.AnyWithProps>({
546 IDBKeyRange,
547 database,
548 tables,
549 transaction,
550 reactivity
551}: {
552 readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
553 readonly IDBKeyRange: typeof globalThis.IDBKeyRange
554 readonly tables: ReadonlyMap<string, IndexedDbVersion.Tables<Source>>
555 readonly transaction: globalThis.IDBTransaction
556 readonly reactivity: Reactivity.Reactivity
557}): Transaction<Source> => {
558 const migration = IndexedDbQueryBuilder.make({
559 database,
560 IDBKeyRange,
561 tables,
562 reactivity
563 }) as any
564
565 migration.transaction = transaction
566
567 migration.createObjectStore = Effect.fnUntraced(function*(table: string) {
568 const createTable = yield* Effect.fromNullishOr(tables.get(table)).pipe(
569 Effect.mapError(
570 (cause) =>
571 new IndexedDbDatabaseError({
572 reason: "MissingTable",
573 cause
574 })
575 )
576 )
577
578 return yield* Effect.try({
579 try: () =>
580 database.current.createObjectStore(createTable.tableName, {
581 keyPath: createTable.keyPath,
582 autoIncrement: createTable.autoIncrement
583 }),
584 catch: (cause) =>
585 new IndexedDbDatabaseError({
586 reason: "TransactionError",
587 cause
588 })
589 })
590 })
591
592 migration.deleteObjectStore = Effect.fnUntraced(function*(table: string) {
593 const createTable = yield* Effect.fromNullishOr(tables.get(table)).pipe(
594 Effect.mapError(
595 (cause) =>
596 new IndexedDbDatabaseError({
597 reason: "MissingTable",
598 cause
599 })
600 )
601 )
602
603 return yield* Effect.try({
604 try: () => database.current.deleteObjectStore(createTable.tableName),
605 catch: (cause) =>
606 new IndexedDbDatabaseError({
607 reason: "TransactionError",
608 cause
609 })
610 })
611 })
612
613 migration.createIndex = Effect.fnUntraced(function*(
614 table: string,
615 indexName: string,
616 options?: IDBIndexParameters
617 ) {
618 const store = transaction.objectStore(table)
619 const sourceTable = tables.get(table)!
620
621 const keyPath = yield* Effect.fromNullishOr(
622 sourceTable.indexes[indexName]
623 ).pipe(
624 Effect.mapError(
625 (cause) =>
626 new IndexedDbDatabaseError({
627 reason: "MissingIndex",
628 cause
629 })
630 )
631 )
632
633 return yield* Effect.try({
634 try: () => store.createIndex(indexName, keyPath, options),
635 catch: (cause) =>
636 new IndexedDbDatabaseError({
637 reason: "TransactionError",
638 cause
639 })
640 })
641 })
642
643 migration.deleteIndex = (table: string, indexName: string) =>
644 Effect.try({
645 try: () => transaction.objectStore(table).deleteIndex(indexName),
646 catch: (cause) =>
647 new IndexedDbDatabaseError({
648 reason: "TransactionError",
649 cause
650 })
651 })
652
653 return migration
654}