mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
Backend (MUL-4762 F1/F4/F5): - withPropertyLock: pg_advisory_xact_lock helper; definition create/update and value writes now serialize config-vs-value and cap-vs-insert races (workspace-level 'props:' lock + per-definition 'prop:' lock, ordered). - propertySortExpr degrades archived definitions to position sort. Frontend (F2/F3/F6): - onIssuePropertiesChanged invalidates plain assignee-group caches too. - Property value mutations cancel list refetches in onMutate and roll back only the touched key against the current bag (concurrent WS writes to other keys survive a failed write). - useUpdateIssue reconcile drops the stale properties bag from the server snapshot; the property pipeline owns that field. - Surface controller passes persisted property filters/sorts through until the catalog query settles (cold cache no longer strips them). Co-authored-by: multica-agent <github@multica.ai>
182 lines
7.3 KiB
TypeScript
182 lines
7.3 KiB
TypeScript
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import { propertyKeys } from "./queries";
|
|
import { useWorkspaceId } from "../hooks";
|
|
import { issueKeys } from "../issues/queries";
|
|
import { invalidatePropertyWindowQueries, onIssuePropertiesChanged } from "../issues/ws-updaters";
|
|
import { findIssueLocation } from "../issues/cache-helpers";
|
|
import type {
|
|
CreatePropertyRequest,
|
|
UpdatePropertyRequest,
|
|
Issue,
|
|
IssuePropertyValue,
|
|
IssuePropertyValues,
|
|
ListIssuesCache,
|
|
} from "../types";
|
|
|
|
export function useCreateProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: (data: CreatePropertyRequest) => api.createProperty(data),
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Definition updates (rename, options, archive). No optimistic patch: edits
|
|
* happen in the settings dialog where a round-trip is acceptable, and config
|
|
* canonicalization (option id assignment) is server-side anyway.
|
|
*/
|
|
export function useUpdateProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ id, ...data }: { id: string } & UpdatePropertyRequest) =>
|
|
api.updateProperty(id, data),
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) });
|
|
// Issue caches embed the value bag; a definition change (rename,
|
|
// option edits) changes how values render, and rows referencing an
|
|
// archived definition must drop out of "+ Add property" menus.
|
|
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read the issue's current property bag from whichever cache holds it: the
|
|
* detail cache first, then any list cache bucket. Board/list surfaces never
|
|
* populate the detail cache, so stopping there would make the optimistic
|
|
* merge below overwrite the whole bag with a single key.
|
|
*/
|
|
function readIssueProperties(qc: ReturnType<typeof useQueryClient>, wsId: string, issueId: string): IssuePropertyValues | undefined {
|
|
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
|
if (detail) return detail.properties ?? {};
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
|
|
if (!data) continue;
|
|
const location = findIssueLocation(data, issueId);
|
|
if (location) return location.issue.properties ?? {};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
|
|
/**
|
|
* Optimistic single-property write on an issue.
|
|
*
|
|
* Concurrency contract (MUL-4463 review round 1/2):
|
|
* - Mutations for the SAME issue serialize via TanStack's mutation
|
|
* `scope`, so full-bag responses cannot land out of order and rapid
|
|
* multi-select toggles cannot interleave.
|
|
* - The optimistic patch merges into the bag read from ANY cache
|
|
* (detail or list) — never replaces it — so board-only surfaces keep
|
|
* the issue's other property values.
|
|
* - Failure restores the snapshot; if no snapshot existed, it falls back
|
|
* to invalidation.
|
|
* - The LAST settled mutation for the issue does an authoritative
|
|
* invalidate of the detail cache plus the definition catalog (usage
|
|
* counts), reconciling any raced WS snapshots under staleTime:Infinity.
|
|
*/
|
|
export function useSetIssueProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ issueId, propertyId, value }: { issueId: string; propertyId: string; value: IssuePropertyValue }) =>
|
|
api.setIssueProperty(issueId, propertyId, value),
|
|
scope: { id: `issue-properties:${wsId}` },
|
|
mutationKey: ["issue-properties", wsId],
|
|
onMutate: async ({ issueId, propertyId, value }) => {
|
|
// Cancel in-flight list refetches too: a response snapshotted before
|
|
// this write would land after the optimistic patch and revert it.
|
|
await Promise.all([
|
|
qc.cancelQueries({ queryKey: issueKeys.detail(wsId, issueId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
|
]);
|
|
const prev = readIssueProperties(qc, wsId, issueId);
|
|
onIssuePropertiesChanged(qc, wsId, issueId, { ...(prev ?? {}), [propertyId]: value });
|
|
return { prevValue: prev?.[propertyId], hadBag: prev !== undefined, issueId, propertyId };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (!ctx) return;
|
|
rollbackSingleKey(qc, wsId, ctx);
|
|
},
|
|
onSuccess: (data, { issueId }) => {
|
|
onIssuePropertiesChanged(qc, wsId, issueId, data.properties ?? {});
|
|
},
|
|
onSettled: (_data, _err, { issueId }) => {
|
|
settleIssuePropertyCaches(qc, wsId, issueId);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUnsetIssueProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ issueId, propertyId }: { issueId: string; propertyId: string }) =>
|
|
api.unsetIssueProperty(issueId, propertyId),
|
|
scope: { id: `issue-properties:${wsId}` },
|
|
mutationKey: ["issue-properties", wsId],
|
|
onMutate: async ({ issueId, propertyId }) => {
|
|
await Promise.all([
|
|
qc.cancelQueries({ queryKey: issueKeys.detail(wsId, issueId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
|
]);
|
|
const prev = readIssueProperties(qc, wsId, issueId);
|
|
if (prev) {
|
|
const next = { ...prev };
|
|
delete next[propertyId];
|
|
onIssuePropertiesChanged(qc, wsId, issueId, next);
|
|
}
|
|
return { prevValue: prev?.[propertyId], hadBag: prev !== undefined, issueId, propertyId };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (!ctx) return;
|
|
rollbackSingleKey(qc, wsId, ctx);
|
|
},
|
|
onSuccess: (data, { issueId }) => {
|
|
onIssuePropertiesChanged(qc, wsId, issueId, data.properties ?? {});
|
|
},
|
|
onSettled: (_data, _err, { issueId }) => {
|
|
settleIssuePropertyCaches(qc, wsId, issueId);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Roll back exactly the key this mutation touched, against the CURRENT bag.
|
|
* Restoring the whole onMutate snapshot would erase concurrent remote writes
|
|
* to other keys that landed via WS while this request was in flight
|
|
* (clean-room review F2 interleave B).
|
|
*/
|
|
function rollbackSingleKey(
|
|
qc: ReturnType<typeof useQueryClient>,
|
|
wsId: string,
|
|
ctx: { prevValue: IssuePropertyValue | undefined; hadBag: boolean; issueId: string; propertyId: string },
|
|
) {
|
|
if (!ctx.hadBag) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, ctx.issueId) });
|
|
return;
|
|
}
|
|
const current = readIssueProperties(qc, wsId, ctx.issueId) ?? {};
|
|
const next = { ...current };
|
|
if (ctx.prevValue === undefined) delete next[ctx.propertyId];
|
|
else next[ctx.propertyId] = ctx.prevValue;
|
|
onIssuePropertiesChanged(qc, wsId, ctx.issueId, next);
|
|
}
|
|
|
|
/** Authoritative reconcile once the LAST in-flight property write settles. */
|
|
function settleIssuePropertyCaches(qc: ReturnType<typeof useQueryClient>, wsId: string, issueId: string) {
|
|
if (qc.isMutating({ mutationKey: ["issue-properties", wsId] }) > 1) return;
|
|
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
|
qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) });
|
|
// Server-filtered/-sorted windows must refetch: the write may have moved
|
|
// this issue across pages or reordered it (onIssuePropertiesChanged
|
|
// already fires this for the WS path; local mutations need it on settle
|
|
// because the self-emitted WS event may be suppressed or raced).
|
|
invalidatePropertyWindowQueries(qc, wsId);
|
|
}
|