diff --git a/.changeset/calm-buses-smile.md b/.changeset/calm-buses-smile.md
new file mode 100644
index 00000000000..e7fe600ceab
--- /dev/null
+++ b/.changeset/calm-buses-smile.md
@@ -0,0 +1,5 @@
+---
+"@effect/platform-browser": patch
+---
+
+Fix IndexedDB query range, ordering, streaming, and transaction semantics.
diff --git a/packages/platform-browser/src/IndexedDbQueryBuilder.ts b/packages/platform-browser/src/IndexedDbQueryBuilder.ts
index 95cae54ecb3..3a4296b0894 100644
--- a/packages/platform-browser/src/IndexedDbQueryBuilder.ts
+++ b/packages/platform-browser/src/IndexedDbQueryBuilder.ts
@@ -136,7 +136,11 @@ export interface IndexedDbQueryBuilder<
readonly tables: Tables
readonly mode: Mode
readonly durability?: IDBTransactionDurability
- }) => (effect: Effect.Effect) => Effect.Effect>
+ }) => (effect: Effect.Effect) => Effect.Effect<
+ A,
+ E | IndexedDbQueryError,
+ Exclude
+ >
}
/**
@@ -752,6 +756,7 @@ const applyDelete = (query: IndexedDbQuery.Delete) =>
durability: query.delete.from.table.durability
})
const objectStore = transaction.objectStore(query.delete.from.table.tableName)
+ const store = query.delete.index === undefined ? objectStore : objectStore.index(query.delete.index)
const predicate = query.predicate
let keyRange: globalThis.IDBKeyRange | undefined = undefined
@@ -782,8 +787,8 @@ const applyDelete = (query: IndexedDbQuery.Delete) =>
let request: globalThis.IDBRequest
- if (query.limitValue !== undefined || predicate) {
- const cursorRequest = objectStore.openCursor()
+ if (query.delete.index !== undefined || query.limitValue !== undefined || predicate) {
+ const cursorRequest = store.openCursor(keyRange)
let count = 0
cursorRequest.onerror = () => {
@@ -897,7 +902,7 @@ const applySelect = Effect.fnUntraced(function*(
const keyPath = query.from.table.keyPath
const predicate = query.predicate
- const data = predicate || keyPath === undefined || query.offsetValue !== undefined ?
+ const data = predicate || keyPath === undefined || query.offsetValue !== undefined || query.reverseValue ?
yield* Effect.callback, IndexedDbQueryError>((resume) => {
const { keyRange, store } = getReadonlyObjectStore(query)
@@ -1001,7 +1006,13 @@ const applyFirst = Effect.fnUntraced(function*(
}
request.onsuccess = () => {
- resume(Effect.succeed(request.result))
+ if (request.result === undefined) {
+ resume(
+ Effect.fail(new Cause.NoSuchElementError(`No such element in table ${query.select.from.table.tableName}`))
+ )
+ } else {
+ resume(Effect.succeed(request.result))
+ }
}
} else {
const request = store.openCursor()
@@ -1516,7 +1527,7 @@ const DeleteProto: Omit<
filter(this: IndexedDbQuery.Delete, filter: (value: IndexedDbTable.Encoded) => boolean) {
const prev = this.predicate
return makeDelete({
- delete: this.delete,
+ ...this,
predicate: prev ? (item) => prev(item) && filter(item) : filter
})
},
@@ -1760,18 +1771,22 @@ const SelectProto: Omit<
}) {
const limit = this.limitValue
const chunkSize = Math.min(options?.chunkSize ?? 100, limit ?? Number.MAX_SAFE_INTEGER)
- const initial = this.limit(chunkSize)
+ const initialOffset = this.offsetValue ?? 0
return Stream.suspend(() => {
let total = 0
+ const initial = this.limit(chunkSize)
return Stream.paginate(initial, (select) =>
Effect.map(
applySelect(select as any),
(data) => {
total += data.length
- ;(select as any).offsetValue = total
const reachedLimit = limit && total >= limit
const isPartial = data.length < chunkSize
- return [data, isPartial || reachedLimit ? Option.none() : Option.some(select)] as const
+ const next = makeSelect({
+ ...select,
+ offsetValue: initialOffset + total
+ })
+ return [data, isPartial || reachedLimit ? Option.none() : Option.some(next)] as const
}
))
})
@@ -1987,7 +2002,11 @@ const QueryBuilderProto: Omit<
return (effect) =>
Effect.suspend(() => {
const transaction = this.database.current.transaction(options.tables, options.mode, options)
- return Effect.provideService(effect, IndexedDbTransaction, transaction)
+ return Effect.provideService(effect, IndexedDbTransaction, transaction).pipe(
+ Effect.onExit((exit) =>
+ exit._tag === "Success" ? awaitTransaction(transaction) : abortTransaction(transaction)
+ )
+ )
}).pipe(
// To prevent async gaps between transaction queries
Effect.provideService(References.PreventSchedulerYield, true)
@@ -1995,6 +2014,30 @@ const QueryBuilderProto: Omit<
}
}
+const abortTransaction = (transaction: globalThis.IDBTransaction) =>
+ Effect.try({
+ try: () => transaction.abort(),
+ catch: () => undefined
+ }).pipe(Effect.ignore)
+
+const awaitTransaction = (transaction: globalThis.IDBTransaction) =>
+ Effect.callback((resume) => {
+ transaction.oncomplete = () => {
+ resume(Effect.void)
+ }
+ transaction.onabort = () => {
+ resume(
+ Effect.fail(
+ new IndexedDbQueryError({
+ reason: "TransactionError",
+ cause: transaction.error
+ })
+ )
+ )
+ }
+ return abortTransaction(transaction)
+ })
+
/**
* Creates an `IndexedDbQueryBuilder` from an open database reference, key-range constructor, table map, and reactivity service.
*
diff --git a/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts b/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts
index 45f8aea79a3..533bca42cb6 100644
--- a/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts
+++ b/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts
@@ -2,6 +2,7 @@ import { IndexedDb, IndexedDbDatabase, IndexedDbTable, IndexedDbVersion } from "
import { afterEach, assert, describe, it } from "@effect/vitest"
import {
Array,
+ Cause,
Context,
DateTime,
Effect,
@@ -1456,4 +1457,95 @@ describe.sequential("IndexedDbQueryBuilder", () => {
assert.deepStrictEqual(data2, { id: 3, title: "test2", count: 3, completed: false })
}).pipe(provideDb(Db))
})
+
+ it.effect("rolls back withTransaction writes when the effect fails", () => {
+ class Db extends IndexedDbDatabase.make(V1, (api) => api.createObjectStore("todo")) {}
+
+ return Effect.gen(function*() {
+ const api = yield* Db
+ yield* Effect.result(
+ api.withTransaction({ tables: ["todo"], mode: "readwrite" })(
+ Effect.andThen(
+ api.from("todo").insert({ id: 1, title: "committed", count: 1, completed: false }),
+ Effect.fail("rollback")
+ )
+ )
+ )
+
+ assert.deepStrictEqual(yield* api.from("todo").select(), [])
+ }).pipe(provideDb(Db))
+ })
+
+ it.effect("applies reverse before a select limit", () => {
+ class Db extends IndexedDbDatabase.make(
+ V1,
+ Effect.fn(function*(api) {
+ yield* api.createObjectStore("todo")
+ yield* api.from("todo").insertAll([
+ { id: 1, title: "one", count: 1, completed: false },
+ { id: 2, title: "two", count: 2, completed: false },
+ { id: 3, title: "three", count: 3, completed: false }
+ ])
+ })
+ ) {}
+
+ return Effect.gen(function*() {
+ const api = yield* Db
+ const rows = yield* api.from("todo").select().reverse().limit(2)
+ assert.deepStrictEqual(rows.map((row) => row.id), [3, 2])
+ }).pipe(provideDb(Db))
+ })
+
+ it.effect("honors an indexed range when delete has a limit", () => {
+ class Db extends IndexedDbDatabase.make(
+ V1,
+ Effect.fn(function*(api) {
+ yield* api.createObjectStore("todo")
+ yield* api.createIndex("todo", "titleIndex")
+ yield* api.from("todo").insertAll([
+ { id: 1, title: "keep", count: 1, completed: false },
+ { id: 2, title: "delete", count: 2, completed: false }
+ ])
+ })
+ ) {}
+
+ return Effect.gen(function*() {
+ const api = yield* Db
+ yield* api.from("todo").delete("titleIndex").equals("delete").limit(1)
+ const rows = yield* api.from("todo").select()
+ assert.deepStrictEqual(rows.map((row) => row.id), [1])
+ }).pipe(provideDb(Db))
+ })
+
+ it.effect("can consume the same paged select stream twice", () => {
+ class Db extends IndexedDbDatabase.make(
+ V1,
+ Effect.fn(function*(api) {
+ yield* api.createObjectStore("todo")
+ yield* api.from("todo").insertAll([
+ { id: 1, title: "one", count: 1, completed: false },
+ { id: 2, title: "two", count: 2, completed: false },
+ { id: 3, title: "three", count: 3, completed: false }
+ ])
+ })
+ ) {}
+
+ return Effect.gen(function*() {
+ const api = yield* Db
+ const stream = api.from("todo").select().stream({ chunkSize: 2 })
+ const first = yield* Stream.runCollect(stream)
+ const second = yield* Stream.runCollect(stream)
+ assert.deepStrictEqual(second.map((row) => row.id), first.map((row) => row.id))
+ }).pipe(provideDb(Db))
+ })
+
+ it.effect("reports NoSuchElementError for an empty ranged first query", () => {
+ class Db extends IndexedDbDatabase.make(V1, (api) => api.createObjectStore("todo")) {}
+
+ return Effect.gen(function*() {
+ const api = yield* Db
+ const error = yield* Effect.flip(api.from("todo").select().equals(1).first())
+ assert.instanceOf(error, Cause.NoSuchElementError)
+ }).pipe(provideDb(Db))
+ })
})