Files
multica/packages/core/issues/ws-updaters.ts
Lambda c402b91c1d fix(properties): harden concurrency and cache coordination from clean-room review
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>
2026-07-15 11:20:14 +08:00

272 lines
11 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import { projectKeys } from "../projects/queries";
import {
applyIssueChange,
invalidateIssueDerivatives,
invalidateStaleListKeys,
} from "./cache-coordinator";
import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
} from "./cache-helpers";
import { cleanupDeletedIssueCaches } from "./delete-cache";
import type { Issue, IssueLabelsResponse, IssueMetadata, IssuePropertyValues, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
qc: QueryClient,
wsId: string,
issue: Issue,
) {
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, addIssueToBuckets(data, issue));
}
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (issue.project_id) {
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}
// Refresh every Project Gantt cache that might be observing this issue.
// We invalidate the whole prefix rather than the issue's own project
// because a fresh issue isn't necessarily scheduled yet; the active Gantt
// page (if any) will refetch and pick it up if it qualifies.
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
export function onIssueUpdated(
qc: QueryClient,
wsId: string,
issue: Partial<Issue> & { id: string },
// assigneeChanged / statusChanged / projectChanged come from the server's
// issue:updated flags — authoritative "did this write move a membership
// dimension" signals. They feed the coordinator's changed-dims input so a
// non-membership change (title / position / priority / label) keeps every
// loaded list in place instead of refetching.
meta: {
assigneeChanged?: boolean;
statusChanged?: boolean;
projectChanged?: boolean;
} = {},
) {
// Look up the OLD parent + cached entity before mutating cache state, so we
// can keep the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page) and diff-fallback the change flags.
const listQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const firstListData = listQueries[0]?.[1];
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const cachedIssue =
detailData ??
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue : undefined);
const oldParentId =
detailData?.parent_issue_id ?? cachedIssue?.parent_issue_id ?? null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
// Prefer the server's flags (authoritative, set on the wire). Fall back to
// diffing the payload against the cached copy only when a flag is absent
// (older backend): the diff is unreliable once a local optimistic move has
// overwritten the cached value, but it still covers remote/agent changes
// and keeps a new frontend on an old backend from regressing (MUL-3669 /
// #4548). The local move itself is covered by useUpdateIssue's own
// coordinator pass, which never depends on these flags.
const oldProjectId = detailData?.project_id ?? cachedIssue?.project_id ?? null;
const changed = {
assignee:
meta.assigneeChanged ??
(cachedIssue !== undefined &&
((issue.assignee_id !== undefined &&
issue.assignee_id !== cachedIssue.assignee_id) ||
(issue.assignee_type !== undefined &&
issue.assignee_type !== cachedIssue.assignee_type))),
project:
meta.projectChanged ??
(issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId),
status:
meta.statusChanged ??
(cachedIssue !== undefined &&
issue.status !== undefined &&
issue.status !== cachedIssue.status),
};
// The coordinator applies the same rules table the local mutations use:
// surgical patch/rebucket where the card is loaded and still belongs,
// surgical remove where the change moved it off a filtered surface, and
// stale keys for the drift a patch cannot fix (enter/leave beyond the
// loaded window, undecidable membership, off-screen bucket counts). The
// server has already committed, so stale keys are flushed immediately.
const change = applyIssueChange(qc, wsId, issue.id, issue, {
changed,
baseIssue: cachedIssue,
});
invalidateStaleListKeys(qc, change.staleKeys);
invalidateIssueDerivatives(qc, wsId, {
statusOrProjectChanged:
issue.status !== undefined || issue.project_id !== undefined,
});
// Invalidate old parent's children (issue was removed from it)
if (oldParentId) {
if (parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
} else {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
// Invalidate new parent's children (issue was added to it)
if (newParentId && parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
}
if (oldParentId || newParentId) {
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
}
}
/**
* Patch an issue's labels in-place across the list cache, my-issues caches,
* the detail cache, and the per-issue label cache. Triggered by the
* `issue_labels:changed` WS event after attach/detach so list/board chips
* and the issue-detail Properties LabelPicker update without a refetch.
*
* The byIssue cache backs `LabelPicker`; without patching it, externally
* driven label changes (agents, other tabs) leave the picker stale until it
* remounts — `staleTime: Infinity` + `refetchOnWindowFocus: false` (see
* `query-client.ts`) means focus changes won't recover it.
*/
export function onIssueLabelsChanged(
qc: QueryClient,
wsId: string,
issueId: string,
labels: Label[],
) {
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { labels }));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
// Patch the Project Gantt caches in-place: the Gantt view applies
// `labelFilters` to the row data, so a stale `labels` array would silently
// hide or surface bars after another tab/agent attached or detached a
// label. Mutating in place (instead of invalidating) avoids a refetch of
// the entire scheduled set on every label toggle.
for (const [key, data] of qc.getQueriesData<Issue[]>({
queryKey: issueKeys.projectGanttAll(wsId),
})) {
if (!data) continue;
const next = data.map((issue) =>
issue.id === issueId ? { ...issue, labels } : issue,
);
qc.setQueryData<Issue[]>(key, next);
}
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
}
/**
* Apply a metadata snapshot to the issue detail + list + my-issues caches.
* The server emits this whenever a single key is set or deleted, so the
* payload is always the FULL post-mutation map — we replace, not merge.
*
* Used for the read-only metadata strip in issue detail. Updates that arrive
* while no view is mounted still keep the caches accurate so the next render
* shows the latest state without a refetch.
*/
export function onIssueMetadataChanged(
qc: QueryClient,
wsId: string,
issueId: string,
metadata: IssueMetadata,
) {
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { metadata }));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, metadata } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
/**
* Apply a custom-property bag snapshot to the issue detail + list caches.
* Mirrors onIssueMetadataChanged: the server emits the FULL post-mutation
* bag on every single-key write, so we replace rather than merge. Also used
* directly by the useSetIssueProperty/useUnsetIssueProperty optimistic path.
*/
export function onIssuePropertiesChanged(
qc: QueryClient,
wsId: string,
issueId: string,
properties: IssuePropertyValues,
) {
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { properties }));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, properties } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
// Plain assignee-group caches are never patched in place (their bucket
// shape differs) and would otherwise hold stale chips forever under
// staleTime:Infinity (clean-room review F2).
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
invalidatePropertyWindowQueries(qc, wsId);
}
/**
* Refetch every issue window whose SERVER-side shape depends on property
* values: queries filtered by `properties` or sorted by `property:<id>`.
* In-place patching keeps them stale under staleTime:Infinity — a value
* edit can change an issue's page membership and ordering, and grouped
* caches never self-heal (review round 3). Windows without property params
* keep the cheap in-place patch above.
*/
export function invalidatePropertyWindowQueries(qc: QueryClient, wsId: string) {
qc.invalidateQueries({
queryKey: issueKeys.all(wsId),
predicate: (query) =>
query.queryKey.some((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) return false;
const rec = part as Record<string, unknown>;
if (
rec.properties &&
typeof rec.properties === "object" &&
Object.keys(rec.properties as Record<string, unknown>).length > 0
) {
return true;
}
return typeof rec.sort_by === "string" && rec.sort_by.startsWith("property:");
}),
});
}
export function onIssueDeleted(
qc: QueryClient,
wsId: string,
issueId: string,
) {
cleanupDeletedIssueCaches(qc, wsId, issueId);
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}