From d9afccdd60d3aaae5c487a96dac132fd7ec2e899 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 02:42:56 +0900 Subject: [PATCH] fix: retain uncertain optimistic mutations --- .../task-5-report.md | 83 ++ src/application/view-models/async-state.ts | 49 +- src/contracts/errors.ts | 26 +- .../reference-resource-form-page.tsx | 33 +- .../adapters/query/application-query.ts | 455 +++++- .../query/optimistic-layer-runtime.ts | 106 +- src/presentation/components/async-surface.tsx | 31 +- src/presentation/forms/form-contracts.ts | 1 + src/presentation/forms/use-app-form.ts | 38 +- src/presentation/i18n/catalog.ts | 6 + tests/component/application-query.test.tsx | 1228 ++++++++++++++++- tests/component/async-surface.test.tsx | 47 +- .../reference-feature/reference-page.test.tsx | 107 ++ .../typecheck/invalid-async-overlay.ts | 1 + tests/unit/optimistic-layer-runtime.test.ts | 85 ++ 15 files changed, 2172 insertions(+), 124 deletions(-) create mode 100644 .superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md diff --git a/.superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md b/.superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md new file mode 100644 index 0000000..595dc00 --- /dev/null +++ b/.superpowers/sdd/2026-08-01-runtime-correctness-remediation/task-5-report.md @@ -0,0 +1,83 @@ +# Task 5 Report: Retain and reconcile uncertain optimistic mutations + +## Status + +Task 5 is implemented. Mutation settlement now follows explicit effect certainty, preserves unknown optimistic projections as ordered uncertain layers, and exposes one-at-a-time reconciliation bound to the original mutation record. Missing post-dispatch certainty is fail-safe `MAYBE_APPLIED`; only controller-owned pre-dispatch failures are marked `NOT_STARTED`. + +## RED evidence + +- Initial focused command: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`. +- Initial result: exit `1`, 2 files, 11 failed / 28 passed. Missing lease/controller APIs failed directly; `APPLIED_CONFIRMED` and `MAYBE_APPLIED` were rolled back; effectless failures retained generic retry semantics. +- Review-driven RED: the three-file focused command including `tests/component/async-surface.test.tsx` exited `1` with 7 failed / 56 passed. It exposed applied-confirmed retry actions, active-submit reset, double reconciliation, stale-scope queue retention, overlay priority, and the incorrect refreshing copy. +- A final isolated RED proved a synchronous `NOT_APPLIED` double action could consume two FIFO records in one event turn. +- The production-form RED exited `1` with 2 failed / 8 passed: applied reconciliation left the original command dirty/retryable, while an `APPLIED_CONFIRMED` failure rendered generic unavailable. +- The final durability/lifecycle RED failed 2 / 2: an anonymous non-optimistic legacy channel did not survive remount, and render-time registry allocation exhausted the definition cap during an abandoned server render. + +## Implementation + +1. `OptimisticLayerLease` now supports `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`. Layers are `pending | uncertain | committed`; only a committed prefix collapses into the base, while projection continues to apply every later layer in order. +2. Reconciliation is single-settlement and idempotent. `APPLIED` converts the uncertain layer to committed; `NOT_APPLIED` removes only that layer; both then collapse/reproject later committed or pending layers. Scope expiry removes stale cache instead of restoring it. +3. Legacy optimistic mutations use the same reusable always-current ordered runtime. This prevents an old manual snapshot from erasing a later successful mutation or authoritative projection. The runtime also supports optimistic entries whose base data was absent and removes them on a not-applied rollback. +4. The mutation bridge derives effect before touching optimistic state: + - `NOT_STARTED` / `NOT_APPLIED`: rollback; + - `APPLIED_CONFIRMED`: commit, then best-effort invalidate; + - `MAYBE_APPLIED`: retain as uncertain, do not invalidate, and enqueue explicit reconciliation. +5. Missing or `NOT_APPLICABLE` command effects, returned failures after dispatch, and thrown execution failures normalize to `MAYBE_APPLIED`. Unknown effects are non-retryable with `contact-support`; applied-confirmed failures are non-retryable with no resend action. Controller-owned stale scope, duplicate admission, identity/preparation, and other known pre-dispatch failures carry `NOT_STARTED`. +6. Unknown records retain their original intent, scope, layer lease, invalidation topics, and coordinator. A FIFO queue prevents parallel `ALLOW_PARALLEL` failures from overwriting each other. Reconciliation is locked through the event turn so a double action cannot consume the next intent, and it does not reset a newer active submit. +7. Scope abort discards every queued record from that scope, settles only its local stale layers, performs no invalidation, and cannot later overwrite new-generation cache data. +8. Async state adds the mutually exclusive `mutation-effect-unknown` overlay with priority `unknown > conflict > pending > stale-degraded > refreshing`. `AsyncSurface` uses dedicated safe copy and only `APPLIED` / `NOT_APPLIED` actions; it does not expose generic retry or mark the surface busy. +9. Unknown-effect admissions live in a bounded QueryClient-owned registry, so bound and legacy controllers can remount without losing reconciliation state. Channels include definition version and generation, validate the exact scope owner, count active admissions globally in O(1), release after late settlement, and preserve FIFO order even when executions finish in reverse. +10. Channel creation and scope-abort listener registration occur only in a committed React effect. An abandoned/server render performs no registry mutation and consumes no channel capacity. Non-optimistic legacy callers must provide a stable `definitionId`; optimistic legacy callers also include their query identity. +11. The reference create form blocks all generic resubmission while effect certainty is unknown. `NOT_APPLIED` preserves input and re-enables submission; `APPLIED` reconciliation and `APPLIED_CONFIRMED` settlement use the form's success-equivalent reset path so the same create command cannot be resent. + +## Test coverage + +- Certainty matrix for all four mutation effects, missing effect, `NOT_APPLICABLE`, thrown execution, ambiguous conflicts, and non-retry semantics. +- Applied-confirmed commit-before-invalidate ordering and retained commit when invalidation fails. +- Out-of-order later commits behind uncertain layers; both reconciliation outcomes; duplicate/reversed lease transitions; external cache projection; expired scope. +- Bound and legacy reconciliation, no-layer legacy fallback, parallel unknown queues, same-turn double actions, active newer submit preservation, scope-wide stale cleanup, and no-prior-cache rollback. +- Bound and non-optimistic legacy remount durability, unrelated legacy isolation, generation isolation, exact scope-owner collision handling, abandoned-render capacity, late empty-channel cleanup, QueryClient-global admission bounds, reverse completion, and fence-during-invalidation races. +- Production create-form coverage for the `MAYBE_APPLIED` block, `NOT_APPLIED` input preservation, and success-equivalent `APPLIED` / `APPLIED_CONFIRMED` settlement. +- Unknown overlay derivation, mutual-exclusion priority, dedicated localized copy, non-busy state, and reconciliation-only actions. +- The negative async-overlay type fixture now includes `mutationEffectUnknown: false`, so it continues to fail for the intended pending/conflict exclusivity violation. + +## Files changed + +- `src/presentation/adapters/query/optimistic-layer-runtime.ts` +- `src/presentation/adapters/query/application-query.ts` +- `src/application/view-models/async-state.ts` +- `src/contracts/errors.ts` +- `src/presentation/components/async-surface.tsx` +- `src/presentation/forms/form-contracts.ts` +- `src/presentation/forms/use-app-form.ts` +- `src/presentation/i18n/catalog.ts` +- `src/features/reference-feature/presentation/reference-resource-form-page.tsx` +- `tests/unit/optimistic-layer-runtime.test.ts` +- `tests/component/application-query.test.tsx` +- `tests/component/async-surface.test.tsx` +- `tests/features/reference-feature/reference-page.test.tsx` +- `tests/fixtures/typecheck/invalid-async-overlay.ts` + +The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scope expansion required to avoid rendering the new indicator as a background refresh and to preserve the overlay type contract. + +## Verification + +- Final focused command (error classification, optimistic runtime, mutation bridge, async surface, form facade, and production reference form): 6 files / 100 tests — PASS. +- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker. +- `corepack pnpm lint` — PASS with zero warnings. +- `corepack pnpm test:all` — PASS: runtime schema 40, unit 741, component 123, integration 23, reference feature 24, recipes 17. +- `git diff --check` — PASS. +- `corepack pnpm run check:types:fixture:async-overlay` — expected non-zero; TypeScript rejects `mutationConflict: true` when `mutationPending: true`, confirming the negative fixture still reaches its intended invariant. + +## Self-review decisions + +- The plan-prescribed `reconcileUnknownEffect(resolution)` API remains intact. Rather than introduce a public token incompatible with that interface, the controller retains intent-bound FIFO records and serializes reconciliation through the current event turn. A repeated action after the first promise settles is an explicit action on the next visible unknown record. +- Scope cleanup removes stale local projection without claiming or invalidating a server outcome. A stale generation cannot use its former record after the queue is discarded. +- Legacy manual snapshot restoration was removed because it could erase later successful work. Shared ordered layers are the minimal mechanism that gives legacy and bound mutations the same re-projection guarantees. +- A non-optimistic legacy mutation has no cache key from which a durable logical identity can be inferred. Its type contract therefore requires a stable caller-supplied `definitionId`; this preserves remount durability without merging unrelated controllers. +- Registry mutation was moved out of render into the committed effect lifecycle. The server-render regression fills the nominal definition count with abandoned renders, then proves a committed mutation can still acquire and execute. +- Browser/Playwright gates were not run; this task changed no browser-only integration. The jsdom component tests cover the new accessible status and actions. + +## Final review + +The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge. diff --git a/src/application/view-models/async-state.ts b/src/application/view-models/async-state.ts index 17e6ab7..3a30cde 100644 --- a/src/application/view-models/async-state.ts +++ b/src/application/view-models/async-state.ts @@ -11,6 +11,7 @@ export const ASYNC_OVERLAYS = Object.freeze([ "refreshing", "stale-degraded", "mutation-pending", + "mutation-effect-unknown", "mutation-conflict", ] as const); @@ -19,30 +20,42 @@ export type AsyncOverlay = refreshing: false; staleDegraded: false; mutationPending: false; + mutationEffectUnknown: false; mutationConflict: false; }> | Readonly<{ refreshing: true; staleDegraded: false; mutationPending: false; + mutationEffectUnknown: false; mutationConflict: false; }> | Readonly<{ refreshing: false; staleDegraded: true; mutationPending: false; + mutationEffectUnknown: false; mutationConflict: false; }> | Readonly<{ refreshing: false; staleDegraded: false; mutationPending: true; + mutationEffectUnknown: false; mutationConflict: false; }> | Readonly<{ refreshing: false; staleDegraded: false; mutationPending: false; + mutationEffectUnknown: true; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: false; + mutationEffectUnknown: false; mutationConflict: true; }>; @@ -54,6 +67,7 @@ export type AsyncSignals = Readonly<{ isStale?: boolean; isDegraded?: boolean; isMutationPending?: boolean; + hasMutationEffectUnknown?: boolean; hasMutationConflict?: boolean; }>; @@ -84,15 +98,17 @@ export function deriveAsyncState(signals: AsyncSignals): AsyncState { : "initial-loading"; const indicator = - signals.hasMutationConflict && hasData - ? "mutation-conflict" - : signals.isMutationPending && hasData - ? "mutation-pending" - : signals.isStale && signals.isDegraded && hasData - ? "stale-degraded" - : signals.isFetching && hasData - ? "refreshing" - : null; + signals.hasMutationEffectUnknown && hasData + ? "mutation-effect-unknown" + : signals.hasMutationConflict && hasData + ? "mutation-conflict" + : signals.isMutationPending && hasData + ? "mutation-pending" + : signals.isStale && signals.isDegraded && hasData + ? "stale-degraded" + : signals.isFetching && hasData + ? "refreshing" + : null; const overlay = overlayFor(indicator); return Object.freeze({ @@ -107,6 +123,7 @@ export function deriveAsyncState(signals: AsyncSignals): AsyncState { export function selectOverlayIndicator( overlay: AsyncOverlay, ): AsyncState["indicator"] { + if (overlay.mutationEffectUnknown) return "mutation-effect-unknown"; if (overlay.mutationConflict) return "mutation-conflict"; if (overlay.mutationPending) return "mutation-pending"; if (overlay.staleDegraded) return "stale-degraded"; @@ -120,6 +137,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { refreshing: true, staleDegraded: false, mutationPending: false, + mutationEffectUnknown: false, mutationConflict: false, }); } @@ -128,6 +146,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { refreshing: false, staleDegraded: true, mutationPending: false, + mutationEffectUnknown: false, mutationConflict: false, }); } @@ -136,6 +155,16 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { refreshing: false, staleDegraded: false, mutationPending: true, + mutationEffectUnknown: false, + mutationConflict: false, + }); + } + if (indicator === "mutation-effect-unknown") { + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: false, + mutationEffectUnknown: true, mutationConflict: false, }); } @@ -144,6 +173,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { refreshing: false, staleDegraded: false, mutationPending: false, + mutationEffectUnknown: false, mutationConflict: true, }); } @@ -151,6 +181,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { refreshing: false, staleDegraded: false, mutationPending: false, + mutationEffectUnknown: false, mutationConflict: false, }); } diff --git a/src/contracts/errors.ts b/src/contracts/errors.ts index c4ff9e4..6ef354a 100644 --- a/src/contracts/errors.ts +++ b/src/contracts/errors.ts @@ -281,7 +281,8 @@ export function createFailure( kind: definition.kind, code: typeof details.code === "string" ? details.code : definition.kind, retryable: - details.effect === "MAYBE_APPLIED" + details.effect === "MAYBE_APPLIED" || + details.effect === "APPLIED_CONFIRMED" ? false : definition.defaultRetryable, operationId, @@ -320,7 +321,28 @@ export function createFailure( action: details.effect === "MAYBE_APPLIED" ? "contact-support" - : definition.action, + : details.effect === "APPLIED_CONFIRMED" + ? "none" + : definition.action, + }); +} + +/** + * Adds controller-owned mutation effect knowledge without weakening the + * fail-safe handling required for an unknown server-side outcome. + */ +export function withFailureEffect( + failure: AppFailure, + effect: Exclude, +): AppFailure { + return Object.freeze({ + ...failure, + effect, + ...(effect === "MAYBE_APPLIED" + ? { retryable: false as const, action: "contact-support" as const } + : effect === "APPLIED_CONFIRMED" + ? { retryable: false as const, action: "none" as const } + : {}), }); } diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx index 2459a75..368daf7 100644 --- a/src/features/reference-feature/presentation/reference-resource-form-page.tsx +++ b/src/features/reference-feature/presentation/reference-resource-form-page.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Button, + AsyncSurface, DirtyNavigationDialog, ErrorSummary, Form, @@ -40,13 +41,23 @@ export default function ReferenceResourceFormPage() { mapToCommand: toCreateReferenceCommand, submit, }); + const mutationEffectUnknown = + mutation.state.indicator === "mutation-effect-unknown"; + const mutationBlocked = + mutationEffectUnknown || mutation.state.indicator === "mutation-pending"; const guard = useDirtyNavigationGuard(form.dirty && !form.pending); return (
void form.submitForm(event)} + onSubmit={(event) => { + if (mutationBlocked) { + event.preventDefault(); + return; + } + void form.submitForm(event); + }} > navigate("/examples/reference-resources")} - disabled={form.pending} + disabled={form.pending || mutationBlocked} > 취소 - } feedback={ - form.result === "success" ? ( + mutationEffectUnknown ? ( + { + void mutation.reconcileUnknownEffect(resolution).then(() => { + if (resolution === "APPLIED") form.settleApplied(); + }); + }} + /> + ) : form.result === "success" ? (

저장했습니다.

) : form.result === "conflict" ? (

충돌을 해결한 뒤 다시 제출할 수 있습니다.

diff --git a/src/presentation/adapters/query/application-query.ts b/src/presentation/adapters/query/application-query.ts index 191541c..98a4432 100644 --- a/src/presentation/adapters/query/application-query.ts +++ b/src/presentation/adapters/query/application-query.ts @@ -3,8 +3,11 @@ import { useEffect, useMemo, useState, + useSyncExternalStore, } from "react"; import { + hashKey, + type QueryClient, useMutation, useQuery, useQueryClient, @@ -18,14 +21,20 @@ import type { Result } from "../../../application/result.ts"; import { createFailure, normalizeUnknownFailure, + withFailureEffect, type AppFailure, } from "../../../contracts/errors.ts"; -import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; +import type { + QueryInvalidationCoordinator, + QueryInvalidationTopic, +} from "../../../contracts/query-invalidation.ts"; import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; +import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts"; import { admitQueryResult, type BoundMutation, type BoundQuery, + MUTATION_COORDINATOR_BOUNDS, type MutationDuplicatePolicy, } from "../../../contracts/server-state.ts"; import { runtimeIdentityToken } from "../../../contracts/query-keys.ts"; @@ -181,27 +190,75 @@ export function useApplicationQuery( }); } -type LegacyMutationOptions = Readonly<{ +type LegacyMutationBase = Readonly<{ execute(input: Input): Promise>; duplicatePolicy?: MutationDuplicatePolicy; invalidate?: readonly QueryInvalidationTopic[]; - optimistic?: Readonly<{ - queryKey: readonly unknown[]; - update(previous: unknown, input: Input): unknown; - }>; currentData?: unknown; }>; +type LegacyMutationOptions = LegacyMutationBase & + ( + | Readonly<{ + /** Stable logical identity required to retain an unknown effect across remounts. */ + definitionId: string; + optimistic?: never; + }> + | Readonly<{ + definitionId?: string; + optimistic: Readonly<{ + queryKey: readonly unknown[]; + update(previous: unknown, input: Input): unknown; + }>; + }> + ); + type ApplicationMutationController = Readonly<{ state: AsyncState; submit(input: Input): Promise>; resolveConflict(): Promise; + reconcileUnknownEffect( + resolution: "APPLIED" | "NOT_APPLIED", + ): Promise; }>; type MutationExecution = | Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }> | Readonly<{ kind: "LEGACY"; input: Input }>; +type MutationAdmission = { + sequence: number; + state: "ACTIVE" | "UNKNOWN" | "RECONCILING" | "SETTLED"; + intent: MutationIntent | null; + scope: CacheScopeSnapshot | null; + optimisticLayer: OptimisticLayerLease | null; + optimisticQueryKey: readonly unknown[] | null; + readonly invalidate: readonly QueryInvalidationTopic[]; + invalidationCoordinator: QueryInvalidationCoordinator | null; +}; + +type UnknownEffectChannel = { + readonly key: string; + readonly registry: UnknownEffectRegistry; + readonly owner: QueryClient; + readonly scope: CacheScopeSnapshot | null; + nextSequence: number; + admissions: MutationAdmission[]; + version: number; + reconciliationInFlight: boolean; + readonly listeners: Set<() => void>; + disposeScopeListener: (() => void) | null; +}; + +type UnknownEffectRegistry = { + readonly channels: Map; + activeAdmissions: number; +}; + +const LEGACY_OPTIMISTIC_SCOPE = Object.freeze({ + isCurrent: () => true, +}); + export function useApplicationMutation( options: BoundMutation, ): ApplicationMutationController; @@ -221,7 +278,11 @@ export function useApplicationMutation( const optimistic = "optimistic" in options ? options.optimistic : undefined; const currentData = "currentData" in options ? options.currentData : undefined; const definitionId = - "definitionId" in options ? options.definitionId : "LEGACY_MUTATION"; + "definitionId" in options + ? options.definitionId ?? "LEGACY_MUTATION" + : "LEGACY_MUTATION"; + const definitionVersion = + "definitionVersion" in options ? options.definitionVersion : 0; const duplicatePolicy = "duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE"; const [conflict, setConflict] = useState(null); @@ -232,6 +293,53 @@ export function useApplicationMutation( "requiresIdempotencyKey" in options ? options.requiresIdempotencyKey : false; + const unknownEffectChannelKey = scope + ? `bound:${scope.generation}:${scope.fingerprint}:${definitionId}:${definitionVersion}` + : `legacy:${definitionId}:${ + optimistic + ? hashKey(optimistic.queryKey) + : "non-optimistic" + }`; + const [acquiredUnknownEffectChannel, setAcquiredUnknownEffectChannel] = + useState(null); + const unknownEffectChannel = + acquiredUnknownEffectChannel?.owner === queryClient && + acquiredUnknownEffectChannel.key === unknownEffectChannelKey && + acquiredUnknownEffectChannel.scope === (scope ?? null) + ? acquiredUnknownEffectChannel + : null; + useEffect(() => { + const acquired = acquireUnknownEffectChannel( + queryClient, + unknownEffectChannelKey, + scope, + ); + setAcquiredUnknownEffectChannel(acquired); + return () => { + if (acquired) { + releaseUnknownEffectChannelIfUnused(queryClient, acquired); + } + }; + }, [queryClient, scope, unknownEffectChannelKey]); + const subscribeToUnknownEffects = useCallback( + (listener: () => void) => { + if (!unknownEffectChannel) return () => {}; + unknownEffectChannel.listeners.add(listener); + return () => { + unknownEffectChannel.listeners.delete(listener); + releaseUnknownEffectChannelIfUnused( + queryClient, + unknownEffectChannel, + ); + }; + }, + [queryClient, unknownEffectChannel], + ); + useSyncExternalStore( + subscribeToUnknownEffects, + () => unknownEffectChannel?.version ?? 0, + () => unknownEffectChannel?.version ?? 0, + ); const mutation = useMutation< Value, ApplicationQueryError, @@ -246,7 +354,7 @@ export function useApplicationMutation( "SCOPE_GENERATION_CHANGED", definitionId, 0, - { code: "MUTATION_SCOPE_STALE" }, + { code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" }, ), ); } @@ -298,7 +406,7 @@ export function useApplicationMutation( "SCOPE_GENERATION_CHANGED", definitionId, 0, - { code: "MUTATION_SCOPE_STALE" }, + { code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" }, ), }); } @@ -311,9 +419,12 @@ export function useApplicationMutation( } catch (error) { return Promise.resolve({ ok: false, - error: normalizeUnknownFailure(error, { - operationId: definitionId, - }), + error: withFailureEffect( + normalizeUnknownFailure(error, { + operationId: definitionId, + }), + "NOT_STARTED", + ), }); } const active = mutationExecutions(queryClient).get(identity) as @@ -331,26 +442,58 @@ export function useApplicationMutation( "DUPLICATE_IN_FLIGHT", definitionId, 0, - { code: "DUPLICATE_IN_FLIGHT" }, + { code: "DUPLICATE_IN_FLIGHT", effect: "NOT_STARTED" }, ), }); } + if ( + !unknownEffectChannel || + unknownEffectChannel.registry.activeAdmissions >= + MUTATION_COORDINATOR_BOUNDS.activeIntentsTotal + ) { + identityLease?.release(); + return Promise.resolve({ + ok: false, + error: createFailure( + "IDENTITY_INTERN_LIMIT_EXCEEDED", + definitionId, + 0, + { + code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED", + effect: "NOT_STARTED", + }, + ), + }); + } + const admission: MutationAdmission = { + sequence: unknownEffectChannel.nextSequence++, + state: "ACTIVE", + intent: null, + scope: scope ?? null, + optimisticLayer: null, + optimisticQueryKey: optimistic?.queryKey ?? null, + invalidate, + invalidationCoordinator: invalidationCoordinator ?? null, + }; + unknownEffectChannel.admissions.push(admission); + unknownEffectChannel.registry.activeAdmissions += 1; + notifyUnknownEffectChannel(unknownEffectChannel); setConflict(null); mutation.reset(); const pending = (async (): Promise> => { - const execution: MutationExecution = - scope - ? Object.freeze({ - kind: "BOUND" as const, - input, - intent: mutationIntentFactory.create({ - operationId: mutationOperationId, - canonicalInputIdentity: identity, - requiresIdempotencyKey, - }), - }) - : Object.freeze({ kind: "LEGACY" as const, input }); + let execution: MutationExecution; + if (scope) { + const intent = mutationIntentFactory.create({ + operationId: mutationOperationId, + canonicalInputIdentity: identity, + requiresIdempotencyKey, + }); + admission.intent = intent; + execution = Object.freeze({ kind: "BOUND", input, intent }); + } else { + execution = Object.freeze({ kind: "LEGACY", input }); + } const mutationLease = invalidate.length === 0 ? null @@ -359,54 +502,60 @@ export function useApplicationMutation( throw new Error("Query invalidation coordinator is not installed."); } try { - let previous: unknown; - let hadPreviousData = false; let optimisticLayer: OptimisticLayerLease | null = null; if (optimistic) { await queryClient.cancelQueries({ queryKey: optimistic.queryKey, exact: true, }); - if (scope) { - optimisticLayer = optimisticLayers(queryClient).begin( - optimistic.queryKey, - input, - optimistic.update, - scope, - ); - } else { - previous = queryClient.getQueryData(optimistic.queryKey); - hadPreviousData = previous !== undefined; - queryClient.setQueryData( - optimistic.queryKey, - optimistic.update(previous, input), - ); - } + optimisticLayer = optimisticLayers(queryClient).begin( + optimistic.queryKey, + input, + optimistic.update, + scope ?? LEGACY_OPTIMISTIC_SCOPE, + ); + admission.optimisticLayer = optimisticLayer; } let value: Value; try { value = await mutation.mutateAsync(execution); } catch (error: unknown) { - if (optimistic) { - if (optimisticLayer) { - optimisticLayer.rollback(); - } else if (hadPreviousData) { - queryClient.setQueryData(optimistic.queryKey, previous); - } else { - queryClient.removeQueries({ - queryKey: optimistic.queryKey, - exact: true, - }); - } - } - const failure = + const reportedFailure = error instanceof ApplicationQueryError ? error.failure : normalizeUnknownFailure(error, { operationId: "APPLICATION_MUTATION", }); - if (failure.kind === "CONFLICT") setConflict(failure); + const failure = mutationFailureWithEffect(reportedFailure); + switch (failure.effect) { + case "NOT_STARTED": + case "NOT_APPLIED": + optimisticLayer?.rollback(); + if (failure.kind === "CONFLICT") setConflict(failure); + settleMutationAdmission(unknownEffectChannel, admission); + break; + case "APPLIED_CONFIRMED": + optimisticLayer?.commit(); + if (!scope || scope.isCurrent()) { + try { + await invalidationCoordinator?.invalidate(invalidate); + } catch { + // A confirmed command remains committed if refresh fails. + } + } + settleMutationAdmission(unknownEffectChannel, admission); + break; + case "MAYBE_APPLIED": + optimisticLayer?.markUncertain(); + if (!scope || scope.isCurrent()) { + admission.state = "UNKNOWN"; + notifyUnknownEffectChannel(unknownEffectChannel); + } else { + settleMutationAdmission(unknownEffectChannel, admission); + } + break; + } return { ok: false, error: failure }; } @@ -416,6 +565,7 @@ export function useApplicationMutation( } catch { // Cache refresh remains best effort after the server has committed. } + settleMutationAdmission(unknownEffectChannel, admission); return { ok: true, value }; } finally { try { @@ -426,9 +576,13 @@ export function useApplicationMutation( } })() .catch((error: unknown) => { - const failure = normalizeUnknownFailure(error, { - operationId: "APPLICATION_MUTATION", - }); + const failure = withFailureEffect( + normalizeUnknownFailure(error, { + operationId: "APPLICATION_MUTATION", + }), + "NOT_STARTED", + ); + settleMutationAdmission(unknownEffectChannel, admission); if (failure.kind === "CONFLICT") setConflict(failure); return { ok: false as const, error: failure }; }) @@ -455,6 +609,7 @@ export function useApplicationMutation( mutationOperationId, requiresIdempotencyKey, scope, + unknownEffectChannel, ], ); @@ -467,14 +622,194 @@ export function useApplicationMutation( await invalidationCoordinator?.invalidate(invalidate); }, [invalidate, invalidationCoordinator, mutation]); + const reconcileUnknownEffect = useCallback( + async (resolution: "APPLIED" | "NOT_APPLIED") => { + if ( + !unknownEffectChannel || + unknownEffectChannel.reconciliationInFlight + ) { + return; + } + const pending = unknownEffectChannel.admissions[0]; + if (!pending || pending.state !== "UNKNOWN") return; + if (pending.scope && !pending.intent) return; + unknownEffectChannel.reconciliationInFlight = true; + pending.state = "RECONCILING"; + notifyUnknownEffectChannel(unknownEffectChannel); + try { + pending.optimisticLayer?.reconcile(resolution); + if ( + resolution === "APPLIED" && + (!pending.scope || pending.scope.isCurrent()) + ) { + try { + await pending.invalidationCoordinator?.invalidate( + pending.invalidate, + ); + } catch { + // Explicit reconciliation remains settled if refresh fails. + } + } + } finally { + if (pending.scope && !pending.scope.isCurrent()) { + removeAdmissionOptimisticQuery(queryClient, pending); + } + settleMutationAdmission(unknownEffectChannel, pending); + if ( + !unknownEffectChannel.admissions.some( + (admission) => admission.state === "ACTIVE", + ) + ) { + mutation.reset(); + } + // Keep the head intent locked through the current event turn so two + // clicks cannot consume two FIFO records when reconciliation itself + // has no asynchronous invalidation work. + await Promise.resolve(); + unknownEffectChannel.reconciliationInFlight = false; + notifyUnknownEffectChannel(unknownEffectChannel); + } + }, + [mutation, queryClient, unknownEffectChannel], + ); + + const unknownEffectHead = unknownEffectChannel?.admissions[0]; + return Object.freeze({ state: deriveAsyncState({ data: currentData ?? true, - isMutationPending: mutation.isPending, + isMutationPending: + mutation.isPending || + unknownEffectHead?.state === "ACTIVE" || + unknownEffectHead?.state === "RECONCILING", + hasMutationEffectUnknown: unknownEffectHead?.state === "UNKNOWN", hasMutationConflict: conflict !== null, }), submit, resolveConflict, + reconcileUnknownEffect, + }); +} + +function mutationFailureWithEffect(failure: AppFailure): AppFailure { + switch (failure.effect) { + case "NOT_STARTED": + case "NOT_APPLIED": + case "APPLIED_CONFIRMED": + case "MAYBE_APPLIED": + return withFailureEffect(failure, failure.effect); + case "NOT_APPLICABLE": + case undefined: + return withFailureEffect(failure, "MAYBE_APPLIED"); + } +} + +const UNKNOWN_EFFECT_CHANNELS = new WeakMap< + object, + UnknownEffectRegistry +>(); + +function acquireUnknownEffectChannel( + queryClient: QueryClient, + key: string, + scope: CacheScopeSnapshot | undefined, +): UnknownEffectChannel | null { + let registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient); + if (!registry) { + registry = { channels: new Map(), activeAdmissions: 0 }; + UNKNOWN_EFFECT_CHANNELS.set(queryClient, registry); + } + const { channels } = registry; + const existing = channels.get(key); + if (existing) return existing.scope === (scope ?? null) ? existing : null; + if ( + channels.size >= MUTATION_COORDINATOR_BOUNDS.activeDefinitionsPerRuntime + ) { + return null; + } + const created: UnknownEffectChannel = { + key, + registry, + owner: queryClient, + scope: scope ?? null, + nextSequence: 1, + admissions: [], + version: 0, + reconciliationInFlight: false, + listeners: new Set(), + disposeScopeListener: null, + }; + channels.set(key, created); + if (scope) { + const discardScope = () => { + for (const admission of created.admissions) { + removeAdmissionOptimisticQuery(queryClient, admission); + markMutationAdmissionSettled(created, admission); + } + created.admissions = []; + notifyUnknownEffectChannel(created); + releaseUnknownEffectChannelIfUnused(queryClient, created); + }; + scope.signal.addEventListener("abort", discardScope, { once: true }); + created.disposeScopeListener = () => + scope.signal.removeEventListener("abort", discardScope); + if (scope.signal.aborted || !scope.isCurrent()) discardScope(); + } + return created; +} + +function releaseUnknownEffectChannelIfUnused( + queryClient: QueryClient, + channel: UnknownEffectChannel, +): void { + if (channel.admissions.length > 0 || channel.listeners.size > 0) return; + const registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient); + const channels = registry?.channels; + if (channels?.get(channel.key) !== channel) return; + channel.disposeScopeListener?.(); + channels.delete(channel.key); + if (channels.size === 0 && registry?.activeAdmissions === 0) { + UNKNOWN_EFFECT_CHANNELS.delete(queryClient); + } +} + +function notifyUnknownEffectChannel(channel: UnknownEffectChannel): void { + channel.version += 1; + for (const listener of [...channel.listeners]) listener(); +} + +function settleMutationAdmission( + channel: UnknownEffectChannel, + admission: MutationAdmission, +): void { + markMutationAdmissionSettled(channel, admission); + while (channel.admissions[0]?.state === "SETTLED") { + channel.admissions.shift(); + } + notifyUnknownEffectChannel(channel); + releaseUnknownEffectChannelIfUnused(channel.owner, channel); +} + +function markMutationAdmissionSettled( + channel: UnknownEffectChannel, + admission: MutationAdmission, +): void { + if (admission.state === "SETTLED") return; + admission.state = "SETTLED"; + channel.registry.activeAdmissions = Math.max( + 0, + channel.registry.activeAdmissions - 1, + ); +} + +function removeAdmissionOptimisticQuery( + queryClient: QueryClient, + admission: MutationAdmission, +): void { + if (!admission.optimisticQueryKey) return; + queryClient.removeQueries({ + queryKey: admission.optimisticQueryKey, + exact: true, }); } diff --git a/src/presentation/adapters/query/optimistic-layer-runtime.ts b/src/presentation/adapters/query/optimistic-layer-runtime.ts index 6c3edbd..38a17a1 100644 --- a/src/presentation/adapters/query/optimistic-layer-runtime.ts +++ b/src/presentation/adapters/query/optimistic-layer-runtime.ts @@ -6,19 +6,25 @@ import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts"; export type OptimisticLayerLease = Readonly<{ commit(): void; rollback(): void; + markUncertain(): void; + reconcile(resolution: "APPLIED" | "NOT_APPLIED"): void; }>; type Layer = { id: number; - status: "pending" | "committed"; + status: "pending" | "uncertain" | "committed"; apply(value: unknown): unknown; }; +type OptimisticLayerScope = Pick & + Partial>; + type EntryState = { queryKey: readonly unknown[]; - scope: CacheScopeSnapshot; + scope: OptimisticLayerScope; base: unknown; layers: Layer[]; + disposeScopeListener: (() => void) | null; }; export function createOptimisticLayerRuntime(queryClient: QueryClient) { @@ -26,6 +32,12 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { let nextId = 1; let writing = false; + function removeEntry(key: string, entry: EntryState): void { + if (entries.get(key) === entry) entries.delete(key); + entry.disposeScopeListener?.(); + entry.disposeScopeListener = null; + } + queryClient.getQueryCache().subscribe((event) => { if ( writing || @@ -42,7 +54,12 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { function project(key: string, entry: EntryState): void { if (!entry.scope.isCurrent()) { - entries.delete(key); + removeEntry(key, entry); + queryClient.removeQueries({ queryKey: entry.queryKey, exact: true }); + return; + } + if (entry.base === undefined && entry.layers.length === 0) { + removeEntry(key, entry); queryClient.removeQueries({ queryKey: entry.queryKey, exact: true }); return; } @@ -50,7 +67,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { try { for (const layer of entry.layers) value = layer.apply(value); } catch { - entries.delete(key); + removeEntry(key, entry); return; } writing = true; @@ -68,7 +85,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { entry.base = committed.apply(entry.base); } project(key, entry); - if (entry.layers.length === 0) entries.delete(key); + if (entry.layers.length === 0) removeEntry(key, entry); } return Object.freeze({ @@ -76,23 +93,42 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { queryKey: readonly unknown[], input: Input, update: (previous: unknown, input: Input) => unknown, - scope: CacheScopeSnapshot, + scope: OptimisticLayerScope, ): OptimisticLayerLease | null { if (!scope.isCurrent()) return null; const current = queryClient.getQueryData(queryKey); - if (current === undefined) return null; const key = hashKey(queryKey); let entry = entries.get(key); if (!entry) { - entry = { queryKey, scope, base: current, layers: [] }; + entry = { + queryKey, + scope, + base: current, + layers: [], + disposeScopeListener: null, + }; entries.set(key, entry); + if (scope.signal) { + const selectedEntry = entry; + const discardScope = () => { + removeEntry(key, selectedEntry); + queryClient.removeQueries({ queryKey, exact: true }); + }; + scope.signal.addEventListener("abort", discardScope, { once: true }); + entry.disposeScopeListener = () => + scope.signal?.removeEventListener("abort", discardScope); + if (scope.signal.aborted || !scope.isCurrent()) { + discardScope(); + return null; + } + } } else if (entry.scope !== scope) { return null; } // §11.5. Overflow falls back to pessimistic execution; an existing // layer is never silently evicted to make room for a new one. if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) { - if (entry.layers.length === 0) entries.delete(key); + if (entry.layers.length === 0) removeEntry(key, entry); return null; } const layer: Layer = { @@ -103,40 +139,64 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) { let projected: unknown; try { projected = update(entry.base, input); - } catch { - if (entry.layers.length === 0) entries.delete(key); - return null; + } catch (error) { + if (entry.layers.length === 0) removeEntry(key, entry); + throw error; } if ( estimateLayerBytes(projected) > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes ) { - if (entry.layers.length === 0) entries.delete(key); + if (entry.layers.length === 0) removeEntry(key, entry); return null; } entry.layers.push(layer); project(key, entry); - let settled = false; + let state: "pending" | "uncertain" | "settled" = "pending"; + const selectedLayer = () => { + if (entries.get(key) !== entry) return undefined; + return entry.layers.find((candidate) => candidate.id === layer.id); + }; return Object.freeze({ commit() { - if (settled) return; - settled = true; - const selected = entry?.layers.find( - (candidate) => candidate.id === layer.id, - ); - if (!entry || !selected) return; + if (state !== "pending") return; + state = "settled"; + const selected = selectedLayer(); + if (!selected) return; selected.status = "committed"; collapse(key, entry); }, rollback() { - if (settled) return; - settled = true; - if (!entry) return; + if (state !== "pending") return; + state = "settled"; + if (!selectedLayer()) return; entry.layers = entry.layers.filter( (candidate) => candidate.id !== layer.id, ); collapse(key, entry); }, + markUncertain() { + if (state !== "pending") return; + state = "uncertain"; + const selected = selectedLayer(); + if (!selected) return; + selected.status = "uncertain"; + collapse(key, entry); + }, + reconcile(resolution) { + if (state !== "uncertain") return; + state = "settled"; + const selected = selectedLayer(); + if (!selected) return; + if (resolution === "APPLIED") { + selected.status = "committed"; + } else { + entry.layers = entry.layers.filter( + (candidate) => candidate.id !== layer.id, + ); + } + collapse(key, entry); + }, }); }, }); diff --git a/src/presentation/components/async-surface.tsx b/src/presentation/components/async-surface.tsx index e4486a6..f172014 100644 --- a/src/presentation/components/async-surface.tsx +++ b/src/presentation/components/async-surface.tsx @@ -87,6 +87,9 @@ export type AsyncSurfaceProps = Readonly<{ onAction?: () => void; onRetry?: () => void; onResolveConflict?: () => void; + onReconcileUnknownEffect?: ( + resolution: "APPLIED" | "NOT_APPLIED", + ) => void; }>; export function AsyncSurface({ @@ -95,6 +98,7 @@ export function AsyncSurface({ onAction, onRetry, onResolveConflict, + onReconcileUnknownEffect, }: AsyncSurfaceProps) { const { message } = useLocale(); if (state.base === "initial-loading") return ; @@ -121,11 +125,13 @@ export function AsyncSurface({ {message( state.indicator === "stale-degraded" ? "async.staleDegraded" - : state.indicator === "mutation-conflict" - ? "async.mutationConflict" - : state.indicator === "mutation-pending" - ? "async.mutationPending" - : "async.refreshing", + : state.indicator === "mutation-effect-unknown" + ? "async.mutationEffectUnknown" + : state.indicator === "mutation-conflict" + ? "async.mutationConflict" + : state.indicator === "mutation-pending" + ? "async.mutationPending" + : "async.refreshing", )} {state.indicator === "stale-degraded" && onRetry ? ( @@ -136,6 +142,21 @@ export function AsyncSurface({ {message("action.resolveConflict")} ) : null} + {state.indicator === "mutation-effect-unknown" && + onReconcileUnknownEffect ? ( + <> + + + + ) : null} ) : null} {children} diff --git a/src/presentation/forms/form-contracts.ts b/src/presentation/forms/form-contracts.ts index 0cd2f74..5fc7670 100644 --- a/src/presentation/forms/form-contracts.ts +++ b/src/presentation/forms/form-contracts.ts @@ -18,6 +18,7 @@ export type FormResultState = | "success" | "validation-error" | "conflict" + | "effect-unknown" | "unavailable"; export type MappedValidationFailure = Readonly<{ diff --git a/src/presentation/forms/use-app-form.ts b/src/presentation/forms/use-app-form.ts index 5518e56..ab11259 100644 --- a/src/presentation/forms/use-app-form.ts +++ b/src/presentation/forms/use-app-form.ts @@ -135,6 +135,26 @@ export function useAppForm< [defaultValues], ); + const settleSuccessfulValues = useCallback( + (settledValues: Values) => { + setFieldErrors({} as FieldErrors); + setFormErrors([]); + setResult("success"); + if (resetOnSuccess) { + setValues(defaultValues); + setInitialValues(defaultValues); + setTouched(new Set()); + } else { + setInitialValues(settledValues); + } + }, + [defaultValues, resetOnSuccess], + ); + + const settleApplied = useCallback(() => { + settleSuccessfulValues(values); + }, [settleSuccessfulValues, values]); + const submitForm = useCallback( async (event?: FormEvent): Promise | null> => { event?.preventDefault(); @@ -170,14 +190,7 @@ export function useAppForm< try { const outcome = await execution; if (outcome.ok) { - setResult("success"); - if (resetOnSuccess) { - setValues(defaultValues); - setInitialValues(defaultValues); - setTouched(new Set()); - } else { - setInitialValues(parsed.data); - } + settleSuccessfulValues(parsed.data); return outcome; } if (outcome.error.kind === "VALIDATION_REJECTED") { @@ -190,6 +203,11 @@ export function useAppForm< setFormErrors(mapped.formErrors); setResult("validation-error"); focusFirstError(mapped.fieldErrors); + } else if (outcome.error.effect === "MAYBE_APPLIED") { + setFormErrors([]); + setResult("effect-unknown"); + } else if (outcome.error.effect === "APPLIED_CONFIRMED") { + settleSuccessfulValues(parsed.data); } else if (outcome.error.kind === "CONFLICT") { setFormErrors([ message("form.conflict"), @@ -207,12 +225,11 @@ export function useAppForm< }, [ allowedServerFields, - defaultValues, focusFirstError, mapToCommand, message, - resetOnSuccess, schema, + settleSuccessfulValues, submit, values, ], @@ -233,6 +250,7 @@ export function useAppForm< setValue, submitForm, reset, + settleApplied, }); } diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index 857d0bb..71033c4 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -11,6 +11,8 @@ const PLATFORM_KO_MESSAGES = { "action.reloadOnce": "한 번 새로고침", "action.contactSupport": "지원 정보 확인", "action.resolveConflict": "충돌 해결", + "action.confirmMutationApplied": "변경됨으로 확인", + "action.confirmMutationNotApplied": "변경되지 않음으로 확인", "action.continueEditing": "계속 작성", "action.discardAndLeave": "변경 버리고 이동", "action.signIn": "로그인 시작", @@ -55,6 +57,7 @@ const PLATFORM_KO_MESSAGES = { "async.refreshing": "최신 정보를 확인하고 있습니다.", "async.staleDegraded": "기존 정보를 표시하고 있습니다.", "async.mutationPending": "변경 사항을 저장하고 있습니다.", + "async.mutationEffectUnknown": "변경 결과를 확인할 수 없습니다.", "async.mutationConflict": "다른 변경과 충돌했습니다.", "access.auth.eyebrow": "401 · 인증 필요", "access.auth.title": "로그인이 필요합니다.", @@ -163,6 +166,8 @@ const PLATFORM_EN_MESSAGES = { "action.reloadOnce": "Reload once", "action.contactSupport": "View support information", "action.resolveConflict": "Resolve conflict", + "action.confirmMutationApplied": "Confirm the change was applied", + "action.confirmMutationNotApplied": "Confirm the change was not applied", "action.continueEditing": "Continue editing", "action.discardAndLeave": "Discard and leave", "action.signIn": "Start sign-in", @@ -207,6 +212,7 @@ const PLATFORM_EN_MESSAGES = { "async.refreshing": "Checking for the latest information.", "async.staleDegraded": "Showing previously loaded information.", "async.mutationPending": "Saving changes.", + "async.mutationEffectUnknown": "The result of the change is unknown.", "async.mutationConflict": "The change conflicts with another update.", "access.auth.eyebrow": "401 · Authentication required", "access.auth.title": "Sign-in is required.", diff --git a/tests/component/application-query.test.tsx b/tests/component/application-query.test.tsx index 68ba02c..a82911e 100644 --- a/tests/component/application-query.test.tsx +++ b/tests/component/application-query.test.tsx @@ -7,6 +7,7 @@ import { } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; +import { renderToString } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { @@ -29,6 +30,7 @@ import { } from "../../src/contracts/query-keys.ts"; import { bindQuery, + MUTATION_COORDINATOR_BOUNDS, type QueryResultMeasure, } from "../../src/contracts/server-state.ts"; import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; @@ -41,15 +43,18 @@ const RESOURCE_INVALIDATION_TOPIC = * here with a local scope fixture rather than through the removable sample * feature. */ -function scopeSnapshot(): CacheScopeSnapshot & { fence(): void } { +function scopeSnapshot( + generation = 1, + fingerprint = "scope-fingerprint-0001", +): CacheScopeSnapshot & { fence(): void } { let current = true; const lifetime = new AbortController(); const identities = createRuntimeIdentityRegistry({ tokenFactory: () => "scope-identity-token-0001", }); return { - generation: 1, - fingerprint: "scope-fingerprint-0001", + generation, + fingerprint, identities, signal: lifetime.signal, isCurrent: () => current, @@ -454,7 +459,10 @@ describe("scope-bound mutation fence", () => { const outcome = await hook.result.current.submit("value"); expect(outcome).toMatchObject({ ok: false, - error: { kind: "SCOPE_GENERATION_CHANGED" }, + error: { + kind: "SCOPE_GENERATION_CHANGED", + effect: "NOT_STARTED", + }, }); expect(execute).not.toHaveBeenCalled(); }); @@ -544,6 +552,1196 @@ describe("scope-bound mutation fence", () => { }); describe("application mutation inbound bridge", () => { + it.each([ + ["NOT_STARTED", ["existing"], 0, null], + ["NOT_APPLIED", ["existing"], 0, null], + ["APPLIED_CONFIRMED", ["existing", "created"], 1, null], + [ + "MAYBE_APPLIED", + ["existing", "created"], + 0, + "mutation-effect-unknown", + ], + ] as const)( + "settles an optimistic failure with %s certainty", + async (effect, expectedData, expectedInvalidations, expectedIndicator) => { + const client = queryClient(); + const key = ["resource", `certainty-${effect}`]; + client.setQueryData(key, ["existing"]); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect, + }); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome: ApplicationResult | undefined; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toMatchObject({ + ok: false, + error: { kind: failure.kind, effect }, + }); + expect(client.getQueryData(key)).toEqual([...expectedData]); + expect(invalidateQueries).toHaveBeenCalledTimes(expectedInvalidations); + expect(hook.result.current.state.indicator).toBe(expectedIndicator); + if (effect === "MAYBE_APPLIED") { + expect(outcome).toMatchObject({ + ok: false, + error: { retryable: false, action: "contact-support" }, + }); + expect(hook.result.current.state.overlay).toMatchObject({ + mutationEffectUnknown: true, + mutationPending: false, + mutationConflict: false, + }); + } + if (effect === "APPLIED_CONFIRMED") { + expect(outcome).toMatchObject({ + ok: false, + error: { retryable: false, action: "none" }, + }); + } + }, + ); + + it("keeps an applied-confirmed layer committed when invalidation fails", async () => { + const client = queryClient(); + const key = ["resource", "applied-invalidation-failure"]; + client.setQueryData(key, ["base"]); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockImplementation(async () => { + expect(client.getQueryData(key)).toEqual(["base", "created"]); + throw new Error("refresh failed"); + }); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "APPLIED_CONFIRMED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-applied-invalidation-failure-v1", + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome: ApplicationResult | undefined; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toMatchObject({ + ok: false, + error: { + effect: "APPLIED_CONFIRMED", + retryable: false, + action: "none", + }, + }); + expect(invalidateQueries).toHaveBeenCalledOnce(); + expect(client.getQueryData(key)).toEqual(["base", "created"]); + }); + + it("treats a command failure with missing effect certainty as maybe applied", async () => { + const client = queryClient(); + const key = ["resource", "missing-effect"]; + client.setQueryData(key, ["existing"]); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0); + const execute = vi.fn(async () => ({ ok: false as const, error: failure })); + const hook = renderHook( + () => + useApplicationMutation({ + execute, + invalidate: [RESOURCE_INVALIDATION_TOPIC], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome: ApplicationResult | undefined; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(execute).toHaveBeenCalledOnce(); + expect(outcome).toMatchObject({ + ok: false, + error: { + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + expect(client.getQueryData(key)).toEqual(["existing", "created"]); + expect(invalidateQueries).not.toHaveBeenCalled(); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }); + + it.each([ + ["APPLIED", ["existing", "created"], 1], + ["NOT_APPLIED", ["existing"], 0], + ] as const)( + "reconciles an unknown optimistic effect as %s exactly once", + async (resolution, expectedData, expectedInvalidations) => { + const client = queryClient(); + const scope = scopeSnapshot(); + const key = ["resource", `reconcile-${resolution}`]; + client.setQueryData(key, ["existing"]); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: `reconcile-${resolution}-v1`, + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + await act(() => hook.result.current.submit("created")); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + + await act(() => hook.result.current.reconcileUnknownEffect(resolution)); + await act(() => + hook.result.current.reconcileUnknownEffect( + resolution === "APPLIED" ? "NOT_APPLIED" : "APPLIED", + ), + ); + + expect(client.getQueryData(key)).toEqual([...expectedData]); + expect(invalidateQueries).toHaveBeenCalledTimes(expectedInvalidations); + expect(hook.result.current.state.indicator).toBeNull(); + }, + ); + + it("reconciles parallel unknown effects without losing either lease", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const key = ["resource", "parallel-unknown"]; + client.setQueryData(key, ["base"]); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const resolvers: Array<(value: ApplicationResult) => void> = []; + const execute = vi.fn( + (_input: string) => + new Promise>((resolve) => { + resolvers.push(resolve); + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "parallel-unknown-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL", + scope, + execute, + invalidate: [RESOURCE_INVALIDATION_TOPIC], + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | undefined; + let second: Promise> | undefined; + act(() => { + first = hook.result.current.submit("created"); + second = hook.result.current.submit("created"); + }); + await waitFor(() => expect(resolvers).toHaveLength(2)); + act(() => resolvers[0]?.({ ok: false, error: failure })); + if (!first) throw new Error("expected first mutation"); + await act(() => first); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + act(() => resolvers[1]?.({ ok: false, error: failure })); + if (!second) throw new Error("expected second mutation"); + await act(() => second); + + expect(client.getQueryData(key)).toEqual(["base", "created", "created"]); + await act(() => hook.result.current.reconcileUnknownEffect("NOT_APPLIED")); + expect(client.getQueryData(key)).toEqual(["base", "created"]); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + + await act(() => hook.result.current.reconcileUnknownEffect("APPLIED")); + expect(client.getQueryData(key)).toEqual(["base", "created"]); + expect(invalidateQueries).toHaveBeenCalledOnce(); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it("closes an unknown optimistic effect safely after scope expiry", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const key = ["resource", "expired-unknown"]; + client.setQueryData(key, ["existing"]); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "expired-unknown-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + await act(() => hook.result.current.submit("created")); + scope.fence(); + await act(() => hook.result.current.reconcileUnknownEffect("APPLIED")); + + expect(client.getQueryState(key)).toBeUndefined(); + expect(invalidateQueries).not.toHaveBeenCalled(); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it("reconciles a legacy unknown effect without an optimistic layer", async () => { + const client = queryClient(); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-no-layer-reconcile-v1", + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + }), + { wrapper: wrapper(client) }, + ); + + await act(() => hook.result.current.submit("created")); + expect(invalidateQueries).not.toHaveBeenCalled(); + + await act(() => hook.result.current.reconcileUnknownEffect("APPLIED")); + expect(invalidateQueries).toHaveBeenCalledOnce(); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it("preserves a later legacy commit when an earlier unknown effect is not applied", async () => { + const client = queryClient(); + const key = ["resource", "legacy-ordered-unknown"]; + client.setQueryData(key, ["base"]); + const uncertainFailure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const resolvers = new Map< + string, + (value: ApplicationResult) => void + >(); + const execute = vi.fn( + (input: string) => + new Promise>((resolve) => { + resolvers.set(input, resolve); + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-active-after-unknown-v1", + duplicatePolicy: "ALLOW_PARALLEL", + execute, + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | undefined; + let second: Promise> | undefined; + act(() => { + first = hook.result.current.submit("first"); + second = hook.result.current.submit("second"); + }); + await waitFor(() => expect(resolvers.size).toBe(2)); + act(() => + resolvers.get("first")?.({ ok: false, error: uncertainFailure }), + ); + if (!first) throw new Error("expected first mutation"); + await act(() => first); + act(() => + resolvers.get("second")?.({ ok: true, value: "second" }), + ); + if (!second) throw new Error("expected second mutation"); + await act(() => second); + + expect(client.getQueryData(key)).toEqual(["base", "first", "second"]); + await act(() => hook.result.current.reconcileUnknownEffect("NOT_APPLIED")); + expect(client.getQueryData(key)).toEqual(["base", "second"]); + }); + + it("keeps a newer submit pending while reconciling an older unknown effect", async () => { + const client = queryClient(); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + let completeSecond: (value: ApplicationResult) => void = () => {}; + const execute = vi.fn((input: string) => + input === "first" + ? Promise.resolve({ ok: false as const, error: failure }) + : new Promise>((resolve) => { + completeSecond = resolve; + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-active-after-unknown-no-layer-v1", + duplicatePolicy: "ALLOW_PARALLEL", + execute, + }), + { wrapper: wrapper(client) }, + ); + + await act(() => hook.result.current.submit("first")); + let second: Promise> | undefined; + act(() => { + second = hook.result.current.submit("second"); + }); + await waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + + await act(() => hook.result.current.reconcileUnknownEffect("NOT_APPLIED")); + expect(hook.result.current.state.indicator).toBe("mutation-pending"); + + completeSecond({ ok: true, value: "second" }); + if (!second) throw new Error("expected second mutation"); + await act(() => second); + await waitFor(() => expect(hook.result.current.state.indicator).toBeNull()); + }); + + it("serializes reconciliation so a double action cannot settle the next intent", async () => { + const client = queryClient(); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + let finishInvalidation: () => void = () => {}; + const invalidation = new Promise((resolve) => { + finishInvalidation = resolve; + }); + vi.spyOn(client, "invalidateQueries").mockImplementation( + async () => invalidation, + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-applied-double-action-v1", + duplicatePolicy: "ALLOW_PARALLEL", + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + }), + { wrapper: wrapper(client) }, + ); + + await act(async () => { + await Promise.all([ + hook.result.current.submit("first"), + hook.result.current.submit("second"), + ]); + }); + + let firstResolution: Promise | undefined; + let duplicateResolution: Promise | undefined; + act(() => { + firstResolution = hook.result.current.reconcileUnknownEffect("APPLIED"); + duplicateResolution = hook.result.current.reconcileUnknownEffect( + "NOT_APPLIED", + ); + }); + await expect(duplicateResolution).resolves.toBeUndefined(); + expect(hook.result.current.state.indicator).toBe("mutation-pending"); + + finishInvalidation(); + if (!firstResolution) throw new Error("expected reconciliation"); + await act(() => firstResolution); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + await act(() => hook.result.current.reconcileUnknownEffect("NOT_APPLIED")); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it("keeps the next intent unresolved after a synchronous not-applied double action", async () => { + const client = queryClient(); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-not-applied-double-action-v1", + duplicatePolicy: "ALLOW_PARALLEL", + execute: async () => ({ ok: false, error: failure }), + }), + { wrapper: wrapper(client) }, + ); + + await act(async () => { + await Promise.all([ + hook.result.current.submit("first"), + hook.result.current.submit("second"), + ]); + }); + + let firstResolution: Promise | undefined; + let duplicateResolution: Promise | undefined; + act(() => { + firstResolution = hook.result.current.reconcileUnknownEffect( + "NOT_APPLIED", + ); + duplicateResolution = hook.result.current.reconcileUnknownEffect( + "APPLIED", + ); + }); + await expect(duplicateResolution).resolves.toBeUndefined(); + if (!firstResolution) throw new Error("expected reconciliation"); + await act(() => firstResolution); + + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + await act(() => hook.result.current.reconcileUnknownEffect("NOT_APPLIED")); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it("clears every stale-scope unknown effect without touching new data", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const key = ["resource", "scope-unknown-queue"]; + client.setQueryData(key, ["base"]); + const invalidateQueries = vi.spyOn(client, "invalidateQueries"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "scope-unknown-queue-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL", + scope, + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + await act(async () => { + await Promise.all([ + hook.result.current.submit("created"), + hook.result.current.submit("created"), + ]); + }); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + + act(() => scope.fence()); + await waitFor(() => expect(hook.result.current.state.indicator).toBeNull()); + expect(client.getQueryState(key)).toBeUndefined(); + client.setQueryData(key, ["new-generation"]); + await act(() => hook.result.current.reconcileUnknownEffect("APPLIED")); + + expect(client.getQueryData(key)).toEqual(["new-generation"]); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("restores a bound unknown-effect head after controller remount", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const key = ["resource", "durable-unknown"]; + client.setQueryData(key, ["base"]); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const mutationOptions = { + definitionId: "durable-unknown-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE" as const, + scope, + execute: vi.fn(async () => ({ ok: false as const, error: failure })), + invalidate: [] as const, + optimistic: { + queryKey: key, + update: (previous: unknown, input: string) => [ + ...(previous as string[]), + input, + ], + }, + }; + const first = renderHook( + () => useApplicationMutation(mutationOptions), + { wrapper: wrapper(client) }, + ); + + await act(() => first.result.current.submit("created")); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + first.unmount(); + + const remounted = renderHook( + () => useApplicationMutation(mutationOptions), + { wrapper: wrapper(client) }, + ); + expect(remounted.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + await act(() => + remounted.result.current.reconcileUnknownEffect("NOT_APPLIED"), + ); + + expect(client.getQueryData(key)).toEqual(["base"]); + expect(remounted.result.current.state.indicator).toBeNull(); + expect(mutationOptions.execute).toHaveBeenCalledOnce(); + }); + + it("restores a non-optimistic legacy unknown effect by stable definition after remount", async () => { + const client = queryClient(); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const mutationOptions = { + definitionId: "legacy-durable-create-v1", + execute: vi.fn(async () => ({ ok: false as const, error: failure })), + }; + const first = renderHook( + () => useApplicationMutation(mutationOptions), + { wrapper: wrapper(client) }, + ); + + await act(() => first.result.current.submit("created")); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + first.unmount(); + + const remounted = renderHook( + () => useApplicationMutation(mutationOptions), + { wrapper: wrapper(client) }, + ); + expect(remounted.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + await act(() => + remounted.result.current.reconcileUnknownEffect("NOT_APPLIED"), + ); + + expect(remounted.result.current.state.indicator).toBeNull(); + expect(mutationOptions.execute).toHaveBeenCalledOnce(); + }); + + it("does not allocate unknown-effect channels for abandoned server renders", async () => { + const client = queryClient(); + const selectedScopes = Array.from( + { length: MUTATION_COORDINATOR_BOUNDS.activeDefinitionsPerRuntime }, + (_, index) => scopeSnapshot(1, `server-render-scope-${index}`), + ); + + function AbandonedMutation({ index }: Readonly<{ index: number }>) { + useApplicationMutation({ + definitionId: `server-render-abandoned-${index}`, + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope: selectedScopes[index]!, + execute: async (input) => ({ ok: true, value: input }), + invalidate: [], + }); + return null; + } + + const ServerWrapper = wrapper(client); + renderToString( + + {selectedScopes.map((_, index) => ( + + ))} + , + ); + + const scope = scopeSnapshot(1, "committed-after-server-render"); + const execute = vi.fn(async (input: string) => ({ + ok: true as const, + value: input, + })); + const committed = renderHook( + () => + useApplicationMutation({ + definitionId: "committed-after-server-render", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + await expect(committed.result.current.submit("created")).resolves.toEqual({ + ok: true, + value: "created", + }); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("isolates channels when a reused fingerprint advances generation", async () => { + const client = queryClient(); + const firstScope = scopeSnapshot(1, "reused-scope-fingerprint"); + const secondScope = scopeSnapshot(2, "reused-scope-fingerprint"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const definition = { + definitionId: "generation-channel-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE" as const, + invalidate: [] as const, + }; + const first = renderHook( + () => + useApplicationMutation({ + ...definition, + scope: firstScope, + execute: async () => ({ ok: false, error: failure }), + }), + { wrapper: wrapper(client) }, + ); + await act(() => first.result.current.submit("first")); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + + const secondExecute = vi.fn(async () => ({ + ok: true as const, + value: "second", + })); + const second = renderHook( + () => + useApplicationMutation({ + ...definition, + scope: secondScope, + execute: secondExecute, + }), + { wrapper: wrapper(client) }, + ); + + expect(second.result.current.state.indicator).toBeNull(); + await expect(second.result.current.submit("second")).resolves.toEqual({ + ok: true, + value: "second", + }); + expect(secondExecute).toHaveBeenCalledOnce(); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }); + + it("isolates unrelated non-optimistic legacy controllers", async () => { + const client = queryClient(); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const first = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-isolation-first-v1", + execute: async () => ({ ok: false, error: failure }), + }), + { wrapper: wrapper(client) }, + ); + const second = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-isolation-second-v1", + execute: async (input) => ({ ok: true, value: input }), + }), + { wrapper: wrapper(client) }, + ); + + await act(() => first.result.current.submit("first")); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + expect(second.result.current.state.indicator).toBeNull(); + await act(() => second.result.current.reconcileUnknownEffect("APPLIED")); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }); + + it("fails closed when identical bound metadata is mounted with a different scope owner", async () => { + const client = queryClient(); + const firstScope = scopeSnapshot(1, "same-owner-fingerprint"); + const secondScope = scopeSnapshot(1, "same-owner-fingerprint"); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const definition = { + definitionId: "scope-owner-channel-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE" as const, + invalidate: [] as const, + }; + const first = renderHook( + () => + useApplicationMutation({ + ...definition, + scope: firstScope, + execute: async () => ({ ok: false, error: failure }), + }), + { wrapper: wrapper(client) }, + ); + await act(() => first.result.current.submit("first")); + + const secondExecute = vi.fn(async () => ({ + ok: true as const, + value: "second", + })); + const second = renderHook( + () => + useApplicationMutation({ + ...definition, + scope: secondScope, + execute: secondExecute, + }), + { wrapper: wrapper(client) }, + ); + + expect(second.result.current.state.indicator).toBeNull(); + await expect(second.result.current.submit("second")).resolves.toMatchObject({ + ok: false, + error: { + kind: "IDENTITY_INTERN_LIMIT_EXCEEDED", + code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED", + effect: "NOT_STARTED", + }, + }); + expect(secondExecute).not.toHaveBeenCalled(); + expect(first.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }); + + it("releases empty unmounted channels after their active mutation settles", async () => { + const client = queryClient(); + const firstScope = scopeSnapshot(1, "release-owner-fingerprint"); + const secondScope = scopeSnapshot(1, "release-owner-fingerprint"); + let resolveFirst: (value: ApplicationResult) => void = () => {}; + const definition = { + definitionId: "release-channel-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL" as const, + invalidate: [] as const, + }; + const first = renderHook( + () => + useApplicationMutation({ + ...definition, + scope: firstScope, + execute: () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + }), + { wrapper: wrapper(client) }, + ); + let pending: Promise> | undefined; + act(() => { + pending = first.result.current.submit("created"); + }); + await waitFor(() => + expect(first.result.current.state.indicator).toBe("mutation-pending"), + ); + first.unmount(); + resolveFirst({ ok: true, value: "created" }); + if (!pending) throw new Error("expected active mutation"); + await act(() => pending); + + const execute = vi.fn(async () => ({ + ok: true as const, + value: "after-release", + })); + const afterRelease = renderHook( + () => + useApplicationMutation({ + ...definition, + scope: secondScope, + execute, + }), + { wrapper: wrapper(client) }, + ); + + await expect(afterRelease.result.current.submit("created")).resolves.toEqual({ + ok: true, + value: "after-release", + }); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("enforces the active intent bound across all QueryClient channels", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const never = () => new Promise>(() => {}); + const options = (definitionId: string) => ({ + definitionId, + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL" as const, + scope, + execute: never, + invalidate: [] as const, + }); + const first = renderHook( + () => useApplicationMutation(options("bound-a")), + { wrapper: wrapper(client) }, + ); + const second = renderHook( + () => useApplicationMutation(options("bound-b")), + { wrapper: wrapper(client) }, + ); + const half = MUTATION_COORDINATOR_BOUNDS.activeIntentsTotal / 2; + + act(() => { + for (let index = 0; index < half; index += 1) { + void first.result.current.submit("same-input"); + void second.result.current.submit("same-input"); + } + }); + + await expect(first.result.current.submit("same-input")).resolves.toMatchObject({ + ok: false, + error: { + kind: "IDENTITY_INTERN_LIMIT_EXCEEDED", + code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED", + effect: "NOT_STARTED", + }, + }); + act(() => scope.fence()); + }); + + it("reconciles parallel unknown effects in submit order after reverse completion", async () => { + const client = queryClient(); + const key = ["resource", "reverse-completion"]; + client.setQueryData(key, ["base"]); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const resolvers = new Map< + string, + (value: ApplicationResult) => void + >(); + const hook = renderHook( + () => + useApplicationMutation({ + duplicatePolicy: "ALLOW_PARALLEL", + execute: (input) => + new Promise((resolve) => { + resolvers.set(input, resolve); + }), + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | undefined; + let second: Promise> | undefined; + act(() => { + first = hook.result.current.submit("first"); + second = hook.result.current.submit("second"); + }); + await waitFor(() => expect(resolvers.size).toBe(2)); + act(() => resolvers.get("second")?.({ ok: false, error: failure })); + if (!second) throw new Error("expected second mutation"); + await act(() => second); + expect(hook.result.current.state.indicator).toBe("mutation-pending"); + + act(() => resolvers.get("first")?.({ ok: false, error: failure })); + if (!first) throw new Error("expected first mutation"); + await act(() => first); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + + await act(() => hook.result.current.reconcileUnknownEffect("NOT_APPLIED")); + expect(client.getQueryData(key)).toEqual(["base", "second"]); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + await act(() => hook.result.current.reconcileUnknownEffect("APPLIED")); + expect(client.getQueryData(key)).toEqual(["base", "second"]); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it("removes committed old-scope data when scope fences during reconciliation invalidation", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const key = ["resource", "reconcile-fence-race"]; + client.setQueryData(key, ["base"]); + let finishInvalidation: () => void = () => {}; + const invalidation = new Promise((resolve) => { + finishInvalidation = resolve; + }); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockImplementation(async () => invalidation); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "MAYBE_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "reconcile-fence-race-v1", + definitionVersion: 1, + operationId: "CREATE", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute: async () => ({ ok: false, error: failure }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + await act(() => hook.result.current.submit("created")); + let reconciliation: Promise | undefined; + act(() => { + reconciliation = hook.result.current.reconcileUnknownEffect("APPLIED"); + }); + await waitFor(() => expect(invalidateQueries).toHaveBeenCalledOnce()); + act(() => scope.fence()); + finishInvalidation(); + if (!reconciliation) throw new Error("expected reconciliation"); + await act(() => reconciliation); + + expect(client.getQueryState(key)).toBeUndefined(); + expect(hook.result.current.state.indicator).toBeNull(); + }); + + it.each(["NOT_APPLICABLE", undefined] as const)( + "treats %s conflict effect certainty as unknown without conflict retry", + async (effect) => { + const client = queryClient(); + const key = ["resource", `ambiguous-conflict-${String(effect)}`]; + client.setQueryData(key, ["base"]); + const conflict = createFailure("CONFLICT", "CREATE", 0, { + ...(effect ? { effect } : {}), + }); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => ({ ok: false, error: conflict }), + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome: ApplicationResult | undefined; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toMatchObject({ + ok: false, + error: { + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + expect(client.getQueryData(key)).toEqual(["base", "created"]); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }, + ); + + it("retains optimistic data when execute throws after dispatch begins", async () => { + const client = queryClient(); + const key = ["resource", "thrown-unknown-effect"]; + client.setQueryData(key, ["base"]); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => { + throw new Error("private transport defect"); + }, + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome: ApplicationResult | undefined; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toMatchObject({ + ok: false, + error: { + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + expect(JSON.stringify(outcome)).not.toContain("private transport defect"); + expect(client.getQueryData(key)).toEqual(["base", "created"]); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }); + it("creates a distinct logical intent for each independently admitted submit", async () => { const client = queryClient(); const scope = scopeSnapshot(); @@ -656,7 +1854,11 @@ describe("application mutation inbound bridge", () => { value: input, })); const hook = renderHook( - () => useApplicationMutation({ execute }), + () => + useApplicationMutation({ + definitionId: "legacy-raw-path-v1", + execute, + }), { wrapper: wrapper(client, factory) }, ); @@ -676,7 +1878,11 @@ describe("application mutation inbound bridge", () => { }), ); const hook = renderHook( - () => useApplicationMutation({ execute }), + () => + useApplicationMutation({ + definitionId: "legacy-duplicate-rejection-v1", + execute, + }), { wrapper: wrapper(client) }, ); @@ -767,6 +1973,7 @@ describe("application mutation inbound bridge", () => { const hook = renderHook( () => useApplicationMutation({ + definitionId: "legacy-parallel-distinct-inputs-v1", execute, currentData: true, }), @@ -907,6 +2114,7 @@ describe("application mutation inbound bridge", () => { ok: false, error: { kind: "UNKNOWN_FAILURE", + effect: "NOT_STARTED", operationId: "APPLICATION_MUTATION", userMessageKey: "error.unknown_failure", }, @@ -919,7 +2127,9 @@ describe("application mutation inbound bridge", () => { it("removes an optimistic cache entry when no prior data existed", async () => { const client = queryClient(); const key = ["resource", "new-optimistic-entry"]; - const failure = createFailure("SERVER_FAILURE", "CREATE", 0); + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "NOT_APPLIED", + }); const hook = renderHook( () => useApplicationMutation({ @@ -947,7 +2157,9 @@ describe("application mutation inbound bridge", () => { const client = queryClient(); const key = ["resource", "list"]; client.setQueryData(key, ["existing"]); - const conflict = createFailure("CONFLICT", "CREATE", 0); + const conflict = createFailure("CONFLICT", "CREATE", 0, { + effect: "NOT_APPLIED", + }); const hook = renderHook( () => useApplicationMutation({ diff --git a/tests/component/async-surface.test.tsx b/tests/component/async-surface.test.tsx index d2c10f9..f1e1c39 100644 --- a/tests/component/async-surface.test.tsx +++ b/tests/component/async-surface.test.tsx @@ -25,6 +25,10 @@ describe("async UI state matrix", () => { [{ data: ["value"], isFetching: true }, "refreshing"], [{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"], [{ data: ["value"], isMutationPending: true }, "mutation-pending"], + [ + { data: ["value"], hasMutationEffectUnknown: true }, + "mutation-effect-unknown", + ], [{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"], ])("derives overlay state %#", (signals, indicator) => { expect(deriveAsyncState(signals).indicator).toBe(indicator); @@ -35,16 +39,55 @@ describe("async UI state matrix", () => { data: ["value"], isFetching: true, isMutationPending: true, + hasMutationEffectUnknown: true, hasMutationConflict: true, }); - expect(state.indicator).toBe("mutation-conflict"); + expect(state.indicator).toBe("mutation-effect-unknown"); expect(state.overlay).toMatchObject({ refreshing: false, mutationPending: false, - mutationConflict: true, + mutationEffectUnknown: true, + mutationConflict: false, }); }); + it("renders unknown mutation effects with reconciliation-only actions", async () => { + const user = userEvent.setup(); + const retry = vi.fn(); + const reconcile = vi.fn(); + const state = deriveAsyncState({ + data: ["value"], + hasMutationEffectUnknown: true, + }); + + render( + + existing content + , + ); + + expect(screen.getByRole("status")).toHaveTextContent( + "변경 결과를 확인할 수 없습니다.", + ); + expect( + screen.getByText("existing content").closest("section"), + ).toHaveAttribute("aria-busy", "false"); + expect( + screen.queryByRole("button", { name: "다시 시도" }), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "변경됨으로 확인" })); + await user.click( + screen.getByRole("button", { name: "변경되지 않음으로 확인" }), + ); + expect(reconcile).toHaveBeenNthCalledWith(1, "APPLIED"); + expect(reconcile).toHaveBeenNthCalledWith(2, "NOT_APPLIED"); + expect(retry).not.toHaveBeenCalled(); + }); + it("keeps content visible while a non-blocking refresh runs", () => { const state = deriveAsyncState({ data: ["value"], isFetching: true }); render(existing content); diff --git a/tests/features/reference-feature/reference-page.test.tsx b/tests/features/reference-feature/reference-page.test.tsx index 7e5c469..573e446 100644 --- a/tests/features/reference-feature/reference-page.test.tsx +++ b/tests/features/reference-feature/reference-page.test.tsx @@ -281,6 +281,7 @@ describe("reference feature page states", () => { "CONFLICT", "CREATE_REFERENCE_RESOURCE", 0, + { effect: "NOT_APPLIED" }, ), }); expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible(); @@ -290,6 +291,112 @@ describe("reference feature page states", () => { expect(screen.getByLabelText("설명")).toHaveValue("Keep this input"); }); + it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => { + const user = userEvent.setup(); + const createResource = vi.fn(async () => ({ + ok: false as const, + error: createFailure( + "SERVER_FAILURE", + "CREATE_REFERENCE_RESOURCE", + 0, + { effect: "MAYBE_APPLIED" }, + ), + })); + renderReference( + inputWith({ createResource }), + "/examples/reference-resources/new", + ); + await user.type( + await screen.findByRole("textbox", { name: /새 항목 이름/ }), + "Unknown result", + ); + await user.click(screen.getByRole("button", { name: "저장" })); + + expect( + await screen.findByText("변경 결과를 확인할 수 없습니다."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "저장" })).toBeDisabled(); + expect( + screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "저장" })); + expect(createResource).toHaveBeenCalledOnce(); + expect( + screen.getByRole("textbox", { name: /새 항목 이름/ }), + ).toHaveValue("Unknown result"); + + await user.click( + screen.getByRole("button", { name: "변경되지 않음으로 확인" }), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(), + ); + expect(createResource).toHaveBeenCalledOnce(); + expect( + screen.getByRole("textbox", { name: /새 항목 이름/ }), + ).toHaveValue("Unknown result"); + }); + + it("settles the form after confirming an unknown create was applied", async () => { + const user = userEvent.setup(); + const createResource = vi.fn(async () => ({ + ok: false as const, + error: createFailure( + "SERVER_FAILURE", + "CREATE_REFERENCE_RESOURCE", + 0, + { effect: "MAYBE_APPLIED" }, + ), + })); + renderReference( + inputWith({ createResource }), + "/examples/reference-resources/new", + ); + const name = await screen.findByRole("textbox", { + name: /새 항목 이름/, + }); + await user.type(name, "Already created"); + await user.click(screen.getByRole("button", { name: "저장" })); + await user.click( + await screen.findByRole("button", { name: "변경됨으로 확인" }), + ); + + expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다."); + expect(name).toHaveValue(""); + await user.click(screen.getByRole("button", { name: "저장" })); + expect(createResource).toHaveBeenCalledOnce(); + }); + + it("treats an applied-confirmed failure as a settled create", async () => { + const user = userEvent.setup(); + const createResource = vi.fn(async () => ({ + ok: false as const, + error: createFailure( + "SERVER_FAILURE", + "CREATE_REFERENCE_RESOURCE", + 0, + { effect: "APPLIED_CONFIRMED" }, + ), + })); + renderReference( + inputWith({ createResource }), + "/examples/reference-resources/new", + ); + const name = await screen.findByRole("textbox", { + name: /새 항목 이름/, + }); + await user.type(name, "Committed despite response"); + await user.click(screen.getByRole("button", { name: "저장" })); + + expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다."); + expect(name).toHaveValue(""); + expect( + screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."), + ).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "저장" })); + expect(createResource).toHaveBeenCalledOnce(); + }); + it("keeps stale data visible during refresh failure and recovers on retry", async () => { const user = userEvent.setup(); const listResources = vi diff --git a/tests/fixtures/typecheck/invalid-async-overlay.ts b/tests/fixtures/typecheck/invalid-async-overlay.ts index 2ddc517..a96b75b 100644 --- a/tests/fixtures/typecheck/invalid-async-overlay.ts +++ b/tests/fixtures/typecheck/invalid-async-overlay.ts @@ -4,5 +4,6 @@ export const invalidPendingConflict: AsyncOverlay = { refreshing: false, staleDegraded: false, mutationPending: true, + mutationEffectUnknown: false, mutationConflict: true, }; diff --git a/tests/unit/optimistic-layer-runtime.test.ts b/tests/unit/optimistic-layer-runtime.test.ts index 16c90a2..e3ccbb5 100644 --- a/tests/unit/optimistic-layer-runtime.test.ts +++ b/tests/unit/optimistic-layer-runtime.test.ts @@ -71,6 +71,71 @@ describe("revision-safe optimistic layer runtime", () => { expect(client.getQueryData(key)).toEqual(["server", "pending"]); }); + it("keeps later committed layers projected while an earlier effect is uncertain", () => { + const client = new QueryClient(); + const key = ["query", "resources"]; + client.setQueryData(key, ["base"]); + const selectedScope = scope(); + const runtime = createOptimisticLayerRuntime(client); + const append = (previous: unknown, input: string) => [ + ...(previous as string[]), + input, + ]; + const first = runtime.begin( + key, + "first", + append, + selectedScope.snapshot, + ); + const second = runtime.begin( + key, + "second", + append, + selectedScope.snapshot, + ); + + first?.markUncertain(); + first?.commit(); + first?.rollback(); + second?.commit(); + expect(client.getQueryData(key)).toEqual(["base", "first", "second"]); + + first?.reconcile("NOT_APPLIED"); + expect(client.getQueryData(key)).toEqual(["base", "second"]); + }); + + it("collapses uncertain and later committed layers in order when reconciled as applied", () => { + const client = new QueryClient(); + const key = ["query", "resources"]; + client.setQueryData(key, ["base"]); + const selectedScope = scope(); + const runtime = createOptimisticLayerRuntime(client); + const append = (previous: unknown, input: string) => [ + ...(previous as string[]), + input, + ]; + const first = runtime.begin( + key, + "first", + append, + selectedScope.snapshot, + ); + const second = runtime.begin( + key, + "second", + append, + selectedScope.snapshot, + ); + + first?.markUncertain(); + second?.commit(); + first?.reconcile("APPLIED"); + expect(client.getQueryData(key)).toEqual(["base", "first", "second"]); + + first?.reconcile("NOT_APPLIED"); + expect(client.getQueryData(key)).toEqual(["base", "first", "second"]); + }); + it("removes scoped data instead of restoring it after scope expiry", () => { const client = new QueryClient(); const key = ["query", "resources"]; @@ -87,4 +152,24 @@ describe("revision-safe optimistic layer runtime", () => { layer?.rollback(); expect(client.getQueryData(key)).toBeUndefined(); }); + + it("does not restore an uncertain layer after its scope expires", () => { + const client = new QueryClient(); + const key = ["query", "resources"]; + client.setQueryData(key, ["base"]); + const selectedScope = scope(); + const runtime = createOptimisticLayerRuntime(client); + const layer = runtime.begin( + key, + "uncertain", + (previous, input) => [...(previous as string[]), input], + selectedScope.snapshot, + ); + layer?.markUncertain(); + + selectedScope.expire(); + layer?.reconcile("APPLIED"); + + expect(client.getQueryData(key)).toBeUndefined(); + }); });