fix: preserve logical mutation intent
This commit is contained in:
@@ -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<Input, Value> = Readonly<{
|
||||
resolveConflict(): Promise<void>;
|
||||
}>;
|
||||
|
||||
type MutationExecution<Input> =
|
||||
| Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }>
|
||||
| Readonly<{ kind: "LEGACY"; input: Input }>;
|
||||
|
||||
export function useApplicationMutation<Input, Value>(
|
||||
options: BoundMutation<Input, Value>,
|
||||
): ApplicationMutationController<Input, Value>;
|
||||
@@ -207,6 +213,7 @@ export function useApplicationMutation<Input, Value>(
|
||||
): ApplicationMutationController<Input, Value> {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidationCoordinator = useQueryInvalidationCoordinator();
|
||||
const mutationIntentFactory = useMutationIntentFactory();
|
||||
const invalidate = useMemo(
|
||||
() => options.invalidate ?? [],
|
||||
[options.invalidate],
|
||||
@@ -219,9 +226,20 @@ export function useApplicationMutation<Input, Value>(
|
||||
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
|
||||
const [conflict, setConflict] = useState<AppFailure | null>(null);
|
||||
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,
|
||||
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<Input, Value>(
|
||||
}
|
||||
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<Input, Value>(
|
||||
mutation.reset();
|
||||
|
||||
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 =
|
||||
invalidate.length === 0
|
||||
? null
|
||||
@@ -346,7 +386,7 @@ export function useApplicationMutation<Input, Value>(
|
||||
|
||||
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<Input, Value>(
|
||||
queryClient,
|
||||
definitionId,
|
||||
duplicatePolicy,
|
||||
mutationIntentFactory,
|
||||
mutationOperationId,
|
||||
requiresIdempotencyKey,
|
||||
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 { 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 (
|
||||
<QueryClientProvider
|
||||
key={generation.generation}
|
||||
client={generation.queryClient}
|
||||
>
|
||||
<ServerStateScopeProvider
|
||||
runtime={scope}
|
||||
transitionFallback={transitionFallback}
|
||||
<MutationIntentProvider factory={mutationIntentFactory}>
|
||||
<QueryClientProvider
|
||||
key={generation.generation}
|
||||
client={generation.queryClient}
|
||||
>
|
||||
<QueryInvalidationProvider coordinator={generation.queryInvalidation}>
|
||||
{children}
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
<ServerStateScopeProvider
|
||||
runtime={scope}
|
||||
transitionFallback={transitionFallback}
|
||||
>
|
||||
<QueryInvalidationProvider coordinator={generation.queryInvalidation}>
|
||||
{children}
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
</MutationIntentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user