mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 10:50:54 +02:00
Replaces the half-functional `position = 0` write-default + client-side `sortIssues` re-sort with a server-authoritative sort_by/sort_direction matrix and float8 fractional indexing for drag-drop: - Server: ListIssues / ListGroupedIssues accept sort_by (position, priority, start_date, due_date, created_at, updated_at, title) and sort_direction; unknown values silently downgrade to created_at desc per the API compatibility contract. - Server: new issues allocate MIN(position) - 1.0 inside the existing IncrementIssueCounter transaction so the legacy `position ASC` ordering still places new issues at the top for old desktop clients. - Server: migration 093 backfills existing rows with sparse float positions (newest first) and adds a (workspace_id, status, position) index. - Server: new PositionRebalanceService re-spaces a bucket asynchronously once the neighbour gap drops below 1e-9, then publishes the new `issue:rebalanced` WS event. - Frontend: issueKeys.list / myList carry a sort tuple in their key, so every list-cache reader/writer migrated from exact `setQueryData` to prefix `setQueriesData` / `getQueriesData` (mutations, ws-updaters, delete-cache, mention-suggestion, issue-chip, issue-detail). Snapshots and rollbacks iterate per-variant so every mounted sort variant rolls back cleanly. - Frontend: `packages/views/issues/utils/sort.ts` deleted; list-view / board-view trust server order, and drag-drop is guarded to Manual sort with a toast pointing the user back to Manual. - Frontend: `issue:rebalanced` parses through `parseWithFallback` with a zod schema + EMPTY_ISSUE_REBALANCED_PAYLOAD fallback; both the strict and the fallback paths invalidate `list` / `myAll` / `assigneeGroupsAll` / `myAssigneeGroupsAll` prefixes so server-authoritative refetch fixes order. - Tests: Go regression for unknown sort_by fallback and `MIN(position)-1` ordering; new core schema test covers the four failure modes for the rebalance payload. Existing 575+ FE tests and full Go suite pass. Phase 1+2 are bundled per Leader's release directive — splitting would leave a window where new issues regress to "appears at the bottom" for old clients. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
import {
|
|
agentActivityKeys,
|
|
agentRunCountsKeys,
|
|
agentTaskSnapshotKeys,
|
|
agentTasksKeys,
|
|
} from "../agents/queries";
|
|
import { labelKeys } from "../labels/queries";
|
|
import type { Issue, ListIssuesCache } from "../types";
|
|
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
|
import { issueKeys } from "./queries";
|
|
|
|
export type DeletedIssueCacheMetadata = {
|
|
parentIssueIds: string[];
|
|
};
|
|
|
|
function collectParentId(
|
|
parentIssueIds: Set<string>,
|
|
parentId: string | null | undefined,
|
|
) {
|
|
if (parentId) parentIssueIds.add(parentId);
|
|
}
|
|
|
|
function collectParentFromListCache(
|
|
parentIssueIds: Set<string>,
|
|
data: ListIssuesCache | undefined,
|
|
issueId: string,
|
|
) {
|
|
const parentId = data
|
|
? findIssueLocation(data, issueId)?.issue.parent_issue_id
|
|
: undefined;
|
|
collectParentId(parentIssueIds, parentId);
|
|
}
|
|
|
|
function parentIdFromChildrenKey(key: QueryKey) {
|
|
const parentId = key[key.length - 1];
|
|
return typeof parentId === "string" ? parentId : null;
|
|
}
|
|
|
|
export function collectDeletedIssueCacheMetadata(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
): DeletedIssueCacheMetadata {
|
|
const parentIssueIds = new Set<string>();
|
|
|
|
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
|
collectParentId(parentIssueIds, detail?.parent_issue_id);
|
|
|
|
// Walk every mounted list / my-list variant — the parent_issue_id lookup
|
|
// only needs one cache to hit (all variants carry the same issue bodies),
|
|
// but querying via prefix means we don't have to know which sort the user
|
|
// currently has open.
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.list(wsId),
|
|
})) {
|
|
collectParentFromListCache(parentIssueIds, data, issueId);
|
|
}
|
|
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
})) {
|
|
collectParentFromListCache(parentIssueIds, data, issueId);
|
|
}
|
|
|
|
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
|
queryKey: [...issueKeys.all(wsId), "children"],
|
|
})) {
|
|
const child = data?.find((issue) => issue.id === issueId);
|
|
if (!child) continue;
|
|
collectParentId(parentIssueIds, child.parent_issue_id);
|
|
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
|
|
}
|
|
|
|
return { parentIssueIds: Array.from(parentIssueIds) };
|
|
}
|
|
|
|
export function pruneDeletedIssueFromListCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
const remove = (old: ListIssuesCache | undefined) =>
|
|
old ? removeIssueFromBuckets(old, issueId) : old;
|
|
// Both list and my-list now key by sort tuple, so prune every mounted
|
|
// variant via the prefix — a single exact-key write would miss any cache
|
|
// not under the user's current sort.
|
|
qc.setQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) }, remove);
|
|
qc.setQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) }, remove);
|
|
}
|
|
|
|
export function pruneDeletedIssueFromParentChildrenCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata: DeletedIssueCacheMetadata,
|
|
) {
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
|
old?.filter((issue) => issue.id !== issueId),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function invalidateDeletedIssueParentCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
metadata: DeletedIssueCacheMetadata,
|
|
) {
|
|
if (metadata.parentIssueIds.length === 0) return;
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
|
|
export function invalidateDeletedIssueDependentCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
) {
|
|
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
|
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
|
}
|
|
|
|
export function invalidateIssueScopedCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
|
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
|
}
|
|
|
|
export function cleanupDeletedIssueCaches(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
|
|
) {
|
|
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
|
|
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
|
|
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
|
|
|
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
|
|
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
|
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
|
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
invalidateDeletedIssueDependentCaches(qc, wsId);
|
|
}
|