Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-buses-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/platform-browser": patch
---

Fix IndexedDB query range, ordering, streaming, and transaction semantics.
63 changes: 53 additions & 10 deletions packages/platform-browser/src/IndexedDbQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ export interface IndexedDbQueryBuilder<
readonly tables: Tables
readonly mode: Mode
readonly durability?: IDBTransactionDurability
}) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, IndexedDbTransaction>>
}) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<
A,
E | IndexedDbQueryError,
Exclude<R, IndexedDbTransaction>
>
}

/**
Expand Down Expand Up @@ -752,6 +756,7 @@ const applyDelete = (query: IndexedDbQuery.Delete<any, never>) =>
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
Expand Down Expand Up @@ -782,8 +787,8 @@ const applyDelete = (query: IndexedDbQuery.Delete<any, never>) =>

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 = () => {
Expand Down Expand Up @@ -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<Array<any>, IndexedDbQueryError>((resume) => {
const { keyRange, store } = getReadonlyObjectStore(query)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1516,7 +1527,7 @@ const DeleteProto: Omit<
filter(this: IndexedDbQuery.Delete<any, never>, filter: (value: IndexedDbTable.Encoded<any>) => boolean) {
const prev = this.predicate
return makeDelete({
delete: this.delete,
...this,
predicate: prev ? (item) => prev(item) && filter(item) : filter
})
},
Expand Down Expand Up @@ -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
}
))
})
Expand Down Expand Up @@ -1987,14 +2002,42 @@ 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)
)
}
}

const abortTransaction = (transaction: globalThis.IDBTransaction) =>
Effect.try({
try: () => transaction.abort(),
catch: () => undefined
}).pipe(Effect.ignore)

const awaitTransaction = (transaction: globalThis.IDBTransaction) =>
Effect.callback<void, IndexedDbQueryError>((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.
*
Expand Down
92 changes: 92 additions & 0 deletions packages/platform-browser/test/IndexedDbQueryBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IndexedDb, IndexedDbDatabase, IndexedDbTable, IndexedDbVersion } from "
import { afterEach, assert, describe, it } from "@effect/vitest"
import {
Array,
Cause,
Context,
DateTime,
Effect,
Expand Down Expand Up @@ -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))
})
})
Loading