fix: cancel session authorization reads
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
|
||||
## Reads
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `listSessions(signal?)` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store.
|
||||
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
|
||||
@@ -52,11 +52,14 @@ export class SessionCorpus {
|
||||
|
||||
/**
|
||||
* List the complete logical corpus with live precedence and cloned headers.
|
||||
* @param signal - optional cancellation for persistence listing.
|
||||
* @returns records in deterministic newest-first order.
|
||||
*/
|
||||
async listSessions(): Promise<SessionRecord[]> {
|
||||
async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
||||
signal?.throwIfAborted()
|
||||
const persistence = this._persistence
|
||||
const persisted = persistence === undefined ? [] : await listPersisted(persistence)
|
||||
const persisted = persistence === undefined ? [] : await listPersisted(persistence, signal)
|
||||
signal?.throwIfAborted()
|
||||
const records = new Map<SessionId, SessionRecord>()
|
||||
for (const header of persisted) {
|
||||
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
|
||||
@@ -239,6 +242,7 @@ async function listPersisted(
|
||||
try {
|
||||
return await persistence.list(signal)
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
throw new SessionQueryError(
|
||||
`session persistence listing failed: ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
|
||||
@@ -115,10 +115,11 @@ export abstract class SessionQueryService extends Service {
|
||||
|
||||
/**
|
||||
* List the complete logical corpus using live-preferred records.
|
||||
* @param signal - optional cancellation for persistence listing.
|
||||
* @returns deterministic newest-first cloned session records.
|
||||
*/
|
||||
listSessions(): Promise<SessionRecord[]> {
|
||||
return this._corpus.listSessions()
|
||||
listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
||||
return this._corpus.listSessions(signal)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,11 +140,15 @@ export abstract class SessionQueryService extends Service {
|
||||
/**
|
||||
* Filter the complete logical corpus with provider-independent predicates.
|
||||
* @param filters - ANDed session metadata and availability clauses.
|
||||
* @param signal - optional cancellation for persistence listing.
|
||||
* @returns matching cloned records in deterministic newest-first order.
|
||||
*/
|
||||
async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
async filterSessions(
|
||||
filters: readonly SessionResultFilter[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionRecord[]> {
|
||||
const ownedFilters = materializeSessionResultFilters(filters)
|
||||
return this._filterSessions(ownedFilters)
|
||||
return this._filterSessions(ownedFilters, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,8 +225,11 @@ export abstract class SessionQueryService extends Service {
|
||||
return this._filterEvents(sessionId, ownedFilters)
|
||||
}
|
||||
|
||||
private async _filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
return filterSessionResults(await this._corpus.listSessions(), filters)
|
||||
private async _filterSessions(
|
||||
filters: readonly SessionResultFilter[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionRecord[]> {
|
||||
return filterSessionResults(await this._corpus.listSessions(signal), filters)
|
||||
}
|
||||
|
||||
private async _filterEvents(
|
||||
|
||||
@@ -130,6 +130,98 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
})
|
||||
}
|
||||
|
||||
const cancellableSessionListings = [
|
||||
{
|
||||
name: 'listSessions',
|
||||
run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.listSessions(signal),
|
||||
},
|
||||
{
|
||||
name: 'filterSessions',
|
||||
run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.filterSessions([], signal),
|
||||
},
|
||||
] as const
|
||||
|
||||
describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => {
|
||||
it('preserves an exact pre-abort reason without entering persistence', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('session listing cancelled before start')
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(run(ctx, controller.signal)).rejects.toBe(reason)
|
||||
expect(TestPersistence.listCalls).toBe(0)
|
||||
expect(TestPersistence.listSignals).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards in-flight cancellation and waits for persistence cleanup before rejecting', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('session listing cancelled in flight')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.listOverride = async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected persistence listing signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
started.resolve(undefined)
|
||||
await aborted
|
||||
abortObserved.resolve(undefined)
|
||||
await cleanup.promise
|
||||
active = false
|
||||
signal.throwIfAborted()
|
||||
return []
|
||||
}
|
||||
|
||||
const pending = run(ctx, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
await abortObserved.promise
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves cancellation after a persistence implementation ignores the signal', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('session listing cancelled before persistence returned')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const listing = Promise.withResolvers<SessionHeader[]>()
|
||||
TestPersistence.listOverride = (_signal) => {
|
||||
started.resolve(undefined)
|
||||
return listing.promise
|
||||
}
|
||||
|
||||
const pending = run(ctx, controller.signal)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
listing.resolve([])
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => {
|
||||
const valid = header('valid-log', 2)
|
||||
|
||||
Reference in New Issue
Block a user