fix: preserve logical mutation intent

This commit is contained in:
DongHyeonka
2026-08-02 01:07:19 +09:00
parent 53d181fbe4
commit cbcc7b5ed7
21 changed files with 724 additions and 96 deletions
@@ -104,38 +104,6 @@ export function classifyProblemEffect<Problem>(
}); });
} }
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. */ /** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */
export function projectCertaintyToUi( export function projectCertaintyToUi(
certainty: MutationEffectCertainty, certainty: MutationEffectCertainty,
+3 -3
View File
@@ -4,6 +4,7 @@ import {
type InstalledHttpContract, type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts"; } from "../../contracts/external-contract-runtime.ts";
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts"; import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
import { import {
decodeJsonBytes, decodeJsonBytes,
isEffectivelyEmpty, isEffectivelyEmpty,
@@ -19,7 +20,6 @@ import {
import { import {
certaintyForAbandonedAttempt, certaintyForAbandonedAttempt,
classifyProblemEffect, classifyProblemEffect,
type MutationIntentContext,
type PhysicalAttemptState, type PhysicalAttemptState,
} from "./http-effect-certainty.ts"; } from "./http-effect-certainty.ts";
import { parseRetryAfter } from "./retry-policy.ts"; import { parseRetryAfter } from "./retry-policy.ts";
@@ -130,7 +130,7 @@ export type CancellationOwner =
export interface HttpExecutionContext { export interface HttpExecutionContext {
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly scope: CacheScopeSnapshot; readonly scope: CacheScopeSnapshot;
readonly intent?: MutationIntentContext; readonly intent?: MutationIntent;
} }
export interface ContractHttpExecutor { export interface ContractHttpExecutor {
@@ -370,7 +370,7 @@ export function createContractHttpExecutor(
if (contract.requestBody === "JSON") { if (contract.requestBody === "JSON") {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
if (context.intent?.idempotencyKey) { if (isCommand && context.intent?.idempotencyKey) {
headers["Idempotency-Key"] = context.intent.idempotencyKey; headers["Idempotency-Key"] = context.intent.idempotencyKey;
} }
@@ -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(),
});
},
});
}
@@ -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;
}>;
+11 -17
View File
@@ -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 { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts";
import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts"; import { createConditionalValidatorStore } from "../adapters/query-cache/conditional-validator-store.ts";
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.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 { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
import { createRestProviderProfile } from "../contracts/rest-profiles.ts"; import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
import type { ClockPort } from "../application/ports/clock-port.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 { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
import { import {
INVALIDATION_REGISTRY, INVALIDATION_REGISTRY,
@@ -166,6 +168,7 @@ export async function createRuntimeAdapters(
const host = const host =
context.host ?? (globalThis as unknown as Record<string, unknown>); context.host ?? (globalThis as unknown as Record<string, unknown>);
const config = context.runtime.config; const config = context.runtime.config;
const mutationIntentFactory = createBrowserMutationIntentFactory();
const externalOwner = externalOwnerFrom(host); const externalOwner = externalOwnerFrom(host);
const authSession = const authSession =
config.AUTH_MODE === "demo" config.AUTH_MODE === "demo"
@@ -343,12 +346,14 @@ export async function createRuntimeAdapters(
} }
}, },
}); });
let contractExecutionSequence = 0;
const contractOperations = Object.freeze({ const contractOperations = Object.freeze({
async execute( async execute(
operationId: string, operationId: string,
input: unknown, input: unknown,
executionContext: Readonly<{ signal?: AbortSignal }> = {}, executionContext: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}> = {},
) { ) {
const operation = const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); 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, { const outcome = await contractHttp.execute(operation, input, {
scope: serverStateScope.getSnapshot(), scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined ...(executionContext.signal === undefined
? {} ? {}
: { signal: executionContext.signal }), : { signal: executionContext.signal }),
...(isCommand ...(executionContext.intent === undefined
? { ? {}
intent: Object.freeze({ : { intent: executionContext.intent }),
intentId,
startedBy: "USER" as const,
...(requiresKey
? { idempotencyKey: `http-key-${contractExecutionSequence}` }
: {}),
}),
}
: {}),
}); });
if (outcome.kind === "UNAUTHENTICATED") { if (outcome.kind === "UNAUTHENTICATED") {
authSession.onUnauthenticated(); authSession.onUnauthenticated();
@@ -412,6 +405,7 @@ export async function createRuntimeAdapters(
}, },
serverStateGeneration, serverStateGeneration,
serverStateScope, serverStateScope,
mutationIntentFactory,
conditionalValidators, conditionalValidators,
crossContextInvalidationStatus: () => crossContextInvalidationStatus: () =>
serverStateGeneration.getSnapshot().crossContextStatus(), serverStateGeneration.getSnapshot().crossContextStatus(),
+3
View File
@@ -31,6 +31,9 @@ export function RuntimeApplication({
<ServerStateGenerationProvider <ServerStateGenerationProvider
store={composition.infrastructure.serverStateGeneration} store={composition.infrastructure.serverStateGeneration}
scope={composition.infrastructure.serverStateScope} scope={composition.infrastructure.serverStateScope}
mutationIntentFactory={
composition.infrastructure.mutationIntentFactory
}
transitionFallback={ transitionFallback={
<div <div
aria-busy="true" aria-busy="true"
+59
View File
@@ -0,0 +1,59 @@
export const MUTATION_INTENT_BOUNDS = Object.freeze({
intentIdMaxBytes: 256,
operationIdMaxBytes: 256,
canonicalInputIdentityMaxBytes: 16_384,
idempotencyKeyMaxBytes: 256,
} as const);
export type MutationIntent = Readonly<{
intentId: string;
operationId: string;
canonicalInputIdentity: string;
idempotencyKey?: string;
createdAtMonotonicMs: number;
}>;
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,
});
}
+3 -1
View File
@@ -6,6 +6,7 @@ import {
type RuntimeIdentityBinding, type RuntimeIdentityBinding,
} from "./query-keys.ts"; } from "./query-keys.ts";
import type { CacheScopeSnapshot } from "./server-state-scope.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 * §10.2. The four fixed profiles. A feature selects one by ID; it never
@@ -234,12 +235,13 @@ export type BoundMutation<Input, Value> = Readonly<{
definitionId: string; definitionId: string;
definitionVersion: number; definitionVersion: number;
operationId: string; operationId: string;
requiresIdempotencyKey: boolean;
owner: string; owner: string;
duplicatePolicy: MutationDuplicatePolicy; duplicatePolicy: MutationDuplicatePolicy;
scope: CacheScopeSnapshot; scope: CacheScopeSnapshot;
execute( execute(
input: Input, input: Input,
context: Readonly<{ signal: AbortSignal }>, context: Readonly<{ signal: AbortSignal; intent: MutationIntent }>,
): Promise<Result<Value>>; ): Promise<Result<Value>>;
invalidate: readonly QueryInvalidationTopic[]; invalidate: readonly QueryInvalidationTopic[];
optimistic?: Readonly<{ optimistic?: Readonly<{
@@ -1,6 +1,7 @@
import type { Result } from "../../../application/result.ts"; import type { Result } from "../../../application/result.ts";
import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts"; import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts";
import type { FailureEffectCertainty } from "../../../contracts/errors.ts"; import type { FailureEffectCertainty } from "../../../contracts/errors.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import { import {
createFailure, createFailure,
kindForStatus, kindForStatus,
@@ -22,7 +23,10 @@ export type InstalledContractOperationExecutor = Readonly<{
execute( execute(
operationId: string, operationId: string,
input: unknown, input: unknown,
context?: Readonly<{ signal?: AbortSignal }>, context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<HttpExecutionOutcome<unknown, unknown>>; ): Promise<HttpExecutionOutcome<unknown, unknown>>;
}>; }>;
@@ -39,10 +43,14 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
const operationId = request.operationId; const operationId = request.operationId;
const input = inputFor(request); const input = inputFor(request);
const signal = "signal" in request ? request.signal : undefined; const signal = "signal" in request ? request.signal : undefined;
const intent = "intent" in request ? request.intent : undefined;
const outcome = await context.contractOperations.execute( const outcome = await context.contractOperations.execute(
operationId, operationId,
input, input,
signal === undefined ? {} : { signal }, {
...(signal === undefined ? {} : { signal }),
...(intent === undefined ? {} : { intent }),
},
); );
return projectExecutionOutcome(operationId, outcome); return projectExecutionOutcome(operationId, outcome);
}, },
@@ -9,6 +9,7 @@ import type {
ReferenceListFilters, ReferenceListFilters,
} from "../application/reference-feature-api.ts"; } from "../application/reference-feature-api.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts"; import type { ReferenceResource } from "../domain/reference-resource.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
type ReferenceOperationMap = Readonly<{ type ReferenceOperationMap = Readonly<{
LIST_REFERENCE_RESOURCES: Readonly<{ LIST_REFERENCE_RESOURCES: Readonly<{
@@ -26,6 +27,7 @@ type ReferenceOperationMap = Readonly<{
routeId: "REFERENCE_RESOURCE_LIST"; routeId: "REFERENCE_RESOURCE_LIST";
body: ReferenceCreateCommand; body: ReferenceCreateCommand;
signal?: AbortSignal; signal?: AbortSignal;
intent?: MutationIntent;
}>; }>;
value: ReferenceResource; value: ReferenceResource;
}>; }>;
@@ -74,13 +76,17 @@ export function createReferenceHttpGateway(
}, },
async create( async create(
command: ReferenceCreateCommand, command: ReferenceCreateCommand,
context?: Readonly<{ signal?: AbortSignal }>, context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
) { ) {
const result = await http.execute({ const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE", operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST", routeId: "REFERENCE_RESOURCE_LIST",
body: command, body: command,
signal: context?.signal, signal: context?.signal,
intent: context?.intent,
}); });
return projectResourceResult("CREATE_REFERENCE_RESOURCE", result); return projectResourceResult("CREATE_REFERENCE_RESOURCE", result);
}, },
@@ -5,6 +5,7 @@ import {
type ReferenceResourceView, type ReferenceResourceView,
} from "../contracts/reference-mapper.ts"; } from "../contracts/reference-mapper.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts"; import type { ReferenceResource } from "../domain/reference-resource.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
export type ReferenceListFilters = Readonly<{ export type ReferenceListFilters = Readonly<{
cursor?: string; cursor?: string;
@@ -26,7 +27,10 @@ export type ReferenceFeatureInput = Readonly<{
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>; ): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource( createResource(
command: ReferenceCreateCommand, command: ReferenceCreateCommand,
context?: Readonly<{ signal?: AbortSignal }>, context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<ReferenceResult<ReferenceResourceView>>; ): Promise<ReferenceResult<ReferenceResourceView>>;
getResource( getResource(
resourceId: string, resourceId: string,
@@ -47,7 +51,10 @@ export type ReferenceGateway = Readonly<{
): Promise<ReferenceResult<readonly ReferenceResource[]>>; ): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create( create(
command: ReferenceCreateCommand, command: ReferenceCreateCommand,
context?: Readonly<{ signal?: AbortSignal }>, context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<ReferenceResult<ReferenceResource>>; ): Promise<ReferenceResult<ReferenceResource>>;
get( get(
resourceId: string, resourceId: string,
@@ -87,6 +87,7 @@ export function useReferenceCreate() {
definitionId: "reference-resource-create-v1", definitionId: "reference-resource-create-v1",
definitionVersion: 1, definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE", operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID, owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE", duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope, scope,
@@ -123,6 +124,7 @@ export function useReferenceFeature() {
definitionId: "reference-resource-create-v1", definitionId: "reference-resource-create-v1",
definitionVersion: 1, definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE", operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID, owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE", duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope, scope,
@@ -21,6 +21,7 @@ import {
type AppFailure, type AppFailure,
} from "../../../contracts/errors.ts"; } from "../../../contracts/errors.ts";
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import { import {
admitQueryResult, admitQueryResult,
type BoundMutation, type BoundMutation,
@@ -29,6 +30,7 @@ import {
} from "../../../contracts/server-state.ts"; } from "../../../contracts/server-state.ts";
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts"; import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx"; import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
import { useMutationIntentFactory } from "./mutation-intent-provider.tsx";
import { import {
createOptimisticLayerRuntime, createOptimisticLayerRuntime,
type OptimisticLayerLease, type OptimisticLayerLease,
@@ -196,6 +198,10 @@ type ApplicationMutationController<Input, Value> = Readonly<{
resolveConflict(): Promise<void>; resolveConflict(): Promise<void>;
}>; }>;
type MutationExecution<Input> =
| Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }>
| Readonly<{ kind: "LEGACY"; input: Input }>;
export function useApplicationMutation<Input, Value>( export function useApplicationMutation<Input, Value>(
options: BoundMutation<Input, Value>, options: BoundMutation<Input, Value>,
): ApplicationMutationController<Input, Value>; ): ApplicationMutationController<Input, Value>;
@@ -207,6 +213,7 @@ export function useApplicationMutation<Input, Value>(
): ApplicationMutationController<Input, Value> { ): ApplicationMutationController<Input, Value> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidationCoordinator = useQueryInvalidationCoordinator(); const invalidationCoordinator = useQueryInvalidationCoordinator();
const mutationIntentFactory = useMutationIntentFactory();
const invalidate = useMemo( const invalidate = useMemo(
() => options.invalidate ?? [], () => options.invalidate ?? [],
[options.invalidate], [options.invalidate],
@@ -219,9 +226,20 @@ export function useApplicationMutation<Input, Value>(
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE"; "duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
const [conflict, setConflict] = useState<AppFailure | null>(null); const [conflict, setConflict] = useState<AppFailure | null>(null);
const scope = "scope" in options ? options.scope : undefined; const scope = "scope" in options ? options.scope : undefined;
const mutation = useMutation<Value, ApplicationQueryError, Input>({ const mutationOperationId =
"operationId" in options ? options.operationId : definitionId;
const requiresIdempotencyKey =
"requiresIdempotencyKey" in options
? options.requiresIdempotencyKey
: false;
const mutation = useMutation<
Value,
ApplicationQueryError,
MutationExecution<Input>
>({
retry: false, retry: false,
mutationFn: async (input) => { mutationFn: async (execution) => {
const input = execution.input;
if (scope && !scope.isCurrent()) { if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError( throw new ApplicationQueryError(
createFailure( createFailure(
@@ -234,7 +252,17 @@ export function useApplicationMutation<Input, Value>(
} }
const result = const result =
"scope" in options "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); : await options.execute(input);
if (scope && !scope.isCurrent()) { if (scope && !scope.isCurrent()) {
const effect = const effect =
@@ -311,6 +339,18 @@ export function useApplicationMutation<Input, Value>(
mutation.reset(); mutation.reset();
const pending = (async (): Promise<ApplicationResult<Value>> => { const pending = (async (): Promise<ApplicationResult<Value>> => {
const execution: MutationExecution<Input> =
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 = const mutationLease =
invalidate.length === 0 invalidate.length === 0
? null ? null
@@ -346,7 +386,7 @@ export function useApplicationMutation<Input, Value>(
let value: Value; let value: Value;
try { try {
value = await mutation.mutateAsync(input); value = await mutation.mutateAsync(execution);
} catch (error: unknown) { } catch (error: unknown) {
if (optimistic) { if (optimistic) {
if (optimisticLayer) { if (optimisticLayer) {
@@ -411,6 +451,9 @@ export function useApplicationMutation<Input, Value>(
queryClient, queryClient,
definitionId, definitionId,
duplicatePolicy, duplicatePolicy,
mutationIntentFactory,
mutationOperationId,
requiresIdempotencyKey,
scope, scope,
], ],
); );
@@ -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<MutationIntentFactory>(
unavailableMutationIntentFactory,
);
export function MutationIntentProvider({
factory,
children,
}: Readonly<{
factory: MutationIntentFactory;
children: ReactNode;
}>) {
return (
<MutationIntentFactoryContext.Provider value={factory}>
{children}
</MutationIntentFactoryContext.Provider>
);
}
export function useMutationIntentFactory(): MutationIntentFactory {
return useContext(MutationIntentFactoryContext);
}
@@ -6,6 +6,8 @@ import { type ReactNode, useSyncExternalStore } from "react";
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts"; import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.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 { QueryInvalidationProvider } from "./query-invalidation-provider.tsx";
import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx"; import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx";
@@ -21,11 +23,13 @@ export type ServerStateGenerationSource = Readonly<{
export function ServerStateGenerationProvider({ export function ServerStateGenerationProvider({
store, store,
scope, scope,
mutationIntentFactory,
children, children,
transitionFallback, transitionFallback,
}: Readonly<{ }: Readonly<{
store: ServerStateGenerationSource; store: ServerStateGenerationSource;
scope: ServerStateScopeRuntime; scope: ServerStateScopeRuntime;
mutationIntentFactory: MutationIntentFactory;
children: ReactNode; children: ReactNode;
transitionFallback?: ReactNode; transitionFallback?: ReactNode;
}>) { }>) {
@@ -35,18 +39,20 @@ export function ServerStateGenerationProvider({
store.getSnapshot, store.getSnapshot,
); );
return ( return (
<QueryClientProvider <MutationIntentProvider factory={mutationIntentFactory}>
key={generation.generation} <QueryClientProvider
client={generation.queryClient} key={generation.generation}
> client={generation.queryClient}
<ServerStateScopeProvider
runtime={scope}
transitionFallback={transitionFallback}
> >
<QueryInvalidationProvider coordinator={generation.queryInvalidation}> <ServerStateScopeProvider
{children} runtime={scope}
</QueryInvalidationProvider> transitionFallback={transitionFallback}
</ServerStateScopeProvider> >
</QueryClientProvider> <QueryInvalidationProvider coordinator={generation.queryInvalidation}>
{children}
</QueryInvalidationProvider>
</ServerStateScopeProvider>
</QueryClientProvider>
</MutationIntentProvider>
); );
} }
+156 -6
View File
@@ -14,6 +14,8 @@ import {
useApplicationMutation, useApplicationMutation,
useApplicationQuery, useApplicationQuery,
} from "../../src/presentation/adapters/query/application-query.ts"; } 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 { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx";
import { import {
defineQueryInvalidationTopic, 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 = { const coordinator: QueryInvalidationCoordinator = {
async invalidate(topics) { async invalidate(topics) {
for (const topic of topics) { for (const topic of topics) {
@@ -93,11 +116,13 @@ function wrapper(client: QueryClient) {
}; };
return function QueryWrapper({ children }: { children: ReactNode }) { return function QueryWrapper({ children }: { children: ReactNode }) {
return ( return (
<QueryClientProvider client={client}> <MutationIntentProvider factory={mutationIntentFactory}>
<QueryInvalidationProvider coordinator={coordinator}> <QueryClientProvider client={client}>
{children} <QueryInvalidationProvider coordinator={coordinator}>
</QueryInvalidationProvider> {children}
</QueryClientProvider> </QueryInvalidationProvider>
</QueryClientProvider>
</MutationIntentProvider>
); );
}; };
} }
@@ -415,6 +440,7 @@ describe("scope-bound mutation fence", () => {
definitionId: "fenced-mutation-v1", definitionId: "fenced-mutation-v1",
definitionVersion: 1, definitionVersion: 1,
operationId: "CREATE_FENCED", operationId: "CREATE_FENCED",
requiresIdempotencyKey: false,
owner: "platform-test", owner: "platform-test",
duplicatePolicy: "REJECT_WHILE_ACTIVE", duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope, scope,
@@ -446,6 +472,7 @@ describe("scope-bound mutation fence", () => {
definitionId: "late-mutation-v1", definitionId: "late-mutation-v1",
definitionVersion: 1, definitionVersion: 1,
operationId: "CREATE_LATE", operationId: "CREATE_LATE",
requiresIdempotencyKey: false,
owner: "platform-test", owner: "platform-test",
duplicatePolicy: "ALLOW_PARALLEL", duplicatePolicy: "ALLOW_PARALLEL",
scope, scope,
@@ -488,6 +515,7 @@ describe("scope-bound mutation fence", () => {
definitionId: "hung-mutation-v1", definitionId: "hung-mutation-v1",
definitionVersion: 1, definitionVersion: 1,
operationId: "CREATE_HUNG", operationId: "CREATE_HUNG",
requiresIdempotencyKey: false,
owner: "platform-test", owner: "platform-test",
duplicatePolicy: "REJECT_WHILE_ACTIVE", duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope, scope,
@@ -516,6 +544,128 @@ describe("scope-bound mutation fence", () => {
}); });
describe("application mutation inbound bridge", () => { 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<string, string>({
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<string>) => void = () => {};
const execute = vi.fn(
() =>
new Promise<ApplicationResult<string>>((resolve) => {
complete = resolve;
}),
);
const hook = renderHook(
() =>
useApplicationMutation<string, string>({
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<ApplicationResult<string>> | null = null;
let joined: Promise<ApplicationResult<string>> | 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<MutationIntentFactory["create"]>();
const factory: MutationIntentFactory = Object.freeze({
create: createIntent,
});
const execute = vi.fn(async (input: string) => ({
ok: true as const,
value: input,
}));
const hook = renderHook(
() => useApplicationMutation<string, string>({ 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 () => { it("rejects a duplicate submit by default while one is active", async () => {
const client = queryClient(); const client = queryClient();
let complete: (value: ApplicationResult<string>) => void = () => {}; let complete: (value: ApplicationResult<string>) => void = () => {};
@@ -43,6 +43,11 @@ describe("server-state generation provider", () => {
`scope-generation-provider-${String(token++).padStart(4, "0")}`, `scope-generation-provider-${String(token++).padStart(4, "0")}`,
}); });
const renderedClients: QueryClient[] = []; const renderedClients: QueryClient[] = [];
const mutationIntentFactory = Object.freeze({
create() {
throw new Error("mutation intent is unused by this provider test");
},
});
function Probe() { function Probe() {
renderedClients.push(useQueryClient()); renderedClients.push(useQueryClient());
return <div>generation-content</div>; return <div>generation-content</div>;
@@ -52,6 +57,7 @@ describe("server-state generation provider", () => {
<ServerStateGenerationProvider <ServerStateGenerationProvider
store={store} store={store}
scope={scope} scope={scope}
mutationIntentFactory={mutationIntentFactory}
transitionFallback={<div>scope-transition</div>} transitionFallback={<div>scope-transition</div>}
> >
<Probe /> <Probe />
@@ -10,6 +10,7 @@ import { describe, expect, it, vi } from "vitest";
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts"; import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.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 { import type {
ReferenceFeatureInput, ReferenceFeatureInput,
ReferenceResult, ReferenceResult,
@@ -19,6 +20,7 @@ import type { ReferenceResourceView } from "../../../src/features/reference-feat
import { createFailure } from "../../../src/contracts/errors.ts"; import { createFailure } from "../../../src/contracts/errors.ts";
import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts"; import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts";
import { QueryInvalidationProvider } from "../../../src/presentation/adapters/query/query-invalidation-provider.tsx"; 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 { ServerStateScopeProvider } from "../../../src/presentation/adapters/query/server-state-scope-provider.tsx";
import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts"; import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx"; import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
@@ -51,21 +53,38 @@ function renderReference(
session, session,
queryInvalidation: invalidation, 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( return render(
<QueryClientProvider client={client}> <MutationIntentProvider factory={mutationIntentFactory}>
<ServerStateScopeProvider runtime={serverStateScope}> <QueryClientProvider client={client}>
<QueryInvalidationProvider coordinator={invalidation}> <ServerStateScopeProvider runtime={serverStateScope}>
<ApplicationProvider <QueryInvalidationProvider coordinator={invalidation}>
application={createTestApplication({ <ApplicationProvider
session, application={createTestApplication({
featureInputs: { [REFERENCE_FEATURE_ID]: input }, session,
})} featureInputs: { [REFERENCE_FEATURE_ID]: input },
> })}
<AppRouter /> >
</ApplicationProvider> <AppRouter />
</QueryInvalidationProvider> </ApplicationProvider>
</ServerStateScopeProvider> </QueryInvalidationProvider>
</QueryClientProvider>, </ServerStateScopeProvider>
</QueryClientProvider>
</MutationIntentProvider>,
); );
} }
@@ -124,9 +124,11 @@ describe("HTTP operation execution contract", () => {
expect(requests[0].url).toBe( expect(requests[0].url).toBe(
"https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new", "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); expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters);
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" }); await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command"); 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].headers.get("X-Correlation-ID")).toBeTruthy();
expect(requests[0].credentials).toBe("same-origin"); expect(requests[0].credentials).toBe("same-origin");
expect(requests[0].cache).toBe("no-store"); expect(requests[0].cache).toBe("no-store");
+139 -2
View File
@@ -21,6 +21,20 @@ const scope = Object.freeze({
const createInstalled: InstalledHttpContract<unknown, unknown, unknown> = const createInstalled: InstalledHttpContract<unknown, unknown, unknown> =
TEST_CREATE_HTTP_CONTRACT; 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( function operation(
overrides: Readonly<{ overrides: Readonly<{
deadlineMs?: number; deadlineMs?: number;
@@ -47,6 +61,129 @@ async function flushMicrotasks(): Promise<void> {
} }
describe("descriptor-driven HTTP execution lifetime", () => { describe("descriptor-driven HTTP execution lifetime", () => {
it("reuses one supplied idempotency key across every physical retry", async () => {
const observedKeys: Array<string | null> = [];
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<string | null> = [];
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([ it.each([
[{}, "limit=20"], [{}, "limit=20"],
[{ limit: "7" }, "limit=7"], [{ limit: "7" }, "limit=7"],
@@ -127,7 +264,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
{ name: "created" }, { name: "created" },
{ {
scope, scope,
intent: { intentId: "intent-1", startedBy: "USER", idempotencyKey: "key-1" }, intent: mutationIntent(),
}, },
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
@@ -169,7 +306,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
{ name: "created" }, { name: "created" },
{ {
scope: fencedScope, scope: fencedScope,
intent: { intentId: "intent-2", startedBy: "USER", idempotencyKey: "key-2" }, intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }),
}, },
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
+120
View File
@@ -4,6 +4,7 @@ import {
createRuntimeAdapters, createRuntimeAdapters,
createRuntimeHttpClient, createRuntimeHttpClient,
} from "../../src/bootstrap/runtime-adapters.ts"; } 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"; import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts";
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"]; type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
@@ -53,6 +54,65 @@ const release: Release = {
}; };
describe("runtime adapter composition", () => { 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 () => { it("constructs the local demo seam and infrastructure adapters", async () => {
const adapters = await createRuntimeAdapters({ const adapters = await createRuntimeAdapters({
runtime, runtime,
@@ -66,6 +126,7 @@ describe("runtime adapter composition", () => {
}); });
expect(adapters.infrastructure.queryClient).toBeDefined(); expect(adapters.infrastructure.queryClient).toBeDefined();
expect(adapters.infrastructure.queryInvalidation).toBeDefined(); expect(adapters.infrastructure.queryInvalidation).toBeDefined();
expect(adapters.infrastructure.mutationIntentFactory).toBeDefined();
expect( expect(
adapters.infrastructure.crossContextInvalidationStatus(), adapters.infrastructure.crossContextInvalidationStatus(),
).toBe("DEGRADED_LOCAL_ONLY"); ).toBe("DEGRADED_LOCAL_ONLY");
@@ -81,6 +142,8 @@ describe("runtime adapter composition", () => {
const adapters = await createRuntimeAdapters({ runtime, release, host: {} }); const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
const previousClient = adapters.infrastructure.queryClient; const previousClient = adapters.infrastructure.queryClient;
const previousCoordinator = adapters.infrastructure.queryInvalidation; const previousCoordinator = adapters.infrastructure.queryInvalidation;
const runtimeMutationIntentFactory =
adapters.infrastructure.mutationIntentFactory;
const clearPrevious = vi.spyOn(previousClient, "clear"); const clearPrevious = vi.spyOn(previousClient, "clear");
await adapters.outputPorts.session.beginSignIn(); await adapters.outputPorts.session.beginSignIn();
@@ -91,6 +154,9 @@ describe("runtime adapter composition", () => {
expect(adapters.infrastructure.queryInvalidation).not.toBe( expect(adapters.infrastructure.queryInvalidation).not.toBe(
previousCoordinator, previousCoordinator,
); );
expect(adapters.infrastructure.mutationIntentFactory).toBe(
runtimeMutationIntentFactory,
);
const clearCallsAfterReplacement = clearPrevious.mock.calls.length; const clearCallsAfterReplacement = clearPrevious.mock.calls.length;
await previousCoordinator.resetLocal(); await previousCoordinator.resetLocal();
@@ -99,6 +165,60 @@ describe("runtime adapter composition", () => {
adapters.infrastructure.dispose(); adapters.infrastructure.dispose();
}); });
it("passes the supplied command intent unchanged and keeps private identity out of URLs and diagnostics", async () => {
const requests: Array<Readonly<{ url: string; headers: Headers }>> = [];
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 () => { it("does not fail boot when Web Storage capability getters throw", async () => {
const host: Record<string, unknown> = {}; const host: Record<string, unknown> = {};
Object.defineProperties(host, { Object.defineProperties(host, {