fix: preserve idempotency keys across command retries (AEG-V16-022)

Create one immutable request per user intent so retries forward the same key; preserve concurrency conflict handling and execution evidence.
This commit is contained in:
2026-08-08 13:35:30 +09:00
parent a84e5c1273
commit a750858dfe
5 changed files with 64 additions and 7 deletions
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { useOptimisticCommand } from '../useOptimisticCommand'
describe('useOptimisticCommand', () => {
it('reuses one idempotency key when an intent is retried', async () => {
const keys: string[] = []
const command = useOptimisticCommand(async (_request, headers) => {
keys.push(headers['Idempotency-Key'])
return { data: { accepted: true } }
})
const request = command.createRequest({ name: 'update' }, 'etag-1')
await command.run(request)
await command.run(request)
expect(keys).toHaveLength(2)
expect(keys[0]).toBe(request.idempotencyKey)
expect(keys[1]).toBe(request.idempotencyKey)
})
it('surfaces a 412 conflict and clears pending state', async () => {
const command = useOptimisticCommand(async () => {
throw { response: { status: 412 } }
})
await expect(command.run(command.createRequest({ name: 'update' }))).rejects.toEqual({ response: { status: 412 } })
expect(command.conflict.value).toBe(true)
expect(command.pending.value).toBe(false)
})
})
@@ -1,5 +1,5 @@
import { ref } from 'vue';
import { createIdempotencyKey } from '../commands/idempotency';
import { createIdempotentCommand } from '../commands/idempotency';
export function useOptimisticCommand(execute) {
const pending = ref(false);
const conflict = ref(false);
@@ -10,7 +10,7 @@ export function useOptimisticCommand(execute) {
pending.value = true;
conflict.value = false;
try {
const headers = { 'Idempotency-Key': createIdempotencyKey() };
const headers = { 'Idempotency-Key': request.idempotencyKey };
if (request.etag)
headers['If-Match'] = request.etag;
const response = await execute(request, headers);
@@ -27,5 +27,8 @@ export function useOptimisticCommand(execute) {
pending.value = false;
}
}
return { pending, conflict, lastCorrelationId, run };
function createRequest(payload, etag) {
return Object.freeze({ ...createIdempotentCommand(payload), etag });
}
return { pending, conflict, lastCorrelationId, createRequest, run };
}
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { createIdempotencyKey } from '../commands/idempotency'
export interface OptimisticCommandRequest<T> { payload: T; etag?: string }
import { createIdempotentCommand, type IdempotentCommand } from '../commands/idempotency'
export interface OptimisticCommandRequest<T> extends IdempotentCommand<T> { etag?: string }
export interface OptimisticCommandResponse<TResult> { data: TResult; etag?: string; correlationId?: string }
export function useOptimisticCommand<TPayload, TResult>(execute: (request: OptimisticCommandRequest<TPayload>, headers: Record<string,string>) => Promise<OptimisticCommandResponse<TResult>>) {
const pending = ref(false); const conflict = ref(false); const lastCorrelationId = ref<string>()
@@ -8,7 +8,7 @@ export function useOptimisticCommand<TPayload, TResult>(execute: (request: Optim
if (pending.value) throw new Error('Command is already in progress')
pending.value=true; conflict.value=false
try {
const headers: Record<string,string> = { 'Idempotency-Key': createIdempotencyKey() }
const headers: Record<string,string> = { 'Idempotency-Key': request.idempotencyKey }
if (request.etag) headers['If-Match']=request.etag
const response=await execute(request,headers); lastCorrelationId.value=response.correlationId; return response
} catch (error: unknown) {
@@ -17,5 +17,8 @@ export function useOptimisticCommand<TPayload, TResult>(execute: (request: Optim
throw error
} finally { pending.value=false }
}
return { pending, conflict, lastCorrelationId, run }
function createRequest(payload: TPayload, etag?: string): OptimisticCommandRequest<TPayload> {
return Object.freeze({ ...createIdempotentCommand(payload), etag })
}
return { pending, conflict, lastCorrelationId, createRequest, run }
}