diff --git a/src/adapters/http/http-effect-certainty.ts b/src/adapters/http/http-effect-certainty.ts index af8d282..824c3a3 100644 --- a/src/adapters/http/http-effect-certainty.ts +++ b/src/adapters/http/http-effect-certainty.ts @@ -104,38 +104,6 @@ export function classifyProblemEffect( }); } -export type MutationIntent = Readonly<{ - intentId: string; - operationId: string; - canonicalInputIdentity: string; - idempotencyKey?: string; - createdAtMonotonicMs: number; -}>; - -export type MutationIntentContext = Readonly<{ - intentId: string; - idempotencyKey?: string; - startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY"; -}>; - -export function createMutationIntent( - input: Readonly<{ - operationId: string; - canonicalInputIdentity: string; - idempotencyKey?: string; - monotonicNow?: () => number; - }>, -): MutationIntent { - const now = input.monotonicNow ?? (() => performance.now()); - return Object.freeze({ - intentId: crypto.randomUUID(), - operationId: input.operationId, - canonicalInputIdentity: input.canonicalInputIdentity, - ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), - createdAtMonotonicMs: now(), - }); -} - /** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */ export function projectCertaintyToUi( certainty: MutationEffectCertainty, diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 227c237..7e86eb8 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -4,6 +4,7 @@ import { type InstalledHttpContract, } from "../../contracts/external-contract-runtime.ts"; import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts"; +import type { MutationIntent } from "../../contracts/mutation-intent.ts"; import { decodeJsonBytes, isEffectivelyEmpty, @@ -19,7 +20,6 @@ import { import { certaintyForAbandonedAttempt, classifyProblemEffect, - type MutationIntentContext, type PhysicalAttemptState, } from "./http-effect-certainty.ts"; import { parseRetryAfter } from "./retry-policy.ts"; @@ -130,7 +130,7 @@ export type CancellationOwner = export interface HttpExecutionContext { readonly signal?: AbortSignal; readonly scope: CacheScopeSnapshot; - readonly intent?: MutationIntentContext; + readonly intent?: MutationIntent; } export interface ContractHttpExecutor { @@ -370,7 +370,7 @@ export function createContractHttpExecutor( if (contract.requestBody === "JSON") { headers["Content-Type"] = "application/json"; } - if (context.intent?.idempotencyKey) { + if (isCommand && context.intent?.idempotencyKey) { headers["Idempotency-Key"] = context.intent.idempotencyKey; } diff --git a/src/adapters/platform/browser-mutation-intent-factory.ts b/src/adapters/platform/browser-mutation-intent-factory.ts new file mode 100644 index 0000000..8500dd2 --- /dev/null +++ b/src/adapters/platform/browser-mutation-intent-factory.ts @@ -0,0 +1,50 @@ +import type { MutationIntentFactory } from "../../application/ports/mutation-intent-factory.ts"; +import { defineMutationIntent } from "../../contracts/mutation-intent.ts"; + +export type BrowserMutationIntentFactoryDependencies = Readonly<{ + randomUUID?: () => string; + monotonicNow?: () => number; +}>; + +export function createBrowserMutationIntentFactory( + dependencies: BrowserMutationIntentFactoryDependencies = {}, +): MutationIntentFactory { + const randomUUID = + dependencies.randomUUID ?? + (() => { + if ( + typeof globalThis.crypto === "undefined" || + typeof globalThis.crypto.randomUUID !== "function" + ) { + throw new TypeError("Secure mutation identity generation is unavailable."); + } + return globalThis.crypto.randomUUID(); + }); + const monotonicNow = + dependencies.monotonicNow ?? + (() => { + if ( + typeof globalThis.performance === "undefined" || + typeof globalThis.performance.now !== "function" + ) { + throw new TypeError("Monotonic time is unavailable."); + } + return globalThis.performance.now(); + }); + + return Object.freeze({ + create(input) { + const intentId = randomUUID(); + const idempotencyKey = input.requiresIdempotencyKey + ? randomUUID() + : undefined; + return defineMutationIntent({ + intentId, + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + createdAtMonotonicMs: monotonicNow(), + }); + }, + }); +} diff --git a/src/application/ports/mutation-intent-factory.ts b/src/application/ports/mutation-intent-factory.ts new file mode 100644 index 0000000..d1bd07b --- /dev/null +++ b/src/application/ports/mutation-intent-factory.ts @@ -0,0 +1,11 @@ +import type { MutationIntent } from "../../contracts/mutation-intent.ts"; + +export type MutationIntentFactoryInput = Readonly<{ + operationId: string; + canonicalInputIdentity: string; + requiresIdempotencyKey: boolean; +}>; + +export type MutationIntentFactory = Readonly<{ + create(input: MutationIntentFactoryInput): MutationIntent; +}>; diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index b500222..f0b5bbb 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -15,11 +15,13 @@ import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache. import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts"; import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts"; import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts"; +import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts"; import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; import { createRestProviderProfile } from "../contracts/rest-profiles.ts"; import type { ClockPort } from "../application/ports/clock-port.ts"; +import type { MutationIntent } from "../contracts/mutation-intent.ts"; import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts"; import { INVALIDATION_REGISTRY, @@ -166,6 +168,7 @@ export async function createRuntimeAdapters( const host = context.host ?? (globalThis as unknown as Record); const config = context.runtime.config; + const mutationIntentFactory = createBrowserMutationIntentFactory(); const externalOwner = externalOwnerFrom(host); const authSession = config.AUTH_MODE === "demo" @@ -343,12 +346,14 @@ export async function createRuntimeAdapters( } }, }); - let contractExecutionSequence = 0; const contractOperations = Object.freeze({ async execute( operationId: string, input: unknown, - executionContext: Readonly<{ signal?: AbortSignal }> = {}, + executionContext: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }> = {}, ) { const operation = COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); @@ -362,26 +367,14 @@ export async function createRuntimeAdapters( }), }); } - contractExecutionSequence += 1; - const intentId = `http-intent-${contractExecutionSequence}`; - const isCommand = operation.contract.commandEffect !== null; - const requiresKey = operation.contract.retrySemantics === "KEYED"; const outcome = await contractHttp.execute(operation, input, { scope: serverStateScope.getSnapshot(), ...(executionContext.signal === undefined ? {} : { signal: executionContext.signal }), - ...(isCommand - ? { - intent: Object.freeze({ - intentId, - startedBy: "USER" as const, - ...(requiresKey - ? { idempotencyKey: `http-key-${contractExecutionSequence}` } - : {}), - }), - } - : {}), + ...(executionContext.intent === undefined + ? {} + : { intent: executionContext.intent }), }); if (outcome.kind === "UNAUTHENTICATED") { authSession.onUnauthenticated(); @@ -412,6 +405,7 @@ export async function createRuntimeAdapters( }, serverStateGeneration, serverStateScope, + mutationIntentFactory, conditionalValidators, crossContextInvalidationStatus: () => serverStateGeneration.getSnapshot().crossContextStatus(), diff --git a/src/bootstrap/runtime-application.tsx b/src/bootstrap/runtime-application.tsx index 480e281..475eeed 100644 --- a/src/bootstrap/runtime-application.tsx +++ b/src/bootstrap/runtime-application.tsx @@ -31,6 +31,9 @@ export function RuntimeApplication({ ; + +const UTF8 = new TextEncoder(); + +function validBoundedString(value: unknown, maxBytes: number): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + UTF8.encode(value).byteLength <= maxBytes + ); +} + +export function defineMutationIntent(intent: MutationIntent): MutationIntent { + if ( + !validBoundedString( + intent.intentId, + MUTATION_INTENT_BOUNDS.intentIdMaxBytes, + ) || + !validBoundedString( + intent.operationId, + MUTATION_INTENT_BOUNDS.operationIdMaxBytes, + ) || + !validBoundedString( + intent.canonicalInputIdentity, + MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes, + ) || + (intent.idempotencyKey !== undefined && + !validBoundedString( + intent.idempotencyKey, + MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes, + )) || + !Number.isFinite(intent.createdAtMonotonicMs) || + intent.createdAtMonotonicMs < 0 + ) { + throw new TypeError("Mutation intent is invalid."); + } + return Object.freeze({ + intentId: intent.intentId, + operationId: intent.operationId, + canonicalInputIdentity: intent.canonicalInputIdentity, + ...(intent.idempotencyKey === undefined + ? {} + : { idempotencyKey: intent.idempotencyKey }), + createdAtMonotonicMs: intent.createdAtMonotonicMs, + }); +} diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts index 7d08682..79de43f 100644 --- a/src/contracts/server-state.ts +++ b/src/contracts/server-state.ts @@ -6,6 +6,7 @@ import { type RuntimeIdentityBinding, } from "./query-keys.ts"; import type { CacheScopeSnapshot } from "./server-state-scope.ts"; +import type { MutationIntent } from "./mutation-intent.ts"; /** * §10.2. The four fixed profiles. A feature selects one by ID; it never @@ -234,12 +235,13 @@ export type BoundMutation = Readonly<{ definitionId: string; definitionVersion: number; operationId: string; + requiresIdempotencyKey: boolean; owner: string; duplicatePolicy: MutationDuplicatePolicy; scope: CacheScopeSnapshot; execute( input: Input, - context: Readonly<{ signal: AbortSignal }>, + context: Readonly<{ signal: AbortSignal; intent: MutationIntent }>, ): Promise>; invalidate: readonly QueryInvalidationTopic[]; optimistic?: Readonly<{ diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts index 0147e56..3540253 100644 --- a/src/features/reference-feature/adapters/create-reference-feature-input.ts +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -1,6 +1,7 @@ import type { Result } from "../../../application/result.ts"; import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts"; import type { FailureEffectCertainty } from "../../../contracts/errors.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; import { createFailure, kindForStatus, @@ -22,7 +23,10 @@ export type InstalledContractOperationExecutor = Readonly<{ execute( operationId: string, input: unknown, - context?: Readonly<{ signal?: AbortSignal }>, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, ): Promise>; }>; @@ -39,10 +43,14 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{ const operationId = request.operationId; const input = inputFor(request); const signal = "signal" in request ? request.signal : undefined; + const intent = "intent" in request ? request.intent : undefined; const outcome = await context.contractOperations.execute( operationId, input, - signal === undefined ? {} : { signal }, + { + ...(signal === undefined ? {} : { signal }), + ...(intent === undefined ? {} : { intent }), + }, ); return projectExecutionOutcome(operationId, outcome); }, diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts index 76ef346..34e2874 100644 --- a/src/features/reference-feature/adapters/reference-http-gateway.ts +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -9,6 +9,7 @@ import type { ReferenceListFilters, } from "../application/reference-feature-api.ts"; import type { ReferenceResource } from "../domain/reference-resource.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; type ReferenceOperationMap = Readonly<{ LIST_REFERENCE_RESOURCES: Readonly<{ @@ -26,6 +27,7 @@ type ReferenceOperationMap = Readonly<{ routeId: "REFERENCE_RESOURCE_LIST"; body: ReferenceCreateCommand; signal?: AbortSignal; + intent?: MutationIntent; }>; value: ReferenceResource; }>; @@ -74,13 +76,17 @@ export function createReferenceHttpGateway( }, async create( command: ReferenceCreateCommand, - context?: Readonly<{ signal?: AbortSignal }>, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, ) { const result = await http.execute({ operationId: "CREATE_REFERENCE_RESOURCE", routeId: "REFERENCE_RESOURCE_LIST", body: command, signal: context?.signal, + intent: context?.intent, }); return projectResourceResult("CREATE_REFERENCE_RESOURCE", result); }, diff --git a/src/features/reference-feature/application/reference-feature-api.ts b/src/features/reference-feature/application/reference-feature-api.ts index 9a766d9..4ee895e 100644 --- a/src/features/reference-feature/application/reference-feature-api.ts +++ b/src/features/reference-feature/application/reference-feature-api.ts @@ -5,6 +5,7 @@ import { type ReferenceResourceView, } from "../contracts/reference-mapper.ts"; import type { ReferenceResource } from "../domain/reference-resource.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; export type ReferenceListFilters = Readonly<{ cursor?: string; @@ -26,7 +27,10 @@ export type ReferenceFeatureInput = Readonly<{ ): Promise>; createResource( command: ReferenceCreateCommand, - context?: Readonly<{ signal?: AbortSignal }>, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, ): Promise>; getResource( resourceId: string, @@ -47,7 +51,10 @@ export type ReferenceGateway = Readonly<{ ): Promise>; create( command: ReferenceCreateCommand, - context?: Readonly<{ signal?: AbortSignal }>, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, ): Promise>; get( resourceId: string, diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts index 651eab5..3701cf0 100644 --- a/src/features/reference-feature/presentation/use-reference-feature.ts +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -87,6 +87,7 @@ export function useReferenceCreate() { definitionId: "reference-resource-create-v1", definitionVersion: 1, operationId: "CREATE_REFERENCE_RESOURCE", + requiresIdempotencyKey: true, owner: REFERENCE_FEATURE_ID, duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, @@ -123,6 +124,7 @@ export function useReferenceFeature() { definitionId: "reference-resource-create-v1", definitionVersion: 1, operationId: "CREATE_REFERENCE_RESOURCE", + requiresIdempotencyKey: true, owner: REFERENCE_FEATURE_ID, duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, diff --git a/src/presentation/adapters/query/application-query.ts b/src/presentation/adapters/query/application-query.ts index 2f14ffd..191541c 100644 --- a/src/presentation/adapters/query/application-query.ts +++ b/src/presentation/adapters/query/application-query.ts @@ -21,6 +21,7 @@ import { type AppFailure, } from "../../../contracts/errors.ts"; import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; +import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; import { admitQueryResult, type BoundMutation, @@ -29,6 +30,7 @@ import { } from "../../../contracts/server-state.ts"; import { runtimeIdentityToken } from "../../../contracts/query-keys.ts"; import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx"; +import { useMutationIntentFactory } from "./mutation-intent-provider.tsx"; import { createOptimisticLayerRuntime, type OptimisticLayerLease, @@ -196,6 +198,10 @@ type ApplicationMutationController = Readonly<{ resolveConflict(): Promise; }>; +type MutationExecution = + | Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }> + | Readonly<{ kind: "LEGACY"; input: Input }>; + export function useApplicationMutation( options: BoundMutation, ): ApplicationMutationController; @@ -207,6 +213,7 @@ export function useApplicationMutation( ): ApplicationMutationController { const queryClient = useQueryClient(); const invalidationCoordinator = useQueryInvalidationCoordinator(); + const mutationIntentFactory = useMutationIntentFactory(); const invalidate = useMemo( () => options.invalidate ?? [], [options.invalidate], @@ -219,9 +226,20 @@ export function useApplicationMutation( "duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE"; const [conflict, setConflict] = useState(null); const scope = "scope" in options ? options.scope : undefined; - const mutation = useMutation({ + const mutationOperationId = + "operationId" in options ? options.operationId : definitionId; + const requiresIdempotencyKey = + "requiresIdempotencyKey" in options + ? options.requiresIdempotencyKey + : false; + const mutation = useMutation< + Value, + ApplicationQueryError, + MutationExecution + >({ retry: false, - mutationFn: async (input) => { + mutationFn: async (execution) => { + const input = execution.input; if (scope && !scope.isCurrent()) { throw new ApplicationQueryError( createFailure( @@ -234,7 +252,17 @@ export function useApplicationMutation( } const result = "scope" in options - ? await options.execute(input, { signal: options.scope.signal }) + ? await options.execute(input, { + signal: options.scope.signal, + intent: + execution.kind === "BOUND" + ? execution.intent + : (() => { + throw new TypeError( + "Bound mutation execution requires an intent.", + ); + })(), + }) : await options.execute(input); if (scope && !scope.isCurrent()) { const effect = @@ -311,6 +339,18 @@ export function useApplicationMutation( 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 }); const mutationLease = invalidate.length === 0 ? null @@ -346,7 +386,7 @@ export function useApplicationMutation( let value: Value; try { - value = await mutation.mutateAsync(input); + value = await mutation.mutateAsync(execution); } catch (error: unknown) { if (optimistic) { if (optimisticLayer) { @@ -411,6 +451,9 @@ export function useApplicationMutation( queryClient, definitionId, duplicatePolicy, + mutationIntentFactory, + mutationOperationId, + requiresIdempotencyKey, scope, ], ); diff --git a/src/presentation/adapters/query/mutation-intent-provider.tsx b/src/presentation/adapters/query/mutation-intent-provider.tsx new file mode 100644 index 0000000..7347e1a --- /dev/null +++ b/src/presentation/adapters/query/mutation-intent-provider.tsx @@ -0,0 +1,35 @@ +import { + createContext, + type ReactNode, + useContext, +} from "react"; + +import type { MutationIntentFactory } from "../../../application/ports/mutation-intent-factory.ts"; + +const unavailableMutationIntentFactory: MutationIntentFactory = Object.freeze({ + create() { + throw new TypeError("Mutation intent factory is not installed."); + }, +}); + +const MutationIntentFactoryContext = createContext( + unavailableMutationIntentFactory, +); + +export function MutationIntentProvider({ + factory, + children, +}: Readonly<{ + factory: MutationIntentFactory; + children: ReactNode; +}>) { + return ( + + {children} + + ); +} + +export function useMutationIntentFactory(): MutationIntentFactory { + return useContext(MutationIntentFactoryContext); +} diff --git a/src/presentation/adapters/query/server-state-generation-provider.tsx b/src/presentation/adapters/query/server-state-generation-provider.tsx index 72f5024..163922a 100644 --- a/src/presentation/adapters/query/server-state-generation-provider.tsx +++ b/src/presentation/adapters/query/server-state-generation-provider.tsx @@ -6,6 +6,8 @@ import { type ReactNode, useSyncExternalStore } from "react"; import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts"; import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.ts"; +import type { MutationIntentFactory } from "../../../application/ports/mutation-intent-factory.ts"; +import { MutationIntentProvider } from "./mutation-intent-provider.tsx"; import { QueryInvalidationProvider } from "./query-invalidation-provider.tsx"; import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx"; @@ -21,11 +23,13 @@ export type ServerStateGenerationSource = Readonly<{ export function ServerStateGenerationProvider({ store, scope, + mutationIntentFactory, children, transitionFallback, }: Readonly<{ store: ServerStateGenerationSource; scope: ServerStateScopeRuntime; + mutationIntentFactory: MutationIntentFactory; children: ReactNode; transitionFallback?: ReactNode; }>) { @@ -35,18 +39,20 @@ export function ServerStateGenerationProvider({ store.getSnapshot, ); return ( - - + - - {children} - - - + + + {children} + + + + ); } diff --git a/tests/component/application-query.test.tsx b/tests/component/application-query.test.tsx index 7c6331d..68ba02c 100644 --- a/tests/component/application-query.test.tsx +++ b/tests/component/application-query.test.tsx @@ -14,6 +14,8 @@ import { useApplicationMutation, useApplicationQuery, } from "../../src/presentation/adapters/query/application-query.ts"; +import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts"; +import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx"; import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx"; import { defineQueryInvalidationTopic, @@ -71,7 +73,28 @@ function queryClient() { }); } -function wrapper(client: QueryClient) { +function deterministicMutationIntentFactory(): MutationIntentFactory { + let sequence = 0; + return Object.freeze({ + create(input) { + sequence += 1; + return Object.freeze({ + intentId: `intent-${sequence}`, + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(input.requiresIdempotencyKey + ? { idempotencyKey: `key-${sequence}` } + : {}), + createdAtMonotonicMs: sequence, + }); + }, + }); +} + +function wrapper( + client: QueryClient, + mutationIntentFactory = deterministicMutationIntentFactory(), +) { const coordinator: QueryInvalidationCoordinator = { async invalidate(topics) { for (const topic of topics) { @@ -93,11 +116,13 @@ function wrapper(client: QueryClient) { }; return function QueryWrapper({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + + {children} + + + ); }; } @@ -415,6 +440,7 @@ describe("scope-bound mutation fence", () => { definitionId: "fenced-mutation-v1", definitionVersion: 1, operationId: "CREATE_FENCED", + requiresIdempotencyKey: false, owner: "platform-test", duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, @@ -446,6 +472,7 @@ describe("scope-bound mutation fence", () => { definitionId: "late-mutation-v1", definitionVersion: 1, operationId: "CREATE_LATE", + requiresIdempotencyKey: false, owner: "platform-test", duplicatePolicy: "ALLOW_PARALLEL", scope, @@ -488,6 +515,7 @@ describe("scope-bound mutation fence", () => { definitionId: "hung-mutation-v1", definitionVersion: 1, operationId: "CREATE_HUNG", + requiresIdempotencyKey: false, owner: "platform-test", duplicatePolicy: "REJECT_WHILE_ACTIVE", scope, @@ -516,6 +544,128 @@ describe("scope-bound mutation fence", () => { }); describe("application mutation inbound bridge", () => { + it("creates a distinct logical intent for each independently admitted submit", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const observedIntents: unknown[] = []; + const execute = vi.fn( + async ( + input: string, + context: Readonly<{ signal: AbortSignal; intent?: unknown }>, + ) => { + observedIntents.push(context.intent); + return { ok: true as const, value: input }; + }, + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "independent-intent-v1", + definitionVersion: 1, + operationId: "CREATE_WITH_INTENT", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + await act(async () => { + await hook.result.current.submit("same-input"); + await hook.result.current.submit("same-input"); + }); + + expect(observedIntents).toHaveLength(2); + expect(observedIntents[0]).toMatchObject({ + intentId: "intent-1", + operationId: "CREATE_WITH_INTENT", + idempotencyKey: "key-1", + }); + expect(observedIntents[1]).toMatchObject({ + intentId: "intent-2", + operationId: "CREATE_WITH_INTENT", + idempotencyKey: "key-2", + }); + expect(observedIntents[0]).not.toEqual(observedIntents[1]); + }); + + it("creates no second intent when JOIN_IDENTICAL shares an admitted submit", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const deterministicFactory = deterministicMutationIntentFactory(); + const createIntent = vi.fn(deterministicFactory.create); + const factory: MutationIntentFactory = Object.freeze({ + create: createIntent, + }); + let complete: (value: ApplicationResult) => void = () => {}; + const execute = vi.fn( + () => + new Promise>((resolve) => { + complete = resolve; + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "joined-intent-v1", + definitionVersion: 1, + operationId: "CREATE_JOINED", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "JOIN_IDENTICAL", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client, factory) }, + ); + + let first: Promise> | null = null; + let joined: Promise> | null = null; + act(() => { + first = hook.result.current.submit("same-input"); + joined = hook.result.current.submit("same-input"); + }); + + expect(first).toBe(joined); + await waitFor(() => expect(execute).toHaveBeenCalledOnce()); + expect(createIntent).toHaveBeenCalledOnce(); + expect(createIntent).toHaveBeenCalledWith({ + operationId: "CREATE_JOINED", + canonicalInputIdentity: + "scope-fingerprint-0001:joined-intent-v1:scope-identity-token-0001", + requiresIdempotencyKey: true, + }); + + complete({ ok: true, value: "same-input" }); + if (!first) throw new Error("expected admitted mutation"); + await act(() => first); + }); + + it("keeps the legacy raw mutation path outside the intent factory", async () => { + const client = queryClient(); + const createIntent = vi.fn(); + const factory: MutationIntentFactory = Object.freeze({ + create: createIntent, + }); + const execute = vi.fn(async (input: string) => ({ + ok: true as const, + value: input, + })); + const hook = renderHook( + () => useApplicationMutation({ execute }), + { wrapper: wrapper(client, factory) }, + ); + + await act(() => hook.result.current.submit("legacy-input")); + + expect(execute).toHaveBeenCalledOnce(); + expect(createIntent).not.toHaveBeenCalled(); + }); + it("rejects a duplicate submit by default while one is active", async () => { const client = queryClient(); let complete: (value: ApplicationResult) => void = () => {}; diff --git a/tests/component/server-state-generation-provider.test.tsx b/tests/component/server-state-generation-provider.test.tsx index 9f0effc..4b198f6 100644 --- a/tests/component/server-state-generation-provider.test.tsx +++ b/tests/component/server-state-generation-provider.test.tsx @@ -43,6 +43,11 @@ describe("server-state generation provider", () => { `scope-generation-provider-${String(token++).padStart(4, "0")}`, }); const renderedClients: QueryClient[] = []; + const mutationIntentFactory = Object.freeze({ + create() { + throw new Error("mutation intent is unused by this provider test"); + }, + }); function Probe() { renderedClients.push(useQueryClient()); return
generation-content
; @@ -52,6 +57,7 @@ describe("server-state generation provider", () => { scope-transition} > diff --git a/tests/features/reference-feature/reference-page.test.tsx b/tests/features/reference-feature/reference-page.test.tsx index 1394e2c..7e5c469 100644 --- a/tests/features/reference-feature/reference-page.test.tsx +++ b/tests/features/reference-feature/reference-page.test.tsx @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest"; import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts"; import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.ts"; +import type { MutationIntentFactory } from "../../../src/application/ports/mutation-intent-factory.ts"; import type { ReferenceFeatureInput, ReferenceResult, @@ -19,6 +20,7 @@ import type { ReferenceResourceView } from "../../../src/features/reference-feat import { createFailure } from "../../../src/contracts/errors.ts"; import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts"; import { QueryInvalidationProvider } from "../../../src/presentation/adapters/query/query-invalidation-provider.tsx"; +import { MutationIntentProvider } from "../../../src/presentation/adapters/query/mutation-intent-provider.tsx"; import { ServerStateScopeProvider } from "../../../src/presentation/adapters/query/server-state-scope-provider.tsx"; import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts"; import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx"; @@ -51,21 +53,38 @@ function renderReference( session, queryInvalidation: invalidation, }); + let intentSequence = 0; + const mutationIntentFactory: MutationIntentFactory = Object.freeze({ + create(input) { + intentSequence += 1; + return Object.freeze({ + intentId: `reference-page-intent-${intentSequence}`, + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(input.requiresIdempotencyKey + ? { idempotencyKey: `reference-page-key-${intentSequence}` } + : {}), + createdAtMonotonicMs: intentSequence, + }); + }, + }); return render( - - - - - - - - - , + + + + + + + + + + + , ); } diff --git a/tests/integration/http-execution-contract.test.ts b/tests/integration/http-execution-contract.test.ts index 187dc36..1efc566 100644 --- a/tests/integration/http-execution-contract.test.ts +++ b/tests/integration/http-execution-contract.test.ts @@ -124,9 +124,11 @@ describe("HTTP operation execution contract", () => { expect(requests[0].url).toBe( "https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new", ); + expect(requests[0].headers.get("Idempotency-Key")).toBeNull(); expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters); await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" }); expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command"); + expect(requests[1].url).not.toContain("logical-command"); expect(requests[0].headers.get("X-Correlation-ID")).toBeTruthy(); expect(requests[0].credentials).toBe("same-origin"); expect(requests[0].cache).toBe("no-store"); diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts index 2b71acc..48228c3 100644 --- a/tests/unit/http-execution-v3.test.ts +++ b/tests/unit/http-execution-v3.test.ts @@ -21,6 +21,20 @@ const scope = Object.freeze({ const createInstalled: InstalledHttpContract = TEST_CREATE_HTTP_CONTRACT; +function mutationIntent( + overrides: Readonly<{ intentId?: string; idempotencyKey?: string }> = {}, +) { + return Object.freeze({ + intentId: overrides.intentId ?? "intent-1", + operationId: "TEST_CREATE_ENTITY", + canonicalInputIdentity: "opaque-input-identity", + ...(overrides.idempotencyKey === undefined + ? { idempotencyKey: "key-1" } + : { idempotencyKey: overrides.idempotencyKey }), + createdAtMonotonicMs: 1, + }); +} + function operation( overrides: Readonly<{ deadlineMs?: number; @@ -47,6 +61,129 @@ async function flushMicrotasks(): Promise { } describe("descriptor-driven HTTP execution lifetime", () => { + it("reuses one supplied idempotency key across every physical retry", async () => { + const observedKeys: Array = []; + let attempt = 0; + const fetcher = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + observedKeys.push(new Headers(init?.headers).get("Idempotency-Key")); + attempt += 1; + if (attempt === 1) throw new TypeError("synchronous pre-dispatch failure"); + return Promise.resolve( + Response.json( + { id: "created", name: "Created" }, + { status: 201 }, + ), + ); + }); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 1, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher, + sleep: async () => {}, + random: () => 0, + }); + const retryingCreate = { + ...createInstalled, + frontend: { + ...createInstalled.frontend, + retryBudget: 1 as const, + }, + }; + + await expect( + executor.execute( + retryingCreate, + { name: "created" }, + { scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) }, + ), + ).resolves.toMatchObject({ kind: "SUCCESS" }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(observedKeys).toEqual(["logical-key", "logical-key"]); + }); + + it("never emits a mutation idempotency header for a query", async () => { + const observedKeys: Array = []; + const fetcher = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => { + observedKeys.push( + new Headers(init?.headers).get("Idempotency-Key"), + ); + return Response.json([]); + }, + ); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher, + }); + + await expect( + executor.execute( + installed, + { limit: 20 }, + { scope }, + ), + ).resolves.toMatchObject({ kind: "SUCCESS" }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(observedKeys).toEqual([null]); + }); + + it("keeps intent identities out of request URLs and safe observations", async () => { + const urls: string[] = []; + const observations: unknown[] = []; + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY", + headers: {}, + credentials: "omit", + }), + fetcher: vi.fn(async (input) => { + urls.push(String(input)); + return Response.json( + { id: "created", name: "Created" }, + { status: 201 }, + ); + }), + observe: (observation) => observations.push(observation), + }); + + await expect( + executor.execute( + createInstalled, + { name: "created" }, + { + scope, + intent: Object.freeze({ + intentId: "private-intent-id", + operationId: "TEST_CREATE_ENTITY", + canonicalInputIdentity: "private-canonical-input", + idempotencyKey: "private-idempotency-key", + createdAtMonotonicMs: 1, + }), + }, + ), + ).resolves.toMatchObject({ kind: "SUCCESS" }); + + const safeEvidence = JSON.stringify({ urls, observations }); + expect(urls).toEqual(["https://api.example/api/test-entities"]); + expect(observations).toHaveLength(1); + expect(safeEvidence).not.toContain("private-intent-id"); + expect(safeEvidence).not.toContain("private-canonical-input"); + expect(safeEvidence).not.toContain("private-idempotency-key"); + }); + it.each([ [{}, "limit=20"], [{ limit: "7" }, "limit=7"], @@ -127,7 +264,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { { name: "created" }, { scope, - intent: { intentId: "intent-1", startedBy: "USER", idempotencyKey: "key-1" }, + intent: mutationIntent(), }, ), ).resolves.toMatchObject({ @@ -169,7 +306,7 @@ describe("descriptor-driven HTTP execution lifetime", () => { { name: "created" }, { scope: fencedScope, - intent: { intentId: "intent-2", startedBy: "USER", idempotencyKey: "key-2" }, + intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }), }, ), ).resolves.toMatchObject({ diff --git a/tests/unit/runtime-adapters.test.ts b/tests/unit/runtime-adapters.test.ts index fefd89c..a756bc9 100644 --- a/tests/unit/runtime-adapters.test.ts +++ b/tests/unit/runtime-adapters.test.ts @@ -4,6 +4,7 @@ import { createRuntimeAdapters, createRuntimeHttpClient, } from "../../src/bootstrap/runtime-adapters.ts"; +import { createBrowserMutationIntentFactory } from "../../src/adapters/platform/browser-mutation-intent-factory.ts"; import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts"; type Runtime = Parameters[0]["runtime"]; @@ -53,6 +54,65 @@ const release: Release = { }; describe("runtime adapter composition", () => { + it("creates validated intent and idempotency identities with independent UUID calls", () => { + const randomUUID = vi + .fn<() => string>() + .mockReturnValueOnce("intent-uuid") + .mockReturnValueOnce("idempotency-uuid"); + const factory = createBrowserMutationIntentFactory({ + randomUUID, + monotonicNow: () => 12.5, + }); + + const intent = factory.create({ + operationId: "CREATE_REFERENCE_RESOURCE", + canonicalInputIdentity: "opaque-canonical-input", + requiresIdempotencyKey: true, + }); + + expect(intent).toEqual({ + intentId: "intent-uuid", + operationId: "CREATE_REFERENCE_RESOURCE", + canonicalInputIdentity: "opaque-canonical-input", + idempotencyKey: "idempotency-uuid", + createdAtMonotonicMs: 12.5, + }); + expect(Object.isFrozen(intent)).toBe(true); + expect(randomUUID).toHaveBeenCalledTimes(2); + }); + + it("rejects invalid or unbounded mutation intent values", () => { + const factory = createBrowserMutationIntentFactory({ + randomUUID: () => "opaque-runtime-identifier", + monotonicNow: () => 1, + }); + + expect(() => + factory.create({ + operationId: " ", + canonicalInputIdentity: "valid-identity", + requiresIdempotencyKey: false, + }), + ).toThrow(TypeError); + expect(() => + factory.create({ + operationId: "CREATE_REFERENCE_RESOURCE", + canonicalInputIdentity: "x".repeat(16_385), + requiresIdempotencyKey: false, + }), + ).toThrow(TypeError); + expect(() => + createBrowserMutationIntentFactory({ + randomUUID: () => "opaque-runtime-identifier", + monotonicNow: () => -1, + }).create({ + operationId: "CREATE_REFERENCE_RESOURCE", + canonicalInputIdentity: "valid-identity", + requiresIdempotencyKey: false, + }), + ).toThrow(TypeError); + }); + it("constructs the local demo seam and infrastructure adapters", async () => { const adapters = await createRuntimeAdapters({ runtime, @@ -66,6 +126,7 @@ describe("runtime adapter composition", () => { }); expect(adapters.infrastructure.queryClient).toBeDefined(); expect(adapters.infrastructure.queryInvalidation).toBeDefined(); + expect(adapters.infrastructure.mutationIntentFactory).toBeDefined(); expect( adapters.infrastructure.crossContextInvalidationStatus(), ).toBe("DEGRADED_LOCAL_ONLY"); @@ -81,6 +142,8 @@ describe("runtime adapter composition", () => { const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); const previousClient = adapters.infrastructure.queryClient; const previousCoordinator = adapters.infrastructure.queryInvalidation; + const runtimeMutationIntentFactory = + adapters.infrastructure.mutationIntentFactory; const clearPrevious = vi.spyOn(previousClient, "clear"); await adapters.outputPorts.session.beginSignIn(); @@ -91,6 +154,9 @@ describe("runtime adapter composition", () => { expect(adapters.infrastructure.queryInvalidation).not.toBe( previousCoordinator, ); + expect(adapters.infrastructure.mutationIntentFactory).toBe( + runtimeMutationIntentFactory, + ); const clearCallsAfterReplacement = clearPrevious.mock.calls.length; await previousCoordinator.resetLocal(); @@ -99,6 +165,60 @@ describe("runtime adapter composition", () => { adapters.infrastructure.dispose(); }); + it("passes the supplied command intent unchanged and keeps private identity out of URLs and diagnostics", async () => { + const requests: Array> = []; + const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + headers: new Headers(init?.headers), + }); + return Response.json( + { id: "resource-1", name: "Created resource" }, + { status: 201 }, + ); + }); + const adapters = await createRuntimeAdapters({ + runtime, + release, + host: {}, + fetcher, + }); + await adapters.outputPorts.session.beginSignIn(); + await vi.waitFor(() => + expect(adapters.infrastructure.serverStateScope.getPhase()).toBe("READY"), + ); + const intent = Object.freeze({ + intentId: "private-intent-id", + operationId: "CREATE_REFERENCE_RESOURCE", + canonicalInputIdentity: "private-canonical-input", + idempotencyKey: "private-idempotency-key", + createdAtMonotonicMs: 42, + }); + + await expect( + adapters.featureInputs["reference-feature"].createResource( + { name: "Created resource" }, + { intent }, + ), + ).resolves.toMatchObject({ ok: true }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.headers.get("Idempotency-Key")).toBe( + "private-idempotency-key", + ); + expect(requests[0]?.url).toBe( + "http://localhost:8080/api/reference-resources", + ); + const safeEvidence = JSON.stringify({ + requests: requests.map((request) => request.url), + diagnostics: adapters.outputPorts.diagnostics.entries(), + }); + expect(safeEvidence).not.toContain("private-intent-id"); + expect(safeEvidence).not.toContain("private-canonical-input"); + expect(safeEvidence).not.toContain("private-idempotency-key"); + adapters.infrastructure.dispose(); + }); + it("does not fail boot when Web Storage capability getters throw", async () => { const host: Record = {}; Object.defineProperties(host, {