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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user