Skip to content
Open
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
88 changes: 88 additions & 0 deletions packages/db/tests/indexed-db/idb-driver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// These tests check the native waiter's timing and cleanup mechanics, not
// product results. Retirement laws stay in retirement-oracle.test.ts.
import { expect, it, vi } from 'vitest'
import { completed, withHarness } from './harness'
import { observeTransactions } from './idb-driver'

it('resolves an already established native cut without scheduling a timer', async () => {
await withHarness(async (h) => {
const native = observeTransactions(h.db.db)
h.disposers.push(() => native.restore())
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await native.until(() => true)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
})

it.each(['admission', 'complete', 'abort'] as const)(
'resumes at native %s and clears the timeout',
async (cut) => {
await withHarness(async (h) => {
const native = observeTransactions(h.db.db)
h.disposers.push(() => native.restore())
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const waiting = native.until(() =>
cut === 'admission'
? native.entries.length === 1
: native.entries[0]?.status === cut,
)
const transaction = h.db.db.transaction('items')
const finished = completed(transaction).catch(() => undefined)
if (cut === 'abort') transaction.abort()
await waiting
expect(native.entries[0]?.status).toBe(
cut === 'admission' ? 'pending' : cut,
)
expect(vi.getTimerCount()).toBe(0)
await finished
} finally {
vi.useRealTimers()
}
})
},
)

it('rejects an unreachable cut, removes its waiter and lets the harness clean up', async () => {
const originalChannel = globalThis.BroadcastChannel
let disposed = false
let waiterRemoved = false
let timersRemaining: number | undefined
const failure = await withHarness(async (h) => {
const native = observeTransactions(h.db.db)
h.disposers.push(() => {
native.restore()
disposed = true
})
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
const ready = vi.fn(() => false)
const waiting = native.until(ready).catch((error: unknown) => error)
await vi.advanceTimersByTimeAsync(1000)
// Record a broken, unbounded waiter without hanging the test itself.
const error = await Promise.race([waiting, Promise.resolve('pending')])
if (error === 'pending') return
const calls = ready.mock.calls.length
await completed(h.db.db.transaction('items'))
waiterRemoved = ready.mock.calls.length === calls
timersRemaining = vi.getTimerCount()
throw error
} finally {
vi.useRealTimers()
}
}).catch((error: unknown) => error)
expect(failure, 'native cut rejects at its own deadline').toBeInstanceOf(
Error,
)
expect(failure).toMatchObject({
message: 'Timed out waiting for native transaction state',
})
expect(waiterRemoved).toBe(true)
expect(timersRemaining).toBe(0)
expect(disposed).toBe(true)
expect(globalThis.BroadcastChannel).toBe(originalChannel)
})
37 changes: 37 additions & 0 deletions packages/db/tests/indexed-db/idb-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,27 @@ export function holdStore(db: IDBDatabase, stores = ['items', '_versions']) {
}
}

/**
* Records native transaction admission and outcome. `until` resumes a test at
* the native event that makes its condition true, so a schedule cut depends on
* provider events rather than on elapsed time or a polling interval.
* An unreachable cut rejects after one second so the harness can clean up.
*/
export function observeTransactions(db: IDBDatabase) {
const entries: Array<{
mode: IDBTransactionMode
status: 'pending' | 'complete' | 'abort'
transaction: IDBTransaction
}> = []
const waiters = new Set<{ ready: () => boolean; resolve: () => void }>()
function notify() {
for (const waiter of waiters) {
if (waiter.ready()) {
waiters.delete(waiter)
waiter.resolve()
}
}
}
const original = db.transaction
db.transaction = function (...args) {
const transaction = original.apply(this, args)
Expand All @@ -55,14 +70,36 @@ export function observeTransactions(db: IDBDatabase) {
entries.push(entry)
transaction.addEventListener('complete', () => {
entry.status = 'complete'
notify()
})
transaction.addEventListener('abort', () => {
entry.status = 'abort'
notify()
})
notify()
return transaction
}
return {
entries,
until: (ready: () => boolean) =>
ready()
? Promise.resolve()
: new Promise<void>((resolve, reject) => {
const waiter = {
ready,
resolve: () => {
clearTimeout(timer)
resolve()
},
}
const timer = setTimeout(() => {
waiters.delete(waiter)
reject(
new Error('Timed out waiting for native transaction state'),
)
}, 1000)
waiters.add(waiter)
}),
restore: () => {
db.transaction = original
},
Expand Down
31 changes: 26 additions & 5 deletions packages/db/tests/indexed-db/retirement-oracle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* Additional histories put closure before admission, between native commit and
* confirmation, after sync acceptance, and during initial/replacement/key reads.
* Native gates issue real requests; no IndexedDB transaction awaits a test gate.
* Each schedule cut resumes at the native admission or outcome event that
* establishes it, not after a polling interval.
* These finite schedules cover the semantic cuts, not every TLC interleaving.
*/
import { describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -112,8 +114,8 @@ describe('admitted native writes retain their actual outcome after closure', ()
return sorted(subjects[slot]!.base.values())
},
)
await vi.waitFor(() =>
expect(native.entries.length).toBe(callers.filter(Boolean).length),
await native.until(
() => native.entries.length === callers.filter(Boolean).length,
)
}
expect(
Expand Down Expand Up @@ -388,19 +390,28 @@ it.each(mixedCells)(
h.disposers.push(() => gate.release())
await gate.started
const beforeAdmission = native.entries.length
// At most one operation is active, so its native transaction is the first
// one admitted after the gate. Its caller settlement is checked against
// that native outcome at the settlement itself, independent of when the
// test resumes after admission.
const nativeAtCaller: Array<string> = []
for (const slot of order) {
const op = operations[slot]!
if (op.prefix === 'active' || op.prefix === 'deciding')
callers[slot] = observe(
start(subjects[slot]!, op.kind, slot + 1),
capture(slot),
() => {
nativeAtCaller[slot] =
native.entries[beforeAdmission]?.status ?? 'notAdmitted'
return capture(slot)()
},
)
}
const activeCount = operations.filter(
(op) => op.prefix === 'active',
).length
await vi.waitFor(() =>
expect(native.entries.length).toBe(beforeAdmission + activeCount),
await native.until(
() => native.entries.length === beforeAdmission + activeCount,
)
for (const slot of [0, 1])
expect(callers[slot]!.result.state).toBe(
Expand Down Expand Up @@ -437,6 +448,16 @@ it.each(mixedCells)(
expect(callers[slot]!.result.state).toBe(
success ? 'fulfilled' : 'rejected',
)
// As in the paired matrix, a rejection may precede the abort event.
if (op.prefix === 'active' && success)
expect(nativeAtCaller[slot], 'native commit at caller success').toBe(
'complete',
)
else if (op.prefix === 'active')
expect(
nativeAtCaller[slot],
'rejected caller cannot report a commit',
).not.toBe('complete')
const rows = success ? apply(initial, op.kind, slot + 1) : initial
assertRows(
callers[slot]!.snapshot()!,
Expand Down
21 changes: 7 additions & 14 deletions packages/db/tests/indexed-db/retirement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,15 @@ for (const reason of reasons) {
h.disposers.push(() => native.restore())
const loading = read === 'initial' ? observe(c.preload()) : undefined
while (Channel.pending.length) Channel.dispatch()
await vi.waitFor(() =>
expect(
native.entries.some((entry) => entry.mode === 'readonly'),
).toBe(true),
await native.until(() =>
native.entries.some((entry) => entry.mode === 'readonly'),
)
await close(h, reason)
closed(c, statuses)
await gate.release()
await Channel.drain()
await vi.waitFor(() =>
expect(
native.entries.every((entry) => entry.status === 'complete'),
).toBe(true),
await native.until(() =>
native.entries.every((entry) => entry.status === 'complete'),
)
await loading?.done
if (loading) expect(loading.result.state).toBe('rejected')
Expand Down Expand Up @@ -196,10 +192,8 @@ for (const reason of reasons)
const caller = observe(c.insert(row).when('settled'), () =>
sorted(c.base.values()),
)
await vi.waitFor(() =>
expect(native.entries.some((entry) => entry.mode === 'readwrite')).toBe(
true,
),
await native.until(() =>
native.entries.some((entry) => entry.mode === 'readwrite'),
)
const inspector = await close(h, reason)
await gate.release()
Expand Down Expand Up @@ -303,8 +297,7 @@ for (const phase of ['idle', 'ready', 'loading', ...kinds] as const) {
: phase === 'idle' || phase === 'ready'
? undefined
: observe(start(c, phase, 1))
if (pending)
await vi.waitFor(() => expect(native.entries.length).toBeGreaterThan(0))
if (pending) await native.until(() => native.entries.length > 0)
const terminated = new Promise<void>((resolve) =>
h.db.db.addEventListener('close', () => resolve(), { once: true }),
)
Expand Down