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,20 @@
# AEG-V16-022 — Optimistic command hook
## Scope
- **WBS / Requirement / UI / Test:** AEG-V16-022 / REQ-V16-FEC-06 / UI-V16-FEC-06 / T-V16-FEC-06
- **Classification:** shared command-boundary correctness fix; no API, DB, policy, or provider change.
## Source / Assumption / Unknown / Decision Required
- **Source:** `AGENTS.md` idempotency rule, `frontend/src/shared/commands/idempotency.*`, and `useOptimisticCommand.*`.
- **Assumption:** the caller creates one request per user intent and reuses that request for retry. Server idempotency remains the authoritative side-effect protection.
- **Unknown:** no CRUD screen currently consumes this hook; integration evidence remains a later screen-slice responsibility.
- **Decision Required:** predecessor AEG-V16-021 has no concrete consumer yet; this implementation does not claim its acceptance evidence.
## Execution evidence
- `createRequest()` freezes a request with an idempotency key once; `run()` forwards that exact key on every retry.
- `If-Match`, 409/412 conflict state, correlation capture, and pending reset are preserved.
- `frontend: pnpm test -- --run src/shared/crud/tests/useOptimisticCommand.spec.ts`: 1 file / 2 tests passed.
- `frontend: pnpm typecheck`: passed before JS companion synchronization; the later JS-only synchronization is covered by the passing runtime test.
@@ -12,6 +12,7 @@ AEG-V16-015,S0,VS-00,Adapter rollback runbook,IN_PROGRESS,TBD,"docs/CURRENT/ui-p
AEG-V16-018,S6,Cross,DataContextHeader,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-018_DATA_CONTEXT_HEADER_SLICE_NOTE.md; frontend/src/shared/ui/components/KsDataContextHeader.vue; frontend/src/shared/ui/components/tests/KsDataContextHeader.spec.ts","FE Lead","2026-08-08: Made projectionVersion and watermark required so stale/rebuildable read-model context cannot be omitted; added visible and accessible stale state plus VersionSet propagation tests. Actual evidence: targeted Vitest 2/2 PASS, frontend typecheck PASS, production build PASS. Build emitted unrelated tracked .js drift, excluded from this Slice. COMPLETED is blocked pending predecessor AEG-V16-017 acceptance and UX/a11y evidence."
AEG-V16-019,S6,Cross,CommandBar,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-019_COMMAND_BAR_SLICE_NOTE.md; frontend/src/shared/ui/components/KsCommandBar.vue; frontend/src/shared/ui/components/tests/KsCommandBar.spec.ts","FE Lead","2026-08-08: Command boundary now suppresses disabled/busy execute events and exposes aggregate busy state. Actual evidence: targeted Vitest 1/1 PASS; frontend typecheck PASS. COMPLETED is blocked pending predecessor AEG-V16-018 acceptance and UX/a11y evidence."
AEG-V16-020,S6,Cross,CRUD Resource v2,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-020_CRUD_RESOURCE_V2_SLICE_NOTE.md; contracts/ui/crud-resource.v2.json; frontend/src/shared/crud/resourceDefinition.ts; frontend/src/shared/crud/tests/resourceDefinition.spec.ts","FE Lead","2026-08-08: Hardened runtime resource-definition checks for schema versions, permission policy, concurrency/idempotency modes, and sensitive grid columns. Actual evidence: resource definition Vitest 3/3 PASS; frontend typecheck PASS. COMPLETED is blocked pending predecessor AEG-V16-019 acceptance and UX/a11y evidence."
AEG-V16-022,S6,Cross,Optimistic command hook,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-022_OPTIMISTIC_COMMAND_SLICE_NOTE.md; frontend/src/shared/crud/useOptimisticCommand.ts; frontend/src/shared/crud/tests/useOptimisticCommand.spec.ts","FE Lead","2026-08-08: Request creation now freezes one Idempotency-Key per user intent and retries reuse it; 409/412 conflict state remains explicit. Actual evidence: targeted Vitest 2/2 PASS; frontend typecheck PASS. COMPLETED is blocked pending actual CRUD-screen integration and predecessor evidence."
AEG-X-008,S0,Cross,OpenAPI artifact 고도화,COMPLETED,2026-08-04,.gitea/workflows/openapi-gate.yml + docs/api/openapi.json,BE/FE Architect,"✅ OpenAPI diff gate implemented: CI/CD automation detects breaking changes (3 checks: parameter removal, status code removal, field removal), blocks merge without approval, auto-comments on PR"
AEG-VS-00-01,S0,VS-00,정책·범위·실패상태 계약 확정,COMPLETED,2026-08-06,"docs/CURRENT/SLICE_SPECS/VS-00-SLICE_SPEC.md + commit e7913db",PM/Architect,"✅ SLICE_SPEC produced: VS-00-SLICE_SPEC.md (state transitions, RBAC, governance gates, DQ rules, compliance). Commit e7913db. 249/253 tests PASS."
AEG-VS-00-02,S0,VS-00,데이터 시점·스키마·정합성 계약,COMPLETED,2026-08-06,"contracts/data/platform-data-contract.v1.json + commit e7913db",Data Architect/DBA,"✅ DATA_CONTRACT v1.0 produced: PIT envelope (published_at/correlation_id/revision), 5 table schemas, DQ rules/lineage, GDPR/PCI-DSS compliance. JSON schema + validation. 249/253 tests PASS."
1 WBS_ID Sprint Slice_ID Task Status Completion_Date Evidence_Link Owner Notes
12 AEG-V16-018 S6 Cross DataContextHeader IN_PROGRESS TBD docs/CURRENT/AEG-V16-018_DATA_CONTEXT_HEADER_SLICE_NOTE.md; frontend/src/shared/ui/components/KsDataContextHeader.vue; frontend/src/shared/ui/components/tests/KsDataContextHeader.spec.ts FE Lead 2026-08-08: Made projectionVersion and watermark required so stale/rebuildable read-model context cannot be omitted; added visible and accessible stale state plus VersionSet propagation tests. Actual evidence: targeted Vitest 2/2 PASS, frontend typecheck PASS, production build PASS. Build emitted unrelated tracked .js drift, excluded from this Slice. COMPLETED is blocked pending predecessor AEG-V16-017 acceptance and UX/a11y evidence.
13 AEG-V16-019 S6 Cross CommandBar IN_PROGRESS TBD docs/CURRENT/AEG-V16-019_COMMAND_BAR_SLICE_NOTE.md; frontend/src/shared/ui/components/KsCommandBar.vue; frontend/src/shared/ui/components/tests/KsCommandBar.spec.ts FE Lead 2026-08-08: Command boundary now suppresses disabled/busy execute events and exposes aggregate busy state. Actual evidence: targeted Vitest 1/1 PASS; frontend typecheck PASS. COMPLETED is blocked pending predecessor AEG-V16-018 acceptance and UX/a11y evidence.
14 AEG-V16-020 S6 Cross CRUD Resource v2 IN_PROGRESS TBD docs/CURRENT/AEG-V16-020_CRUD_RESOURCE_V2_SLICE_NOTE.md; contracts/ui/crud-resource.v2.json; frontend/src/shared/crud/resourceDefinition.ts; frontend/src/shared/crud/tests/resourceDefinition.spec.ts FE Lead 2026-08-08: Hardened runtime resource-definition checks for schema versions, permission policy, concurrency/idempotency modes, and sensitive grid columns. Actual evidence: resource definition Vitest 3/3 PASS; frontend typecheck PASS. COMPLETED is blocked pending predecessor AEG-V16-019 acceptance and UX/a11y evidence.
15 AEG-V16-022 S6 Cross Optimistic command hook IN_PROGRESS TBD docs/CURRENT/AEG-V16-022_OPTIMISTIC_COMMAND_SLICE_NOTE.md; frontend/src/shared/crud/useOptimisticCommand.ts; frontend/src/shared/crud/tests/useOptimisticCommand.spec.ts FE Lead 2026-08-08: Request creation now freezes one Idempotency-Key per user intent and retries reuse it; 409/412 conflict state remains explicit. Actual evidence: targeted Vitest 2/2 PASS; frontend typecheck PASS. COMPLETED is blocked pending actual CRUD-screen integration and predecessor evidence.
16 AEG-X-008 S0 Cross OpenAPI artifact 고도화 COMPLETED 2026-08-04 .gitea/workflows/openapi-gate.yml + docs/api/openapi.json BE/FE Architect ✅ OpenAPI diff gate implemented: CI/CD automation detects breaking changes (3 checks: parameter removal, status code removal, field removal), blocks merge without approval, auto-comments on PR
17 AEG-VS-00-01 S0 VS-00 정책·범위·실패상태 계약 확정 COMPLETED 2026-08-06 docs/CURRENT/SLICE_SPECS/VS-00-SLICE_SPEC.md + commit e7913db PM/Architect ✅ SLICE_SPEC produced: VS-00-SLICE_SPEC.md (state transitions, RBAC, governance gates, DQ rules, compliance). Commit e7913db. 249/253 tests PASS.
18 AEG-VS-00-02 S0 VS-00 데이터 시점·스키마·정합성 계약 COMPLETED 2026-08-06 contracts/data/platform-data-contract.v1.json + commit e7913db Data Architect/DBA ✅ DATA_CONTRACT v1.0 produced: PIT envelope (published_at/correlation_id/revision), 5 table schemas, DQ rules/lineage, GDPR/PCI-DSS compliance. JSON schema + validation. 249/253 tests PASS.
@@ -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 }
}