/** * Builds and opens typed IndexedDB databases from versioned schema migrations. * * This module turns an `IndexedDbVersion` migration chain into an * `IndexedDbDatabase` layer. The layer opens the browser database, runs any * pending upgrade migrations, provides a query builder for the current schema, * and exposes a `rebuild` effect that deletes and reopens the database. * Migration transactions can create or delete object stores and indexes, and * database failures are represented as `IndexedDbDatabaseError` values. * * @since 4.0.0 */ import * as Context from "effect/Context" import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as Effectable from "effect/Effectable" import * as Fiber from "effect/Fiber" import * as Layer from "effect/Layer" import * as MutableRef from "effect/MutableRef" import * as Semaphore from "effect/Semaphore" import * as Reactivity from "effect/unstable/reactivity/Reactivity" import * as IndexedDb from "./IndexedDb.ts" import * as IndexedDbQueryBuilder from "./IndexedDbQueryBuilder.ts" import type * as IndexedDbTable from "./IndexedDbTable.ts" import type * as IndexedDbVersion from "./IndexedDbVersion.ts" const TypeId = "~@effect/platform-browser/IndexedDbDatabase" const ErrorTypeId = "~@effect/platform-browser/IndexedDbDatabase/IndexedDbDatabaseError" const SchemaProto = { [TypeId]: { _A: (_: never) => _ }, ...Effectable.Prototype>({ label: "IndexedDbSchema", evaluate() { return this.getQueryBuilder } }), get getQueryBuilder() { const self = this as unknown as IndexedDbSchema return IndexedDbDatabase.useSync(({ database, IDBKeyRange, reactivity }) => IndexedDbQueryBuilder.make({ database, IDBKeyRange, tables: self.version.tables, reactivity }) ) }, add( this: IndexedDbSchema, version: Version, migrate: ( fromQuery: Transaction, toQuery: Transaction ) => Effect.Effect ) { return makeMigration({ fromVersion: this.version, version, migrate, previous: this }) }, layer(this: IndexedDbSchema, databaseName: string) { return layer(databaseName, this) } } /** * String union describing the failure categories for IndexedDB database opening, migration, and schema operations. * * @category errors * @since 4.0.0 */ export type ErrorReason = | "TransactionError" | "MissingTable" | "OpenError" | "UpgradeError" | "Aborted" | "Blocked" | "MissingIndex" /** * Tagged error for IndexedDB database operations, carrying a database error reason and the original cause. * * @category errors * @since 4.0.0 */ export class IndexedDbDatabaseError extends Data.TaggedError( "IndexedDbDatabaseError" )<{ reason: ErrorReason cause: unknown }> { /** * Marks this value as an IndexedDB database error for runtime guards. * * @since 4.0.0 */ readonly [ErrorTypeId]: typeof ErrorTypeId = ErrorTypeId override readonly message = this.reason } /** * Service tag for an open IndexedDB database, its `IDBKeyRange` constructor, reactivity service, and rebuild effect. * * **When to use** * * Use when you need access to the live database service after an * `IndexedDbSchema` layer has been provided, especially for `rebuild` or * lower-level database primitives. * * **Details** * * `database` is a mutable reference to the current `IDBDatabase`. `IDBKeyRange` * and `reactivity` are shared with query builders created from the schema. * * **Gotchas** * * `rebuild` closes and deletes the browser database, then reopens it and reruns * migrations. Records not recreated by migrations are removed. * * @see {@link IndexedDb.IndexedDb} for the lower-level browser IndexedDB primitives * @see {@link make} for creating a schema that provides this service as a layer * * @category models * @since 4.0.0 */ export class IndexedDbDatabase extends Context.Service< IndexedDbDatabase, { readonly database: MutableRef.MutableRef readonly IDBKeyRange: typeof globalThis.IDBKeyRange readonly reactivity: Reactivity.Reactivity["Service"] readonly rebuild: Effect.Effect } >()(TypeId) {} /** * Describes an IndexedDB schema version and its migrations, and acts as an effect that yields a query builder for the target version. * * @category models * @since 4.0.0 */ export interface IndexedDbSchema< in out FromVersion extends IndexedDbVersion.AnyWithProps, in out ToVersion extends IndexedDbVersion.AnyWithProps, out Error = never > extends Effect.Effect< IndexedDbQueryBuilder.IndexedDbQueryBuilder, never, IndexedDbDatabase > { new(_: never): {} readonly previous: [FromVersion] extends [never] ? undefined : IndexedDbSchema readonly fromVersion: FromVersion readonly version: ToVersion readonly migrate: [FromVersion] extends [never] ? (query: Transaction) => Effect.Effect : ( fromQuery: Transaction, toQuery: Transaction ) => Effect.Effect readonly add: ( version: Version, migrate: ( fromQuery: Transaction, toQuery: Transaction ) => Effect.Effect ) => IndexedDbSchema readonly getQueryBuilder: Effect.Effect< IndexedDbQueryBuilder.IndexedDbQueryBuilder, never, IndexedDbDatabase > readonly layer: ( databaseName: string ) => Layer.Layer< IndexedDbDatabase, IndexedDbDatabaseError, IndexedDb.IndexedDb > } /** * Query builder available during a database migration, extended with object-store and index management helpers for the active `IDBTransaction`. * * @category models * @since 4.0.0 */ export interface Transaction< Source extends IndexedDbVersion.AnyWithProps = never > extends Omit, "transaction"> { readonly transaction: globalThis.IDBTransaction readonly createObjectStore: < A extends IndexedDbTable.TableName> >( table: A ) => Effect.Effect readonly deleteObjectStore: < A extends IndexedDbTable.TableName> >( table: A ) => Effect.Effect readonly createIndex: < Name extends IndexedDbTable.TableName> >( table: Name, indexName: IndexFromTableName, options?: IDBIndexParameters ) => Effect.Effect readonly deleteIndex: < Name extends IndexedDbTable.TableName> >( table: Name, indexName: IndexFromTableName ) => Effect.Effect } /** * Extracts the string-literal index names defined by an `IndexedDbTable`. * * @category models * @since 4.0.0 */ export type IndexFromTable = IsStringLiteral< Extract, string> > extends true ? Extract, string> : never /** * Extracts the valid index names for a table name within an IndexedDB version. * * @category models * @since 4.0.0 */ export type IndexFromTableName< Version extends IndexedDbVersion.AnyWithProps, Table extends string > = IndexFromTable< IndexedDbTable.WithName, Table> > /** * Type-erased IndexedDB schema shape used when traversing schema migration chains. * * @category models * @since 4.0.0 */ export interface Any { readonly previous?: Any | undefined readonly layer: ( databaseName: string ) => Layer.Layer< IndexedDbDatabase, IndexedDbDatabaseError, IndexedDb.IndexedDb > } /** * Type-erased `IndexedDbSchema` covering any source version, target version, and migration error type. * * @category models * @since 4.0.0 */ export type AnySchema = IndexedDbSchema< IndexedDbVersion.AnyWithProps, IndexedDbVersion.AnyWithProps, any > /** * Creates the initial `IndexedDbSchema` from a version and an initialization migration run during database upgrade. * * @category constructors * @since 4.0.0 */ export const make = < InitialVersion extends IndexedDbVersion.AnyWithProps, Error >( initialVersion: InitialVersion, init: (toQuery: Transaction) => Effect.Effect ): IndexedDbSchema => { // oxlint-disable-next-line typescript/no-extraneous-class function Initial() {} Object.setPrototypeOf(Initial, SchemaProto) ;(Initial as any).version = initialVersion ;(Initial as any).migrate = init return Initial as any } const makeMigration = < FromVersion extends IndexedDbVersion.AnyWithProps, ToVersion extends IndexedDbVersion.AnyWithProps, Error >(options: { readonly previous: | IndexedDbSchema | IndexedDbSchema readonly fromVersion: FromVersion readonly version: ToVersion readonly migrate: ( fromQuery: Transaction, toQuery: Transaction ) => Effect.Effect }): IndexedDbSchema => { // oxlint-disable-next-line typescript/no-extraneous-class function Migration() {} Object.setPrototypeOf(Migration, SchemaProto) ;(Migration as any).previous = options.previous ;(Migration as any).fromVersion = options.fromVersion ;(Migration as any).version = options.version ;(Migration as any).migrate = options.migrate return Migration as any } const layer = ( databaseName: DatabaseName, migration: Any ) => Layer.effect( IndexedDbDatabase, Effect.gen(function*() { const { IDBKeyRange, indexedDB } = yield* IndexedDb.IndexedDb const reactivity = yield* Reactivity.Reactivity const context = yield* Effect.context() const runForkWith = Effect.runForkWith(context) let oldVersion = 0 const migrations: Array = [] let current = migration while (current) { migrations.unshift(current) current = (current as unknown as AnySchema).previous as any } const version = migrations.length const database = MutableRef.make(null as any) const open = Effect.callback< void, IndexedDbDatabaseError >((resume) => { const request = indexedDB.open(databaseName, version) request.onblocked = (event) => { resume( Effect.fail( new IndexedDbDatabaseError({ reason: "Blocked", cause: event }) ) ) } request.onerror = (event) => { const idbRequest = event.target as IDBRequest resume( Effect.fail( new IndexedDbDatabaseError({ reason: "OpenError", cause: idbRequest.error }) ) ) } let fiber: Fiber.Fiber | undefined request.onupgradeneeded = (event) => { const idbRequest = event.target as IDBRequest const db = idbRequest.result const transaction = idbRequest.transaction oldVersion = event.oldVersion MutableRef.set(database, db) if (transaction === null) { return resume( Effect.fail( new IndexedDbDatabaseError({ reason: "TransactionError", cause: null }) ) ) } transaction.onabort = (event) => { resume( Effect.fail( new IndexedDbDatabaseError({ reason: "Aborted", cause: event }) ) ) } transaction.onerror = (event) => { resume( Effect.fail( new IndexedDbDatabaseError({ reason: "TransactionError", cause: event }) ) ) } const effect = Effect.forEach( migrations.slice(oldVersion), (untypedMigration) => { if (untypedMigration.previous === undefined) { const migration = untypedMigration as any as AnySchema const api = makeTransactionProto({ database, IDBKeyRange, tables: migration.version.tables, transaction, reactivity }) return (migration as any).migrate(api) as Effect.Effect< void, IndexedDbDatabaseError > } else if (untypedMigration.previous) { const migration = untypedMigration as any as AnySchema const fromApi = makeTransactionProto({ database, IDBKeyRange, tables: migration.fromVersion.tables, transaction, reactivity }) const toApi = makeTransactionProto({ database, IDBKeyRange, tables: migration.version.tables, transaction, reactivity }) return migration.migrate(fromApi, toApi) as Effect.Effect< void, IndexedDbDatabaseError > } return Effect.die(new Error("Invalid migration")) }, { discard: true } ).pipe( Effect.mapError( (cause) => new IndexedDbDatabaseError({ reason: "UpgradeError", cause }) ), Effect.provideService(IndexedDbQueryBuilder.IndexedDbTransaction, transaction) ) fiber = runForkWith(effect) fiber.currentDispatcher.flush() } request.onsuccess = (event) => { const idbRequest = event.target as IDBRequest const db = idbRequest.result MutableRef.set(database, db) if (fiber) { // ensure migration errors are propagated resume(Effect.asVoid(Fiber.join(fiber))) } else { resume(Effect.void) } } }) yield* Effect.addFinalizer(() => { database.current?.close() return Effect.void }) yield* open const rebuildLock = Semaphore.makeUnsafe(1).withPermit const rebuild = Effect.callback((resume) => { database.current?.close() const request = indexedDB.deleteDatabase(databaseName) request.onerror = (_) => { resume( Effect.fail( new IndexedDbDatabaseError({ reason: "OpenError", cause: request.error }) ) ) } request.onsuccess = () => { resume(Effect.void) } }).pipe( Effect.flatMap(() => open), rebuildLock ) return IndexedDbDatabase.of({ database, IDBKeyRange, rebuild, reactivity }) }) ).pipe( Layer.provide(Reactivity.layer) ) // ----------------------------------------------------------------------------- // Internal // ----------------------------------------------------------------------------- type IsStringLiteral = T extends string ? string extends T ? false : true : false const makeTransactionProto = ({ IDBKeyRange, database, tables, transaction, reactivity }: { readonly database: MutableRef.MutableRef readonly IDBKeyRange: typeof globalThis.IDBKeyRange readonly tables: ReadonlyMap> readonly transaction: globalThis.IDBTransaction readonly reactivity: Reactivity.Reactivity["Service"] }): Transaction => { const migration = IndexedDbQueryBuilder.make({ database, IDBKeyRange, tables, reactivity }) as any migration.transaction = transaction migration.createObjectStore = Effect.fnUntraced(function*(table: string) { const createTable = yield* Effect.fromNullishOr(tables.get(table)).pipe( Effect.mapError( (cause) => new IndexedDbDatabaseError({ reason: "MissingTable", cause }) ) ) return yield* Effect.try({ try: () => database.current.createObjectStore(createTable.tableName, { keyPath: createTable.keyPath, autoIncrement: createTable.autoIncrement }), catch: (cause) => new IndexedDbDatabaseError({ reason: "TransactionError", cause }) }) }) migration.deleteObjectStore = Effect.fnUntraced(function*(table: string) { const createTable = yield* Effect.fromNullishOr(tables.get(table)).pipe( Effect.mapError( (cause) => new IndexedDbDatabaseError({ reason: "MissingTable", cause }) ) ) return yield* Effect.try({ try: () => database.current.deleteObjectStore(createTable.tableName), catch: (cause) => new IndexedDbDatabaseError({ reason: "TransactionError", cause }) }) }) migration.createIndex = Effect.fnUntraced(function*( table: string, indexName: string, options?: IDBIndexParameters ) { const store = transaction.objectStore(table) const sourceTable = tables.get(table)! const keyPath = yield* Effect.fromNullishOr( sourceTable.indexes[indexName] ).pipe( Effect.mapError( (cause) => new IndexedDbDatabaseError({ reason: "MissingIndex", cause }) ) ) return yield* Effect.try({ try: () => store.createIndex(indexName, keyPath, options), catch: (cause) => new IndexedDbDatabaseError({ reason: "TransactionError", cause }) }) }) migration.deleteIndex = (table: string, indexName: string) => Effect.try({ try: () => transaction.objectStore(table).deleteIndex(indexName), catch: (cause) => new IndexedDbDatabaseError({ reason: "TransactionError", cause }) }) return migration }