649 lines
19 KiB
TypeScript
649 lines
19 KiB
TypeScript
/**
|
|
* 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<IndexedDbSchema<any, any, any>>({
|
|
label: "IndexedDbSchema",
|
|
evaluate() {
|
|
return this.getQueryBuilder
|
|
}
|
|
}),
|
|
get getQueryBuilder() {
|
|
const self = this as unknown as IndexedDbSchema<any, any, any>
|
|
return IndexedDbDatabase.useSync(({ database, IDBKeyRange, reactivity }) =>
|
|
IndexedDbQueryBuilder.make({
|
|
database,
|
|
IDBKeyRange,
|
|
tables: self.version.tables,
|
|
reactivity
|
|
})
|
|
)
|
|
},
|
|
add<Version extends IndexedDbVersion.AnyWithProps>(
|
|
this: IndexedDbSchema<any, any, any>,
|
|
version: Version,
|
|
migrate: (
|
|
fromQuery: Transaction<any>,
|
|
toQuery: Transaction<Version>
|
|
) => Effect.Effect<void, Error>
|
|
) {
|
|
return makeMigration({
|
|
fromVersion: this.version,
|
|
version,
|
|
migrate,
|
|
previous: this
|
|
})
|
|
},
|
|
layer(this: IndexedDbSchema<any, any, any>, 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<globalThis.IDBDatabase>
|
|
readonly IDBKeyRange: typeof globalThis.IDBKeyRange
|
|
readonly reactivity: Reactivity.Reactivity["Service"]
|
|
readonly rebuild: Effect.Effect<void, IndexedDbDatabaseError>
|
|
}
|
|
>()(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<ToVersion>,
|
|
never,
|
|
IndexedDbDatabase
|
|
>
|
|
{
|
|
new(_: never): {}
|
|
|
|
readonly previous: [FromVersion] extends [never] ? undefined
|
|
: IndexedDbSchema<never, FromVersion, Error>
|
|
readonly fromVersion: FromVersion
|
|
readonly version: ToVersion
|
|
|
|
readonly migrate: [FromVersion] extends [never] ? (query: Transaction<ToVersion>) => Effect.Effect<void, Error>
|
|
: (
|
|
fromQuery: Transaction<FromVersion>,
|
|
toQuery: Transaction<ToVersion>
|
|
) => Effect.Effect<void, Error>
|
|
|
|
readonly add: <Version extends IndexedDbVersion.AnyWithProps, MigrationError>(
|
|
version: Version,
|
|
migrate: (
|
|
fromQuery: Transaction<ToVersion>,
|
|
toQuery: Transaction<Version>
|
|
) => Effect.Effect<void, MigrationError>
|
|
) => IndexedDbSchema<ToVersion, Version, MigrationError | Error>
|
|
|
|
readonly getQueryBuilder: Effect.Effect<
|
|
IndexedDbQueryBuilder.IndexedDbQueryBuilder<ToVersion>,
|
|
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<IndexedDbQueryBuilder.IndexedDbQueryBuilder<Source>, "transaction"> {
|
|
readonly transaction: globalThis.IDBTransaction
|
|
|
|
readonly createObjectStore: <
|
|
A extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
|
|
>(
|
|
table: A
|
|
) => Effect.Effect<globalThis.IDBObjectStore, IndexedDbDatabaseError>
|
|
|
|
readonly deleteObjectStore: <
|
|
A extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
|
|
>(
|
|
table: A
|
|
) => Effect.Effect<void, IndexedDbDatabaseError>
|
|
|
|
readonly createIndex: <
|
|
Name extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
|
|
>(
|
|
table: Name,
|
|
indexName: IndexFromTableName<Source, Name>,
|
|
options?: IDBIndexParameters
|
|
) => Effect.Effect<globalThis.IDBIndex, IndexedDbDatabaseError>
|
|
|
|
readonly deleteIndex: <
|
|
Name extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
|
|
>(
|
|
table: Name,
|
|
indexName: IndexFromTableName<Source, Name>
|
|
) => Effect.Effect<void, IndexedDbDatabaseError>
|
|
}
|
|
|
|
/**
|
|
* Extracts the string-literal index names defined by an `IndexedDbTable`.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export type IndexFromTable<Table extends IndexedDbTable.AnyWithProps> = IsStringLiteral<
|
|
Extract<keyof IndexedDbTable.Indexes<Table>, string>
|
|
> extends true ? Extract<keyof IndexedDbTable.Indexes<Table>, 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<IndexedDbVersion.Tables<Version>, 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<InitialVersion>) => Effect.Effect<void, Error>
|
|
): IndexedDbSchema<never, InitialVersion, Error> => {
|
|
// 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<FromVersion, ToVersion, Error>
|
|
| IndexedDbSchema<never, FromVersion, Error>
|
|
readonly fromVersion: FromVersion
|
|
readonly version: ToVersion
|
|
readonly migrate: (
|
|
fromQuery: Transaction<FromVersion>,
|
|
toQuery: Transaction<ToVersion>
|
|
) => Effect.Effect<void, Error>
|
|
}): IndexedDbSchema<FromVersion, ToVersion, Error> => {
|
|
// 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 extends string>(
|
|
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<Any> = []
|
|
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<globalThis.IDBDatabase>(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<IDBDatabase>
|
|
|
|
resume(
|
|
Effect.fail(
|
|
new IndexedDbDatabaseError({
|
|
reason: "OpenError",
|
|
cause: idbRequest.error
|
|
})
|
|
)
|
|
)
|
|
}
|
|
|
|
let fiber: Fiber.Fiber<void, IndexedDbDatabaseError> | undefined
|
|
request.onupgradeneeded = (event) => {
|
|
const idbRequest = event.target as IDBRequest<IDBDatabase>
|
|
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<IDBDatabase>
|
|
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<void, IndexedDbDatabaseError>((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> = T extends string ? string extends T ? false
|
|
: true
|
|
: false
|
|
|
|
const makeTransactionProto = <Source extends IndexedDbVersion.AnyWithProps>({
|
|
IDBKeyRange,
|
|
database,
|
|
tables,
|
|
transaction,
|
|
reactivity
|
|
}: {
|
|
readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
|
|
readonly IDBKeyRange: typeof globalThis.IDBKeyRange
|
|
readonly tables: ReadonlyMap<string, IndexedDbVersion.Tables<Source>>
|
|
readonly transaction: globalThis.IDBTransaction
|
|
readonly reactivity: Reactivity.Reactivity["Service"]
|
|
}): Transaction<Source> => {
|
|
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
|
|
}
|