From 9efdc3b31666636fd0cc01f912026913f32d883d Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Sat, 1 Aug 2026 18:34:44 +0000 Subject: [PATCH 1/2] Add reproduction for platform-browser/IndexedDbQueryBuilder issue --- .../test/IndexedDbQueryBuilder.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts b/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts index 45f8aea79a3..4951fb2d20e 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,84 @@ 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)) + }) }) From e162ab2244a4ad300f9962cf72d610a460253f8c Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Mon, 3 Aug 2026 11:29:37 +1200 Subject: [PATCH 2/2] Fix IndexedDB query builder semantics --- .changeset/calm-buses-smile.md | 5 ++ .../src/IndexedDbQueryBuilder.ts | 63 ++++++++++++++--- .../test/IndexedDbQueryBuilder.test.ts | 69 +++++++++++-------- 3 files changed, 98 insertions(+), 39 deletions(-) create mode 100644 .changeset/calm-buses-smile.md 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 4951fb2d20e..533bca42cb6 100644 --- a/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts +++ b/packages/platform-browser/test/IndexedDbQueryBuilder.test.ts @@ -1463,26 +1463,31 @@ describe.sequential("IndexedDbQueryBuilder", () => { 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") + 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 } - ]) - })) {} + 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 @@ -1492,14 +1497,17 @@ describe.sequential("IndexedDbQueryBuilder", () => { }) 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 } - ]) - })) {} + 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 @@ -1510,14 +1518,17 @@ describe.sequential("IndexedDbQueryBuilder", () => { }) 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 } - ]) - })) {} + 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