Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,13 +559,31 @@ export class ApiClient {
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
if (params?.q?.trim()) search.set("q", params.q.trim());
if (params?.status) search.set("status", params.status);
if (params?.statuses?.length) search.set("statuses", params.statuses.join(","));
if (params?.priority) search.set("priority", params.priority);
if (params?.priorities?.length) search.set("priorities", params.priorities.join(","));
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params?.include_no_assignee) search.set("include_no_assignee", "true");
if (params?.creator_filters?.length) {
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params?.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
if (params?.include_no_project) search.set("include_no_project", "true");
if (params?.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params?.top_level_only) search.set("top_level_only", "true");
// No `.length` guard on purpose: an empty ids array must still send
// `ids=` — the server treats a PRESENT-but-empty list as an empty window
// (nothing running), while an absent param means no restriction.
if (params?.ids) search.set("ids", params.ids.join(","));
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params?.metadata && Object.keys(params.metadata).length > 0) {
search.set("metadata", JSON.stringify(params.metadata));
Expand All @@ -580,6 +598,19 @@ export class ApiClient {
if (params?.date_end) search.set("date_end", params.date_end);
if (params?.sort_by) search.set("sort", params.sort_by);
if (params?.sort_direction) search.set("direction", params.sort_direction);
// An ids facet can carry hundreds of UUIDs (agents-working filter) —
// enough to blow the ~8 KB request-line cap of common reverse proxies.
// Route those windows through the POST twin, which takes the SAME
// key/value pairs as a JSON body.
if (params?.ids) {
const raw = await this.fetch<unknown>("/api/issues/query", {
method: "POST",
body: JSON.stringify(Object.fromEntries(search)),
});
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
endpoint: "POST /api/issues/query",
});
}
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
Expand Down
101 changes: 101 additions & 0 deletions packages/core/issues/cache-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { QueryClient, hashKey } from "@tanstack/react-query";
import {
applyIssueChange,
rollbackIssueChange,
type IssueFlatCache,
} from "./cache-coordinator";
import { issueChangedDims } from "./surface/membership";
import { issueKeys, type IssueSortParam } from "./queries";
Expand All @@ -25,6 +26,36 @@ const membersKey = issueKeys.myListSorted(
sort,
);
const inboxKey = inboxKeys.list(WS_ID);
const flatKey = issueKeys.flat(WS_ID, "workspace:all", {}, sort);
const flatTitleKey = issueKeys.flat(
WS_ID,
"workspace:all",
{},
{ sort_by: "title", sort_direction: "asc" },
);
const flatFilteredKey = issueKeys.flat(
WS_ID,
"workspace:all",
{ statuses: ["todo"], priorities: ["high"] },
sort,
);
const flatUpdatedWindowKey = issueKeys.flat(
WS_ID,
"workspace:all",
{},
{
...sort,
date_field: "updated_at",
date_start: "2025-01-01T00:00:00Z",
date_end: "2025-02-01T00:00:00Z",
},
);
const flatSearchKey = issueKeys.flat(
WS_ID,
"workspace:all",
{ q: "Issue 1" },
sort,
);

function makeIssue(idx: number, overrides: Partial<Issue> = {}): Issue {
return {
Expand Down Expand Up @@ -113,6 +144,76 @@ describe("applyIssueChange", () => {
expect(result.staleKeys).toEqual([]);
});

it("patches a loaded flat row without refetching an unrelated position window", () => {
qc.setQueryData<IssueFlatCache>(flatKey, {
pages: [{ issues: [issue()], total: 1 }],
pageParams: [0],
});

const patch = { title: "renamed in table" };
const result = applyIssueChange(qc, WS_ID, "issue-1", patch, {
changed: issueChangedDims(patch, issue()),
baseIssue: issue(),
});

expect(
qc.getQueryData<IssueFlatCache>(flatKey)?.pages[0]?.issues[0]?.title,
).toBe("renamed in table");
expect(result.staleKeys.map(hashKey)).not.toContain(hashKey(flatKey));

rollbackIssueChange(qc, WS_ID, "issue-1", result);
expect(
qc.getQueryData<IssueFlatCache>(flatKey)?.pages[0]?.issues[0]?.title,
).toBe("Issue 1");
});

it("marks only flat windows whose sort or facet depends on the changed field", () => {
for (const key of [
flatKey,
flatTitleKey,
flatFilteredKey,
flatUpdatedWindowKey,
]) {
qc.setQueryData<IssueFlatCache>(key, {
pages: [{ issues: [issue()], total: 1 }],
pageParams: [0],
});
}

const titlePatch = { title: "renamed" };
const titleResult = applyIssueChange(qc, WS_ID, "issue-1", titlePatch, {
changed: issueChangedDims(titlePatch, issue()),
baseIssue: issue(),
});
expect(titleResult.staleKeys.map(hashKey)).toEqual([
hashKey(flatTitleKey),
hashKey(flatUpdatedWindowKey),
]);

const statusPatch = { status: "done" as const };
const statusResult = applyIssueChange(qc, WS_ID, "issue-1", statusPatch, {
changed: issueChangedDims(statusPatch, issue()),
baseIssue: issue(),
});
expect(statusResult.staleKeys.map(hashKey)).toContain(hashKey(flatFilteredKey));
expect(statusResult.staleKeys.map(hashKey)).not.toContain(hashKey(flatKey));
});

it("reconciles a searched flat window when an edited title can change membership", () => {
qc.setQueryData<IssueFlatCache>(flatSearchKey, {
pages: [{ issues: [issue()], total: 1 }],
pageParams: [0],
});

const patch = { title: "No longer matches" };
const result = applyIssueChange(qc, WS_ID, "issue-1", patch, {
changed: issueChangedDims(patch, issue()),
baseIssue: issue(),
});

expect(result.staleKeys.map(hashKey)).toContain(hashKey(flatSearchKey));
});

it("status change: rebuckets loaded cards, patches inbox, adjusts counts for absent-but-member lists", () => {
qc.setQueryData<ListIssuesCache>(wsKey, bucketed([issue()]));
// p1 list loaded but the card is beyond its loaded window — the change
Expand Down
164 changes: 159 additions & 5 deletions packages/core/issues/cache-coordinator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { hashKey, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { issueKeys, type MyIssuesFilter } from "./queries";
import {
hashKey,
type InfiniteData,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import {
issueKeys,
type IssueFlatFilter,
type IssueSortParam,
type MyIssuesFilter,
} from "./queries";
import { inboxKeys } from "../inbox/queries";
import { patchInboxIssueStatus } from "../inbox/ws-updaters";
import { projectKeys } from "../projects/queries";
Expand All @@ -15,7 +25,14 @@ import {
listFilterDependsOn,
type IssueChangedDims,
} from "./surface/membership";
import type { InboxItem, Issue, ListIssuesCache } from "../types";
import type {
InboxItem,
Issue,
ListIssuesCache,
ListIssuesResponse,
} from "../types";

export type IssueFlatCache = InfiniteData<ListIssuesResponse, number>;

/**
* IssueCacheCoordinator — the one rules table for how a single issue change
Expand Down Expand Up @@ -64,6 +81,7 @@ export interface IssueCacheChangeResult {
/** Pre-change snapshots of every cache this change touched — feed back to
* {@link rollbackIssueChange} from onError. */
prevLists: [QueryKey, ListIssuesCache][];
prevFlatLists: [QueryKey, IssueFlatCache][];
prevDetail: Issue | undefined;
prevInboxList: InboxItem[] | undefined;
/** Loaded list keys whose server result may have drifted (membership
Expand Down Expand Up @@ -103,6 +121,108 @@ function bucketedListEntries(
);
}

function flatListEntries(
qc: QueryClient,
wsId: string,
): [QueryKey, IssueFlatCache][] {
return qc
.getQueriesData<IssueFlatCache>({ queryKey: issueKeys.flatAll(wsId) })
.filter(
(entry): entry is [QueryKey, IssueFlatCache] =>
!!entry[1] && Array.isArray(entry[1].pages),
);
}

function flatContractFromKey(key: QueryKey): {
scope: string | undefined;
filter: IssueFlatFilter;
sort: IssueSortParam;
} {
return {
scope: typeof key[3] === "string" ? key[3] : undefined,
filter: (key[4] ?? {}) as IssueFlatFilter,
sort: (key[5] ?? {}) as IssueSortParam,
};
}

function patchFieldChanged<K extends keyof Issue>(
patch: Partial<Issue>,
base: Issue | undefined,
field: K,
) {
if (!Object.prototype.hasOwnProperty.call(patch, field)) return false;
return !base || !Object.is(patch[field], base[field]);
}

/** Whether a patch can change a flat window's membership or ordering. A
* loaded row is always patched optimistically; only windows whose server
* contract depends on the changed field need the follow-up refetch. */
function flatWindowNeedsReconcile(
key: QueryKey,
patch: Partial<Issue>,
base: Issue | undefined,
changed: IssueChangedDims,
) {
const { scope, filter, sort } = flatContractFromKey(key);
const anyIssueFieldChanged = Object.keys(patch).some((field) =>
patchFieldChanged(patch, base, field as keyof Issue),
);

if (listFilterDependsOn(scope, filter, changed)) return true;
if (filter.q && patchFieldChanged(patch, base, "title")) return true;
if (changed.status && (filter.statuses?.length ?? 0) > 0) return true;
if (
patchFieldChanged(patch, base, "priority") &&
(filter.priorities?.length ?? 0) > 0
) {
return true;
}
if (
changed.assignee &&
((filter.assignee_filters?.length ?? 0) > 0 || filter.include_no_assignee)
) {
return true;
}
if (changed.project && ((filter.project_ids?.length ?? 0) > 0 || filter.include_no_project)) {
return true;
}
if (patchFieldChanged(patch, base, "parent_issue_id") && filter.top_level_only) {
return true;
}
if (sort.date_field === "updated_at" && anyIssueFieldChanged) return true;
if (
sort.date_field === "created_at" &&
patchFieldChanged(patch, base, "created_at")
) {
return true;
}

switch (sort.sort_by ?? "position") {
case "title":
return patchFieldChanged(patch, base, "title");
case "status":
return changed.status;
case "priority":
return patchFieldChanged(patch, base, "priority");
case "created_at":
return patchFieldChanged(patch, base, "created_at");
case "updated_at":
// Every persisted issue edit advances updated_at even though the
// optimistic request payload does not carry the server timestamp.
return anyIssueFieldChanged;
case "start_date":
return patchFieldChanged(patch, base, "start_date");
case "due_date":
return patchFieldChanged(patch, base, "due_date");
case "position":
return patchFieldChanged(patch, base, "position");
default:
// Custom-property ordering is reconciled by the dedicated property
// mutation/WS pipeline, which has the full property-bag snapshot.
return false;
}
}

export function applyIssueChange(
qc: QueryClient,
wsId: string,
Expand All @@ -120,6 +240,7 @@ export function applyIssueChange(
): IssueCacheChangeResult {
const { changed, baseIssue } = opts;
const prevLists: [QueryKey, ListIssuesCache][] = [];
const prevFlatLists: [QueryKey, IssueFlatCache][] = [];
const staleKeys: QueryKey[] = [];
let prevIssue: Issue | undefined = baseIssue;

Expand Down Expand Up @@ -207,6 +328,29 @@ export function applyIssueChange(
staleKeys.push(key);
}

// Patch loaded flat rows immediately. Only refetch windows whose encoded
// server filter/sort actually depends on this change; e.g. a title edit in
// a position-sorted unfiltered table is fully reconciled by the patch.
for (const [key, data] of flatListEntries(qc, wsId)) {
let found: Issue | undefined;
const pages = data.pages.map((page) => ({
...page,
issues: page.issues.map((issue) => {
if (issue.id !== id) return issue;
found = issue;
return { ...issue, ...patch };
}),
}));
if (found) {
if (!prevIssue) prevIssue = found;
prevFlatLists.push([key, data]);
qc.setQueryData<IssueFlatCache>(key, { ...data, pages });
}
if (flatWindowNeedsReconcile(key, patch, found ?? baseIssue, changed)) {
staleKeys.push(key);
}
}

const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
if (prevDetail) {
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), {
Expand All @@ -224,7 +368,14 @@ export function applyIssueChange(
if (prevInboxList) patchInboxIssueStatus(qc, wsId, id, patch.status);
}

return { prevLists, prevDetail, prevInboxList, staleKeys, prevIssue };
return {
prevLists,
prevFlatLists,
prevDetail,
prevInboxList,
staleKeys,
prevIssue,
};
}

/** Restore every snapshot captured by {@link applyIssueChange} — the onError
Expand All @@ -235,12 +386,15 @@ export function rollbackIssueChange(
id: string,
result: Pick<
IssueCacheChangeResult,
"prevLists" | "prevDetail" | "prevInboxList"
"prevLists" | "prevFlatLists" | "prevDetail" | "prevInboxList"
>,
) {
for (const [key, snapshot] of result.prevLists) {
qc.setQueryData(key, snapshot);
}
for (const [key, snapshot] of result.prevFlatLists) {
qc.setQueryData(key, snapshot);
}
if (result.prevDetail !== undefined) {
qc.setQueryData(issueKeys.detail(wsId, id), result.prevDetail);
}
Expand Down
Loading
Loading